@event4u/agent-config 2.13.0 → 2.14.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 (64) hide show
  1. package/.agent-src/commands/memory/learn-low-impact.md +143 -0
  2. package/.agent-src/rules/ask-when-uncertain.md +10 -6
  3. package/.agent-src/rules/copilot-routing.md +1 -1
  4. package/.agent-src/rules/devcontainer-routing.md +1 -1
  5. package/.agent-src/rules/external-reference-deep-dive.md +1 -1
  6. package/.agent-src/rules/fast-path-marker-visibility.md +38 -0
  7. package/.agent-src/rules/low-impact-corpus-privacy-floor.md +74 -0
  8. package/.agent-src/rules/symfony-routing.md +1 -1
  9. package/.agent-src/skills/ai-council/SKILL.md +208 -8
  10. package/.agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  11. package/.claude-plugin/marketplace.json +2 -1
  12. package/CHANGELOG.md +299 -124
  13. package/README.md +6 -6
  14. package/config/gitignore-block.txt +6 -0
  15. package/docs/architecture.md +12 -12
  16. package/docs/archive/CHANGELOG-pre-2.11.0.md +141 -0
  17. package/docs/catalog.md +10 -7
  18. package/docs/contracts/adr-architectural-consensus-mechanism.md +4 -3
  19. package/docs/contracts/adr-level-6-productization.md +7 -9
  20. package/docs/contracts/ai-council-config.md +492 -20
  21. package/docs/contracts/command-clusters.md +1 -1
  22. package/docs/contracts/command-surface-tiers.md +3 -2
  23. package/docs/contracts/cost-profile-defaults.md +5 -0
  24. package/docs/contracts/decision-engine-gates.md +5 -0
  25. package/docs/contracts/decision-trace-v1.md +2 -2
  26. package/docs/contracts/file-ownership-matrix.json +1735 -72
  27. package/docs/contracts/installed-tools-lockfile.md +2 -1
  28. package/docs/contracts/low-impact-corpus-format.md +95 -0
  29. package/docs/contracts/mcp-beta-criteria.md +6 -5
  30. package/docs/contracts/mcp-cloud-scope.md +5 -4
  31. package/docs/contracts/multi-tool-projection-fidelity.md +8 -2
  32. package/docs/contracts/release-trunk-sync.md +4 -3
  33. package/docs/contracts/tier-3-contrib-plugin.md +5 -6
  34. package/docs/getting-started.md +2 -2
  35. package/docs/guidelines/agent-infra/installed-tools-manifest.md +2 -1
  36. package/docs/installation.md +32 -0
  37. package/package.json +1 -1
  38. package/scripts/_cli/cmd_doctor.py +134 -0
  39. package/scripts/ai_council/airgap.py +165 -0
  40. package/scripts/ai_council/cli_hints.py +123 -0
  41. package/scripts/ai_council/clients.py +787 -5
  42. package/scripts/ai_council/compile_corpus.py +178 -0
  43. package/scripts/ai_council/confidence_gate.py +156 -0
  44. package/scripts/ai_council/config.py +1007 -11
  45. package/scripts/ai_council/consensus.py +41 -2
  46. package/scripts/ai_council/events_log.py +137 -0
  47. package/scripts/ai_council/learn_low_impact_preview.py +252 -0
  48. package/scripts/ai_council/low_impact.py +714 -0
  49. package/scripts/ai_council/low_impact_corpus.py +466 -0
  50. package/scripts/ai_council/low_impact_intake.py +163 -0
  51. package/scripts/ai_council/modes.py +6 -1
  52. package/scripts/ai_council/necessity.py +782 -0
  53. package/scripts/ai_council/orchestrator.py +252 -14
  54. package/scripts/ai_council/probation_gate.py +152 -0
  55. package/scripts/ai_council/redact_low_impact_entry.py +155 -0
  56. package/scripts/ai_council/replay.py +155 -0
  57. package/scripts/ai_council/session.py +19 -1
  58. package/scripts/ai_council/shadow_dispatch.py +235 -0
  59. package/scripts/ai_council/solo_dispatch.py +226 -0
  60. package/scripts/audit_cloud_compatibility.py +74 -0
  61. package/scripts/audit_command_surface.py +363 -0
  62. package/scripts/check_council_layout.py +11 -0
  63. package/scripts/council_cli.py +1046 -15
  64. package/scripts/install.sh +12 -0
@@ -26,6 +26,40 @@ REPO_ROOT = Path(__file__).resolve().parents[1]
26
26
  SETTINGS_FILE = REPO_ROOT / ".agent-settings.yml"
27
27
  AI_COUNCIL_FILE = REPO_ROOT / "agents" / ".ai-council.yml"
28
28
 
29
+ # Canonical output dirs per ai-council § "Output path convention".
30
+ # Enforced at write-time by `_validate_council_output_path` so shell-side
31
+ # `>` redirects and forgetful agents can't strand artefacts at agents/ root.
32
+ COUNCIL_CANONICAL_DIRS: dict[str, str] = {
33
+ "responses": "agents/council-responses",
34
+ "sessions": "agents/council-sessions",
35
+ "questions": "agents/council-questions",
36
+ }
37
+
38
+
39
+ def _validate_council_output_path(
40
+ path_str: str, *, kind: str, subcommand: str,
41
+ ) -> Path:
42
+ """Reject non-canonical --output paths at write-time.
43
+
44
+ `kind` selects the expected canonical dir (`responses`, `sessions`,
45
+ `questions`). Raises `argparse.ArgumentTypeError` on violation so
46
+ `main()` surfaces a clean ❌ message and returns 2.
47
+ """
48
+ expected_rel = COUNCIL_CANONICAL_DIRS[kind]
49
+ expected_abs = (REPO_ROOT / expected_rel).resolve()
50
+ p = Path(path_str)
51
+ target = p if p.is_absolute() else (REPO_ROOT / p)
52
+ target_resolved = target.resolve()
53
+ try:
54
+ target_resolved.relative_to(expected_abs)
55
+ except ValueError as exc:
56
+ raise argparse.ArgumentTypeError(
57
+ f"council:{subcommand} --output must live under "
58
+ f"{expected_rel}/ (per ai-council § Output path convention); "
59
+ f"got {path_str!r}."
60
+ ) from exc
61
+ return p
62
+
29
63
  sys.path.insert(0, str(REPO_ROOT))
30
64
 
31
65
  from scripts.ai_council.bundler import ( # noqa: E402
@@ -33,30 +67,46 @@ from scripts.ai_council.bundler import ( # noqa: E402
33
67
  )
34
68
  from scripts.ai_council.clients import ( # noqa: E402
35
69
  DEFAULT_MAX_TOKENS, UNLIMITED_TOKENS_FALLBACK,
36
- AnthropicClient, CouncilResponse, ExternalAIClient, GeminiClient,
37
- ManualClient, OpenAIClient, PerplexityClient, XAIClient,
38
- load_anthropic_key, load_openai_key,
70
+ AnthropicClient, AnthropicCliClient, CliClient, CliClientError,
71
+ CouncilResponse, ExternalAIClient, GeminiClient, GeminiCliClient,
72
+ ManualClient, OpenAIClient, OpenAICliClient, PerplexityClient,
73
+ PerplexityCliClient, XAIClient, XAICliClient,
74
+ load_anthropic_key, load_cli_call_counts, load_openai_key,
75
+ quota_summary_line, reset_cli_call_counts,
39
76
  )
40
77
  from scripts.ai_council.advisors import ( # noqa: E402
41
78
  AdvisorPlan, build_persona_labels, plan_advisor_swap,
42
79
  )
80
+ from scripts.ai_council.cli_hints import format_install_hints # noqa: E402
43
81
  from scripts.ai_council.config import ( # noqa: E402
44
82
  AdvisorConfig, CouncilConfig, CouncilConfigError,
45
83
  load_council_config, resolve_api_key,
46
84
  )
85
+ from scripts.ai_council.solo_dispatch import ( # noqa: E402
86
+ AuthCache, select_solo_member,
87
+ )
47
88
  from scripts.ai_council.modes import ( # noqa: E402
48
89
  InvalidModeError, resolve_mode,
49
90
  )
91
+ from scripts.ai_council.events_log import append_event # noqa: E402
92
+ from scripts.ai_council.necessity import ( # noqa: E402
93
+ ClassificationResult, SizeFitVerdict, classify_necessity,
94
+ classify_size_fit, downgrade_message, educate_message,
95
+ )
50
96
  from scripts.ai_council.orchestrator import ( # noqa: E402
51
97
  ConsensusResult,
52
98
  CostBudget, CouncilQuestion, DebateCapExceeded, DebateCheckpoint,
53
- PeerReviewResult, consult, estimate, render,
99
+ DebateCostEstimate,
100
+ PeerReviewResult, consult, estimate, estimate_debate_cost, render,
54
101
  run_consensus_scoring, run_debate, run_peer_review,
55
102
  )
56
103
  from scripts.ai_council.pricing import ( # noqa: E402
57
104
  PriceTable, estimate_cost, load_prices,
58
105
  )
59
106
  from scripts.ai_council.project_context import detect_project_context # noqa: E402
107
+ from scripts.ai_council.replay import ( # noqa: E402
108
+ DecisionReplayInputs, render_decision_replay,
109
+ )
60
110
 
61
111
  SCHEMA_VERSION = 1
62
112
 
@@ -64,6 +114,14 @@ SCHEMA_VERSION = 1
64
114
  #: in ``_construct_api_member``; both must stay in sync.
65
115
  _API_PROVIDERS = frozenset({"anthropic", "openai", "gemini", "xai", "perplexity"})
66
116
 
117
+ #: Provider names with a wired ``mode=cli`` subclass. Mirrors the
118
+ #: routing table in ``_construct_cli_member``; both must stay in sync.
119
+ #: Phase 2 ships ``anthropic``; Phase 3 adds ``openai`` + ``gemini``;
120
+ #: Phase 4 adds ``xai`` + ``perplexity`` (community CLIs, no
121
+ #: subscription savings — they still consume the API key and remain
122
+ #: ``billable=True``).
123
+ _CLI_PROVIDERS = frozenset({"anthropic", "openai", "gemini", "xai", "perplexity"})
124
+
67
125
 
68
126
  class CouncilDisabledError(RuntimeError):
69
127
  """Raised when ai_council.enabled is false or no member is enabled."""
@@ -112,6 +170,10 @@ def _synthesize_ai_council_block(cfg: CouncilConfig) -> dict[str, Any]:
112
170
  entry["api_key_ref"] = m.api_key_ref
113
171
  if m.mode is not None:
114
172
  entry["mode"] = m.mode
173
+ if m.binary is not None:
174
+ entry["binary"] = m.binary
175
+ if m.model_ladder:
176
+ entry["model_ladder"] = list(m.model_ladder)
115
177
  members[name] = entry
116
178
  advisors: dict[str, dict[str, Any]] = {}
117
179
  for name, a in cfg.advisors.items():
@@ -143,6 +205,47 @@ def _synthesize_ai_council_block(cfg: CouncilConfig) -> dict[str, Any]:
143
205
  "minority_threshold": cfg.consensus_scoring.minority_threshold,
144
206
  "lenses": list(cfg.consensus_scoring.lenses),
145
207
  },
208
+ "cli_call_budget": {
209
+ "max_calls_per_day": dict(cfg.cli_call_budget.max_calls_per_day),
210
+ "warn_at": cfg.cli_call_budget.warn_at,
211
+ },
212
+ "necessity_classifier": {
213
+ "enabled": cfg.necessity_classifier.enabled,
214
+ "mode": cfg.necessity_classifier.mode,
215
+ "user_explicit_mode": cfg.necessity_classifier.user_explicit_mode,
216
+ },
217
+ "model_downgrade": {
218
+ "enabled": cfg.model_downgrade.enabled,
219
+ "auto_apply": cfg.model_downgrade.auto_apply,
220
+ },
221
+ "debate": {
222
+ "max_cost_usd": cfg.debate.max_cost_usd,
223
+ "cost_disclosure": {
224
+ "mode": cfg.debate.cost_disclosure.mode,
225
+ "threshold_usd": cfg.debate.cost_disclosure.threshold_usd,
226
+ "show_per_member": cfg.debate.cost_disclosure.show_per_member,
227
+ },
228
+ },
229
+ "lens_overrides": {
230
+ "necessity_classifier_mode": dict(
231
+ cfg.lens_overrides.necessity_classifier_mode,
232
+ ),
233
+ "necessity_classifier_user_explicit_mode": dict(
234
+ cfg.lens_overrides.necessity_classifier_user_explicit_mode,
235
+ ),
236
+ "model_downgrade": {
237
+ lens: {"enabled": md.enabled, "auto_apply": md.auto_apply}
238
+ for lens, md in cfg.lens_overrides.model_downgrade.items()
239
+ },
240
+ "cost_disclosure": {
241
+ lens: {
242
+ "mode": cd.mode,
243
+ "threshold_usd": cd.threshold_usd,
244
+ "show_per_member": cd.show_per_member,
245
+ }
246
+ for lens, cd in cfg.lens_overrides.cost_disclosure.items()
247
+ },
248
+ },
146
249
  "members": members,
147
250
  "advisors": advisors,
148
251
  }
@@ -154,6 +257,7 @@ def build_members(
154
257
  invocation_mode: str | None = None,
155
258
  model_overrides: dict[str, str] | None = None,
156
259
  siblings_overrides: dict[str, list[str]] | None = None,
260
+ skipped: list[dict[str, Any]] | None = None,
157
261
  ) -> list[ExternalAIClient]:
158
262
  """Construct enabled council members from settings.
159
263
 
@@ -171,6 +275,16 @@ def build_members(
171
275
  becomes its own billable member with independent cost tracking.
172
276
  Mutually exclusive with `model_overrides` for the same provider;
173
277
  requires `mode=api`; provider must be enabled in settings.
278
+
279
+ `skipped` is an optional caller-owned list. When provided, each
280
+ cli-mode member that fails to construct (binary missing) is appended
281
+ as `{"member": <name>, "reason": "binary_missing", "detail": <msg>}`
282
+ instead of crashing the loop. The skip is also surfaced on stderr
283
+ as `[council] SKIP <name>: <detail>` so the run log carries it
284
+ even when the caller passes ``None``. Phase 5 Step 2 contract:
285
+ a missing CLI binary degrades that member only — never silently
286
+ drops, never crashes the whole council unless every configured
287
+ member ends up skipped.
174
288
  """
175
289
  ai = (settings.get("ai_council") or {}) if isinstance(settings, dict) else {}
176
290
  if not ai.get("enabled"):
@@ -180,6 +294,9 @@ def build_members(
180
294
  )
181
295
  members_cfg = ai.get("members") or {}
182
296
  global_mode = ai.get("mode")
297
+ cli_budget_cfg = (ai.get("cli_call_budget") or {}) if isinstance(ai, dict) else {}
298
+ cli_caps = (cli_budget_cfg.get("max_calls_per_day") or {}) if isinstance(cli_budget_cfg, dict) else {}
299
+ cli_warn_at = float(cli_budget_cfg.get("warn_at", 0.8)) if isinstance(cli_budget_cfg, dict) else 0.8
183
300
  overrides = model_overrides or {}
184
301
  siblings = siblings_overrides or {}
185
302
  unknown = set(overrides) - set(members_cfg)
@@ -232,6 +349,37 @@ def build_members(
232
349
  members.append(
233
350
  _construct_api_member(name, model, api_key_ref=cfg.get("api_key_ref")),
234
351
  )
352
+ elif mode == "cli" and name in _CLI_PROVIDERS:
353
+ try:
354
+ members.append(
355
+ _construct_cli_member(
356
+ name,
357
+ model,
358
+ binary=cfg.get("binary"),
359
+ max_calls_per_day=cli_caps.get(name),
360
+ warn_at=cli_warn_at,
361
+ ),
362
+ )
363
+ except CliClientError as exc:
364
+ _, _, display = _CLI_FACTORY[name]
365
+ detail = (
366
+ f"{exc} Install the {display} CLI or flip "
367
+ f"ai_council.members.{name}.mode back to 'api'."
368
+ )
369
+ entry = {
370
+ "member": name,
371
+ "reason": "binary_missing",
372
+ "detail": detail,
373
+ }
374
+ if skipped is not None:
375
+ skipped.append(entry)
376
+ print(f"[council] SKIP {name}: {detail}", file=sys.stderr)
377
+ continue
378
+ elif mode == "cli":
379
+ raise CouncilDisabledError(
380
+ f"member {name!r} resolves to mode=cli but no CLI client is "
381
+ f"wired (known: {sorted(_CLI_PROVIDERS)!r})."
382
+ )
235
383
  elif mode == "manual":
236
384
  members.append(ManualClient(name=name, model=model or "manual"))
237
385
  elif mode == "playwright":
@@ -244,6 +392,13 @@ def build_members(
244
392
  f"name not in {sorted(_API_PROVIDERS)!r}."
245
393
  )
246
394
  if not members:
395
+ if skipped:
396
+ names = ", ".join(s["member"] for s in skipped)
397
+ raise CouncilDisabledError(
398
+ f"no council member could be constructed — every enabled "
399
+ f"member was skipped ({names}). See [council] SKIP entries "
400
+ f"on stderr for the per-member reason."
401
+ )
247
402
  raise CouncilDisabledError(
248
403
  "no council member has `enabled: true` — enable at least one in "
249
404
  ".agent-settings.yml under ai_council.members.*."
@@ -372,6 +527,61 @@ def _construct_api_member(
372
527
  )
373
528
 
374
529
 
530
+ #: Provider → (class-attribute-name, default_model, human_display) for
531
+ #: cli-mode routing. The class ref is looked up via ``getattr`` on this
532
+ #: module at call time so ``monkeypatch.setattr(council_cli, "AnthropicCliClient", X)``
533
+ #: keeps working from tests. The display string is used by
534
+ #: ``build_members`` to render the "Install the <X> CLI" hint in
535
+ #: skip-with-reason logs without re-importing every subclass at the
536
+ #: call site.
537
+ _CLI_FACTORY: dict[str, tuple[str, str, str]] = {
538
+ "anthropic": ("AnthropicCliClient", "claude-sonnet-4-5", "Claude"),
539
+ "openai": ("OpenAICliClient", "gpt-5", "Codex"),
540
+ "gemini": ("GeminiCliClient", "gemini-2.5-pro", "Gemini"),
541
+ "xai": ("XAICliClient", "grok-4", "Grok (community)"),
542
+ "perplexity": ("PerplexityCliClient", "sonar-pro", "Perplexity (community)"),
543
+ }
544
+
545
+
546
+ def _construct_cli_member(
547
+ name: str,
548
+ model: str | None,
549
+ *,
550
+ binary: str | None = None,
551
+ max_calls_per_day: int | None = None,
552
+ warn_at: float = 0.8,
553
+ ) -> ExternalAIClient:
554
+ """Build a cli-mode client for a known provider name.
555
+
556
+ ``binary`` overrides the provider default (e.g. ``/opt/claude``);
557
+ ``None`` falls through to ``shutil.which(default_binary)``. The
558
+ daily quota is plumbed through to the subclass; ``None`` disables
559
+ the local counter (only stderr-based quota detection remains).
560
+ ``warn_at`` (step-8 P1) is the fractional threshold flipping the
561
+ pre-run quota summary to its ``⚠️`` shape; default 0.8 mirrors
562
+ ``CliCallBudgetConfig``.
563
+ Lets the subclass' ``CliClientError`` propagate so ``build_members``
564
+ can convert it into a structured per-member skip entry without
565
+ crashing the whole council (the original "fail loudly for the
566
+ entire council" contract is preserved when no other member
567
+ survives — the empty-members guard at the end of ``build_members``
568
+ fires with the skip log attached).
569
+ """
570
+ if name in _CLI_FACTORY:
571
+ attr, default_model, _display = _CLI_FACTORY[name]
572
+ cls = globals()[attr]
573
+ return cls(
574
+ model=model or default_model,
575
+ binary=binary,
576
+ max_calls_per_day=max_calls_per_day,
577
+ warn_at=warn_at,
578
+ )
579
+ raise CouncilDisabledError(
580
+ f"member {name!r} has no cli transport "
581
+ f"(known: {sorted(_CLI_PROVIDERS)!r})."
582
+ )
583
+
584
+
375
585
  def build_question(
376
586
  *,
377
587
  input_path: Path,
@@ -506,6 +716,9 @@ def _serialise_consensus(consensus: ConsensusResult) -> dict[str, Any]:
506
716
  "consensus_strength": m.consensus_strength,
507
717
  "dissent_count": m.dissent_count,
508
718
  "scorers": list(m.scorers),
719
+ "concur_count": m.concur_count,
720
+ "dissent_reasons": [list(pair) for pair in m.dissent_reasons],
721
+ "evidence_quality": m.evidence_quality,
509
722
  }
510
723
  for fid, m in consensus.metadata.items()
511
724
  },
@@ -514,6 +727,64 @@ def _serialise_consensus(consensus: ConsensusResult) -> dict[str, Any]:
514
727
  }
515
728
 
516
729
 
730
+ def _decision_replay_settings(
731
+ ai_cfg: dict[str, Any], lens: str,
732
+ ) -> tuple[bool, bool]:
733
+ """Resolve (enabled, include_member_arguments) for ``lens``.
734
+
735
+ Per-lens override under ``lenses.<lens>.decision_replay`` beats the
736
+ global ``decision_replay`` block. Defaults: enabled=True,
737
+ include_member_arguments=True (Phase 9 ships ON by default — the
738
+ artefact is the audit trail GPT review of PR #148 called out as
739
+ missing).
740
+ """
741
+ global_block = ai_cfg.get("decision_replay") or {}
742
+ enabled = global_block.get("enabled", True)
743
+ include_args = global_block.get("include_member_arguments", True)
744
+ lenses = ai_cfg.get("lenses") or {}
745
+ lens_block = (lenses.get(lens) or {}).get("decision_replay")
746
+ if isinstance(lens_block, dict):
747
+ if "enabled" in lens_block:
748
+ enabled = lens_block["enabled"]
749
+ if "include_member_arguments" in lens_block:
750
+ include_args = lens_block["include_member_arguments"]
751
+ return bool(enabled), bool(include_args)
752
+
753
+
754
+ def _maybe_write_decision_replay(
755
+ *,
756
+ ai_cfg: dict[str, Any],
757
+ lens: str,
758
+ out_path: Path,
759
+ consensus: ConsensusResult | None,
760
+ deliberation: list[CouncilResponse],
761
+ original_ask: str,
762
+ ) -> Path | None:
763
+ """Write ``decision-replay.md`` alongside ``out_path`` when enabled.
764
+
765
+ No-op when ``decision_replay.enabled`` resolves to ``False`` for the
766
+ lens or when ``consensus`` is ``None`` (nothing to replay). Returns
767
+ the artefact path on success, ``None`` otherwise.
768
+ """
769
+ enabled, include_args = _decision_replay_settings(ai_cfg, lens)
770
+ if not enabled or consensus is None:
771
+ return None
772
+ replay = render_decision_replay(
773
+ DecisionReplayInputs(
774
+ findings=list(consensus.findings),
775
+ scores=list(consensus.scores),
776
+ metadata=dict(consensus.metadata),
777
+ deliberation=deliberation,
778
+ original_ask=original_ask,
779
+ include_member_arguments=include_args,
780
+ ),
781
+ )
782
+ target = out_path.parent / "decision-replay.md"
783
+ target.parent.mkdir(parents=True, exist_ok=True)
784
+ target.write_text(replay, encoding="utf-8")
785
+ return target
786
+
787
+
517
788
  # ── peer-review (Phase 5 / F1, Karpathy anonymous review) ──────────
518
789
 
519
790
 
@@ -681,6 +952,7 @@ def cmd_estimate(
681
952
  ai_cfg = (settings.get("ai_council") or {}) if isinstance(settings, dict) else {}
682
953
  advisor_plans = _build_advisor_plans(ai_cfg, REPO_ROOT)
683
954
  explicit_overrides = _parse_model_overrides(getattr(args, "model", None))
955
+ skipped: list[dict[str, Any]] = []
684
956
  if members is None:
685
957
  members = build_members(
686
958
  settings,
@@ -689,6 +961,7 @@ def cmd_estimate(
689
961
  advisor_plans, explicit_overrides,
690
962
  ),
691
963
  siblings_overrides=_parse_siblings_overrides(getattr(args, "siblings", None)),
964
+ skipped=skipped,
692
965
  )
693
966
  if table is None:
694
967
  table = load_prices()
@@ -705,6 +978,7 @@ def cmd_estimate(
705
978
  if getattr(args, "debate", False):
706
979
  return _emit_debate_estimate(
707
980
  args, ai_cfg, members, billable, estimates, advisor_plans,
981
+ skipped=skipped,
708
982
  )
709
983
  extra_calls, extra_usd = _consensus_cost_delta(
710
984
  ai_cfg, question.mode, estimates, len(billable),
@@ -719,6 +993,8 @@ def cmd_estimate(
719
993
  advisor_summary = _format_advisor_summary(advisor_plans, billable)
720
994
  if advisor_summary:
721
995
  sys.stdout.write(advisor_summary + "\n")
996
+ if skipped:
997
+ sys.stdout.write(format_install_hints(skipped) + "\n")
722
998
  sys.stdout.write(
723
999
  format_estimate_table(
724
1000
  billable, estimates,
@@ -738,6 +1014,8 @@ def _emit_debate_estimate(
738
1014
  billable: list[ExternalAIClient],
739
1015
  estimates: list[Any],
740
1016
  advisor_plans: Any,
1017
+ *,
1018
+ skipped: list[dict[str, Any]] | None = None,
741
1019
  ) -> int:
742
1020
  """Render the round-by-round debate cost projection.
743
1021
 
@@ -772,6 +1050,8 @@ def _emit_debate_estimate(
772
1050
  advisor_summary = _format_advisor_summary(advisor_plans, billable)
773
1051
  if advisor_summary:
774
1052
  sys.stdout.write(advisor_summary + "\n")
1053
+ if skipped:
1054
+ sys.stdout.write(format_install_hints(skipped) + "\n")
775
1055
  for round_idx in range(1, rounds + 1):
776
1056
  sys.stdout.write(f"\nRound {round_idx} of {rounds}:\n")
777
1057
  sys.stdout.write(format_estimate_table(billable, estimates) + "\n")
@@ -851,6 +1131,374 @@ def _deserialise_consensus(data: dict[str, Any] | None) -> ConsensusResult | Non
851
1131
  )
852
1132
 
853
1133
 
1134
+ def _resolve_necessity_mode(
1135
+ ai_cfg: dict[str, Any],
1136
+ lens: str,
1137
+ invocation: str = "agent",
1138
+ ) -> tuple[bool, str]:
1139
+ """Return ``(enabled, effective_mode)`` for the necessity classifier.
1140
+
1141
+ Two-tier resolution (step-8 D2):
1142
+
1143
+ - ``invocation="agent"`` → reads ``necessity_classifier.mode`` with
1144
+ per-lens override at ``lenses.<lens>.necessity_classifier.mode``
1145
+ (default ``educate``).
1146
+ - ``invocation="user_explicit"`` → reads
1147
+ ``necessity_classifier.user_explicit_mode`` with per-lens override
1148
+ at ``lenses.<lens>.necessity_classifier.user_explicit_mode``
1149
+ (default ``warn-only``).
1150
+
1151
+ Reads the synthesized dict shape produced by
1152
+ :func:`_synthesize_ai_council_block`, so both typed-config and
1153
+ legacy-settings paths are honoured.
1154
+ """
1155
+ nc_block = ai_cfg.get("necessity_classifier") or {}
1156
+ enabled = bool(nc_block.get("enabled", True))
1157
+ lens_overrides = ai_cfg.get("lens_overrides") or {}
1158
+ if invocation == "user_explicit":
1159
+ global_mode = str(nc_block.get("user_explicit_mode", "warn-only"))
1160
+ overrides = (
1161
+ lens_overrides.get("necessity_classifier_user_explicit_mode") or {}
1162
+ )
1163
+ else:
1164
+ global_mode = str(nc_block.get("mode", "educate"))
1165
+ overrides = lens_overrides.get("necessity_classifier_mode") or {}
1166
+ return enabled, str(overrides.get(lens, global_mode))
1167
+
1168
+
1169
+ def _provider_caps_snapshot(ai_cfg: dict[str, Any]) -> dict[str, dict[str, str]]:
1170
+ """Return ``{provider: {mode, model}}`` for enabled members.
1171
+
1172
+ Step-8 D3 events-log snapshot. Captures only public capability
1173
+ metadata (no API keys, no prompt content) so the log line stays
1174
+ within the privacy floor. Disabled members are excluded.
1175
+ """
1176
+ members = ai_cfg.get("members") or {}
1177
+ snapshot: dict[str, dict[str, str]] = {}
1178
+ if not isinstance(members, dict):
1179
+ return snapshot
1180
+ for name, cfg in members.items():
1181
+ if not isinstance(cfg, dict) or not cfg.get("enabled", True):
1182
+ continue
1183
+ snapshot[str(name)] = {
1184
+ "mode": str(cfg.get("mode", "")),
1185
+ "model": str(cfg.get("model", "")),
1186
+ }
1187
+ return snapshot
1188
+
1189
+
1190
+ def _necessity_gate(
1191
+ *, prompt: str, lens: str, invocation: str, proceed_anyway: bool,
1192
+ ai_cfg: dict[str, Any], stdout=None, original_ask: str = "",
1193
+ ) -> tuple[bool, int, ClassificationResult | None]:
1194
+ """Apply the Phase-6 necessity classifier before any member fires.
1195
+
1196
+ Returns ``(proceed, exit_code, result)``. ``proceed=True`` means the
1197
+ dispatcher continues; ``proceed=False`` means the caller should
1198
+ return ``exit_code`` immediately. ``result`` carries the verdict for
1199
+ session.md provenance on the proceed path (None when classifier is
1200
+ disabled / off).
1201
+
1202
+ Step-8 D3: every non-disabled branch emits one
1203
+ :func:`append_event` line. ``original_ask`` is forwarded to the
1204
+ events log so the sha256[:12] hash anchors the line to the
1205
+ user-side question without leaking content. When the caller does
1206
+ not have an ``original_ask`` value, the prompt itself is hashed
1207
+ (legacy CLIs route through this path).
1208
+ """
1209
+ out = stdout if stdout is not None else sys.stdout
1210
+ enabled, mode = _resolve_necessity_mode(ai_cfg, lens, invocation=invocation)
1211
+ if not enabled or mode == "off":
1212
+ return True, 0, None
1213
+ result = classify_necessity(prompt, lens=lens, invocation=invocation)
1214
+ caps = _provider_caps_snapshot(ai_cfg)
1215
+ hashed = original_ask or prompt
1216
+
1217
+ def _emit(action: str) -> None:
1218
+ append_event({
1219
+ "lens": lens, "invocation": invocation,
1220
+ "action": action, "verdict": result.verdict,
1221
+ "category": result.category,
1222
+ "mode": mode, "provider_caps": caps,
1223
+ "original_ask": hashed,
1224
+ })
1225
+
1226
+ if result.verdict != "unnecessary":
1227
+ if result.verdict == "borderline":
1228
+ out.write(
1229
+ f"council:necessity · borderline ({result.category}) · "
1230
+ f"{result.rationale}\n"
1231
+ )
1232
+ _emit("proceed")
1233
+ return True, 0, result
1234
+ # verdict == "unnecessary"
1235
+ if mode == "warn-only":
1236
+ # Annotated but never skips (step-8 D2). Applies to both
1237
+ # invocation tiers when the mode resolves to warn-only.
1238
+ out.write(
1239
+ f"council:necessity · warn-only ({result.category}) · "
1240
+ f"{result.rationale}\n"
1241
+ )
1242
+ _emit("proceed")
1243
+ return True, 0, result
1244
+ if mode == "block":
1245
+ out.write(
1246
+ f"council:necessity · skipped ({result.category}) · "
1247
+ f"{result.rationale}\n"
1248
+ f"council:necessity · mode=block — `--proceed-anyway` has "
1249
+ f"no effect on the block path.\n"
1250
+ )
1251
+ _emit("skip_necessity")
1252
+ return False, 0, result
1253
+ # mode == "educate"
1254
+ if invocation == "agent":
1255
+ out.write(
1256
+ f"council:necessity · skipped (agent, {result.category}) · "
1257
+ f"{result.rationale}\n"
1258
+ )
1259
+ _emit("skip_necessity")
1260
+ return False, 0, result
1261
+ # invocation == "user_explicit"
1262
+ if proceed_anyway:
1263
+ out.write(
1264
+ f"council:necessity · override (user_explicit + "
1265
+ f"--proceed-anyway, {result.category}) · "
1266
+ f"{result.rationale}\n"
1267
+ )
1268
+ _emit("proceed")
1269
+ return True, 0, result
1270
+ out.write(educate_message(result, lens) + "\n")
1271
+ _emit("skip_necessity")
1272
+ return False, 2, result
1273
+
1274
+
1275
+ def _resolve_model_downgrade(
1276
+ ai_cfg: dict[str, Any], lens: str,
1277
+ ) -> tuple[bool, bool]:
1278
+ """Return ``(enabled, auto_apply)`` for the size-fit downgrade gate.
1279
+
1280
+ Per-lens override at ``lenses.<lens>.model_downgrade`` wins over the
1281
+ global ``model_downgrade`` block. Reads the synthesized dict shape
1282
+ from :func:`_synthesize_ai_council_block` so both typed-config and
1283
+ legacy paths are honoured.
1284
+ """
1285
+ md_block = ai_cfg.get("model_downgrade") or {}
1286
+ enabled = bool(md_block.get("enabled", True))
1287
+ auto_apply = bool(md_block.get("auto_apply", False))
1288
+ overrides = (
1289
+ (ai_cfg.get("lens_overrides") or {}).get("model_downgrade") or {}
1290
+ )
1291
+ lens_override = overrides.get(lens) if isinstance(overrides, dict) else None
1292
+ if isinstance(lens_override, dict):
1293
+ enabled = bool(lens_override.get("enabled", enabled))
1294
+ auto_apply = bool(lens_override.get("auto_apply", auto_apply))
1295
+ return enabled, auto_apply
1296
+
1297
+
1298
+ def _size_fit_gate(
1299
+ *, prompt: str, lens: str, members: list[ExternalAIClient],
1300
+ ai_cfg: dict[str, Any], stdout=None,
1301
+ ) -> list[tuple[str, SizeFitVerdict, bool]]:
1302
+ """Apply the Phase-7 size-fit classifier across enabled members.
1303
+
1304
+ Iterates every member with a configured ``model_ladder`` and runs
1305
+ :func:`classify_size_fit`. When ``auto_apply`` is true and a
1306
+ downgrade is suggested, the member's ``model`` attribute is rewritten
1307
+ in place; otherwise the suggestion is surfaced as a stdout notice
1308
+ and the original model stands. Members without a ladder are skipped
1309
+ silently.
1310
+
1311
+ Returns a list of ``(member_name, verdict, applied)`` tuples for
1312
+ session.md provenance. Never blocks the dispatch — Phase 7 is a
1313
+ suggestion gate, not a refusal gate.
1314
+ """
1315
+ out = stdout if stdout is not None else sys.stdout
1316
+ enabled, auto_apply = _resolve_model_downgrade(ai_cfg, lens)
1317
+ decisions: list[tuple[str, SizeFitVerdict, bool]] = []
1318
+ if not enabled:
1319
+ return decisions
1320
+ members_cfg = ai_cfg.get("members") or {}
1321
+ for member in members:
1322
+ member_cfg = members_cfg.get(member.name) or {}
1323
+ ladder = member_cfg.get("model_ladder") or ()
1324
+ if not ladder:
1325
+ continue
1326
+ verdict = classify_size_fit(
1327
+ prompt, current_model=member.model, ladder=ladder, lens=lens,
1328
+ )
1329
+ applied = False
1330
+ if not verdict.fit and verdict.suggested_model:
1331
+ if auto_apply:
1332
+ out.write(
1333
+ f"council:size-fit · {member.name} · auto-downgrade "
1334
+ f"`{member.model}` → `{verdict.suggested_model}` · "
1335
+ f"{verdict.reason}\n"
1336
+ )
1337
+ member.model = verdict.suggested_model
1338
+ applied = True
1339
+ else:
1340
+ out.write(
1341
+ f"council:size-fit · {member.name} · "
1342
+ f"{downgrade_message(verdict, member.model)}\n"
1343
+ )
1344
+ decisions.append((member.name, verdict, applied))
1345
+ return decisions
1346
+
1347
+
1348
+ def _resolve_cost_disclosure(
1349
+ ai_cfg: dict[str, Any], lens: str,
1350
+ ) -> tuple[str, float, bool]:
1351
+ """Return ``(mode, threshold_usd, show_per_member)`` for the lens.
1352
+
1353
+ Per-lens override at ``lenses.<lens>.cost_disclosure`` wins over the
1354
+ global ``debate.cost_disclosure`` block. The ``debate`` lens gets
1355
+ the debate-scoped defaults; other lenses default to ``off`` unless
1356
+ explicitly overridden (Phase 8 step 5 \u2014 cheap lenses are opt-in).
1357
+ """
1358
+ debate_block = ai_cfg.get("debate") or {}
1359
+ debate_disc = debate_block.get("cost_disclosure") or {}
1360
+ if lens == "debate":
1361
+ mode = str(debate_disc.get("mode", "always"))
1362
+ threshold = float(debate_disc.get("threshold_usd", 1.00))
1363
+ show_per_member = bool(debate_disc.get("show_per_member", True))
1364
+ else:
1365
+ mode = "off"
1366
+ threshold = 1.00
1367
+ show_per_member = True
1368
+ overrides = (
1369
+ (ai_cfg.get("lens_overrides") or {}).get("cost_disclosure") or {}
1370
+ )
1371
+ lens_override = overrides.get(lens) if isinstance(overrides, dict) else None
1372
+ if isinstance(lens_override, dict):
1373
+ mode = str(lens_override.get("mode", mode))
1374
+ threshold = float(lens_override.get("threshold_usd", threshold))
1375
+ show_per_member = bool(lens_override.get("show_per_member", show_per_member))
1376
+ return mode, threshold, show_per_member
1377
+
1378
+
1379
+ def _format_cost_disclosure(
1380
+ est: DebateCostEstimate, *, lens: str, show_per_member: bool,
1381
+ ) -> str:
1382
+ """Render the pre-flight disclosure block for stdout.
1383
+
1384
+ Mirrors the roadmap spec: total range across N members \u00d7 R rounds,
1385
+ optional per-member breakdown, and a subscription-member call-out
1386
+ for CLI / manual transports that don't sum into USD totals.
1387
+ """
1388
+ lines = [
1389
+ f"council:{lens} \u00b7 cost-disclosure \u00b7 estimated "
1390
+ f"${est.low_usd:.4f} \u2013 ${est.high_usd:.4f} "
1391
+ f"(expected ${est.expected_usd:.4f}) across "
1392
+ f"{len(est.per_member)} billable members \u00d7 {est.rounds} rounds",
1393
+ ]
1394
+ if show_per_member and est.per_member:
1395
+ lines.append(" per member:")
1396
+ for pm in est.per_member:
1397
+ lines.append(
1398
+ f" \u00b7 {pm['name']:<14} {pm['model']:<22} "
1399
+ f"${pm['low_usd']:.4f} \u2013 ${pm['high_usd']:.4f}",
1400
+ )
1401
+ if est.subscription_members:
1402
+ lines.append(" subscription (no USD spend):")
1403
+ for sm in est.subscription_members:
1404
+ label = sm.get("subscription_label") or sm.get("transport", "")
1405
+ lines.append(
1406
+ f" \u00b7 {sm['name']:<14} {sm['model']:<22} ({label})",
1407
+ )
1408
+ return "\n".join(lines) + "\n"
1409
+
1410
+
1411
+ def _debate_refusal_cap(
1412
+ ai_cfg: dict[str, Any],
1413
+ ) -> float:
1414
+ """Resolve the hard refusal cap (``debate.max_cost_usd``).
1415
+
1416
+ Returns 0.0 when disabled. The cap is unconditional \u2014 no
1417
+ ``--proceed-anyway`` override (the user must lower rounds, drop
1418
+ members, or raise the cap explicitly).
1419
+ """
1420
+ debate_block = ai_cfg.get("debate") or {}
1421
+ return float(debate_block.get("max_cost_usd", 5.00) or 0.0)
1422
+
1423
+
1424
+ def _emit_shadow_slo_banner() -> None:
1425
+ """Pre-flight SLO banner for solo-dispatch invocations (step-9 P10).
1426
+
1427
+ Reads ``agents/council-shadow-log.jsonl`` and prints the 7-day rolling
1428
+ disagreement rate. ``OK``, ``WARN``, ``BREACH`` are all surfaced so the
1429
+ user can see when single-member quality is drifting. Never auto-flips
1430
+ back to full council \u2014 visibility-first, action-second (D10).
1431
+ """
1432
+ try:
1433
+ from scripts.ai_council import shadow_dispatch as _sd
1434
+ rate, n = _sd.compute_disagreement_rate(_sd.SHADOW_LOG_PATH)
1435
+ if n == 0:
1436
+ return
1437
+ sys.stdout.write(_sd.slo_banner(rate, n) + "\n")
1438
+ except Exception: # noqa: BLE001 \u2014 banner must never break dispatch.
1439
+ return
1440
+
1441
+
1442
+ def _apply_solo_dispatch(
1443
+ members: list[ExternalAIClient],
1444
+ ) -> tuple[list[ExternalAIClient], str | None]:
1445
+ """Filter ``members`` to a single solo-dispatch pick (step-9 P9).
1446
+
1447
+ Loads the routing chain from ``agents/.ai-council.yml`` and asks
1448
+ :func:`select_solo_member` for the first chain entry whose member
1449
+ is runtime-present. The probe is conservative: a member counts as
1450
+ auth-valid iff ``build_members`` returned a runtime client for it
1451
+ \u2014 build_members has already filtered out missing binaries / bad
1452
+ keys via the ``skipped`` list. Deep CLI auth probes (e.g.
1453
+ ``claude auth status``) are reserved for the shadow-mode path.
1454
+
1455
+ Returns ``(filtered_members, marker)``. ``marker`` is a one-line
1456
+ info banner the caller prints to stdout (``None`` when no banner
1457
+ is needed, e.g. config missing). Returns the unfiltered list when
1458
+ no solo member can be picked \u2014 caller never fails the decision.
1459
+ """
1460
+ try:
1461
+ cfg = load_council_config(AI_COUNCIL_FILE)
1462
+ except (CouncilConfigError, FileNotFoundError):
1463
+ return members, None
1464
+ if not cfg.routing.solo_member_fallback_chain:
1465
+ return (
1466
+ members,
1467
+ "council:solo \u00b7 WARN \u00b7 --single requested but "
1468
+ "routing.solo_member_fallback_chain is empty \u2014 "
1469
+ "escalating to full council.",
1470
+ )
1471
+ runtime_names = {getattr(m, "name", "") for m in members}
1472
+ pick = select_solo_member(
1473
+ cfg.routing,
1474
+ cfg.members,
1475
+ auth_cache=AuthCache(),
1476
+ probe=lambda name, _t: name in runtime_names,
1477
+ )
1478
+ if pick is None:
1479
+ return (
1480
+ members,
1481
+ "council:solo \u00b7 WARN \u00b7 solo dispatch unavailable "
1482
+ "(no chain member runtime-present) \u2014 escalating to "
1483
+ "full council.",
1484
+ )
1485
+ filtered = [m for m in members if getattr(m, "name", "") == pick]
1486
+ if not filtered:
1487
+ # Defensive: ``pick`` came from runtime_names so this should
1488
+ # be unreachable. If we ever get here, escalate rather than
1489
+ # ship an empty council.
1490
+ return (
1491
+ members,
1492
+ "council:solo \u00b7 WARN \u00b7 selected member vanished "
1493
+ "between probe and filter \u2014 escalating to full council.",
1494
+ )
1495
+ return (
1496
+ filtered,
1497
+ f"council:solo \u00b7 dispatching to {pick} only "
1498
+ f"(routing.solo_member_fallback_chain).",
1499
+ )
1500
+
1501
+
854
1502
  def cmd_run(
855
1503
  args: argparse.Namespace,
856
1504
  *,
@@ -864,6 +1512,7 @@ def cmd_run(
864
1512
  ai_cfg = (settings.get("ai_council") or {}) if isinstance(settings, dict) else {}
865
1513
  advisor_plans = _build_advisor_plans(ai_cfg, REPO_ROOT)
866
1514
  explicit_overrides = _parse_model_overrides(getattr(args, "model", None))
1515
+ skipped: list[dict[str, Any]] = []
867
1516
  if members is None:
868
1517
  members = build_members(
869
1518
  settings,
@@ -872,7 +1521,13 @@ def cmd_run(
872
1521
  advisor_plans, explicit_overrides,
873
1522
  ),
874
1523
  siblings_overrides=_parse_siblings_overrides(getattr(args, "siblings", None)),
1524
+ skipped=skipped,
875
1525
  )
1526
+ if getattr(args, "single", False):
1527
+ members, solo_banner = _apply_solo_dispatch(members)
1528
+ if solo_banner:
1529
+ sys.stdout.write(solo_banner + "\n")
1530
+ _emit_shadow_slo_banner()
876
1531
  if table is None:
877
1532
  table = load_prices()
878
1533
  question, artefact = build_question(
@@ -880,6 +1535,22 @@ def cmd_run(
880
1535
  max_tokens=_resolve_max_tokens(args, ai_cfg),
881
1536
  prompt_mode_override=getattr(args, "prompt_mode", None),
882
1537
  )
1538
+ proceed, gate_exit, _necessity_result = _necessity_gate(
1539
+ prompt=question.user_prompt,
1540
+ lens=question.mode,
1541
+ invocation=getattr(args, "invocation", "agent"),
1542
+ proceed_anyway=getattr(args, "proceed_anyway", False),
1543
+ ai_cfg=ai_cfg,
1544
+ original_ask=getattr(args, "original_ask", "") or "",
1545
+ )
1546
+ if not proceed:
1547
+ return gate_exit
1548
+ _size_fit_gate(
1549
+ prompt=question.user_prompt,
1550
+ lens=question.mode,
1551
+ members=members,
1552
+ ai_cfg=ai_cfg,
1553
+ )
883
1554
  project = detect_project_context(REPO_ROOT)
884
1555
  billable = [m for m in members if getattr(m, "billable", True)]
885
1556
  estimates = estimate(question, billable, table,
@@ -898,6 +1569,8 @@ def cmd_run(
898
1569
  advisor_summary = _format_advisor_summary(advisor_plans, billable)
899
1570
  if advisor_summary:
900
1571
  sys.stdout.write(advisor_summary + "\n")
1572
+ if skipped:
1573
+ sys.stdout.write(format_install_hints(skipped) + "\n")
901
1574
  sys.stdout.write(
902
1575
  format_estimate_table(
903
1576
  billable, estimates,
@@ -908,6 +1581,43 @@ def cmd_run(
908
1581
  ) + "\n"
909
1582
  )
910
1583
 
1584
+ # Step-8 P1 — pre-run quota summary. After estimate / before
1585
+ # dispatch so the user sees the budget shape before --confirm.
1586
+ # Uncapped providers are omitted by ``quota_summary_line``; when
1587
+ # no CLI member has a configured cap the summary is empty and we
1588
+ # write nothing.
1589
+ cli_members = [m for m in members if isinstance(m, CliClient)]
1590
+ summary, warn_providers = quota_summary_line(cli_members)
1591
+ if summary:
1592
+ sys.stdout.write(summary + "\n")
1593
+ for prov in warn_providers:
1594
+ sys.stdout.write(f"council:quota · WARN · {prov} near limit\n")
1595
+
1596
+ # Phase 8 step 5 — opt-in cost disclosure for non-debate lenses.
1597
+ # Default mode is "off" for analysis / default (cheap enough that
1598
+ # the disclosure is friction); users opt in by setting
1599
+ # `lenses.<name>.cost_disclosure.mode` in agents/.ai-council.yml.
1600
+ disc_mode, disc_threshold, disc_show = _resolve_cost_disclosure(
1601
+ ai_cfg, question.mode,
1602
+ )
1603
+ if disc_mode != "off":
1604
+ run_estimate = estimate_debate_cost(
1605
+ question, members, table,
1606
+ rounds=1, project=project,
1607
+ original_ask=args.original_ask,
1608
+ advisor_plans=advisor_plans,
1609
+ )
1610
+ if disc_mode == "always" or (
1611
+ disc_mode == "above_threshold"
1612
+ and run_estimate.expected_usd > disc_threshold
1613
+ ):
1614
+ sys.stdout.write(
1615
+ _format_cost_disclosure(
1616
+ run_estimate, lens=question.mode,
1617
+ show_per_member=disc_show,
1618
+ )
1619
+ )
1620
+
911
1621
  if not args.confirm:
912
1622
  sys.stdout.write(
913
1623
  "\nNo --confirm flag — estimate only. Re-run with --confirm to "
@@ -971,13 +1681,22 @@ def cmd_run(
971
1681
  payload["peer_review"] = _serialise_peer_review(peer_review)
972
1682
  if consensus is not None:
973
1683
  payload["consensus"] = _serialise_consensus(consensus)
974
- out_path = Path(args.output)
1684
+ out_path = _validate_council_output_path(
1685
+ args.output, kind="responses", subcommand="run",
1686
+ )
975
1687
  out_path.parent.mkdir(parents=True, exist_ok=True)
976
1688
  out_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
977
1689
  sys.stdout.write(
978
1690
  f"\ncouncil:run · wrote {out_path} "
979
1691
  f"(estimated ${estimated_total:.4f} / actual ${actual_total:.4f})\n"
980
1692
  )
1693
+ replay_path = _maybe_write_decision_replay(
1694
+ ai_cfg=ai_cfg, lens=question.mode, out_path=out_path,
1695
+ consensus=consensus, deliberation=responses,
1696
+ original_ask=args.original_ask,
1697
+ )
1698
+ if replay_path is not None:
1699
+ sys.stdout.write(f"council:run · wrote {replay_path}\n")
981
1700
  errors = [r for r in responses if r.error]
982
1701
  return 1 if errors and len(errors) == len(responses) else 0
983
1702
 
@@ -1114,6 +1833,7 @@ def cmd_debate(
1114
1833
  ai_cfg = (settings.get("ai_council") or {}) if isinstance(settings, dict) else {}
1115
1834
  advisor_plans = _build_advisor_plans(ai_cfg, REPO_ROOT)
1116
1835
  explicit_overrides = _parse_model_overrides(getattr(args, "model", None))
1836
+ skipped: list[dict[str, Any]] = []
1117
1837
  if members is None:
1118
1838
  members = build_members(
1119
1839
  settings,
@@ -1124,6 +1844,7 @@ def cmd_debate(
1124
1844
  siblings_overrides=_parse_siblings_overrides(
1125
1845
  getattr(args, "siblings", None),
1126
1846
  ),
1847
+ skipped=skipped,
1127
1848
  )
1128
1849
  if table is None:
1129
1850
  table = load_prices()
@@ -1132,6 +1853,22 @@ def cmd_debate(
1132
1853
  max_tokens=_resolve_max_tokens(args, ai_cfg),
1133
1854
  prompt_mode_override="debate",
1134
1855
  )
1856
+ proceed, gate_exit, _necessity_result = _necessity_gate(
1857
+ prompt=question.user_prompt,
1858
+ lens="debate",
1859
+ invocation=getattr(args, "invocation", "agent"),
1860
+ proceed_anyway=getattr(args, "proceed_anyway", False),
1861
+ ai_cfg=ai_cfg,
1862
+ original_ask=getattr(args, "original_ask", "") or "",
1863
+ )
1864
+ if not proceed:
1865
+ return gate_exit
1866
+ _size_fit_gate(
1867
+ prompt=question.user_prompt,
1868
+ lens="debate",
1869
+ members=members,
1870
+ ai_cfg=ai_cfg,
1871
+ )
1135
1872
  project = detect_project_context(REPO_ROOT)
1136
1873
  billable = [m for m in members if getattr(m, "billable", True)]
1137
1874
 
@@ -1166,6 +1903,8 @@ def cmd_debate(
1166
1903
  advisor_summary = _format_advisor_summary(advisor_plans, billable)
1167
1904
  if advisor_summary:
1168
1905
  sys.stdout.write(advisor_summary + "\n")
1906
+ if skipped:
1907
+ sys.stdout.write(format_install_hints(skipped) + "\n")
1169
1908
  sys.stdout.write(
1170
1909
  format_estimate_table(billable, estimates) + "\n"
1171
1910
  )
@@ -1174,6 +1913,39 @@ def cmd_debate(
1174
1913
  f" PROJECTED TOTAL: ${projected_total:.4f}\n"
1175
1914
  )
1176
1915
 
1916
+ # Phase 8 — pre-flight cost disclosure + hard refusal cap.
1917
+ debate_estimate = estimate_debate_cost(
1918
+ question, members, table,
1919
+ rounds=rounds, project=project,
1920
+ original_ask=args.original_ask,
1921
+ advisor_plans=advisor_plans,
1922
+ )
1923
+ disc_mode, disc_threshold, disc_show = _resolve_cost_disclosure(
1924
+ ai_cfg, "debate",
1925
+ )
1926
+ should_disclose = (
1927
+ disc_mode == "always"
1928
+ or (
1929
+ disc_mode == "above_threshold"
1930
+ and debate_estimate.expected_usd > disc_threshold
1931
+ )
1932
+ )
1933
+ if should_disclose:
1934
+ sys.stdout.write(
1935
+ _format_cost_disclosure(
1936
+ debate_estimate, lens="debate", show_per_member=disc_show,
1937
+ )
1938
+ )
1939
+ cap = _debate_refusal_cap(ai_cfg)
1940
+ if cap > 0 and debate_estimate.high_usd > cap:
1941
+ sys.stderr.write(
1942
+ f"❌ council:debate refused · high-end estimate "
1943
+ f"${debate_estimate.high_usd:.4f} exceeds "
1944
+ f"debate.max_cost_usd=${cap:.2f}. Lower --rounds, drop "
1945
+ f"members, or raise the cap in agents/.ai-council.yml.\n"
1946
+ )
1947
+ return 4
1948
+
1177
1949
  if not args.confirm:
1178
1950
  sys.stdout.write(
1179
1951
  "\nNo --confirm flag — estimate only. Re-run with --confirm to "
@@ -1189,7 +1961,9 @@ def cmd_debate(
1189
1961
  max_total_usd=float(cost_cfg.get("max_total_usd", 0.0) or 0.0),
1190
1962
  )
1191
1963
 
1192
- out_dir = Path(args.output)
1964
+ out_dir = _validate_council_output_path(
1965
+ args.output, kind="responses", subcommand="debate",
1966
+ )
1193
1967
  seed: list[CouncilResponse] | None = None
1194
1968
  if getattr(args, "continue_as_debate", None):
1195
1969
  seed = _load_debate_seed(Path(args.continue_as_debate), billable)
@@ -1263,7 +2037,8 @@ def cmd_render(args: argparse.Namespace) -> int:
1263
2037
  Lens resolution order: explicit ``--prompt-mode`` > ``prompt_mode``
1264
2038
  in the payload > ``mode`` in the payload > ``None`` (default decision
1265
2039
  template). R4 Q4 escape hatch ``--prose-synthesis`` overrides the
1266
- table.
2040
+ table. ``--output`` writes to ``agents/council-sessions/`` (enforced);
2041
+ omit it for stdout.
1267
2042
  """
1268
2043
  payload = json.loads(Path(args.responses).read_text(encoding="utf-8"))
1269
2044
  items = payload.get("responses") or []
@@ -1274,16 +2049,107 @@ def cmd_render(args: argparse.Namespace) -> int:
1274
2049
  prose = payload.get("prose_synthesis")
1275
2050
  consensus = _deserialise_consensus(payload.get("consensus"))
1276
2051
  peer_review = _deserialise_peer_review(payload.get("peer_review"))
1277
- sys.stdout.write(
1278
- render(
1279
- _deserialise_responses(items),
1280
- mode=mode,
1281
- prose_synthesis=prose,
1282
- consensus=consensus,
1283
- peer_review=peer_review,
2052
+ body = render(
2053
+ _deserialise_responses(items),
2054
+ mode=mode,
2055
+ prose_synthesis=prose,
2056
+ consensus=consensus,
2057
+ peer_review=peer_review,
2058
+ )
2059
+ if getattr(args, "output", None):
2060
+ out_path = _validate_council_output_path(
2061
+ args.output, kind="sessions", subcommand="render",
1284
2062
  )
1285
- + "\n"
2063
+ out_path.parent.mkdir(parents=True, exist_ok=True)
2064
+ out_path.write_text(body + "\n", encoding="utf-8")
2065
+ sys.stdout.write(f"council:render · wrote {out_path}\n")
2066
+ return 0
2067
+ sys.stdout.write(body + "\n")
2068
+ return 0
2069
+
2070
+
2071
+ def _cmd_replay_low_impact_stats(args: argparse.Namespace) -> int:
2072
+ """Summarise the session's ``low-impact-resolutions.md`` (Phase 11).
2073
+
2074
+ The log file lives next to the ``responses`` JSON. Missing or empty
2075
+ log → prints an explicit "no entries" line and returns 0 (a session
2076
+ with no low-impact resolutions is not an error).
2077
+ """
2078
+ from scripts.ai_council.low_impact import ( # noqa: WPS433 — local import
2079
+ parse_low_impact_log,
2080
+ render_low_impact_stats,
1286
2081
  )
2082
+
2083
+ responses_path = Path(args.responses)
2084
+ log_path = responses_path.parent / "low-impact-resolutions.md"
2085
+ if not log_path.exists():
2086
+ sys.stdout.write(
2087
+ "council:replay · no low-impact-resolutions.md alongside "
2088
+ f"{responses_path} — session had no fast-path entries.\n",
2089
+ )
2090
+ return 0
2091
+ body = log_path.read_text(encoding="utf-8")
2092
+ stats = parse_low_impact_log(body)
2093
+ out = render_low_impact_stats(stats)
2094
+ if getattr(args, "output", None):
2095
+ target = _validate_council_output_path(
2096
+ args.output, kind="sessions", subcommand="replay",
2097
+ )
2098
+ target.parent.mkdir(parents=True, exist_ok=True)
2099
+ target.write_text(out, encoding="utf-8")
2100
+ sys.stdout.write(f"council:replay · wrote {target}\n")
2101
+ return 0
2102
+ sys.stdout.write(out)
2103
+ return 0
2104
+
2105
+
2106
+ def cmd_replay(args: argparse.Namespace) -> int:
2107
+ """Re-render the ``decision-replay.md`` audit trail (Phase 9).
2108
+
2109
+ Reads a saved ``council:run`` JSON payload, rebuilds the consensus
2110
+ bundle, and emits the replay markdown to stdout (default) or to
2111
+ ``--output``. Pure re-projection — no model calls. Returns 2 when
2112
+ the payload lacks consensus data (Phase 9 prerequisite).
2113
+
2114
+ When ``--low-impact-stats`` is set, the consensus replay is skipped
2115
+ and the session's ``low-impact-resolutions.md`` (Phase 11) is
2116
+ summarised instead — count, status breakdown, members used, cost.
2117
+ """
2118
+ if getattr(args, "low_impact_stats", False):
2119
+ return _cmd_replay_low_impact_stats(args)
2120
+ payload = json.loads(Path(args.responses).read_text(encoding="utf-8"))
2121
+ consensus = _deserialise_consensus(payload.get("consensus"))
2122
+ if consensus is None:
2123
+ sys.stderr.write(
2124
+ "❌ council:replay: payload has no `consensus` block — "
2125
+ "rerun with consensus_scoring enabled for this lens.\n"
2126
+ )
2127
+ return 2
2128
+ deliberation = _deserialise_responses(payload.get("responses") or [])
2129
+ include_args = (
2130
+ bool(args.include_member_arguments)
2131
+ if args.include_member_arguments is not None
2132
+ else True
2133
+ )
2134
+ body = render_decision_replay(
2135
+ DecisionReplayInputs(
2136
+ findings=list(consensus.findings),
2137
+ scores=list(consensus.scores),
2138
+ metadata=dict(consensus.metadata),
2139
+ deliberation=deliberation,
2140
+ original_ask=str(payload.get("original_ask", "")),
2141
+ include_member_arguments=include_args,
2142
+ ),
2143
+ )
2144
+ if getattr(args, "output", None):
2145
+ out_path = _validate_council_output_path(
2146
+ args.output, kind="sessions", subcommand="replay",
2147
+ )
2148
+ out_path.parent.mkdir(parents=True, exist_ok=True)
2149
+ out_path.write_text(body, encoding="utf-8")
2150
+ sys.stdout.write(f"council:replay · wrote {out_path}\n")
2151
+ else:
2152
+ sys.stdout.write(body)
1287
2153
  return 0
1288
2154
 
1289
2155
 
@@ -1395,6 +2261,81 @@ def _add_common_input_args(p: argparse.ArgumentParser) -> None:
1395
2261
  "enabled: true in agents/.ai-council.yml.")
1396
2262
 
1397
2263
 
2264
+ def cmd_shadow_report(args: argparse.Namespace) -> int:
2265
+ """Print the 7-day rolling disagreement rate + SLO status (step-9 P10)."""
2266
+ from pathlib import Path as _Path
2267
+
2268
+ from scripts.ai_council import shadow_dispatch as _sd
2269
+
2270
+ log_path = _Path(args.log) if args.log else _sd.SHADOW_LOG_PATH
2271
+ rate, n = _sd.compute_disagreement_rate(
2272
+ log_path, window_days=int(args.window_days)
2273
+ )
2274
+ print(_sd.slo_banner(rate, n))
2275
+ return 0
2276
+
2277
+
2278
+ def cmd_quota(
2279
+ args: argparse.Namespace,
2280
+ *,
2281
+ settings: dict[str, Any] | None = None,
2282
+ ) -> int:
2283
+ """Dump today's CLI-quota state (step-8 P1, D1).
2284
+
2285
+ Reads ``~/.event4u/agent-config/cli-calls.json`` plus the configured
2286
+ caps from ``.agent-settings.yml`` and prints one line per provider
2287
+ that has a configured ``max_calls_per_day``. ``--reset <provider>``
2288
+ (gated behind ``--confirm``) clears the counter for that provider.
2289
+ """
2290
+ s = settings if settings is not None else load_settings()
2291
+ ai_cfg = (s.get("ai_council") or {}) if isinstance(s, dict) else {}
2292
+ cli_budget_cfg = (
2293
+ (ai_cfg.get("cli_call_budget") or {}) if isinstance(ai_cfg, dict) else {}
2294
+ )
2295
+ caps = (
2296
+ (cli_budget_cfg.get("max_calls_per_day") or {})
2297
+ if isinstance(cli_budget_cfg, dict)
2298
+ else {}
2299
+ )
2300
+ warn_at = (
2301
+ float(cli_budget_cfg.get("warn_at", 0.8))
2302
+ if isinstance(cli_budget_cfg, dict)
2303
+ else 0.8
2304
+ )
2305
+
2306
+ if getattr(args, "reset", None):
2307
+ provider = args.reset
2308
+ if not getattr(args, "confirm", False):
2309
+ sys.stderr.write(
2310
+ f"❌ council:quota: --reset {provider} requires --confirm.\n"
2311
+ )
2312
+ return 2
2313
+ reset_cli_call_counts(provider=provider)
2314
+ sys.stdout.write(f"council:quota · reset · {provider}\n")
2315
+ return 0
2316
+
2317
+ counts = load_cli_call_counts()
2318
+ if not caps:
2319
+ sys.stdout.write(
2320
+ "council:quota · no providers have a configured "
2321
+ "cli_call_budget.max_calls_per_day cap.\n"
2322
+ )
2323
+ return 0
2324
+ for provider in sorted(caps):
2325
+ limit = int(caps[provider])
2326
+ used = int(counts.get(provider, 0))
2327
+ ratio = used / limit if limit > 0 else 0.0
2328
+ status = "ok"
2329
+ if used >= limit:
2330
+ status = "exhausted"
2331
+ elif ratio >= warn_at:
2332
+ status = "warn"
2333
+ sys.stdout.write(
2334
+ f"council:quota · {provider} · {used}/{limit} · {status}\n"
2335
+ )
2336
+ return 0
2337
+
2338
+
1398
2339
  def build_parser() -> argparse.ArgumentParser:
1399
2340
  parser = argparse.ArgumentParser(
1400
2341
  prog="agent-config council",
@@ -1431,6 +2372,26 @@ def build_parser() -> argparse.ArgumentParser:
1431
2372
  "artefacts. Set by the host agent when the consuming "
1432
2373
  "rule/skill/command declares council_depth: deep. "
1433
2374
  "Overridden by explicit --rounds.")
2375
+ p_run.add_argument("--invocation", choices=["agent", "user_explicit"],
2376
+ default="agent",
2377
+ help="Source signal for the necessity classifier "
2378
+ "(Phase 6). 'agent' = autonomous (default; silent "
2379
+ "skip when unnecessary). 'user_explicit' = manual "
2380
+ "user invocation (educate path when unnecessary, "
2381
+ "requires --proceed-anyway to override).")
2382
+ p_run.add_argument("--proceed-anyway", action="store_true",
2383
+ dest="proceed_anyway", default=False,
2384
+ help="Override the necessity-classifier skip / educate "
2385
+ "verdict for this invocation (Phase 6). Has no "
2386
+ "effect when the classifier verdict is "
2387
+ "`necessary` or `borderline`.")
2388
+ p_run.add_argument("--single", action="store_true", default=False,
2389
+ help="Dispatch to a single member from "
2390
+ "routing.solo_member_fallback_chain (step-9 P9). "
2391
+ "Falls back to the full council when the chain is "
2392
+ "empty or no chain member is runtime-present. "
2393
+ "Overridden by env "
2394
+ "AGENT_CONFIG_FORCE_FULL_COUNCIL=1.")
1434
2395
  _add_prose_synthesis_arg(p_run)
1435
2396
 
1436
2397
  p_deb = sub.add_parser(
@@ -1454,6 +2415,19 @@ def build_parser() -> argparse.ArgumentParser:
1454
2415
  help="Seed round 1 from an existing council session "
1455
2416
  "JSON. Members + models must match the current "
1456
2417
  "invocation.")
2418
+ p_deb.add_argument("--invocation", choices=["agent", "user_explicit"],
2419
+ default="agent",
2420
+ help="Source signal for the necessity classifier "
2421
+ "(Phase 6). 'agent' = autonomous (default; silent "
2422
+ "skip when unnecessary). 'user_explicit' = manual "
2423
+ "user invocation (educate path when unnecessary, "
2424
+ "requires --proceed-anyway to override).")
2425
+ p_deb.add_argument("--proceed-anyway", action="store_true",
2426
+ dest="proceed_anyway", default=False,
2427
+ help="Override the necessity-classifier skip / educate "
2428
+ "verdict for this invocation (Phase 6). Has no "
2429
+ "effect when the classifier verdict is "
2430
+ "`necessary` or `borderline`.")
1457
2431
  _add_prose_synthesis_arg(p_deb)
1458
2432
 
1459
2433
  p_ren = sub.add_parser("render", help="Re-render a saved responses JSON.")
@@ -1465,8 +2439,59 @@ def build_parser() -> argparse.ArgumentParser:
1465
2439
  default=None, dest="prompt_mode",
1466
2440
  help="Override the synthesis-template lens. Defaults "
1467
2441
  "to the `mode` recorded in the responses JSON.")
2442
+ p_ren.add_argument("--output", default=None,
2443
+ help="Write the rendered markdown to a file under "
2444
+ "agents/council-sessions/ (enforced). Omit for "
2445
+ "stdout. Prefer this over shell redirects so "
2446
+ "the canonical-path check fires at write-time.")
1468
2447
  _add_prose_synthesis_arg(p_ren)
1469
2448
 
2449
+ p_rep = sub.add_parser(
2450
+ "replay",
2451
+ help="Re-render decision-replay.md from a saved responses JSON (Phase 9).",
2452
+ )
2453
+ p_rep.add_argument("responses",
2454
+ help="Path to the JSON written by `council run`.")
2455
+ p_rep.add_argument("--output", default=None,
2456
+ help="Optional file to write the replay markdown. "
2457
+ "Defaults to stdout.")
2458
+ rep_group = p_rep.add_mutually_exclusive_group()
2459
+ rep_group.add_argument("--redact-member-arguments",
2460
+ dest="include_member_arguments",
2461
+ action="store_const", const=False, default=None,
2462
+ help="Emit the redacted view (consensus + dissent "
2463
+ "counts only, no per-member arguments).")
2464
+ rep_group.add_argument("--include-member-arguments",
2465
+ dest="include_member_arguments",
2466
+ action="store_const", const=True,
2467
+ help="Include per-member arguments (default).")
2468
+ p_rep.add_argument("--low-impact-stats", action="store_true", default=False,
2469
+ help="Skip the decision replay and print a summary of "
2470
+ "low-impact fast-path resolutions for the session "
2471
+ "(parses `low-impact-resolutions.md` alongside the "
2472
+ "responses JSON).")
2473
+
2474
+ p_quo = sub.add_parser(
2475
+ "quota",
2476
+ help="Dump today's CLI-quota state and configured caps (step-8 P1).",
2477
+ )
2478
+ p_quo.add_argument("--reset", default=None, metavar="PROVIDER",
2479
+ help="Reset today's counter for one provider. "
2480
+ "Requires --confirm.")
2481
+ p_quo.add_argument("--confirm", action="store_true", default=False,
2482
+ help="Confirm a mutating --reset operation.")
2483
+
2484
+ p_sha = sub.add_parser(
2485
+ "shadow-report",
2486
+ help="Read agents/council-shadow-log.jsonl and print the 7-day "
2487
+ "rolling disagreement rate + SLO status (step-9 P10).",
2488
+ )
2489
+ p_sha.add_argument("--log", default=None,
2490
+ help="Path to the shadow log (default: "
2491
+ "agents/council-shadow-log.jsonl).")
2492
+ p_sha.add_argument("--window-days", type=int, default=7,
2493
+ help="Rolling window in days (default: 7).")
2494
+
1470
2495
  return parser
1471
2496
 
1472
2497
 
@@ -1495,6 +2520,12 @@ def main(argv: list[str] | None = None) -> int:
1495
2520
  return cmd_debate(args)
1496
2521
  if args.cmd == "render":
1497
2522
  return cmd_render(args)
2523
+ if args.cmd == "replay":
2524
+ return cmd_replay(args)
2525
+ if args.cmd == "quota":
2526
+ return cmd_quota(args)
2527
+ if args.cmd == "shadow-report":
2528
+ return cmd_shadow_report(args)
1498
2529
  except CouncilDisabledError as exc:
1499
2530
  sys.stderr.write(f"❌ council:{args.cmd}: {exc}\n")
1500
2531
  return 2