@event4u/agent-config 2.12.0 → 2.13.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/council/analysis.md +142 -0
- package/.agent-src/commands/council/debate.md +129 -0
- package/.agent-src/commands/council/default.md +8 -0
- package/.agent-src/commands/council/design.md +16 -12
- package/.agent-src/commands/council/optimize.md +16 -15
- package/.agent-src/commands/council/pr.md +12 -12
- package/.agent-src/commands/council.md +48 -2
- package/.agent-src/personas/advisors/contrarian.md +95 -0
- package/.agent-src/personas/advisors/executor.md +99 -0
- package/.agent-src/personas/advisors/expansionist.md +98 -0
- package/.agent-src/personas/advisors/first-principles.md +98 -0
- package/.agent-src/personas/advisors/outsider.md +102 -0
- package/.agent-src/rules/copilot-routing.md +19 -0
- package/.agent-src/rules/devcontainer-routing.md +20 -0
- package/.agent-src/rules/laravel-routing.md +20 -0
- package/.agent-src/rules/symfony-routing.md +20 -0
- package/.agent-src/skills/ai-council/SKILL.md +180 -2
- package/.agent-src/skills/copilot-config/SKILL.md +1 -1
- package/.agent-src/skills/devcontainer/SKILL.md +1 -1
- package/.agent-src/skills/laravel/SKILL.md +1 -1
- package/.agent-src/skills/project-analysis-core/SKILL.md +1 -1
- package/.agent-src/skills/project-analyzer/SKILL.md +1 -1
- package/.agent-src/skills/symfony-workflow/SKILL.md +1 -1
- package/.agent-src/skills/universal-project-analysis/SKILL.md +1 -1
- package/.agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/.claude-plugin/marketplace.json +3 -1
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +47 -0
- package/CONTRIBUTING.md +5 -0
- package/README.md +3 -3
- package/config/agent-settings.template.yml +5 -93
- package/docs/architecture/multi-tool-projection.md +53 -0
- package/docs/architecture/{compression.md → source-projection.md} +21 -3
- package/docs/architecture.md +5 -5
- package/docs/catalog.md +21 -11
- package/docs/contracts/adr-architectural-consensus-mechanism.md +67 -0
- package/docs/contracts/ai-council-config.md +186 -0
- package/docs/contracts/command-clusters.md +57 -1
- package/docs/contracts/multi-tool-projection-fidelity.md +109 -0
- package/docs/getting-started.md +2 -2
- package/package.json +1 -1
- package/scripts/_archive/README.md +59 -0
- package/scripts/ai_council/_default_prices.py +10 -1
- package/scripts/ai_council/advisors.py +148 -0
- package/scripts/ai_council/clients.py +172 -0
- package/scripts/ai_council/config.py +368 -0
- package/scripts/ai_council/consensus.py +290 -0
- package/scripts/ai_council/orchestrator.py +628 -14
- package/scripts/ai_council/prompts.py +335 -0
- package/scripts/check_compressed_paths.py +6 -1
- package/scripts/ci_time_ratio.py +168 -0
- package/scripts/council_cli.py +973 -29
- package/scripts/measure_projection_bytes.py +159 -0
- package/scripts/measure_roadmap_trajectory.py +112 -0
- package/scripts/probe_projection_fidelity.py +202 -0
- package/scripts/score_skill_selection.py +198 -0
- package/scripts/skill_collision_clusters.py +162 -0
- /package/scripts/{_backfill_skill_domains.py → _archive/_backfill_skill_domains.py} +0 -0
- /package/scripts/{_bootstrap_tier_frontmatter.py → _archive/_bootstrap_tier_frontmatter.py} +0 -0
- /package/scripts/{_p43_bodies.py → _archive/_p43_bodies.py} +0 -0
- /package/scripts/{_p43_compress.py → _archive/_p43_compress.py} +0 -0
- /package/scripts/{_p4_migrate.py → _archive/_p4_migrate.py} +0 -0
- /package/scripts/{_phase2_shim_helper.py → _archive/_phase2_shim_helper.py} +0 -0
- /package/scripts/{_pilot_council_question.py → _archive/_pilot_council_question.py} +0 -0
package/scripts/council_cli.py
CHANGED
|
@@ -24,6 +24,7 @@ import yaml
|
|
|
24
24
|
|
|
25
25
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
26
26
|
SETTINGS_FILE = REPO_ROOT / ".agent-settings.yml"
|
|
27
|
+
AI_COUNCIL_FILE = REPO_ROOT / "agents" / ".ai-council.yml"
|
|
27
28
|
|
|
28
29
|
sys.path.insert(0, str(REPO_ROOT))
|
|
29
30
|
|
|
@@ -32,14 +33,25 @@ from scripts.ai_council.bundler import ( # noqa: E402
|
|
|
32
33
|
)
|
|
33
34
|
from scripts.ai_council.clients import ( # noqa: E402
|
|
34
35
|
DEFAULT_MAX_TOKENS, UNLIMITED_TOKENS_FALLBACK,
|
|
35
|
-
AnthropicClient, CouncilResponse, ExternalAIClient,
|
|
36
|
-
OpenAIClient,
|
|
36
|
+
AnthropicClient, CouncilResponse, ExternalAIClient, GeminiClient,
|
|
37
|
+
ManualClient, OpenAIClient, PerplexityClient, XAIClient,
|
|
38
|
+
load_anthropic_key, load_openai_key,
|
|
39
|
+
)
|
|
40
|
+
from scripts.ai_council.advisors import ( # noqa: E402
|
|
41
|
+
AdvisorPlan, build_persona_labels, plan_advisor_swap,
|
|
42
|
+
)
|
|
43
|
+
from scripts.ai_council.config import ( # noqa: E402
|
|
44
|
+
AdvisorConfig, CouncilConfig, CouncilConfigError,
|
|
45
|
+
load_council_config, resolve_api_key,
|
|
37
46
|
)
|
|
38
47
|
from scripts.ai_council.modes import ( # noqa: E402
|
|
39
48
|
InvalidModeError, resolve_mode,
|
|
40
49
|
)
|
|
41
50
|
from scripts.ai_council.orchestrator import ( # noqa: E402
|
|
42
|
-
|
|
51
|
+
ConsensusResult,
|
|
52
|
+
CostBudget, CouncilQuestion, DebateCapExceeded, DebateCheckpoint,
|
|
53
|
+
PeerReviewResult, consult, estimate, render,
|
|
54
|
+
run_consensus_scoring, run_debate, run_peer_review,
|
|
43
55
|
)
|
|
44
56
|
from scripts.ai_council.pricing import ( # noqa: E402
|
|
45
57
|
PriceTable, estimate_cost, load_prices,
|
|
@@ -48,21 +60,92 @@ from scripts.ai_council.project_context import detect_project_context # noqa: E
|
|
|
48
60
|
|
|
49
61
|
SCHEMA_VERSION = 1
|
|
50
62
|
|
|
63
|
+
#: Provider names accepted under `mode=api`. Mirrors the routing table
|
|
64
|
+
#: in ``_construct_api_member``; both must stay in sync.
|
|
65
|
+
_API_PROVIDERS = frozenset({"anthropic", "openai", "gemini", "xai", "perplexity"})
|
|
66
|
+
|
|
51
67
|
|
|
52
68
|
class CouncilDisabledError(RuntimeError):
|
|
53
69
|
"""Raised when ai_council.enabled is false or no member is enabled."""
|
|
54
70
|
|
|
55
71
|
|
|
56
|
-
def load_settings(
|
|
72
|
+
def load_settings(
|
|
73
|
+
path: Path = SETTINGS_FILE,
|
|
74
|
+
*,
|
|
75
|
+
ai_council_path: Path = AI_COUNCIL_FILE,
|
|
76
|
+
) -> dict[str, Any]:
|
|
57
77
|
"""Load merged settings via the centralized loader.
|
|
58
78
|
|
|
59
79
|
road-to-portable-dev-preferences P3 migration: tolerance contract
|
|
60
80
|
(missing file / malformed YAML / no PyYAML) is handled uniformly by
|
|
61
81
|
``load_agent_settings``. ``ai_council.*`` keys are not whitelisted,
|
|
62
82
|
so the project file remains authoritative for council config.
|
|
83
|
+
|
|
84
|
+
Step-2 council-redesign overlay: when ``agents/.ai-council.yml``
|
|
85
|
+
exists it is the single source of truth — the validated config is
|
|
86
|
+
synthesized back into ``settings['ai_council']`` and wins over any
|
|
87
|
+
legacy block in ``.agent-settings.yml``. The pre-2 path stays alive
|
|
88
|
+
so the migration breadcrumb in ``.agent-settings.yml`` can ship
|
|
89
|
+
independently.
|
|
63
90
|
"""
|
|
64
91
|
from scripts._lib.agent_settings import load_agent_settings
|
|
65
|
-
|
|
92
|
+
settings = load_agent_settings(project_path=path)
|
|
93
|
+
if ai_council_path.exists():
|
|
94
|
+
cfg = load_council_config(ai_council_path)
|
|
95
|
+
settings["ai_council"] = _synthesize_ai_council_block(cfg)
|
|
96
|
+
return settings
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _synthesize_ai_council_block(cfg: CouncilConfig) -> dict[str, Any]:
|
|
100
|
+
"""Project a validated ``CouncilConfig`` onto the legacy dict shape.
|
|
101
|
+
|
|
102
|
+
``build_members`` and the ``_resolve_*`` helpers read the legacy
|
|
103
|
+
``ai_council.*`` keys — keeping the projection identical means no
|
|
104
|
+
downstream caller changes. ``api_key_ref`` is carried through; raw
|
|
105
|
+
keys are never resolved here (resolution is lazy, per enabled
|
|
106
|
+
member, inside ``_construct_api_member``).
|
|
107
|
+
"""
|
|
108
|
+
members: dict[str, dict[str, Any]] = {}
|
|
109
|
+
for name, m in cfg.members.items():
|
|
110
|
+
entry: dict[str, Any] = {"enabled": m.enabled, "model": m.model}
|
|
111
|
+
if m.api_key_ref is not None:
|
|
112
|
+
entry["api_key_ref"] = m.api_key_ref
|
|
113
|
+
if m.mode is not None:
|
|
114
|
+
entry["mode"] = m.mode
|
|
115
|
+
members[name] = entry
|
|
116
|
+
advisors: dict[str, dict[str, Any]] = {}
|
|
117
|
+
for name, a in cfg.advisors.items():
|
|
118
|
+
entry = {
|
|
119
|
+
"enabled": a.enabled,
|
|
120
|
+
"member": a.member,
|
|
121
|
+
"persona": a.persona,
|
|
122
|
+
}
|
|
123
|
+
if a.model is not None:
|
|
124
|
+
entry["model"] = a.model
|
|
125
|
+
advisors[name] = entry
|
|
126
|
+
return {
|
|
127
|
+
"enabled": cfg.enabled,
|
|
128
|
+
"mode": cfg.defaults.mode,
|
|
129
|
+
"min_rounds": cfg.defaults.min_rounds,
|
|
130
|
+
"deep_min_rounds": cfg.defaults.deep_min_rounds,
|
|
131
|
+
"max_output_tokens": cfg.defaults.max_output_tokens,
|
|
132
|
+
"session_retention_days": cfg.defaults.session_retention_days,
|
|
133
|
+
"debate_max_rounds": cfg.defaults.debate_max_rounds,
|
|
134
|
+
"cost_budget": {
|
|
135
|
+
"max_input_tokens": cfg.cost_budget.max_input_tokens,
|
|
136
|
+
"max_output_tokens": cfg.cost_budget.max_output_tokens,
|
|
137
|
+
"max_calls": cfg.cost_budget.max_calls,
|
|
138
|
+
"max_total_usd": cfg.cost_budget.max_total_usd,
|
|
139
|
+
},
|
|
140
|
+
"consensus_scoring": {
|
|
141
|
+
"enabled": cfg.consensus_scoring.enabled,
|
|
142
|
+
"strong_threshold": cfg.consensus_scoring.strong_threshold,
|
|
143
|
+
"minority_threshold": cfg.consensus_scoring.minority_threshold,
|
|
144
|
+
"lenses": list(cfg.consensus_scoring.lenses),
|
|
145
|
+
},
|
|
146
|
+
"members": members,
|
|
147
|
+
"advisors": advisors,
|
|
148
|
+
}
|
|
66
149
|
|
|
67
150
|
|
|
68
151
|
def build_members(
|
|
@@ -138,12 +221,17 @@ def build_members(
|
|
|
138
221
|
raise CouncilDisabledError(
|
|
139
222
|
f"--siblings requires mode=api for member {name!r} (got {mode!r})."
|
|
140
223
|
)
|
|
224
|
+
api_key_ref = cfg.get("api_key_ref")
|
|
141
225
|
for sib_model in siblings[name]:
|
|
142
|
-
members.append(
|
|
226
|
+
members.append(
|
|
227
|
+
_construct_api_member(name, sib_model, api_key_ref=api_key_ref),
|
|
228
|
+
)
|
|
143
229
|
continue
|
|
144
230
|
model = overrides.get(name) or cfg.get("model")
|
|
145
|
-
if mode == "api" and name in
|
|
146
|
-
members.append(
|
|
231
|
+
if mode == "api" and name in _API_PROVIDERS:
|
|
232
|
+
members.append(
|
|
233
|
+
_construct_api_member(name, model, api_key_ref=cfg.get("api_key_ref")),
|
|
234
|
+
)
|
|
147
235
|
elif mode == "manual":
|
|
148
236
|
members.append(ManualClient(name=name, model=model or "manual"))
|
|
149
237
|
elif mode == "playwright":
|
|
@@ -152,7 +240,8 @@ def build_members(
|
|
|
152
240
|
)
|
|
153
241
|
else:
|
|
154
242
|
raise CouncilDisabledError(
|
|
155
|
-
f"member {name!r} has no transport — mode={mode},
|
|
243
|
+
f"member {name!r} has no transport — mode={mode}, "
|
|
244
|
+
f"name not in {sorted(_API_PROVIDERS)!r}."
|
|
156
245
|
)
|
|
157
246
|
if not members:
|
|
158
247
|
raise CouncilDisabledError(
|
|
@@ -162,16 +251,124 @@ def build_members(
|
|
|
162
251
|
return members
|
|
163
252
|
|
|
164
253
|
|
|
165
|
-
def
|
|
166
|
-
|
|
254
|
+
def _build_advisor_plans(
|
|
255
|
+
ai_cfg: dict[str, Any],
|
|
256
|
+
repo_root: Path,
|
|
257
|
+
) -> dict[str, AdvisorPlan]:
|
|
258
|
+
"""Reconstruct AdvisorConfig from the projected dict, then plan swaps.
|
|
259
|
+
|
|
260
|
+
The legacy ``ai_council.advisors`` dict shape is the projection
|
|
261
|
+
written by ``_synthesize_ai_council_block``. Disabled advisors are
|
|
262
|
+
silently skipped by ``plan_advisor_swap``; one-per-provider is
|
|
263
|
+
enforced there. Returns empty when no advisor block is present.
|
|
264
|
+
"""
|
|
265
|
+
raw = ai_cfg.get("advisors") if isinstance(ai_cfg, dict) else None
|
|
266
|
+
if not raw:
|
|
267
|
+
return {}
|
|
268
|
+
advisors: dict[str, AdvisorConfig] = {}
|
|
269
|
+
for name, entry in raw.items():
|
|
270
|
+
if not isinstance(entry, dict):
|
|
271
|
+
continue
|
|
272
|
+
advisors[name] = AdvisorConfig(
|
|
273
|
+
name=name,
|
|
274
|
+
enabled=bool(entry.get("enabled", False)),
|
|
275
|
+
member=str(entry.get("member", "")),
|
|
276
|
+
persona=str(entry.get("persona", "")),
|
|
277
|
+
model=entry.get("model"),
|
|
278
|
+
)
|
|
279
|
+
return plan_advisor_swap(advisors, repo_root)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _advisor_model_overrides(
|
|
283
|
+
plans: dict[str, AdvisorPlan],
|
|
284
|
+
explicit: dict[str, str] | None,
|
|
285
|
+
) -> dict[str, str]:
|
|
286
|
+
"""Merge advisor model_overrides under explicit ``--model`` flags.
|
|
287
|
+
|
|
288
|
+
Explicit CLI ``--model`` overrides win over advisor-bound model
|
|
289
|
+
overrides — the user's flag is always authoritative.
|
|
290
|
+
"""
|
|
291
|
+
merged: dict[str, str] = {}
|
|
292
|
+
for member, plan in plans.items():
|
|
293
|
+
if plan.model_override:
|
|
294
|
+
merged[member] = plan.model_override
|
|
295
|
+
if explicit:
|
|
296
|
+
merged.update(explicit)
|
|
297
|
+
return merged
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _format_advisor_summary(
|
|
301
|
+
plans: dict[str, AdvisorPlan],
|
|
302
|
+
members: list[ExternalAIClient],
|
|
303
|
+
) -> str:
|
|
304
|
+
"""Render the ``advisor: <persona> on <member> via <model>`` lines."""
|
|
305
|
+
if not plans:
|
|
306
|
+
return ""
|
|
307
|
+
member_models = {m.name: m.model for m in members}
|
|
308
|
+
rows: list[str] = []
|
|
309
|
+
for member, plan in plans.items():
|
|
310
|
+
model = member_models.get(member, plan.model_override or "?")
|
|
311
|
+
rows.append(
|
|
312
|
+
f" advisor: {plan.display_name} on {member} via {model}"
|
|
313
|
+
)
|
|
314
|
+
return "\n".join(rows)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _construct_api_member(
|
|
318
|
+
name: str,
|
|
319
|
+
model: str | None,
|
|
320
|
+
*,
|
|
321
|
+
api_key_ref: str | None = None,
|
|
322
|
+
) -> ExternalAIClient:
|
|
323
|
+
"""Build an api-mode client for a known provider name.
|
|
324
|
+
|
|
325
|
+
``api_key_ref`` carries the validated ``file:<path>`` / ``env:<VAR>``
|
|
326
|
+
reference from ``agents/.ai-council.yml`` and is resolved lazily here
|
|
327
|
+
so the council does not require keys for disabled providers. When
|
|
328
|
+
``api_key_ref`` is ``None`` (no new config yet, or legacy code path),
|
|
329
|
+
fall back to the per-provider loaders so the pre-step-2
|
|
330
|
+
``.agent-settings.yml`` flow keeps working during migration. Tests
|
|
331
|
+
monkeypatch the legacy loaders — that path stays intact.
|
|
332
|
+
"""
|
|
167
333
|
if name == "anthropic":
|
|
168
|
-
|
|
169
|
-
|
|
334
|
+
api_key = (
|
|
335
|
+
resolve_api_key(api_key_ref, scope="ai_council.members.anthropic")
|
|
336
|
+
if api_key_ref else load_anthropic_key()
|
|
337
|
+
)
|
|
338
|
+
return AnthropicClient(model=model or "claude-sonnet-4-5", api_key=api_key)
|
|
170
339
|
if name == "openai":
|
|
171
|
-
|
|
172
|
-
|
|
340
|
+
api_key = (
|
|
341
|
+
resolve_api_key(api_key_ref, scope="ai_council.members.openai")
|
|
342
|
+
if api_key_ref else load_openai_key()
|
|
343
|
+
)
|
|
344
|
+
return OpenAIClient(model=model or "gpt-4o", api_key=api_key)
|
|
345
|
+
if name == "gemini":
|
|
346
|
+
if not api_key_ref:
|
|
347
|
+
raise CouncilDisabledError(
|
|
348
|
+
"member 'gemini' requires api_key_ref in agents/.ai-council.yml "
|
|
349
|
+
"(e.g. `env:GEMINI_API_KEY`) — no legacy fallback."
|
|
350
|
+
)
|
|
351
|
+
api_key = resolve_api_key(api_key_ref, scope="ai_council.members.gemini")
|
|
352
|
+
return GeminiClient(model=model or "gemini-2.5-pro", api_key=api_key)
|
|
353
|
+
if name == "xai":
|
|
354
|
+
if not api_key_ref:
|
|
355
|
+
raise CouncilDisabledError(
|
|
356
|
+
"member 'xai' requires api_key_ref in agents/.ai-council.yml "
|
|
357
|
+
"(e.g. `env:XAI_API_KEY`) — no legacy fallback."
|
|
358
|
+
)
|
|
359
|
+
api_key = resolve_api_key(api_key_ref, scope="ai_council.members.xai")
|
|
360
|
+
return XAIClient(model=model or "grok-4", api_key=api_key)
|
|
361
|
+
if name == "perplexity":
|
|
362
|
+
if not api_key_ref:
|
|
363
|
+
raise CouncilDisabledError(
|
|
364
|
+
"member 'perplexity' requires api_key_ref in agents/.ai-council.yml "
|
|
365
|
+
"(e.g. `env:PERPLEXITY_API_KEY`) — no legacy fallback."
|
|
366
|
+
)
|
|
367
|
+
api_key = resolve_api_key(api_key_ref, scope="ai_council.members.perplexity")
|
|
368
|
+
return PerplexityClient(model=model or "sonar-pro", api_key=api_key)
|
|
173
369
|
raise CouncilDisabledError(
|
|
174
|
-
f"member {name!r} has no api transport
|
|
370
|
+
f"member {name!r} has no api transport "
|
|
371
|
+
f"(known: {sorted(_API_PROVIDERS)!r})."
|
|
175
372
|
)
|
|
176
373
|
|
|
177
374
|
|
|
@@ -180,8 +377,16 @@ def build_question(
|
|
|
180
377
|
input_path: Path,
|
|
181
378
|
input_mode: str,
|
|
182
379
|
max_tokens: int,
|
|
380
|
+
prompt_mode_override: str | None = None,
|
|
183
381
|
) -> tuple[CouncilQuestion, str]:
|
|
184
|
-
"""Bundle the input file. Returns (question, artefact_label).
|
|
382
|
+
"""Bundle the input file. Returns (question, artefact_label).
|
|
383
|
+
|
|
384
|
+
`prompt_mode_override` swaps the per-mode neutrality addendum looked
|
|
385
|
+
up by `system_prompt_for(question.mode, ...)`. The bundle shape is
|
|
386
|
+
unchanged — the bundler still uses `input_mode` to format the
|
|
387
|
+
artefact. Routed by the `/council pr|design|optimize|analysis`
|
|
388
|
+
wrappers via the `--prompt-mode` CLI flag.
|
|
389
|
+
"""
|
|
185
390
|
if input_mode == "prompt":
|
|
186
391
|
text = input_path.read_text(encoding="utf-8")
|
|
187
392
|
ctx = bundle_prompt(text)
|
|
@@ -193,13 +398,19 @@ def build_question(
|
|
|
193
398
|
raise ValueError(
|
|
194
399
|
f"unsupported input mode: {input_mode!r} (use prompt | roadmap)"
|
|
195
400
|
)
|
|
196
|
-
|
|
401
|
+
mode = prompt_mode_override or ctx.mode
|
|
402
|
+
return CouncilQuestion(mode=mode, user_prompt=ctx.text,
|
|
197
403
|
max_tokens=max_tokens), artefact
|
|
198
404
|
|
|
199
405
|
|
|
200
406
|
def format_estimate_table(
|
|
201
407
|
members: list[ExternalAIClient],
|
|
202
408
|
estimates: list[Any],
|
|
409
|
+
*,
|
|
410
|
+
consensus_delta_usd: float = 0.0,
|
|
411
|
+
consensus_extra_calls: int = 0,
|
|
412
|
+
peer_review_delta_usd: float = 0.0,
|
|
413
|
+
peer_review_extra_calls: int = 0,
|
|
203
414
|
) -> str:
|
|
204
415
|
rows = [
|
|
205
416
|
f" {m.name}/{m.model}: "
|
|
@@ -207,10 +418,204 @@ def format_estimate_table(
|
|
|
207
418
|
for m, e in zip(members, estimates)
|
|
208
419
|
]
|
|
209
420
|
total = sum(e.total_usd for e in estimates)
|
|
421
|
+
if consensus_extra_calls > 0:
|
|
422
|
+
rows.append(
|
|
423
|
+
f" +consensus scoring: +{consensus_extra_calls} calls "
|
|
424
|
+
f"(~+${consensus_delta_usd:.4f})"
|
|
425
|
+
)
|
|
426
|
+
total += consensus_delta_usd
|
|
427
|
+
if peer_review_extra_calls > 0:
|
|
428
|
+
rows.append(
|
|
429
|
+
f" +peer-review: +{peer_review_extra_calls} calls "
|
|
430
|
+
f"(~+${peer_review_delta_usd:.4f})"
|
|
431
|
+
)
|
|
432
|
+
total += peer_review_delta_usd
|
|
210
433
|
rows.append(f" TOTAL: ${total:.4f}")
|
|
211
434
|
return "\n".join(rows)
|
|
212
435
|
|
|
213
436
|
|
|
437
|
+
def _consensus_cost_delta(
|
|
438
|
+
ai_cfg: dict[str, Any],
|
|
439
|
+
prompt_mode: str,
|
|
440
|
+
estimates: list[Any],
|
|
441
|
+
n_billable: int,
|
|
442
|
+
) -> tuple[int, float]:
|
|
443
|
+
"""Return ``(extra_calls, extra_usd)`` for the consensus round.
|
|
444
|
+
|
|
445
|
+
Active when ``ai_council.consensus_scoring.enabled`` is true AND the
|
|
446
|
+
invocation's lens is in ``consensus_scoring.lenses``. Each member
|
|
447
|
+
contributes two extra calls (extraction + scoring); the worst-case
|
|
448
|
+
cost uses the base per-member estimate as a ceiling.
|
|
449
|
+
"""
|
|
450
|
+
cs = ai_cfg.get("consensus_scoring") or {}
|
|
451
|
+
if not cs.get("enabled"):
|
|
452
|
+
return 0, 0.0
|
|
453
|
+
lenses = cs.get("lenses") or ["analysis"]
|
|
454
|
+
if prompt_mode not in lenses:
|
|
455
|
+
return 0, 0.0
|
|
456
|
+
extra_calls = 2 * n_billable
|
|
457
|
+
extra_usd = 2.0 * sum(e.total_usd for e in estimates)
|
|
458
|
+
return extra_calls, extra_usd
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _maybe_run_consensus(
|
|
462
|
+
ai_cfg: dict[str, Any],
|
|
463
|
+
question: CouncilQuestion,
|
|
464
|
+
members: list[ExternalAIClient],
|
|
465
|
+
responses: list[CouncilResponse],
|
|
466
|
+
budget: CostBudget,
|
|
467
|
+
table: PriceTable,
|
|
468
|
+
project: Any,
|
|
469
|
+
args: argparse.Namespace,
|
|
470
|
+
) -> ConsensusResult | None:
|
|
471
|
+
"""Run the consensus scoring round when enabled for this lens."""
|
|
472
|
+
cs = ai_cfg.get("consensus_scoring") or {}
|
|
473
|
+
if not cs.get("enabled"):
|
|
474
|
+
return None
|
|
475
|
+
lenses = cs.get("lenses") or ["analysis"]
|
|
476
|
+
if question.mode not in lenses:
|
|
477
|
+
return None
|
|
478
|
+
return run_consensus_scoring(
|
|
479
|
+
members, responses,
|
|
480
|
+
budget=budget, table=table, project=project,
|
|
481
|
+
original_ask=args.original_ask,
|
|
482
|
+
max_tokens=question.max_tokens,
|
|
483
|
+
strong_threshold=float(cs.get("strong_threshold", 0.7)),
|
|
484
|
+
minority_threshold=float(cs.get("minority_threshold", 0.4)),
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _serialise_consensus(consensus: ConsensusResult) -> dict[str, Any]:
|
|
489
|
+
"""Project ConsensusResult onto a JSON-safe dict for session payloads."""
|
|
490
|
+
return {
|
|
491
|
+
"findings": [
|
|
492
|
+
{"id": f.id, "source": f.source, "text": f.text}
|
|
493
|
+
for f in consensus.findings
|
|
494
|
+
],
|
|
495
|
+
"scores": [
|
|
496
|
+
{
|
|
497
|
+
"finding_id": s.finding_id, "scorer": s.scorer,
|
|
498
|
+
"score": s.score, "agree": s.agree, "reason": s.reason,
|
|
499
|
+
}
|
|
500
|
+
for s in consensus.scores
|
|
501
|
+
],
|
|
502
|
+
"metadata": {
|
|
503
|
+
fid: {
|
|
504
|
+
"mean_score": m.mean_score,
|
|
505
|
+
"agreement_rate": m.agreement_rate,
|
|
506
|
+
"consensus_strength": m.consensus_strength,
|
|
507
|
+
"dissent_count": m.dissent_count,
|
|
508
|
+
"scorers": list(m.scorers),
|
|
509
|
+
}
|
|
510
|
+
for fid, m in consensus.metadata.items()
|
|
511
|
+
},
|
|
512
|
+
"extraction_responses": _serialise_responses(consensus.extraction_responses),
|
|
513
|
+
"scoring_responses": _serialise_responses(consensus.scoring_responses),
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
# ── peer-review (Phase 5 / F1, Karpathy anonymous review) ──────────
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _peer_review_active(ai_cfg: dict[str, Any], args: argparse.Namespace) -> bool:
|
|
521
|
+
"""Return True when peer-review should fire for this invocation.
|
|
522
|
+
|
|
523
|
+
Resolution chain (highest priority first):
|
|
524
|
+
1. ``--peer-review`` CLI flag — explicit opt-in.
|
|
525
|
+
2. ``ai_council.peer_review.enabled: true`` in
|
|
526
|
+
``agents/.ai-council.yml`` — opt-in via config.
|
|
527
|
+
Both default to false; peer-review is opt-in by R2 verdict.
|
|
528
|
+
"""
|
|
529
|
+
if getattr(args, "peer_review", False):
|
|
530
|
+
return True
|
|
531
|
+
pr_cfg = ai_cfg.get("peer_review") or {}
|
|
532
|
+
return bool(pr_cfg.get("enabled"))
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _peer_review_cost_delta(
|
|
536
|
+
ai_cfg: dict[str, Any],
|
|
537
|
+
args: argparse.Namespace,
|
|
538
|
+
estimates: list[Any],
|
|
539
|
+
n_billable: int,
|
|
540
|
+
) -> tuple[int, float]:
|
|
541
|
+
"""Return ``(extra_calls, extra_usd)`` for the peer-review round.
|
|
542
|
+
|
|
543
|
+
One extra call per billable member (each reviews the others). The
|
|
544
|
+
worst-case cost uses the base per-member estimate as a ceiling —
|
|
545
|
+
same heuristic as ``_consensus_cost_delta``.
|
|
546
|
+
"""
|
|
547
|
+
if not _peer_review_active(ai_cfg, args):
|
|
548
|
+
return 0, 0.0
|
|
549
|
+
if n_billable < 2:
|
|
550
|
+
# Need ≥ 2 distinct deliberation outputs for peer-review to
|
|
551
|
+
# have anything to review. The orchestrator no-ops below 2.
|
|
552
|
+
return 0, 0.0
|
|
553
|
+
extra_calls = n_billable
|
|
554
|
+
extra_usd = sum(e.total_usd for e in estimates)
|
|
555
|
+
return extra_calls, extra_usd
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _maybe_run_peer_review(
|
|
559
|
+
ai_cfg: dict[str, Any],
|
|
560
|
+
args: argparse.Namespace,
|
|
561
|
+
question: CouncilQuestion,
|
|
562
|
+
members: list[ExternalAIClient],
|
|
563
|
+
responses: list[CouncilResponse],
|
|
564
|
+
budget: CostBudget,
|
|
565
|
+
table: PriceTable,
|
|
566
|
+
project: Any,
|
|
567
|
+
*,
|
|
568
|
+
persona_labels: dict[str, str] | None = None,
|
|
569
|
+
) -> PeerReviewResult | None:
|
|
570
|
+
"""Run the peer-review pass when opted in.
|
|
571
|
+
|
|
572
|
+
No-ops if fewer than 2 successful deliberation responses exist —
|
|
573
|
+
the orchestrator surfaces the empty result in that case.
|
|
574
|
+
|
|
575
|
+
``persona_labels`` (Phase 6) flows through to ``anonymize_responses``
|
|
576
|
+
so advisor-mode runs render as ``Response A (Contrarian)`` instead
|
|
577
|
+
of bare ``Response A``. Plain-member runs pass ``None``.
|
|
578
|
+
"""
|
|
579
|
+
if not _peer_review_active(ai_cfg, args):
|
|
580
|
+
return None
|
|
581
|
+
result = run_peer_review(
|
|
582
|
+
members, responses,
|
|
583
|
+
budget=budget, table=table, project=project,
|
|
584
|
+
original_ask=args.original_ask,
|
|
585
|
+
max_tokens=question.max_tokens,
|
|
586
|
+
persona_labels=persona_labels,
|
|
587
|
+
)
|
|
588
|
+
if not result.responses:
|
|
589
|
+
return None
|
|
590
|
+
return result
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _serialise_peer_review(peer_review: PeerReviewResult) -> dict[str, Any]:
|
|
594
|
+
"""Project PeerReviewResult onto a JSON-safe dict for session payloads."""
|
|
595
|
+
return {
|
|
596
|
+
"responses": _serialise_responses(peer_review.responses),
|
|
597
|
+
"label_to_source": dict(peer_review.label_to_source),
|
|
598
|
+
"persona_labels": dict(peer_review.persona_labels),
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _deserialise_peer_review(
|
|
603
|
+
data: dict[str, Any] | None,
|
|
604
|
+
) -> PeerReviewResult | None:
|
|
605
|
+
"""Reconstruct a PeerReviewResult from a session payload section.
|
|
606
|
+
|
|
607
|
+
Returns ``None`` for payloads predating Phase 5 or runs where the
|
|
608
|
+
flag was not passed.
|
|
609
|
+
"""
|
|
610
|
+
if not data:
|
|
611
|
+
return None
|
|
612
|
+
return PeerReviewResult(
|
|
613
|
+
responses=_deserialise_responses(data.get("responses") or []),
|
|
614
|
+
label_to_source=dict(data.get("label_to_source") or {}),
|
|
615
|
+
persona_labels=dict(data.get("persona_labels") or {}),
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
|
|
214
619
|
# ── subcommands ─────────────────────────────────────────────────────
|
|
215
620
|
|
|
216
621
|
|
|
@@ -273,29 +678,112 @@ def cmd_estimate(
|
|
|
273
678
|
"""Print per-member cost preview. No API calls."""
|
|
274
679
|
if settings is None:
|
|
275
680
|
settings = load_settings()
|
|
681
|
+
ai_cfg = (settings.get("ai_council") or {}) if isinstance(settings, dict) else {}
|
|
682
|
+
advisor_plans = _build_advisor_plans(ai_cfg, REPO_ROOT)
|
|
683
|
+
explicit_overrides = _parse_model_overrides(getattr(args, "model", None))
|
|
276
684
|
if members is None:
|
|
277
685
|
members = build_members(
|
|
278
686
|
settings,
|
|
279
687
|
invocation_mode=args.mode_override,
|
|
280
|
-
model_overrides=
|
|
688
|
+
model_overrides=_advisor_model_overrides(
|
|
689
|
+
advisor_plans, explicit_overrides,
|
|
690
|
+
),
|
|
281
691
|
siblings_overrides=_parse_siblings_overrides(getattr(args, "siblings", None)),
|
|
282
692
|
)
|
|
283
693
|
if table is None:
|
|
284
694
|
table = load_prices()
|
|
285
|
-
ai_cfg = (settings.get("ai_council") or {}) if isinstance(settings, dict) else {}
|
|
286
695
|
question, _ = build_question(
|
|
287
696
|
input_path=Path(args.question), input_mode=args.input_mode,
|
|
288
697
|
max_tokens=_resolve_max_tokens(args, ai_cfg),
|
|
698
|
+
prompt_mode_override=getattr(args, "prompt_mode", None),
|
|
289
699
|
)
|
|
290
700
|
project = detect_project_context(REPO_ROOT)
|
|
291
701
|
billable = [m for m in members if getattr(m, "billable", True)]
|
|
292
702
|
estimates = estimate(question, billable, table,
|
|
293
|
-
project=project, original_ask=args.original_ask
|
|
703
|
+
project=project, original_ask=args.original_ask,
|
|
704
|
+
advisor_plans=advisor_plans)
|
|
705
|
+
if getattr(args, "debate", False):
|
|
706
|
+
return _emit_debate_estimate(
|
|
707
|
+
args, ai_cfg, members, billable, estimates, advisor_plans,
|
|
708
|
+
)
|
|
709
|
+
extra_calls, extra_usd = _consensus_cost_delta(
|
|
710
|
+
ai_cfg, question.mode, estimates, len(billable),
|
|
711
|
+
)
|
|
712
|
+
pr_extra_calls, pr_extra_usd = _peer_review_cost_delta(
|
|
713
|
+
ai_cfg, args, estimates, len(billable),
|
|
714
|
+
)
|
|
294
715
|
sys.stdout.write(
|
|
295
716
|
f"council:estimate · mode={question.mode} · members={len(members)} "
|
|
296
717
|
f"(billable={len(billable)})\n"
|
|
297
718
|
)
|
|
298
|
-
|
|
719
|
+
advisor_summary = _format_advisor_summary(advisor_plans, billable)
|
|
720
|
+
if advisor_summary:
|
|
721
|
+
sys.stdout.write(advisor_summary + "\n")
|
|
722
|
+
sys.stdout.write(
|
|
723
|
+
format_estimate_table(
|
|
724
|
+
billable, estimates,
|
|
725
|
+
consensus_delta_usd=extra_usd,
|
|
726
|
+
consensus_extra_calls=extra_calls,
|
|
727
|
+
peer_review_delta_usd=pr_extra_usd,
|
|
728
|
+
peer_review_extra_calls=pr_extra_calls,
|
|
729
|
+
) + "\n"
|
|
730
|
+
)
|
|
731
|
+
return 0
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def _emit_debate_estimate(
|
|
735
|
+
args: argparse.Namespace,
|
|
736
|
+
ai_cfg: dict[str, Any],
|
|
737
|
+
members: list[ExternalAIClient],
|
|
738
|
+
billable: list[ExternalAIClient],
|
|
739
|
+
estimates: list[Any],
|
|
740
|
+
advisor_plans: Any,
|
|
741
|
+
) -> int:
|
|
742
|
+
"""Render the round-by-round debate cost projection.
|
|
743
|
+
|
|
744
|
+
Upper bound only — progressive disclosure may stop the debate early.
|
|
745
|
+
Cost shape mirrors ``cmd_debate``: one call per billable member per
|
|
746
|
+
round, default ``ai_council.min_rounds`` (typically 2), capped at
|
|
747
|
+
``ai_council.debate_max_rounds`` (typically 4).
|
|
748
|
+
"""
|
|
749
|
+
min_rounds = int(ai_cfg.get("min_rounds", 2))
|
|
750
|
+
max_rounds_cap = int(ai_cfg.get("debate_max_rounds", 4))
|
|
751
|
+
requested = (
|
|
752
|
+
int(args.rounds) if getattr(args, "rounds", None) is not None
|
|
753
|
+
else min_rounds
|
|
754
|
+
)
|
|
755
|
+
if requested < 1:
|
|
756
|
+
raise argparse.ArgumentTypeError(
|
|
757
|
+
f"--rounds must be >= 1 (got {requested})"
|
|
758
|
+
)
|
|
759
|
+
if requested > max_rounds_cap:
|
|
760
|
+
raise argparse.ArgumentTypeError(
|
|
761
|
+
f"--rounds={requested} exceeds debate_max_rounds={max_rounds_cap}; "
|
|
762
|
+
f"raise the cap in agents/.ai-council.yml or lower --rounds."
|
|
763
|
+
)
|
|
764
|
+
rounds = requested
|
|
765
|
+
per_round_usd = sum(e.total_usd for e in estimates)
|
|
766
|
+
projected_total = per_round_usd * rounds
|
|
767
|
+
sys.stdout.write(
|
|
768
|
+
f"council:estimate · mode=debate · members={len(members)} "
|
|
769
|
+
f"(billable={len(billable)}) · rounds={rounds} "
|
|
770
|
+
f"(cap={max_rounds_cap})\n"
|
|
771
|
+
)
|
|
772
|
+
advisor_summary = _format_advisor_summary(advisor_plans, billable)
|
|
773
|
+
if advisor_summary:
|
|
774
|
+
sys.stdout.write(advisor_summary + "\n")
|
|
775
|
+
for round_idx in range(1, rounds + 1):
|
|
776
|
+
sys.stdout.write(f"\nRound {round_idx} of {rounds}:\n")
|
|
777
|
+
sys.stdout.write(format_estimate_table(billable, estimates) + "\n")
|
|
778
|
+
if round_idx < rounds:
|
|
779
|
+
sys.stdout.write(" " + "─" * 40 + "\n")
|
|
780
|
+
sys.stdout.write(
|
|
781
|
+
f"\n PROJECTED TOTAL ({rounds} rounds): ${projected_total:.4f}\n"
|
|
782
|
+
)
|
|
783
|
+
sys.stdout.write(
|
|
784
|
+
" Note: progressive disclosure may stop the debate early; "
|
|
785
|
+
"this is an upper bound.\n"
|
|
786
|
+
)
|
|
299
787
|
return 0
|
|
300
788
|
|
|
301
789
|
|
|
@@ -325,6 +813,44 @@ def _deserialise_responses(items: list[dict[str, Any]]) -> list[CouncilResponse]
|
|
|
325
813
|
return out
|
|
326
814
|
|
|
327
815
|
|
|
816
|
+
def _deserialise_consensus(data: dict[str, Any] | None) -> ConsensusResult | None:
|
|
817
|
+
"""Reconstruct a ConsensusResult from a serialised payload section.
|
|
818
|
+
|
|
819
|
+
Used by ``cmd_render`` to re-render saved sessions that captured a
|
|
820
|
+
consensus round. Returns ``None`` when the payload predates Phase 4
|
|
821
|
+
or the round was skipped for the lens.
|
|
822
|
+
"""
|
|
823
|
+
if not data:
|
|
824
|
+
return None
|
|
825
|
+
from scripts.ai_council.consensus import (
|
|
826
|
+
ConsensusMetadata, Finding, FindingScore,
|
|
827
|
+
aggregate_scores, bucket_by_threshold,
|
|
828
|
+
)
|
|
829
|
+
findings = [
|
|
830
|
+
Finding(id=f["id"], source=f["source"], text=f["text"])
|
|
831
|
+
for f in (data.get("findings") or [])
|
|
832
|
+
]
|
|
833
|
+
scores = [
|
|
834
|
+
FindingScore(
|
|
835
|
+
finding_id=s["finding_id"], scorer=s["scorer"],
|
|
836
|
+
score=int(s["score"]), agree=bool(s["agree"]),
|
|
837
|
+
reason=s.get("reason", ""),
|
|
838
|
+
)
|
|
839
|
+
for s in (data.get("scores") or [])
|
|
840
|
+
]
|
|
841
|
+
metadata = aggregate_scores(findings, scores)
|
|
842
|
+
bucket = bucket_by_threshold(findings, metadata)
|
|
843
|
+
return ConsensusResult(
|
|
844
|
+
bucket=bucket, findings=findings, scores=scores, metadata=metadata,
|
|
845
|
+
extraction_responses=_deserialise_responses(
|
|
846
|
+
data.get("extraction_responses") or [],
|
|
847
|
+
),
|
|
848
|
+
scoring_responses=_deserialise_responses(
|
|
849
|
+
data.get("scoring_responses") or [],
|
|
850
|
+
),
|
|
851
|
+
)
|
|
852
|
+
|
|
853
|
+
|
|
328
854
|
def cmd_run(
|
|
329
855
|
args: argparse.Namespace,
|
|
330
856
|
*,
|
|
@@ -335,29 +861,52 @@ def cmd_run(
|
|
|
335
861
|
"""Estimate, then run the council. Requires --confirm to spend."""
|
|
336
862
|
if settings is None:
|
|
337
863
|
settings = load_settings()
|
|
864
|
+
ai_cfg = (settings.get("ai_council") or {}) if isinstance(settings, dict) else {}
|
|
865
|
+
advisor_plans = _build_advisor_plans(ai_cfg, REPO_ROOT)
|
|
866
|
+
explicit_overrides = _parse_model_overrides(getattr(args, "model", None))
|
|
338
867
|
if members is None:
|
|
339
868
|
members = build_members(
|
|
340
869
|
settings,
|
|
341
870
|
invocation_mode=args.mode_override,
|
|
342
|
-
model_overrides=
|
|
871
|
+
model_overrides=_advisor_model_overrides(
|
|
872
|
+
advisor_plans, explicit_overrides,
|
|
873
|
+
),
|
|
343
874
|
siblings_overrides=_parse_siblings_overrides(getattr(args, "siblings", None)),
|
|
344
875
|
)
|
|
345
876
|
if table is None:
|
|
346
877
|
table = load_prices()
|
|
347
|
-
ai_cfg = (settings.get("ai_council") or {}) if isinstance(settings, dict) else {}
|
|
348
878
|
question, artefact = build_question(
|
|
349
879
|
input_path=Path(args.question), input_mode=args.input_mode,
|
|
350
880
|
max_tokens=_resolve_max_tokens(args, ai_cfg),
|
|
881
|
+
prompt_mode_override=getattr(args, "prompt_mode", None),
|
|
351
882
|
)
|
|
352
883
|
project = detect_project_context(REPO_ROOT)
|
|
353
884
|
billable = [m for m in members if getattr(m, "billable", True)]
|
|
354
885
|
estimates = estimate(question, billable, table,
|
|
355
|
-
project=project, original_ask=args.original_ask
|
|
886
|
+
project=project, original_ask=args.original_ask,
|
|
887
|
+
advisor_plans=advisor_plans)
|
|
888
|
+
extra_calls, extra_usd = _consensus_cost_delta(
|
|
889
|
+
ai_cfg, question.mode, estimates, len(billable),
|
|
890
|
+
)
|
|
891
|
+
pr_extra_calls, pr_extra_usd = _peer_review_cost_delta(
|
|
892
|
+
ai_cfg, args, estimates, len(billable),
|
|
893
|
+
)
|
|
356
894
|
sys.stdout.write(
|
|
357
895
|
f"council:run · mode={question.mode} · members={len(members)} "
|
|
358
896
|
f"(billable={len(billable)})\n"
|
|
359
897
|
)
|
|
360
|
-
|
|
898
|
+
advisor_summary = _format_advisor_summary(advisor_plans, billable)
|
|
899
|
+
if advisor_summary:
|
|
900
|
+
sys.stdout.write(advisor_summary + "\n")
|
|
901
|
+
sys.stdout.write(
|
|
902
|
+
format_estimate_table(
|
|
903
|
+
billable, estimates,
|
|
904
|
+
consensus_delta_usd=extra_usd,
|
|
905
|
+
consensus_extra_calls=extra_calls,
|
|
906
|
+
peer_review_delta_usd=pr_extra_usd,
|
|
907
|
+
peer_review_extra_calls=pr_extra_calls,
|
|
908
|
+
) + "\n"
|
|
909
|
+
)
|
|
361
910
|
|
|
362
911
|
if not args.confirm:
|
|
363
912
|
sys.stdout.write(
|
|
@@ -378,10 +927,28 @@ def cmd_run(
|
|
|
378
927
|
members, question, budget,
|
|
379
928
|
table=table, project=project,
|
|
380
929
|
original_ask=args.original_ask, rounds=rounds,
|
|
930
|
+
advisor_plans=advisor_plans,
|
|
931
|
+
)
|
|
932
|
+
# Pipeline order (R4 verdict): deliberation → peer-review → consensus
|
|
933
|
+
# → synthesis. Peer-review anonymises only deliberation outputs;
|
|
934
|
+
# consensus-scoring runs on the de-anonymised findings.
|
|
935
|
+
persona_labels = build_persona_labels(advisor_plans, billable)
|
|
936
|
+
peer_review = _maybe_run_peer_review(
|
|
937
|
+
ai_cfg, args, question, members, responses, budget, table, project,
|
|
938
|
+
persona_labels=persona_labels,
|
|
939
|
+
)
|
|
940
|
+
consensus = _maybe_run_consensus(
|
|
941
|
+
ai_cfg, question, members, responses, budget, table, project, args,
|
|
381
942
|
)
|
|
382
943
|
estimated_total = sum(e.total_usd for e in estimates)
|
|
383
944
|
actual_total = 0.0
|
|
384
|
-
|
|
945
|
+
all_responses: list[CouncilResponse] = list(responses)
|
|
946
|
+
if peer_review is not None:
|
|
947
|
+
all_responses.extend(peer_review.responses)
|
|
948
|
+
if consensus is not None:
|
|
949
|
+
all_responses.extend(consensus.extraction_responses)
|
|
950
|
+
all_responses.extend(consensus.scoring_responses)
|
|
951
|
+
for r in all_responses:
|
|
385
952
|
if r.error:
|
|
386
953
|
continue
|
|
387
954
|
ce = estimate_cost(r.provider, r.model, r.input_tokens, r.output_tokens, table)
|
|
@@ -389,6 +956,9 @@ def cmd_run(
|
|
|
389
956
|
payload = {
|
|
390
957
|
"schema_version": SCHEMA_VERSION,
|
|
391
958
|
"mode": question.mode,
|
|
959
|
+
"prompt_mode": getattr(args, "prompt_mode", None),
|
|
960
|
+
"prose_synthesis": getattr(args, "prose_synthesis", None),
|
|
961
|
+
"peer_review_enabled": _peer_review_active(ai_cfg, args),
|
|
392
962
|
"artefact": artefact,
|
|
393
963
|
"original_ask": args.original_ask,
|
|
394
964
|
"members": [f"{m.name}/{m.model}" for m in members],
|
|
@@ -397,6 +967,10 @@ def cmd_run(
|
|
|
397
967
|
"cost_usd_actual": round(actual_total, 6),
|
|
398
968
|
"responses": _serialise_responses(responses),
|
|
399
969
|
}
|
|
970
|
+
if peer_review is not None:
|
|
971
|
+
payload["peer_review"] = _serialise_peer_review(peer_review)
|
|
972
|
+
if consensus is not None:
|
|
973
|
+
payload["consensus"] = _serialise_consensus(consensus)
|
|
400
974
|
out_path = Path(args.output)
|
|
401
975
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
402
976
|
out_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
|
@@ -408,11 +982,308 @@ def cmd_run(
|
|
|
408
982
|
return 1 if errors and len(errors) == len(responses) else 0
|
|
409
983
|
|
|
410
984
|
|
|
985
|
+
def _debate_round_filename(round_number: int) -> str:
|
|
986
|
+
return f"debate-round-{round_number}.json"
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
def _write_debate_round(
|
|
990
|
+
out_dir: Path,
|
|
991
|
+
round_number: int,
|
|
992
|
+
responses: list[CouncilResponse],
|
|
993
|
+
*,
|
|
994
|
+
question: CouncilQuestion,
|
|
995
|
+
members: list[ExternalAIClient],
|
|
996
|
+
artefact: str,
|
|
997
|
+
original_ask: str,
|
|
998
|
+
total_planned_rounds: int,
|
|
999
|
+
table: PriceTable,
|
|
1000
|
+
prompt_mode: str | None,
|
|
1001
|
+
prose_synthesis: bool | None,
|
|
1002
|
+
) -> Path:
|
|
1003
|
+
"""Persist a single debate round as a self-contained JSON.
|
|
1004
|
+
|
|
1005
|
+
Each round file mirrors the ``cmd_run`` payload shape — re-rendering
|
|
1006
|
+
via ``council render <debate-round-N.json>`` works without special
|
|
1007
|
+
handling. Round-specific keys (``debate_round``, ``debate_total_rounds``)
|
|
1008
|
+
are additive so the renderer can ignore them safely.
|
|
1009
|
+
"""
|
|
1010
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
1011
|
+
actual_total = 0.0
|
|
1012
|
+
for r in responses:
|
|
1013
|
+
if r.error:
|
|
1014
|
+
continue
|
|
1015
|
+
ce = estimate_cost(r.provider, r.model, r.input_tokens, r.output_tokens, table)
|
|
1016
|
+
actual_total += ce.total_usd
|
|
1017
|
+
payload = {
|
|
1018
|
+
"schema_version": SCHEMA_VERSION,
|
|
1019
|
+
"mode": question.mode,
|
|
1020
|
+
"prompt_mode": prompt_mode,
|
|
1021
|
+
"prose_synthesis": prose_synthesis,
|
|
1022
|
+
"artefact": artefact,
|
|
1023
|
+
"original_ask": original_ask,
|
|
1024
|
+
"members": [f"{m.name}/{m.model}" for m in members],
|
|
1025
|
+
"debate_round": round_number,
|
|
1026
|
+
"debate_total_rounds": total_planned_rounds,
|
|
1027
|
+
"rounds": 1,
|
|
1028
|
+
"cost_usd_actual": round(actual_total, 6),
|
|
1029
|
+
"responses": _serialise_responses(responses),
|
|
1030
|
+
}
|
|
1031
|
+
out_path = out_dir / _debate_round_filename(round_number)
|
|
1032
|
+
out_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
|
1033
|
+
return out_path
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
def _load_debate_seed(
|
|
1037
|
+
path: Path,
|
|
1038
|
+
expected_members: list[ExternalAIClient],
|
|
1039
|
+
) -> list[CouncilResponse]:
|
|
1040
|
+
"""Load `--continue-as-debate` seed: round-1 responses from a prior session.
|
|
1041
|
+
|
|
1042
|
+
The seed file must be the JSON written by ``cmd_run`` (or a prior
|
|
1043
|
+
debate round). Members + models must match the current invocation —
|
|
1044
|
+
a mismatch is a hard error per the Phase 7 contract, not a silent
|
|
1045
|
+
fallback. The host agent surfaces the mismatch and asks the user
|
|
1046
|
+
to either re-run with matching members or drop ``--continue-as-debate``.
|
|
1047
|
+
"""
|
|
1048
|
+
if not path.exists():
|
|
1049
|
+
raise FileNotFoundError(
|
|
1050
|
+
f"--continue-as-debate path not found: {path}"
|
|
1051
|
+
)
|
|
1052
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
1053
|
+
source_members = list(payload.get("members") or [])
|
|
1054
|
+
expected_labels = [f"{m.name}/{m.model}" for m in expected_members]
|
|
1055
|
+
if source_members != expected_labels:
|
|
1056
|
+
raise CouncilDisabledError(
|
|
1057
|
+
f"--continue-as-debate member mismatch: source session has "
|
|
1058
|
+
f"{source_members!r}, current invocation has {expected_labels!r}. "
|
|
1059
|
+
f"Re-run with matching members or drop --continue-as-debate."
|
|
1060
|
+
)
|
|
1061
|
+
return _deserialise_responses(payload.get("responses") or [])
|
|
1062
|
+
|
|
1063
|
+
|
|
1064
|
+
def _make_debate_continue_prompt(
|
|
1065
|
+
*, auto_continue: bool,
|
|
1066
|
+
stream: Any = None,
|
|
1067
|
+
) -> Any:
|
|
1068
|
+
"""Build the on_continue callback for `run_debate()`.
|
|
1069
|
+
|
|
1070
|
+
``--auto-continue`` returns ``None`` so the orchestrator skips the
|
|
1071
|
+
gate entirely (still subject to the hard-cap check). Interactive
|
|
1072
|
+
mode prints the checkpoint line and reads y/N from stdin.
|
|
1073
|
+
"""
|
|
1074
|
+
if auto_continue:
|
|
1075
|
+
return None
|
|
1076
|
+
out = stream or sys.stdout
|
|
1077
|
+
|
|
1078
|
+
def _prompt(checkpoint: DebateCheckpoint) -> bool:
|
|
1079
|
+
out.write(
|
|
1080
|
+
f"\ndebate:checkpoint round={checkpoint.completed_round}/"
|
|
1081
|
+
f"{checkpoint.total_planned_rounds} "
|
|
1082
|
+
f"cost_so_far=${checkpoint.cost_so_far_usd:.4f} "
|
|
1083
|
+
f"next_round_estimate=${checkpoint.next_round_estimate_usd:.4f} "
|
|
1084
|
+
f"— continue? [y/N]: "
|
|
1085
|
+
)
|
|
1086
|
+
out.flush()
|
|
1087
|
+
try:
|
|
1088
|
+
answer = sys.stdin.readline().strip().lower()
|
|
1089
|
+
except (EOFError, KeyboardInterrupt):
|
|
1090
|
+
return False
|
|
1091
|
+
return answer in {"y", "yes"}
|
|
1092
|
+
|
|
1093
|
+
return _prompt
|
|
1094
|
+
|
|
1095
|
+
|
|
1096
|
+
def cmd_debate(
|
|
1097
|
+
args: argparse.Namespace,
|
|
1098
|
+
*,
|
|
1099
|
+
settings: dict[str, Any] | None = None,
|
|
1100
|
+
members: list[ExternalAIClient] | None = None,
|
|
1101
|
+
table: PriceTable | None = None,
|
|
1102
|
+
) -> int:
|
|
1103
|
+
"""Run a multi-round debate with progressive cost disclosure.
|
|
1104
|
+
|
|
1105
|
+
Phase 7 contract: each member produces an initial position in
|
|
1106
|
+
Round 1, then rebuts the strongest opposing position in subsequent
|
|
1107
|
+
rounds. The orchestrator pauses after each round and asks the user
|
|
1108
|
+
to continue (``--auto-continue`` bypasses the prompt). Round files
|
|
1109
|
+
are persisted incrementally so an interrupted debate leaves a
|
|
1110
|
+
recoverable trail.
|
|
1111
|
+
"""
|
|
1112
|
+
if settings is None:
|
|
1113
|
+
settings = load_settings()
|
|
1114
|
+
ai_cfg = (settings.get("ai_council") or {}) if isinstance(settings, dict) else {}
|
|
1115
|
+
advisor_plans = _build_advisor_plans(ai_cfg, REPO_ROOT)
|
|
1116
|
+
explicit_overrides = _parse_model_overrides(getattr(args, "model", None))
|
|
1117
|
+
if members is None:
|
|
1118
|
+
members = build_members(
|
|
1119
|
+
settings,
|
|
1120
|
+
invocation_mode=args.mode_override,
|
|
1121
|
+
model_overrides=_advisor_model_overrides(
|
|
1122
|
+
advisor_plans, explicit_overrides,
|
|
1123
|
+
),
|
|
1124
|
+
siblings_overrides=_parse_siblings_overrides(
|
|
1125
|
+
getattr(args, "siblings", None),
|
|
1126
|
+
),
|
|
1127
|
+
)
|
|
1128
|
+
if table is None:
|
|
1129
|
+
table = load_prices()
|
|
1130
|
+
question, artefact = build_question(
|
|
1131
|
+
input_path=Path(args.question), input_mode=args.input_mode,
|
|
1132
|
+
max_tokens=_resolve_max_tokens(args, ai_cfg),
|
|
1133
|
+
prompt_mode_override="debate",
|
|
1134
|
+
)
|
|
1135
|
+
project = detect_project_context(REPO_ROOT)
|
|
1136
|
+
billable = [m for m in members if getattr(m, "billable", True)]
|
|
1137
|
+
|
|
1138
|
+
# Resolve round count: explicit --rounds wins; otherwise default 2.
|
|
1139
|
+
# Hard ceiling: ai_council.debate_max_rounds (Phase 0 reserved key).
|
|
1140
|
+
max_rounds_cap = int(ai_cfg.get("debate_max_rounds", 4))
|
|
1141
|
+
requested = (
|
|
1142
|
+
int(args.rounds) if getattr(args, "rounds", None) is not None else 2
|
|
1143
|
+
)
|
|
1144
|
+
if requested < 1:
|
|
1145
|
+
raise argparse.ArgumentTypeError(
|
|
1146
|
+
f"--rounds must be >= 1 (got {requested})"
|
|
1147
|
+
)
|
|
1148
|
+
if requested > max_rounds_cap:
|
|
1149
|
+
raise argparse.ArgumentTypeError(
|
|
1150
|
+
f"--rounds={requested} exceeds debate_max_rounds={max_rounds_cap}; "
|
|
1151
|
+
f"raise the cap in agents/.ai-council.yml or lower --rounds."
|
|
1152
|
+
)
|
|
1153
|
+
rounds = requested
|
|
1154
|
+
|
|
1155
|
+
estimates = estimate(
|
|
1156
|
+
question, billable, table,
|
|
1157
|
+
project=project, original_ask=args.original_ask,
|
|
1158
|
+
advisor_plans=advisor_plans,
|
|
1159
|
+
)
|
|
1160
|
+
per_round_usd = sum(e.total_usd for e in estimates)
|
|
1161
|
+
projected_total = per_round_usd * rounds
|
|
1162
|
+
sys.stdout.write(
|
|
1163
|
+
f"council:debate · members={len(members)} (billable={len(billable)}) "
|
|
1164
|
+
f"· rounds={rounds} (cap={max_rounds_cap})\n"
|
|
1165
|
+
)
|
|
1166
|
+
advisor_summary = _format_advisor_summary(advisor_plans, billable)
|
|
1167
|
+
if advisor_summary:
|
|
1168
|
+
sys.stdout.write(advisor_summary + "\n")
|
|
1169
|
+
sys.stdout.write(
|
|
1170
|
+
format_estimate_table(billable, estimates) + "\n"
|
|
1171
|
+
)
|
|
1172
|
+
sys.stdout.write(
|
|
1173
|
+
f" × {rounds} rounds (worst case, before progressive disclosure)\n"
|
|
1174
|
+
f" PROJECTED TOTAL: ${projected_total:.4f}\n"
|
|
1175
|
+
)
|
|
1176
|
+
|
|
1177
|
+
if not args.confirm:
|
|
1178
|
+
sys.stdout.write(
|
|
1179
|
+
"\nNo --confirm flag — estimate only. Re-run with --confirm to "
|
|
1180
|
+
"start the debate.\n"
|
|
1181
|
+
)
|
|
1182
|
+
return 0
|
|
1183
|
+
|
|
1184
|
+
cost_cfg = ai_cfg.get("cost_budget") or {}
|
|
1185
|
+
budget = CostBudget(
|
|
1186
|
+
max_input_tokens=int(cost_cfg.get("max_input_tokens", 50_000)),
|
|
1187
|
+
max_output_tokens=int(cost_cfg.get("max_output_tokens", 20_000)),
|
|
1188
|
+
max_calls=int(cost_cfg.get("max_calls", 10)),
|
|
1189
|
+
max_total_usd=float(cost_cfg.get("max_total_usd", 0.0) or 0.0),
|
|
1190
|
+
)
|
|
1191
|
+
|
|
1192
|
+
out_dir = Path(args.output)
|
|
1193
|
+
seed: list[CouncilResponse] | None = None
|
|
1194
|
+
if getattr(args, "continue_as_debate", None):
|
|
1195
|
+
seed = _load_debate_seed(Path(args.continue_as_debate), billable)
|
|
1196
|
+
sys.stdout.write(
|
|
1197
|
+
f"council:debate · seeding round 1 from "
|
|
1198
|
+
f"{args.continue_as_debate} ({len(seed)} responses)\n"
|
|
1199
|
+
)
|
|
1200
|
+
|
|
1201
|
+
written: list[Path] = []
|
|
1202
|
+
|
|
1203
|
+
def _on_round_complete(round_number: int, results: list[CouncilResponse]) -> None:
|
|
1204
|
+
path = _write_debate_round(
|
|
1205
|
+
out_dir, round_number, results,
|
|
1206
|
+
question=question, members=members,
|
|
1207
|
+
artefact=artefact, original_ask=args.original_ask,
|
|
1208
|
+
total_planned_rounds=rounds, table=table,
|
|
1209
|
+
prompt_mode="debate",
|
|
1210
|
+
prose_synthesis=getattr(args, "prose_synthesis", None),
|
|
1211
|
+
)
|
|
1212
|
+
written.append(path)
|
|
1213
|
+
errors = [r for r in results if r.error]
|
|
1214
|
+
sys.stdout.write(
|
|
1215
|
+
f"council:debate · wrote {path} "
|
|
1216
|
+
f"({len(results) - len(errors)}/{len(results)} ok)\n"
|
|
1217
|
+
)
|
|
1218
|
+
|
|
1219
|
+
on_continue = _make_debate_continue_prompt(
|
|
1220
|
+
auto_continue=bool(getattr(args, "auto_continue", False)),
|
|
1221
|
+
)
|
|
1222
|
+
|
|
1223
|
+
try:
|
|
1224
|
+
all_rounds = run_debate(
|
|
1225
|
+
members, question,
|
|
1226
|
+
budget=budget, table=table, project=project,
|
|
1227
|
+
original_ask=args.original_ask,
|
|
1228
|
+
max_rounds=rounds,
|
|
1229
|
+
on_round_complete=_on_round_complete,
|
|
1230
|
+
on_continue=on_continue,
|
|
1231
|
+
advisor_plans=advisor_plans,
|
|
1232
|
+
seed_round_1=seed,
|
|
1233
|
+
)
|
|
1234
|
+
except DebateCapExceeded as exc:
|
|
1235
|
+
sys.stderr.write(
|
|
1236
|
+
f"❌ council:debate cap reached after round {exc.completed_round}: "
|
|
1237
|
+
f"{exc}\n"
|
|
1238
|
+
f"Partial debate persisted under {out_dir} "
|
|
1239
|
+
f"({len(written)} rounds).\n"
|
|
1240
|
+
)
|
|
1241
|
+
return 3
|
|
1242
|
+
|
|
1243
|
+
actual_total = 0.0
|
|
1244
|
+
for rnd in all_rounds:
|
|
1245
|
+
for r in rnd:
|
|
1246
|
+
if r.error:
|
|
1247
|
+
continue
|
|
1248
|
+
ce = estimate_cost(
|
|
1249
|
+
r.provider, r.model, r.input_tokens, r.output_tokens, table,
|
|
1250
|
+
)
|
|
1251
|
+
actual_total += ce.total_usd
|
|
1252
|
+
sys.stdout.write(
|
|
1253
|
+
f"\ncouncil:debate · {len(all_rounds)} round(s) complete · "
|
|
1254
|
+
f"actual ${actual_total:.4f} (cap projection ${projected_total:.4f})\n"
|
|
1255
|
+
)
|
|
1256
|
+
errors_last = [r for r in all_rounds[-1] if r.error] if all_rounds else []
|
|
1257
|
+
return 1 if errors_last and len(errors_last) == len(all_rounds[-1]) else 0
|
|
1258
|
+
|
|
1259
|
+
|
|
411
1260
|
def cmd_render(args: argparse.Namespace) -> int:
|
|
412
|
-
"""Re-render a saved responses JSON to the markdown report.
|
|
1261
|
+
"""Re-render a saved responses JSON to the markdown report.
|
|
1262
|
+
|
|
1263
|
+
Lens resolution order: explicit ``--prompt-mode`` > ``prompt_mode``
|
|
1264
|
+
in the payload > ``mode`` in the payload > ``None`` (default decision
|
|
1265
|
+
template). R4 Q4 escape hatch ``--prose-synthesis`` overrides the
|
|
1266
|
+
table.
|
|
1267
|
+
"""
|
|
413
1268
|
payload = json.loads(Path(args.responses).read_text(encoding="utf-8"))
|
|
414
1269
|
items = payload.get("responses") or []
|
|
415
|
-
|
|
1270
|
+
explicit = getattr(args, "prompt_mode", None)
|
|
1271
|
+
mode = explicit or payload.get("prompt_mode") or payload.get("mode")
|
|
1272
|
+
prose = getattr(args, "prose_synthesis", None)
|
|
1273
|
+
if prose is None:
|
|
1274
|
+
prose = payload.get("prose_synthesis")
|
|
1275
|
+
consensus = _deserialise_consensus(payload.get("consensus"))
|
|
1276
|
+
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,
|
|
1284
|
+
)
|
|
1285
|
+
+ "\n"
|
|
1286
|
+
)
|
|
416
1287
|
return 0
|
|
417
1288
|
|
|
418
1289
|
|
|
@@ -479,6 +1350,15 @@ def _add_common_input_args(p: argparse.ArgumentParser) -> None:
|
|
|
479
1350
|
p.add_argument("--input-mode", choices=["prompt", "roadmap"],
|
|
480
1351
|
default="prompt",
|
|
481
1352
|
help="How to bundle the file (default: prompt).")
|
|
1353
|
+
p.add_argument("--prompt-mode",
|
|
1354
|
+
choices=["pr", "design", "optimize", "analysis"],
|
|
1355
|
+
default=None, dest="prompt_mode",
|
|
1356
|
+
help="Lens-override for the system-prompt addendum. "
|
|
1357
|
+
"The bundle shape stays as --input-mode; only "
|
|
1358
|
+
"the per-mode neutrality addendum is swapped "
|
|
1359
|
+
"(see scripts/ai_council/prompts.py _MODE_TABLE). "
|
|
1360
|
+
"Routed by the /council pr|design|optimize|"
|
|
1361
|
+
"analysis wrappers.")
|
|
482
1362
|
p.add_argument("--max-tokens", type=int, default=None,
|
|
483
1363
|
help="Per-member output budget. Default reads "
|
|
484
1364
|
"ai_council.max_output_tokens from .agent-settings.yml "
|
|
@@ -505,6 +1385,14 @@ def _add_common_input_args(p: argparse.ArgumentParser) -> None:
|
|
|
505
1385
|
"skill.")
|
|
506
1386
|
p.add_argument("--original-ask", default="",
|
|
507
1387
|
help="The user's framing sentence (flows into handoff).")
|
|
1388
|
+
p.add_argument("--peer-review", dest="peer_review", action="store_true",
|
|
1389
|
+
default=False,
|
|
1390
|
+
help="Run an anonymous peer-review pass after the main "
|
|
1391
|
+
"deliberation. Each member critiques the others' "
|
|
1392
|
+
"(anonymised) responses for blind spots before "
|
|
1393
|
+
"synthesis. Adds N extra API calls. Opt-in per the "
|
|
1394
|
+
"R2 verdict; also accepts ai_council.peer_review."
|
|
1395
|
+
"enabled: true in agents/.ai-council.yml.")
|
|
508
1396
|
|
|
509
1397
|
|
|
510
1398
|
def build_parser() -> argparse.ArgumentParser:
|
|
@@ -516,6 +1404,15 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
516
1404
|
|
|
517
1405
|
p_est = sub.add_parser("estimate", help="Pre-call cost preview (no spend).")
|
|
518
1406
|
_add_common_input_args(p_est)
|
|
1407
|
+
p_est.add_argument("--debate", action="store_true", default=False,
|
|
1408
|
+
help="Render the round-by-round projection for a "
|
|
1409
|
+
"debate run (one call per member per round). "
|
|
1410
|
+
"Progressive disclosure may stop the debate "
|
|
1411
|
+
"early — this is an upper bound.")
|
|
1412
|
+
p_est.add_argument("--rounds", type=int, default=None,
|
|
1413
|
+
help="Debate round count for --debate. Defaults to "
|
|
1414
|
+
"ai_council.min_rounds (typically 2); capped "
|
|
1415
|
+
"at ai_council.debate_max_rounds (typically 4).")
|
|
519
1416
|
|
|
520
1417
|
p_run = sub.add_parser("run", help="Run the council; --confirm required to spend.")
|
|
521
1418
|
_add_common_input_args(p_run)
|
|
@@ -534,14 +1431,59 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
534
1431
|
"artefacts. Set by the host agent when the consuming "
|
|
535
1432
|
"rule/skill/command declares council_depth: deep. "
|
|
536
1433
|
"Overridden by explicit --rounds.")
|
|
1434
|
+
_add_prose_synthesis_arg(p_run)
|
|
1435
|
+
|
|
1436
|
+
p_deb = sub.add_parser(
|
|
1437
|
+
"debate",
|
|
1438
|
+
help="Multi-round debate with progressive cost disclosure (Phase 7).",
|
|
1439
|
+
)
|
|
1440
|
+
_add_common_input_args(p_deb)
|
|
1441
|
+
p_deb.add_argument("--output", required=True,
|
|
1442
|
+
help="Directory to write debate-round-N.json files.")
|
|
1443
|
+
p_deb.add_argument("--confirm", action="store_true",
|
|
1444
|
+
help="Required to actually start the debate.")
|
|
1445
|
+
p_deb.add_argument("--rounds", type=int, default=None,
|
|
1446
|
+
help="Number of debate rounds (default 2). Capped by "
|
|
1447
|
+
"ai_council.debate_max_rounds in agents/.ai-council.yml.")
|
|
1448
|
+
p_deb.add_argument("--auto-continue", action="store_true",
|
|
1449
|
+
default=False, dest="auto_continue",
|
|
1450
|
+
help="Skip the between-round y/N prompt. The hard cap "
|
|
1451
|
+
"against cost_budget.max_total_usd still applies.")
|
|
1452
|
+
p_deb.add_argument("--continue-as-debate", default=None,
|
|
1453
|
+
dest="continue_as_debate", metavar="PATH",
|
|
1454
|
+
help="Seed round 1 from an existing council session "
|
|
1455
|
+
"JSON. Members + models must match the current "
|
|
1456
|
+
"invocation.")
|
|
1457
|
+
_add_prose_synthesis_arg(p_deb)
|
|
537
1458
|
|
|
538
1459
|
p_ren = sub.add_parser("render", help="Re-render a saved responses JSON.")
|
|
539
1460
|
p_ren.add_argument("responses",
|
|
540
1461
|
help="Path to the JSON written by `council run`.")
|
|
1462
|
+
p_ren.add_argument("--prompt-mode",
|
|
1463
|
+
choices=["default", "pr", "design", "optimize", "analysis",
|
|
1464
|
+
"prompt", "roadmap", "diff", "files"],
|
|
1465
|
+
default=None, dest="prompt_mode",
|
|
1466
|
+
help="Override the synthesis-template lens. Defaults "
|
|
1467
|
+
"to the `mode` recorded in the responses JSON.")
|
|
1468
|
+
_add_prose_synthesis_arg(p_ren)
|
|
541
1469
|
|
|
542
1470
|
return parser
|
|
543
1471
|
|
|
544
1472
|
|
|
1473
|
+
def _add_prose_synthesis_arg(p: argparse.ArgumentParser) -> None:
|
|
1474
|
+
"""R4 Q4 escape hatch — toggle structured vs prose synthesis."""
|
|
1475
|
+
group = p.add_mutually_exclusive_group()
|
|
1476
|
+
group.add_argument("--prose-synthesis", dest="prose_synthesis",
|
|
1477
|
+
action="store_const", const=True, default=None,
|
|
1478
|
+
help="Force open-ended prose synthesis (bare slot) "
|
|
1479
|
+
"regardless of lens. R4 Q4 escape hatch.")
|
|
1480
|
+
group.add_argument("--no-prose-synthesis", dest="prose_synthesis",
|
|
1481
|
+
action="store_const", const=False,
|
|
1482
|
+
help="Force the structured default decision-lens "
|
|
1483
|
+
"template even on a creative lens "
|
|
1484
|
+
"(design / optimize). Symmetric escape hatch.")
|
|
1485
|
+
|
|
1486
|
+
|
|
545
1487
|
def main(argv: list[str] | None = None) -> int:
|
|
546
1488
|
args = build_parser().parse_args(argv)
|
|
547
1489
|
try:
|
|
@@ -549,6 +1491,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
549
1491
|
return cmd_estimate(args)
|
|
550
1492
|
if args.cmd == "run":
|
|
551
1493
|
return cmd_run(args)
|
|
1494
|
+
if args.cmd == "debate":
|
|
1495
|
+
return cmd_debate(args)
|
|
552
1496
|
if args.cmd == "render":
|
|
553
1497
|
return cmd_render(args)
|
|
554
1498
|
except CouncilDisabledError as exc:
|