@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.
- package/.agent-src/commands/memory/learn-low-impact.md +143 -0
- package/.agent-src/rules/ask-when-uncertain.md +10 -6
- package/.agent-src/rules/copilot-routing.md +1 -1
- package/.agent-src/rules/devcontainer-routing.md +1 -1
- package/.agent-src/rules/external-reference-deep-dive.md +1 -1
- package/.agent-src/rules/fast-path-marker-visibility.md +38 -0
- package/.agent-src/rules/low-impact-corpus-privacy-floor.md +74 -0
- package/.agent-src/rules/symfony-routing.md +1 -1
- package/.agent-src/skills/ai-council/SKILL.md +208 -8
- package/.agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/.claude-plugin/marketplace.json +2 -1
- package/CHANGELOG.md +299 -124
- package/README.md +6 -6
- package/config/gitignore-block.txt +6 -0
- package/docs/architecture.md +12 -12
- package/docs/archive/CHANGELOG-pre-2.11.0.md +141 -0
- package/docs/catalog.md +10 -7
- package/docs/contracts/adr-architectural-consensus-mechanism.md +4 -3
- package/docs/contracts/adr-level-6-productization.md +7 -9
- package/docs/contracts/ai-council-config.md +492 -20
- package/docs/contracts/command-clusters.md +1 -1
- package/docs/contracts/command-surface-tiers.md +3 -2
- package/docs/contracts/cost-profile-defaults.md +5 -0
- package/docs/contracts/decision-engine-gates.md +5 -0
- package/docs/contracts/decision-trace-v1.md +2 -2
- package/docs/contracts/file-ownership-matrix.json +1735 -72
- package/docs/contracts/installed-tools-lockfile.md +2 -1
- package/docs/contracts/low-impact-corpus-format.md +95 -0
- package/docs/contracts/mcp-beta-criteria.md +6 -5
- package/docs/contracts/mcp-cloud-scope.md +5 -4
- package/docs/contracts/multi-tool-projection-fidelity.md +8 -2
- package/docs/contracts/release-trunk-sync.md +4 -3
- package/docs/contracts/tier-3-contrib-plugin.md +5 -6
- package/docs/getting-started.md +2 -2
- package/docs/guidelines/agent-infra/installed-tools-manifest.md +2 -1
- package/docs/installation.md +32 -0
- package/package.json +1 -1
- package/scripts/_cli/cmd_doctor.py +134 -0
- package/scripts/ai_council/airgap.py +165 -0
- package/scripts/ai_council/cli_hints.py +123 -0
- package/scripts/ai_council/clients.py +787 -5
- package/scripts/ai_council/compile_corpus.py +178 -0
- package/scripts/ai_council/confidence_gate.py +156 -0
- package/scripts/ai_council/config.py +1007 -11
- package/scripts/ai_council/consensus.py +41 -2
- package/scripts/ai_council/events_log.py +137 -0
- package/scripts/ai_council/learn_low_impact_preview.py +252 -0
- package/scripts/ai_council/low_impact.py +714 -0
- package/scripts/ai_council/low_impact_corpus.py +466 -0
- package/scripts/ai_council/low_impact_intake.py +163 -0
- package/scripts/ai_council/modes.py +6 -1
- package/scripts/ai_council/necessity.py +782 -0
- package/scripts/ai_council/orchestrator.py +252 -14
- package/scripts/ai_council/probation_gate.py +152 -0
- package/scripts/ai_council/redact_low_impact_entry.py +155 -0
- package/scripts/ai_council/replay.py +155 -0
- package/scripts/ai_council/session.py +19 -1
- package/scripts/ai_council/shadow_dispatch.py +235 -0
- package/scripts/ai_council/solo_dispatch.py +226 -0
- package/scripts/audit_cloud_compatibility.py +74 -0
- package/scripts/audit_command_surface.py +363 -0
- package/scripts/check_council_layout.py +11 -0
- package/scripts/council_cli.py +1046 -15
- package/scripts/install.sh +12 -0
package/scripts/council_cli.py
CHANGED
|
@@ -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,
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
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
|
-
|
|
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
|