@event4u/agent-config 1.18.0 → 1.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. package/.agent-src/commands/agent-handoff.md +14 -10
  2. package/.agent-src/commands/chat-history/import.md +170 -0
  3. package/.agent-src/commands/chat-history/learn.md +178 -0
  4. package/.agent-src/commands/chat-history/show.md +17 -18
  5. package/.agent-src/commands/chat-history.md +26 -25
  6. package/.agent-src/commands/council/default.md +77 -82
  7. package/.agent-src/commands/create-pr.md +28 -8
  8. package/.agent-src/commands/feature/roadmap.md +22 -0
  9. package/.agent-src/commands/roadmap/create.md +38 -6
  10. package/.agent-src/commands/roadmap/execute.md +36 -9
  11. package/.agent-src/commands/sync-gitignore.md +1 -1
  12. package/.agent-src/contexts/communication/rules-auto/skill-quality-mechanics.md +76 -0
  13. package/.agent-src/contexts/communication/rules-auto/slash-command-routing-policy-mechanics.md +3 -3
  14. package/.agent-src/contexts/communication/rules-auto/user-interaction-mechanics.md +5 -12
  15. package/.agent-src/rules/agent-authority.md +1 -0
  16. package/.agent-src/rules/agent-docs.md +1 -0
  17. package/.agent-src/rules/analysis-skill-routing.md +1 -0
  18. package/.agent-src/rules/architecture.md +1 -0
  19. package/.agent-src/rules/artifact-drafting-protocol.md +1 -0
  20. package/.agent-src/rules/artifact-engagement-recording.md +1 -0
  21. package/.agent-src/rules/ask-when-uncertain.md +1 -0
  22. package/.agent-src/rules/augment-portability.md +1 -0
  23. package/.agent-src/rules/augment-source-of-truth.md +1 -0
  24. package/.agent-src/rules/autonomous-execution.md +1 -0
  25. package/.agent-src/rules/capture-learnings.md +1 -0
  26. package/.agent-src/rules/cli-output-handling.md +2 -2
  27. package/.agent-src/rules/command-suggestion-policy.md +1 -0
  28. package/.agent-src/rules/commit-conventions.md +1 -0
  29. package/.agent-src/rules/commit-policy.md +1 -0
  30. package/.agent-src/rules/context-hygiene.md +22 -0
  31. package/.agent-src/rules/direct-answers.md +11 -2
  32. package/.agent-src/rules/docker-commands.md +1 -0
  33. package/.agent-src/rules/docs-sync.md +1 -0
  34. package/.agent-src/rules/downstream-changes.md +1 -0
  35. package/.agent-src/rules/e2e-testing.md +1 -0
  36. package/.agent-src/rules/guidelines.md +1 -0
  37. package/.agent-src/rules/improve-before-implement.md +1 -0
  38. package/.agent-src/rules/language-and-tone.md +38 -6
  39. package/.agent-src/rules/laravel-translations.md +1 -0
  40. package/.agent-src/rules/markdown-safe-codeblocks.md +1 -0
  41. package/.agent-src/rules/minimal-safe-diff.md +1 -0
  42. package/.agent-src/rules/missing-tool-handling.md +1 -0
  43. package/.agent-src/rules/model-recommendation.md +1 -0
  44. package/.agent-src/rules/no-attribution-footers.md +48 -0
  45. package/.agent-src/rules/no-cheap-questions.md +1 -0
  46. package/.agent-src/rules/no-roadmap-references.md +2 -1
  47. package/.agent-src/rules/non-destructive-by-default.md +1 -0
  48. package/.agent-src/rules/onboarding-gate.md +26 -0
  49. package/.agent-src/rules/package-ci-checks.md +1 -0
  50. package/.agent-src/rules/php-coding.md +1 -0
  51. package/.agent-src/rules/preservation-guard.md +1 -0
  52. package/.agent-src/rules/review-routing-awareness.md +1 -0
  53. package/.agent-src/rules/reviewer-awareness.md +1 -0
  54. package/.agent-src/rules/roadmap-progress-sync.md +22 -0
  55. package/.agent-src/rules/role-mode-adherence.md +2 -2
  56. package/.agent-src/rules/rule-type-governance.md +1 -0
  57. package/.agent-src/rules/runtime-safety.md +1 -0
  58. package/.agent-src/rules/scope-control.md +1 -0
  59. package/.agent-src/rules/security-sensitive-stop.md +1 -0
  60. package/.agent-src/rules/size-enforcement.md +1 -0
  61. package/.agent-src/rules/skill-improvement-trigger.md +1 -0
  62. package/.agent-src/rules/skill-quality.md +50 -0
  63. package/.agent-src/rules/slash-command-routing-policy.md +39 -0
  64. package/.agent-src/rules/think-before-action.md +1 -0
  65. package/.agent-src/rules/token-efficiency.md +1 -0
  66. package/.agent-src/rules/tool-safety.md +1 -0
  67. package/.agent-src/rules/ui-audit-gate.md +1 -0
  68. package/.agent-src/rules/upstream-proposal.md +1 -0
  69. package/.agent-src/rules/user-interaction.md +22 -5
  70. package/.agent-src/rules/verify-before-complete.md +1 -0
  71. package/.agent-src/skills/ai-council/SKILL.md +4 -5
  72. package/.agent-src/skills/dcf-modeling/SKILL.md +89 -0
  73. package/.agent-src/skills/funnel-analysis/SKILL.md +100 -0
  74. package/.agent-src/skills/md-language-check/SKILL.md +1 -1
  75. package/.agent-src/skills/okr-tree-modeling/SKILL.md +93 -0
  76. package/.agent-src/skills/rice-prioritization/SKILL.md +100 -0
  77. package/.agent-src/skills/roadmap-management/SKILL.md +29 -4
  78. package/.agent-src/skills/subagent-orchestration/SKILL.md +34 -2
  79. package/.agent-src/skills/unit-economics-modeling/SKILL.md +104 -0
  80. package/.agent-src/skills/using-git-worktrees/SKILL.md +1 -0
  81. package/.agent-src/skills/verify-completion-evidence/SKILL.md +8 -1
  82. package/.agent-src/templates/agent-settings.md +21 -26
  83. package/.agent-src/templates/roadmaps.md +8 -3
  84. package/.agent-src/templates/scripts/work_engine/hook_bootstrap.py +16 -5
  85. package/.agent-src/templates/scripts/work_engine/hooks/__init__.py +4 -4
  86. package/.agent-src/templates/scripts/work_engine/hooks/builtin/__init__.py +4 -4
  87. package/.agent-src/templates/scripts/work_engine/hooks/builtin/_chat_history_base.py +7 -51
  88. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_append.py +1 -2
  89. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_halt_append.py +1 -2
  90. package/.agent-src/templates/scripts/work_engine/hooks/builtin/decision_trace.py +163 -0
  91. package/.agent-src/templates/scripts/work_engine/hooks/builtin/memory_visibility.py +110 -0
  92. package/.agent-src/templates/scripts/work_engine/hooks/settings.py +36 -0
  93. package/.agent-src/templates/scripts/work_engine/scoring/decision_trace.py +141 -0
  94. package/.agent-src/templates/scripts/work_engine/scoring/memory_visibility.py +125 -0
  95. package/.agent-src/templates/skill.md +30 -1
  96. package/.claude-plugin/marketplace.json +8 -4
  97. package/AGENTS.md +44 -3
  98. package/CHANGELOG.md +173 -0
  99. package/README.md +22 -22
  100. package/config/agent-settings.template.yml +42 -13
  101. package/config/gitignore-block.txt +4 -4
  102. package/docs/architecture.md +3 -3
  103. package/docs/catalog.md +18 -13
  104. package/docs/contracts/adr-chat-history-split.md +10 -1
  105. package/docs/contracts/adr-settings-sync-engine.md +127 -0
  106. package/docs/contracts/command-clusters.md +1 -1
  107. package/docs/contracts/cross-wing-handoff.md +133 -0
  108. package/docs/contracts/decision-trace-v1.md +146 -0
  109. package/docs/contracts/file-ownership-matrix.json +348 -126
  110. package/docs/contracts/hook-architecture-v1.md +220 -0
  111. package/docs/contracts/memory-visibility-v1.md +122 -0
  112. package/docs/contracts/one-off-script-lifecycle.md +109 -0
  113. package/docs/contracts/rule-interactions.yml +22 -0
  114. package/docs/customization.md +2 -1
  115. package/docs/development.md +4 -1
  116. package/docs/getting-started.md +21 -29
  117. package/docs/guidelines/agent-infra/ask-when-uncertain-demos.md +1 -1
  118. package/docs/guidelines/agent-infra/layered-settings.md +32 -13
  119. package/docs/hook-payload-capture.md +221 -0
  120. package/docs/migrations/commands-1.15.0.md +17 -12
  121. package/docs/skills-catalog.md +5 -4
  122. package/llms.txt +4 -3
  123. package/package.json +1 -1
  124. package/scripts/agent-config +45 -1
  125. package/scripts/ai_council/_default_prices.py +4 -4
  126. package/scripts/ai_council/bundler.py +3 -3
  127. package/scripts/ai_council/clients.py +25 -9
  128. package/scripts/ai_council/modes.py +3 -4
  129. package/scripts/ai_council/one_off_archive/2026-05/README.md +22 -0
  130. package/scripts/ai_council/one_off_archive/2026-05/_one_off_roundtrip.py +13 -8
  131. package/scripts/ai_council/one_off_archive/2026-05/_one_off_tier_retrofit.py +180 -0
  132. package/scripts/ai_council/pricing.py +10 -9
  133. package/scripts/ai_council/session.py +92 -0
  134. package/scripts/build_rule_trigger_matrix.py +1 -9
  135. package/scripts/capture_showcase_session.py +361 -0
  136. package/scripts/chat_history.py +963 -597
  137. package/scripts/check_always_budget.py +7 -2
  138. package/scripts/check_references.py +12 -2
  139. package/scripts/context_hygiene_hook.py +14 -6
  140. package/scripts/council_cli.py +407 -0
  141. package/scripts/hook_manifest.yaml +217 -0
  142. package/scripts/hooks/__init__.py +1 -0
  143. package/scripts/hooks/augment-chat-history.sh +10 -0
  144. package/scripts/hooks/augment-dispatcher.sh +72 -0
  145. package/scripts/hooks/cline-dispatcher.sh +86 -0
  146. package/scripts/hooks/cowork-dispatcher.sh +98 -0
  147. package/scripts/hooks/cursor-dispatcher.sh +76 -0
  148. package/scripts/hooks/dispatch_hook.py +383 -0
  149. package/scripts/hooks/envelope.py +98 -0
  150. package/scripts/hooks/gemini-dispatcher.sh +117 -0
  151. package/scripts/hooks/state_io.py +122 -0
  152. package/scripts/hooks/windsurf-dispatcher.sh +123 -0
  153. package/scripts/hooks_status.py +157 -0
  154. package/scripts/install-hooks.sh +2 -2
  155. package/scripts/install.py +725 -87
  156. package/scripts/install.sh +38 -1
  157. package/scripts/lint_handoffs.py +214 -0
  158. package/scripts/lint_hook_manifest.py +217 -0
  159. package/scripts/lint_one_off_age.py +184 -0
  160. package/scripts/lint_rule_tiers.py +78 -0
  161. package/scripts/lint_showcase_sessions.py +148 -0
  162. package/scripts/minimal_safe_diff_hook.py +245 -0
  163. package/scripts/onboarding_gate_hook.py +13 -8
  164. package/scripts/readme_linter.py +12 -3
  165. package/scripts/redact_hook_capture.py +148 -0
  166. package/scripts/roadmap_progress_hook.py +5 -0
  167. package/scripts/schemas/skill.schema.json +5 -0
  168. package/scripts/skill_linter.py +163 -1
  169. package/scripts/sync_agent_settings.py +32 -129
  170. package/scripts/sync_yaml_rt.py +734 -0
  171. package/scripts/update_prices.py +3 -3
  172. package/scripts/verify_before_complete_hook.py +216 -0
  173. package/.agent-src/commands/chat-history/checkpoint.md +0 -126
  174. package/.agent-src/commands/chat-history/clear.md +0 -103
  175. package/.agent-src/commands/chat-history/resume.md +0 -183
  176. package/.agent-src/rules/chat-history-cadence.md +0 -109
  177. package/.agent-src/rules/chat-history-ownership.md +0 -123
  178. package/.agent-src/rules/chat-history-visibility.md +0 -96
  179. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_heartbeat.py +0 -50
  180. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_turn_check.py +0 -49
  181. package/scripts/check_phase_coupling.py +0 -148
@@ -92,9 +92,14 @@ BASELINE_FILE = REPO_ROOT / ".github" / "budget-baseline.txt"
92
92
  # growth above the ceiling fails CI even while the entry remains.
93
93
  # When Phase 2A retires a rule, drop its entry here AND in
94
94
  # `tests/test_always_budget.py::KNOWN_PER_RULE_BREACHES`.
95
+ #
96
+ # Phase 2 of road-to-feedback-consolidation.md added a single-line
97
+ # `tier: "safety-floor"` frontmatter key (21 chars) to every safety-floor
98
+ # rule. Both ceilings below were re-baselined +21 to absorb that
99
+ # frontmatter-only growth without trimming Iron-Law content.
95
100
  KNOWN_PER_RULE_BREACHES: dict[str, int] = {
96
- "non-destructive-by-default.md": 7_887,
97
- "scope-control.md": 8_529,
101
+ "non-destructive-by-default.md": 7_908,
102
+ "scope-control.md": 8_550,
98
103
  }
99
104
 
100
105
 
@@ -33,8 +33,10 @@ class BrokenRef:
33
33
 
34
34
  SCAN_DIRS = [".agent-src", "agents"]
35
35
  SKIP_DIRS = [
36
- "agents/roadmaps/archive", # archived roadmaps have historical refs
37
- "agents/council-sessions", # per-user audit trail (gitignored), captured provider output
36
+ "agents/roadmaps/archive", # archived roadmaps have historical refs
37
+ "agents/council-sessions", # per-user audit trail (gitignored), captured provider output
38
+ "agents/council-questions", # design Q&A trail — forward-refs to planned artifacts
39
+ "agents/analysis", # plate-comparison working docs — forward-refs to planned artifacts
38
40
  ]
39
41
  ROOT = Path(".")
40
42
 
@@ -280,6 +282,14 @@ def check_file(filepath: Path, artifacts: dict[str, set[str]], root: Path) -> Li
280
282
  # checkable file paths.
281
283
  if not resolved and raw_ref.startswith("agents/state/"):
282
284
  resolved = True
285
+ # `agents/.agent-prices.md` is a runtime-bootstrapped pricing
286
+ # cache — gitignored (.gitignore:/agents/.agent-prices.md),
287
+ # auto-generated by scripts/ai_council/pricing.py from
288
+ # _default_prices.py if missing. Same class as agents/state/*
289
+ # but a single named file, not a directory pattern, so the
290
+ # carve-out stays narrow.
291
+ if not resolved and raw_ref == "agents/.agent-prices.md":
292
+ resolved = True
283
293
  if not resolved:
284
294
  broken.append(BrokenRef(
285
295
  file=str(filepath), line=i, ref=m.group(1),
@@ -30,6 +30,12 @@ import json
30
30
  import sys
31
31
  from pathlib import Path
32
32
 
33
+ # Re-use the shared atomic-write helper so concerns honour the single
34
+ # `agents/state/.dispatcher.lock` discipline (hook-architecture-v1.md
35
+ # § Concurrency, Phase 7.4).
36
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
37
+ from hooks.state_io import atomic_write_json # noqa: E402
38
+
33
39
  STATE_DIR = Path("agents") / "state"
34
40
  STATE_FILE = STATE_DIR / "context-hygiene.json"
35
41
 
@@ -118,12 +124,9 @@ def _update(state: dict, tool: str | None) -> dict:
118
124
 
119
125
 
120
126
  def _write_state(consumer_root: Path, state: dict) -> None:
121
- state_dir = consumer_root / STATE_DIR
122
- state_dir.mkdir(parents=True, exist_ok=True)
123
- target = consumer_root / STATE_FILE
124
- tmp = target.with_suffix(".json.tmp")
125
- tmp.write_text(json.dumps(state, indent=2) + "\n", encoding="utf-8")
126
- tmp.replace(target)
127
+ """Write the state file atomically under the shared dispatcher lock
128
+ (hook-architecture-v1.md § Concurrency, Phase 7.4)."""
129
+ atomic_write_json(consumer_root / STATE_FILE, state)
127
130
 
128
131
 
129
132
  def run(stdin_text: str, *, consumer_root: Path, verbose: bool = False) -> int:
@@ -136,6 +139,11 @@ def run(stdin_text: str, *, consumer_root: Path, verbose: bool = False) -> int:
136
139
  except json.JSONDecodeError:
137
140
  pass # silent no-op, never block
138
141
 
142
+ # Unwrap dispatcher envelope (Phase 7.3, hook-architecture-v1.md).
143
+ if all(k in payload for k in ("schema_version", "platform", "event", "payload")):
144
+ inner = payload.get("payload")
145
+ payload = inner if isinstance(inner, dict) else {}
146
+
139
147
  target = consumer_root / STATE_FILE
140
148
  state = _load_state(target)
141
149
  state = _update(state, _extract_tool(payload))
@@ -0,0 +1,407 @@
1
+ #!/usr/bin/env python3
2
+ """Council CLI — `./agent-config council:{estimate,run,render}`.
3
+
4
+ Wraps `scripts.ai_council.orchestrator` for non-interactive callers.
5
+ Subcommands:
6
+
7
+ estimate Bundle + estimate per-member cost (no API call, no spend).
8
+ run Same + estimate, then call the council. Requires --confirm.
9
+ render Re-render a saved responses JSON to the markdown report.
10
+
11
+ `./agent-config` is non-interactive by contract — the cost gate is an
12
+ explicit `--confirm` flag, never an interactive y/n.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import json
18
+ import sys
19
+ from dataclasses import asdict
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ import yaml
24
+
25
+ REPO_ROOT = Path(__file__).resolve().parents[1]
26
+ SETTINGS_FILE = REPO_ROOT / ".agent-settings.yml"
27
+
28
+ sys.path.insert(0, str(REPO_ROOT))
29
+
30
+ from scripts.ai_council.bundler import ( # noqa: E402
31
+ BundleTooLarge, bundle_prompt, bundle_roadmap,
32
+ )
33
+ from scripts.ai_council.clients import ( # noqa: E402
34
+ AnthropicClient, CouncilResponse, ExternalAIClient, ManualClient,
35
+ OpenAIClient, load_anthropic_key, load_openai_key,
36
+ )
37
+ from scripts.ai_council.modes import ( # noqa: E402
38
+ InvalidModeError, resolve_mode,
39
+ )
40
+ from scripts.ai_council.orchestrator import ( # noqa: E402
41
+ CostBudget, CouncilQuestion, consult, estimate, render,
42
+ )
43
+ from scripts.ai_council.pricing import ( # noqa: E402
44
+ PriceTable, estimate_cost, load_prices,
45
+ )
46
+ from scripts.ai_council.project_context import detect_project_context # noqa: E402
47
+
48
+ SCHEMA_VERSION = 1
49
+
50
+
51
+ class CouncilDisabledError(RuntimeError):
52
+ """Raised when ai_council.enabled is false or no member is enabled."""
53
+
54
+
55
+ def load_settings(path: Path = SETTINGS_FILE) -> dict[str, Any]:
56
+ if not path.exists():
57
+ return {}
58
+ return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
59
+
60
+
61
+ def build_members(
62
+ settings: dict[str, Any],
63
+ *,
64
+ invocation_mode: str | None = None,
65
+ model_overrides: dict[str, str] | None = None,
66
+ ) -> list[ExternalAIClient]:
67
+ """Construct enabled council members from settings.
68
+
69
+ Honours `ai_council.enabled` (master switch) and per-member
70
+ `enabled` flags. Raises `CouncilDisabledError` when the council is
71
+ off or no member is wired up.
72
+
73
+ `model_overrides` is a per-invocation `{member_name: model_id}`
74
+ map that wins over the per-member `model` in settings. Members not
75
+ listed fall back to the settings value, then the per-client default.
76
+ """
77
+ ai = (settings.get("ai_council") or {}) if isinstance(settings, dict) else {}
78
+ if not ai.get("enabled"):
79
+ raise CouncilDisabledError(
80
+ "ai_council.enabled is false in .agent-settings.yml — "
81
+ "flip it on before invoking council:* commands."
82
+ )
83
+ members_cfg = ai.get("members") or {}
84
+ global_mode = ai.get("mode")
85
+ overrides = model_overrides or {}
86
+ unknown = set(overrides) - set(members_cfg)
87
+ if unknown:
88
+ raise CouncilDisabledError(
89
+ f"--model targets unknown member(s) {sorted(unknown)!r}; "
90
+ f"known members: {sorted(members_cfg)!r}."
91
+ )
92
+ members: list[ExternalAIClient] = []
93
+ for name, cfg in members_cfg.items():
94
+ cfg = cfg or {}
95
+ if not cfg.get("enabled"):
96
+ continue
97
+ mode = resolve_mode(
98
+ name,
99
+ invocation_mode=invocation_mode,
100
+ member_settings=cfg,
101
+ global_mode=global_mode,
102
+ )
103
+ model = overrides.get(name) or cfg.get("model")
104
+ if mode == "api" and name == "anthropic":
105
+ members.append(AnthropicClient(model=model or "claude-sonnet-4-5",
106
+ api_key=load_anthropic_key()))
107
+ elif mode == "api" and name == "openai":
108
+ members.append(OpenAIClient(model=model or "gpt-4o",
109
+ api_key=load_openai_key()))
110
+ elif mode == "manual":
111
+ members.append(ManualClient(name=name, model=model or "manual"))
112
+ elif mode == "playwright":
113
+ raise CouncilDisabledError(
114
+ f"member {name!r} resolves to mode=playwright (Phase 2c, not wired)."
115
+ )
116
+ else:
117
+ raise CouncilDisabledError(
118
+ f"member {name!r} has no transport — mode={mode}, name not in {{anthropic,openai}}."
119
+ )
120
+ if not members:
121
+ raise CouncilDisabledError(
122
+ "no council member has `enabled: true` — enable at least one in "
123
+ ".agent-settings.yml under ai_council.members.*."
124
+ )
125
+ return members
126
+
127
+
128
+ def build_question(
129
+ *,
130
+ input_path: Path,
131
+ input_mode: str,
132
+ max_tokens: int,
133
+ ) -> tuple[CouncilQuestion, str]:
134
+ """Bundle the input file. Returns (question, artefact_label)."""
135
+ if input_mode == "prompt":
136
+ text = input_path.read_text(encoding="utf-8")
137
+ ctx = bundle_prompt(text)
138
+ artefact = str(input_path)
139
+ elif input_mode == "roadmap":
140
+ ctx = bundle_roadmap(input_path)
141
+ artefact = str(input_path)
142
+ else:
143
+ raise ValueError(
144
+ f"unsupported input mode: {input_mode!r} (use prompt | roadmap)"
145
+ )
146
+ return CouncilQuestion(mode=ctx.mode, user_prompt=ctx.text,
147
+ max_tokens=max_tokens), artefact
148
+
149
+
150
+ def format_estimate_table(
151
+ members: list[ExternalAIClient],
152
+ estimates: list[Any],
153
+ ) -> str:
154
+ rows = [
155
+ f" {m.name}/{m.model}: "
156
+ f"~{e.input_tokens} in + {e.output_tokens} out = ${e.total_usd:.4f}"
157
+ for m, e in zip(members, estimates)
158
+ ]
159
+ total = sum(e.total_usd for e in estimates)
160
+ rows.append(f" TOTAL: ${total:.4f}")
161
+ return "\n".join(rows)
162
+
163
+
164
+ # ── subcommands ─────────────────────────────────────────────────────
165
+
166
+
167
+ def cmd_estimate(
168
+ args: argparse.Namespace,
169
+ *,
170
+ settings: dict[str, Any] | None = None,
171
+ members: list[ExternalAIClient] | None = None,
172
+ table: PriceTable | None = None,
173
+ ) -> int:
174
+ """Print per-member cost preview. No API calls."""
175
+ if settings is None:
176
+ settings = load_settings()
177
+ if members is None:
178
+ members = build_members(
179
+ settings,
180
+ invocation_mode=args.mode_override,
181
+ model_overrides=_parse_model_overrides(getattr(args, "model", None)),
182
+ )
183
+ if table is None:
184
+ table = load_prices()
185
+ question, _ = build_question(
186
+ input_path=Path(args.question), input_mode=args.input_mode,
187
+ max_tokens=args.max_tokens,
188
+ )
189
+ project = detect_project_context(REPO_ROOT)
190
+ billable = [m for m in members if getattr(m, "billable", True)]
191
+ estimates = estimate(question, billable, table,
192
+ project=project, original_ask=args.original_ask)
193
+ sys.stdout.write(
194
+ f"council:estimate · mode={question.mode} · members={len(members)} "
195
+ f"(billable={len(billable)})\n"
196
+ )
197
+ sys.stdout.write(format_estimate_table(billable, estimates) + "\n")
198
+ return 0
199
+
200
+
201
+ def _serialise_responses(responses: list[CouncilResponse]) -> list[dict[str, Any]]:
202
+ out: list[dict[str, Any]] = []
203
+ for r in responses:
204
+ d = asdict(r)
205
+ # `metadata` may contain non-JSON types; coerce.
206
+ d["metadata"] = {k: str(v) for k, v in (d.get("metadata") or {}).items()}
207
+ out.append(d)
208
+ return out
209
+
210
+
211
+ def _deserialise_responses(items: list[dict[str, Any]]) -> list[CouncilResponse]:
212
+ out: list[CouncilResponse] = []
213
+ for d in items:
214
+ out.append(CouncilResponse(
215
+ provider=d.get("provider", ""),
216
+ model=d.get("model", ""),
217
+ text=d.get("text", ""),
218
+ input_tokens=int(d.get("input_tokens", 0) or 0),
219
+ output_tokens=int(d.get("output_tokens", 0) or 0),
220
+ latency_ms=int(d.get("latency_ms", 0) or 0),
221
+ error=d.get("error"),
222
+ metadata=dict(d.get("metadata") or {}),
223
+ ))
224
+ return out
225
+
226
+
227
+ def cmd_run(
228
+ args: argparse.Namespace,
229
+ *,
230
+ settings: dict[str, Any] | None = None,
231
+ members: list[ExternalAIClient] | None = None,
232
+ table: PriceTable | None = None,
233
+ ) -> int:
234
+ """Estimate, then run the council. Requires --confirm to spend."""
235
+ if settings is None:
236
+ settings = load_settings()
237
+ if members is None:
238
+ members = build_members(
239
+ settings,
240
+ invocation_mode=args.mode_override,
241
+ model_overrides=_parse_model_overrides(getattr(args, "model", None)),
242
+ )
243
+ if table is None:
244
+ table = load_prices()
245
+ question, artefact = build_question(
246
+ input_path=Path(args.question), input_mode=args.input_mode,
247
+ max_tokens=args.max_tokens,
248
+ )
249
+ project = detect_project_context(REPO_ROOT)
250
+ billable = [m for m in members if getattr(m, "billable", True)]
251
+ estimates = estimate(question, billable, table,
252
+ project=project, original_ask=args.original_ask)
253
+ sys.stdout.write(
254
+ f"council:run · mode={question.mode} · members={len(members)} "
255
+ f"(billable={len(billable)})\n"
256
+ )
257
+ sys.stdout.write(format_estimate_table(billable, estimates) + "\n")
258
+
259
+ if not args.confirm:
260
+ sys.stdout.write(
261
+ "\nNo --confirm flag — estimate only. Re-run with --confirm to "
262
+ "invoke the council and write the response.\n"
263
+ )
264
+ return 0
265
+
266
+ cost_cfg = (settings.get("ai_council") or {}).get("cost_budget") or {}
267
+ budget = CostBudget(
268
+ max_input_tokens=int(cost_cfg.get("max_input_tokens", 50_000)),
269
+ max_output_tokens=int(cost_cfg.get("max_output_tokens", 20_000)),
270
+ max_calls=int(cost_cfg.get("max_calls", 10)),
271
+ max_total_usd=float(cost_cfg.get("max_total_usd", 0.0) or 0.0),
272
+ )
273
+ responses = consult(
274
+ members, question, budget,
275
+ table=table, project=project,
276
+ original_ask=args.original_ask, rounds=args.rounds,
277
+ )
278
+ estimated_total = sum(e.total_usd for e in estimates)
279
+ actual_total = 0.0
280
+ for r in responses:
281
+ if r.error:
282
+ continue
283
+ ce = estimate_cost(r.provider, r.model, r.input_tokens, r.output_tokens, table)
284
+ actual_total += ce.total_usd
285
+ payload = {
286
+ "schema_version": SCHEMA_VERSION,
287
+ "mode": question.mode,
288
+ "artefact": artefact,
289
+ "original_ask": args.original_ask,
290
+ "members": [f"{m.name}/{m.model}" for m in members],
291
+ "rounds": args.rounds,
292
+ "cost_usd_estimated": round(estimated_total, 6),
293
+ "cost_usd_actual": round(actual_total, 6),
294
+ "responses": _serialise_responses(responses),
295
+ }
296
+ out_path = Path(args.output)
297
+ out_path.parent.mkdir(parents=True, exist_ok=True)
298
+ out_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
299
+ sys.stdout.write(
300
+ f"\ncouncil:run · wrote {out_path} "
301
+ f"(estimated ${estimated_total:.4f} / actual ${actual_total:.4f})\n"
302
+ )
303
+ errors = [r for r in responses if r.error]
304
+ return 1 if errors and len(errors) == len(responses) else 0
305
+
306
+
307
+ def cmd_render(args: argparse.Namespace) -> int:
308
+ """Re-render a saved responses JSON to the markdown report."""
309
+ payload = json.loads(Path(args.responses).read_text(encoding="utf-8"))
310
+ items = payload.get("responses") or []
311
+ sys.stdout.write(render(_deserialise_responses(items)) + "\n")
312
+ return 0
313
+
314
+
315
+ # ── argparse + main ─────────────────────────────────────────────────
316
+
317
+
318
+ def _parse_model_overrides(items: list[str] | None) -> dict[str, str]:
319
+ """Parse repeated `--model name=model-id` flags into a dict.
320
+
321
+ Empty/None list → empty dict (no override). Bad shape raises
322
+ `argparse.ArgumentTypeError` so the CLI surfaces the error.
323
+ """
324
+ out: dict[str, str] = {}
325
+ for raw in items or []:
326
+ if "=" not in raw:
327
+ raise argparse.ArgumentTypeError(
328
+ f"--model expects '<member>=<model-id>', got {raw!r}."
329
+ )
330
+ name, model = raw.split("=", 1)
331
+ name, model = name.strip(), model.strip()
332
+ if not name or not model:
333
+ raise argparse.ArgumentTypeError(
334
+ f"--model member and model-id must both be non-empty: {raw!r}."
335
+ )
336
+ out[name] = model
337
+ return out
338
+
339
+
340
+ def _add_common_input_args(p: argparse.ArgumentParser) -> None:
341
+ p.add_argument("question", type=str,
342
+ help="Path to the question file (text or roadmap).")
343
+ p.add_argument("--input-mode", choices=["prompt", "roadmap"],
344
+ default="prompt",
345
+ help="How to bundle the file (default: prompt).")
346
+ p.add_argument("--max-tokens", type=int, default=1024,
347
+ help="Per-member output budget (default: 1024).")
348
+ p.add_argument("--mode-override", choices=["api", "manual"], default=None,
349
+ help="Override every member's transport mode.")
350
+ p.add_argument("--model", action="append", default=None, dest="model",
351
+ metavar="MEMBER=MODEL_ID",
352
+ help="Per-invocation model override, e.g. "
353
+ "--model anthropic=claude-sonnet-4-5. Repeatable. "
354
+ "Wins over `ai_council.members.<name>.model` in "
355
+ ".agent-settings.yml; the settings file is not "
356
+ "modified.")
357
+ p.add_argument("--original-ask", default="",
358
+ help="The user's framing sentence (flows into handoff).")
359
+
360
+
361
+ def build_parser() -> argparse.ArgumentParser:
362
+ parser = argparse.ArgumentParser(
363
+ prog="agent-config council",
364
+ description="Non-interactive council orchestration.",
365
+ )
366
+ sub = parser.add_subparsers(dest="cmd", required=True)
367
+
368
+ p_est = sub.add_parser("estimate", help="Pre-call cost preview (no spend).")
369
+ _add_common_input_args(p_est)
370
+
371
+ p_run = sub.add_parser("run", help="Run the council; --confirm required to spend.")
372
+ _add_common_input_args(p_run)
373
+ p_run.add_argument("--output", required=True,
374
+ help="Path to write the responses JSON.")
375
+ p_run.add_argument("--confirm", action="store_true",
376
+ help="Required to actually invoke the council.")
377
+ p_run.add_argument("--rounds", type=int, default=1,
378
+ help="Number of debate rounds (1-3).")
379
+
380
+ p_ren = sub.add_parser("render", help="Re-render a saved responses JSON.")
381
+ p_ren.add_argument("responses",
382
+ help="Path to the JSON written by `council run`.")
383
+
384
+ return parser
385
+
386
+
387
+ def main(argv: list[str] | None = None) -> int:
388
+ args = build_parser().parse_args(argv)
389
+ try:
390
+ if args.cmd == "estimate":
391
+ return cmd_estimate(args)
392
+ if args.cmd == "run":
393
+ return cmd_run(args)
394
+ if args.cmd == "render":
395
+ return cmd_render(args)
396
+ except CouncilDisabledError as exc:
397
+ sys.stderr.write(f"❌ council:{args.cmd}: {exc}\n")
398
+ return 2
399
+ except (BundleTooLarge, InvalidModeError, FileNotFoundError,
400
+ argparse.ArgumentTypeError) as exc:
401
+ sys.stderr.write(f"❌ council:{args.cmd}: {exc}\n")
402
+ return 2
403
+ return 1
404
+
405
+
406
+ if __name__ == "__main__":
407
+ raise SystemExit(main())