@event4u/agent-config 9.28.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 (147) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/AGENTS.md +6 -6
  3. package/CHANGELOG.md +66 -0
  4. package/README.md +1 -1
  5. package/agents/templates/.ai-council.yml.example +55 -24
  6. package/dist/agent-src/commands/cost/report.md +2 -2
  7. package/dist/agent-src/commands/team/adversarial.md +8 -7
  8. package/dist/agent-src/commands/team/delegate.md +13 -12
  9. package/dist/agent-src/commands/team/review.md +9 -8
  10. package/dist/agent-src/commands/team/status.md +9 -8
  11. package/dist/agent-src/commands/team.md +29 -19
  12. package/dist/agent-src/contexts/execution/auto-dispatch-classification.md +88 -2
  13. package/dist/agent-src/contexts/execution/auto-orchestration-activation.md +29 -21
  14. package/dist/agent-src/contexts/execution/host-capability-manifest.md +26 -15
  15. package/dist/agent-src/contexts/execution/orchestration-telemetry.md +18 -0
  16. package/dist/agent-src/contexts/execution/subagent-response-contract.md +54 -0
  17. package/dist/agent-src/contexts/execution/subagent-routing.md +13 -11
  18. package/dist/agent-src/contexts/execution/subagent-spawn-contract.md +25 -0
  19. package/dist/agent-src/contexts/execution/subagent-steering.md +6 -3
  20. package/dist/agent-src/rules/delegation-policy.md +11 -7
  21. package/dist/agent-src/rules/settings-ask-protocol.md +8 -7
  22. package/dist/agent-src/rules/token-budget-discipline.md +1 -1
  23. package/dist/agent-src/skills/forensics-report/SKILL.md +114 -0
  24. package/dist/agent-src/skills/forensics-report/evals/triggers.json +17 -0
  25. package/dist/agent-src/skills/premortem/SKILL.md +30 -1
  26. package/dist/agent-src/skills/reasoning-orchestrator/SKILL.md +2 -1
  27. package/dist/agent-src/skills/subagent-orchestration/SKILL.md +7 -5
  28. package/dist/agent-src/skills/subagent-orchestration/schemas/subagent-status.json +61 -3
  29. package/dist/agent-src/skills/test-case-discovery/SKILL.md +1 -1
  30. package/dist/agent-src/templates/AGENTS.md +4 -4
  31. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  32. package/dist/agent-src/templates/roadmaps.md +23 -0
  33. package/dist/cli-delegate/{chunk-22GCIJ2O.js → chunk-66OVWGB5.js} +1 -1
  34. package/dist/cli-delegate/{chunk-GB2FSUEE.js → chunk-LCFJIQ4J.js} +32 -0
  35. package/dist/cli-delegate/{chunk-VSYT7OHO.js → chunk-LVRYBKTI.js} +31 -2
  36. package/dist/cli-delegate/{chunk-DVRS2KVR.js → chunk-MMXA5GY7.js} +10 -17
  37. package/dist/cli-delegate/{chunk-JXARCGVN.js → chunk-MP3JT3IU.js} +1 -1
  38. package/dist/cli-delegate/{chunk-PQSAQKHW.js → chunk-NZ5N2XQL.js} +3 -3
  39. package/dist/cli-delegate/{chunk-7OMQCH4V.js → chunk-OJM3MBJM.js} +1 -1
  40. package/dist/cli-delegate/{chunk-2BE5I2MN.js → chunk-RCIA67XN.js} +111 -90
  41. package/dist/cli-delegate/cmd_analyze_session.js +1 -1
  42. package/dist/cli-delegate/cmd_brand_status.js +1 -1
  43. package/dist/cli-delegate/cmd_conformance.js +6 -43
  44. package/dist/cli-delegate/cmd_doctor.js +9 -5
  45. package/dist/cli-delegate/cmd_explain.js +2 -2
  46. package/dist/cli-delegate/cmd_export.js +4 -4
  47. package/dist/cli-delegate/cmd_handoff.js +3 -3
  48. package/dist/cli-delegate/cmd_mcp_available.js +1 -1
  49. package/dist/cli-delegate/cmd_migrate.js +2 -2
  50. package/dist/cli-delegate/cmd_packs_active.js +2 -2
  51. package/dist/cli-delegate/cmd_prune.js +4 -4
  52. package/dist/cli-delegate/cmd_route_audit.js +2 -2
  53. package/dist/cli-delegate/cmd_route_explain.js +1 -1
  54. package/dist/cli-delegate/cmd_settings_get.js +1 -7
  55. package/dist/cli-delegate/cmd_settings_migrate.js +4 -4
  56. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  57. package/dist/cli-delegate/cmd_sync.js +4 -4
  58. package/dist/cli-delegate/cmd_uninstall.js +4 -4
  59. package/dist/cli-delegate/cmd_update.js +1 -1
  60. package/dist/cli-delegate/cmd_upgrade.js +1 -1
  61. package/dist/cli-delegate/cmd_validate.js +4 -4
  62. package/dist/cli-delegate/cmd_versions.js +1 -1
  63. package/dist/discovery/deprecation-report.md +1 -1
  64. package/dist/discovery/discovery-manifest.json +69 -20
  65. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  66. package/dist/discovery/discovery-manifest.summary.md +3 -2
  67. package/dist/discovery/orphan-report.md +1 -1
  68. package/dist/discovery/packs.json +39 -2
  69. package/dist/discovery/trust-report.md +2 -2
  70. package/dist/discovery/workspaces.json +12 -4
  71. package/dist/hooks/dispatch.js +373 -35
  72. package/dist/install/install.mjs +42 -17
  73. package/dist/mcp/registry-manifest.json +2 -2
  74. package/dist/mcp/server.json +2 -2
  75. package/dist/mcp/server.mjs +651 -262
  76. package/dist/router.json +1 -1
  77. package/dist/scripts/_lib/agent_settings.js +54 -0
  78. package/dist/scripts/_lib/agent_settings.js.map +1 -1
  79. package/dist/scripts/condense.js +147 -2
  80. package/dist/scripts/condense.js.map +1 -1
  81. package/dist/server/schemas/settings.js +6 -7
  82. package/dist/server/schemas/settings.js.map +1 -1
  83. package/dist/shared/settingsCarveOut.js +0 -9
  84. package/dist/shared/settingsCarveOut.js.map +1 -1
  85. package/dist/shared/settingsClasses.js +11 -9
  86. package/dist/shared/settingsClasses.js.map +1 -1
  87. package/dist/ui/assets/{index-DmR0mbk4.js → index-JDAF00bC.js} +2 -2
  88. package/dist/ui/assets/{index-DmR0mbk4.js.map → index-JDAF00bC.js.map} +1 -1
  89. package/dist/ui/index.html +1 -1
  90. package/docs/contracts/settings-classes.md +39 -9
  91. package/package.json +1 -1
  92. package/src/agent-src/templates/AGENTS.md +4 -4
  93. package/src/config/agent-settings.template.yml +52 -66
  94. package/src/config/discovery/packs.yml +11 -0
  95. package/src/config/discovery/workspaces.yml +1 -1
  96. package/src/config/gate-violation-baselines.json +3 -3
  97. package/src/scripts/_cli/cmd_conformance.ts +14 -42
  98. package/src/scripts/_cli/cmd_doctor.ts +65 -48
  99. package/src/scripts/_lib/agent_settings.ts +58 -0
  100. package/src/scripts/_lib/auto_dispatch.corpus.test.ts +1 -1
  101. package/src/scripts/_lib/auto_dispatch.ts +23 -18
  102. package/src/scripts/_lib/capsule_trigger.ts +124 -0
  103. package/src/scripts/_lib/host_capability.ts +63 -6
  104. package/src/scripts/_lib/judgment_ladder.ts +382 -0
  105. package/src/scripts/_lib/orchestration_gate.ts +8 -0
  106. package/src/scripts/_lib/orchestration_record.ts +37 -0
  107. package/src/scripts/_lib/orchestration_savings.ts +1 -1
  108. package/src/scripts/_lib/self_repair.ts +160 -13
  109. package/src/scripts/_lib/self_repair_store.ts +28 -0
  110. package/src/scripts/_lib/subagent_capsule.ts +182 -0
  111. package/src/scripts/_lib/subagent_response.ts +19 -0
  112. package/src/scripts/_lib/subagent_spawn.ts +13 -0
  113. package/src/scripts/_lib/subagent_steering.ts +9 -4
  114. package/src/scripts/_lib/tier_budget_routing.ts +6 -1
  115. package/src/scripts/_lib/worker_budget.ts +37 -2
  116. package/src/scripts/ai_council/clients.ts +76 -54
  117. package/src/scripts/ai_council/config.ts +94 -6
  118. package/src/scripts/ai_council/handoff.ts +113 -0
  119. package/src/scripts/ai_council/modes.ts +19 -4
  120. package/src/scripts/ai_council/orchestrator.ts +105 -0
  121. package/src/scripts/ai_council/quorum.ts +84 -0
  122. package/src/scripts/ai_council/session.ts +44 -0
  123. package/src/scripts/ai_council/transport_resolver.ts +127 -1
  124. package/src/scripts/ai_team/availability.ts +110 -0
  125. package/src/scripts/ai_team/config.ts +34 -14
  126. package/src/scripts/ai_team/team_dispatch.ts +71 -29
  127. package/src/scripts/bench_critic_protocol.ts +396 -0
  128. package/src/scripts/check_detector_corpus.ts +202 -0
  129. package/src/scripts/check_hook_bundle_freshness.ts +4 -1
  130. package/src/scripts/condense.ts +162 -2
  131. package/src/scripts/council_cli.ts +401 -25
  132. package/src/scripts/forensics_report.ts +333 -0
  133. package/src/scripts/hooks/delegation_nudge_hook.ts +69 -35
  134. package/src/scripts/lint_discovery_vocabulary.ts +2 -0
  135. package/src/scripts/lint_never_silent.ts +276 -0
  136. package/src/scripts/lint_no_activation_gates.ts +271 -0
  137. package/src/scripts/orchestration_record.ts +8 -0
  138. package/src/scripts/rebuild_hook_bundle.ts +177 -0
  139. package/src/scripts/release.ts +74 -2
  140. package/src/scripts/routing_doctor.ts +21 -36
  141. package/src/scripts/score_skill_selection.ts +19 -5
  142. package/src/scripts/self_repair_cli.ts +287 -57
  143. package/src/scripts/team_events_spike.ts +271 -0
  144. package/src/scripts/team_review_gate_hook.ts +31 -9
  145. package/src/server/schemas/settings.ts +10 -17
  146. package/src/shared/settingsCarveOut.ts +0 -10
  147. 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,6 +64,7 @@ import { format_install_hints } from './ai_council/cli_hints.js';
63
64
  import {
64
65
  type AdvisorConfig,
65
66
  type CouncilConfig,
67
+ type QuorumSetting,
66
68
  COUNCIL_CONFIG_ENV,
67
69
  COUNCIL_CONFIG_USER_GLOBAL_REL,
68
70
  CouncilConfigError,
@@ -71,7 +73,10 @@ import {
71
73
  resolve_config_path,
72
74
  } from './ai_council/config.js';
73
75
  import { AuthCache, select_solo_member } from './ai_council/solo_dispatch.js';
74
- 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';
75
80
  import { appendEvent } from './ai_council/events_log.js';
76
81
  import {
77
82
  type ClassificationResult,
@@ -82,7 +87,8 @@ import {
82
87
  educate_message,
83
88
  } from './ai_council/necessity.js';
84
89
  import type {
85
- DebateCheckpoint} from './ai_council/orchestrator.js';
90
+ DebateCheckpoint,
91
+ RenderAbsentMember} from './ai_council/orchestrator.js';
86
92
  import {
87
93
  ConsensusResult,
88
94
  CostBudget,
@@ -128,6 +134,8 @@ import {
128
134
  render_deanonymization_block,
129
135
  with_chairman_fields,
130
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';
131
139
 
132
140
  // ── argparse-style exit plumbing ────────────────────────────────────
133
141
  // Mirror CPython argparse: a `prog: error: …` line on stderr + exit 2.
@@ -503,6 +511,13 @@ function _synthesize_ai_council_block(cfg: CouncilConfig): Dict {
503
511
  max_calls_per_day: _mapToObject(cfg.cli_call_budget.max_calls_per_day),
504
512
  warn_at: cfg.cli_call_budget.warn_at,
505
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,
506
521
  necessity_classifier: {
507
522
  enabled: cfg.necessity_classifier.enabled,
508
523
  mode: cfg.necessity_classifier.mode,
@@ -550,6 +565,118 @@ interface BuildMembersOptions {
550
565
  model_overrides?: Record<string, string> | null;
551
566
  siblings_overrides?: Record<string, string[]> | null;
552
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}.`;
553
680
  }
554
681
 
555
682
  function build_members(settings: Dict, opts: BuildMembersOptions = {}): ExternalAIClient[] {
@@ -557,6 +684,12 @@ function build_members(settings: Dict, opts: BuildMembersOptions = {}): External
557
684
  const model_overrides = opts.model_overrides ?? null;
558
685
  const siblings_overrides = opts.siblings_overrides ?? null;
559
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();
560
693
 
561
694
  const ai = _isDict(settings) ? ((settings['ai_council'] as Dict) || {}) : {};
562
695
  if (!_pyBool(ai['enabled'])) {
@@ -617,6 +750,18 @@ function build_members(settings: Dict, opts: BuildMembersOptions = {}): External
617
750
  );
618
751
  }
619
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;
620
765
  for (const name of Object.keys(members_cfg)) {
621
766
  const cfg = ((members_cfg[name] as Dict) || {}) as Dict;
622
767
  if (!_pyBool(cfg['enabled'])) {
@@ -629,15 +774,34 @@ function build_members(settings: Dict, opts: BuildMembersOptions = {}): External
629
774
  }
630
775
  continue;
631
776
  }
632
- 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,
633
790
  invocationMode: invocation_mode,
634
791
  memberSettings: cfg,
635
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 } : {}),
636
798
  });
637
799
  if (name in siblings) {
638
- if (mode !== 'api') {
800
+ if (resolved.transport !== 'api') {
639
801
  throw new CouncilDisabledError(
640
- `--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')}).`,
641
805
  );
642
806
  }
643
807
  const api_key_ref = (cfg['api_key_ref'] as string | null) ?? null;
@@ -658,19 +822,69 @@ function build_members(settings: Dict, opts: BuildMembersOptions = {}): External
658
822
  }
659
823
  continue;
660
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
+ }
661
844
  const model = (overrides[name] as string | undefined) || (cfg['model'] as string | undefined) || null;
662
- if (mode === 'api' && _API_PROVIDERS.has(name)) {
663
- members.push(
664
- _construct_api_member(name, model, {
665
- api_key_ref: (cfg['api_key_ref'] as string | null) ?? null,
666
- // Council opts in explicitly (client-default OFF); on unless
667
- // the operator sets `prompt_cache: false`.
668
- enable_prompt_cache: cfg['prompt_cache'] !== false,
669
- // road-to-cache-economy Phase 4 — see the siblings branch above.
670
- prompt_cache_ttl: (cfg['prompt_cache_ttl'] as '5m' | '1h' | undefined) ?? undefined,
671
- }),
672
- );
673
- } 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)) {
674
888
  try {
675
889
  members.push(
676
890
  _construct_cli_member(name, model, {
@@ -696,30 +910,32 @@ function build_members(settings: Dict, opts: BuildMembersOptions = {}): External
696
910
  reason: 'binary_missing',
697
911
  detail,
698
912
  };
913
+ absent_count += 1;
699
914
  if (skipped !== null) {
700
915
  skipped.push(entry);
701
916
  }
702
917
  _stderr(`[council] SKIP ${name}: ${detail}\n`);
703
918
  continue;
704
919
  }
705
- } else if (mode === 'cli') {
920
+ } else if (resolved.transport === 'cli') {
706
921
  throw new CouncilDisabledError(
707
922
  `member ${_pyReprStr(name)} resolves to mode=cli but no CLI client is ` +
708
923
  `wired (known: ${_pyReprStrList(_pySortedStr(_CLI_PROVIDERS))}).`,
709
924
  );
710
- } else if (mode === 'manual') {
925
+ } else if (resolved.transport === 'manual') {
711
926
  members.push(new ManualClient({ name, model: model || 'manual' }));
712
- } else if (mode === 'playwright') {
713
- throw new CouncilDisabledError(
714
- `member ${_pyReprStr(name)} resolves to mode=playwright (Phase 2c, not wired).`,
715
- );
716
927
  } else {
717
928
  throw new CouncilDisabledError(
718
- `member ${_pyReprStr(name)} has no transport — mode=${mode}, ` +
719
- `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))}.`,
720
932
  );
721
933
  }
722
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
+ }
723
939
  if (members.length === 0) {
724
940
  if (skipped && skipped.length > 0) {
725
941
  const names = skipped.map((s) => String(s['member'])).join(', ');
@@ -1334,6 +1550,105 @@ function _objToMap(o: Dict): Map<string, string> {
1334
1550
  return m;
1335
1551
  }
1336
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
+
1337
1652
  // ── round / depth / token resolution ────────────────────────────────
1338
1653
 
1339
1654
  function _resolve_rounds(args: Args, ai_cfg: Dict): number {
@@ -1380,12 +1695,14 @@ function cmd_estimate(
1380
1695
  const advisor_plans = _build_advisor_plans(ai_cfg, REPO_ROOT);
1381
1696
  const explicit_overrides = _parse_model_overrides(_getattr<string[] | null>(args, 'model', null));
1382
1697
  const skipped: Dict[] = [];
1698
+ const quorum_out: { result: QuorumResult | null } = { result: null };
1383
1699
  if (members === null) {
1384
1700
  members = build_members(settings, {
1385
1701
  invocation_mode: args.mode_override,
1386
1702
  model_overrides: _advisor_model_overrides(advisor_plans, explicit_overrides),
1387
1703
  siblings_overrides: _parse_siblings_overrides(_getattr<string[] | null>(args, 'siblings', null)),
1388
1704
  skipped,
1705
+ quorum_out,
1389
1706
  });
1390
1707
  }
1391
1708
  if (table === null) {
@@ -1424,6 +1741,9 @@ function cmd_estimate(
1424
1741
  if (skipped.length > 0) {
1425
1742
  _stdout(format_install_hints(skipped) + '\n');
1426
1743
  }
1744
+ if (quorum_out.result !== null) {
1745
+ _stdout(_format_quorum_line(quorum_out.result) + '\n');
1746
+ }
1427
1747
  _stdout(
1428
1748
  format_estimate_table(billable, estimates, {
1429
1749
  consensus_delta_usd: extra_usd,
@@ -2035,12 +2355,14 @@ function cmd_run(
2035
2355
  const advisor_plans = _build_advisor_plans(ai_cfg, REPO_ROOT);
2036
2356
  const explicit_overrides = _parse_model_overrides(_getattr<string[] | null>(args, 'model', null));
2037
2357
  const skipped: Dict[] = [];
2358
+ const quorum_out: { result: QuorumResult | null } = { result: null };
2038
2359
  if (members === null) {
2039
2360
  members = build_members(settings, {
2040
2361
  invocation_mode: args.mode_override,
2041
2362
  model_overrides: _advisor_model_overrides(advisor_plans, explicit_overrides),
2042
2363
  siblings_overrides: _parse_siblings_overrides(_getattr<string[] | null>(args, 'siblings', null)),
2043
2364
  skipped,
2365
+ quorum_out,
2044
2366
  });
2045
2367
  }
2046
2368
  if (_getattr(args, 'single', false)) {
@@ -2103,6 +2425,9 @@ function cmd_run(
2103
2425
  if (skipped.length > 0) {
2104
2426
  _stdout(format_install_hints(skipped) + '\n');
2105
2427
  }
2428
+ if (quorum_out.result !== null) {
2429
+ _stdout(_format_quorum_line(quorum_out.result) + '\n');
2430
+ }
2106
2431
  _stdout(
2107
2432
  format_estimate_table(billable, estimates, {
2108
2433
  consensus_delta_usd: extra_usd,
@@ -2198,6 +2523,38 @@ function cmd_run(
2198
2523
  stance_repairs.push(r);
2199
2524
  },
2200
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
+ );
2201
2558
  const persona_labels = build_persona_labels(advisor_plans, billable);
2202
2559
  const peer_review = _maybe_run_peer_review(
2203
2560
  ai_cfg,
@@ -2279,6 +2636,21 @@ function cmd_run(
2279
2636
  if (blind_review_map !== null) {
2280
2637
  payload['blind_review_map'] = _mapToObject(blind_review_map);
2281
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;
2282
2654
  const out_path = _validate_council_output_path(args.output as string, {
2283
2655
  kind: 'responses',
2284
2656
  subcommand: 'run',
@@ -2708,6 +3080,9 @@ function cmd_render(args: Args): number {
2708
3080
  chairman: _isDict(payload['chairman'])
2709
3081
  ? (payload['chairman'] as { member: string | null; annotation: string; text: string | null })
2710
3082
  : null,
3083
+ handoff: _deserialise_handoff(payload['handoff']),
3084
+ absent_members: _deserialise_absent_members(payload['absent_members']),
3085
+ quorum: _deserialise_quorum(payload['quorum']),
2711
3086
  });
2712
3087
  if (_getattr<string | null>(args, 'output', null)) {
2713
3088
  const out_path = _validate_council_output_path(args.output as string, {
@@ -3403,5 +3778,6 @@ export {
3403
3778
  _parse_model_overrides,
3404
3779
  _parse_siblings_overrides,
3405
3780
  _synthesize_ai_council_block,
3781
+ _postRunQuorum,
3406
3782
  CouncilDisabledError,
3407
3783
  };