@event4u/agent-config 1.17.0 → 1.19.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 (158) hide show
  1. package/.agent-src/commands/council/default.md +74 -76
  2. package/.agent-src/commands/feature/roadmap.md +22 -0
  3. package/.agent-src/commands/roadmap/create.md +38 -6
  4. package/.agent-src/commands/roadmap/execute.md +36 -9
  5. package/.agent-src/rules/agent-authority.md +1 -0
  6. package/.agent-src/rules/agent-docs.md +1 -0
  7. package/.agent-src/rules/analysis-skill-routing.md +1 -0
  8. package/.agent-src/rules/architecture.md +1 -0
  9. package/.agent-src/rules/artifact-drafting-protocol.md +1 -0
  10. package/.agent-src/rules/artifact-engagement-recording.md +1 -0
  11. package/.agent-src/rules/ask-when-uncertain.md +1 -0
  12. package/.agent-src/rules/augment-portability.md +1 -0
  13. package/.agent-src/rules/augment-source-of-truth.md +1 -0
  14. package/.agent-src/rules/autonomous-execution.md +1 -0
  15. package/.agent-src/rules/capture-learnings.md +1 -0
  16. package/.agent-src/rules/chat-history-cadence.md +34 -0
  17. package/.agent-src/rules/chat-history-ownership.md +1 -0
  18. package/.agent-src/rules/chat-history-visibility.md +1 -0
  19. package/.agent-src/rules/cli-output-handling.md +2 -2
  20. package/.agent-src/rules/command-suggestion-policy.md +1 -0
  21. package/.agent-src/rules/commit-conventions.md +1 -0
  22. package/.agent-src/rules/commit-policy.md +1 -0
  23. package/.agent-src/rules/context-hygiene.md +28 -0
  24. package/.agent-src/rules/direct-answers.md +18 -26
  25. package/.agent-src/rules/docker-commands.md +1 -0
  26. package/.agent-src/rules/docs-sync.md +1 -0
  27. package/.agent-src/rules/downstream-changes.md +1 -0
  28. package/.agent-src/rules/e2e-testing.md +1 -0
  29. package/.agent-src/rules/guidelines.md +1 -0
  30. package/.agent-src/rules/improve-before-implement.md +1 -0
  31. package/.agent-src/rules/language-and-tone.md +1 -0
  32. package/.agent-src/rules/laravel-translations.md +1 -0
  33. package/.agent-src/rules/markdown-safe-codeblocks.md +1 -0
  34. package/.agent-src/rules/minimal-safe-diff.md +1 -0
  35. package/.agent-src/rules/missing-tool-handling.md +1 -0
  36. package/.agent-src/rules/model-recommendation.md +1 -0
  37. package/.agent-src/rules/no-cheap-questions.md +15 -21
  38. package/.agent-src/rules/no-roadmap-references.md +1 -0
  39. package/.agent-src/rules/non-destructive-by-default.md +1 -0
  40. package/.agent-src/rules/onboarding-gate.md +33 -0
  41. package/.agent-src/rules/package-ci-checks.md +1 -0
  42. package/.agent-src/rules/php-coding.md +1 -0
  43. package/.agent-src/rules/preservation-guard.md +1 -0
  44. package/.agent-src/rules/review-routing-awareness.md +1 -0
  45. package/.agent-src/rules/reviewer-awareness.md +1 -0
  46. package/.agent-src/rules/roadmap-progress-sync.md +49 -0
  47. package/.agent-src/rules/role-mode-adherence.md +2 -2
  48. package/.agent-src/rules/rule-type-governance.md +29 -0
  49. package/.agent-src/rules/runtime-safety.md +1 -0
  50. package/.agent-src/rules/scope-control.md +1 -0
  51. package/.agent-src/rules/security-sensitive-stop.md +1 -0
  52. package/.agent-src/rules/size-enforcement.md +1 -0
  53. package/.agent-src/rules/skill-improvement-trigger.md +1 -0
  54. package/.agent-src/rules/skill-quality.md +1 -0
  55. package/.agent-src/rules/slash-command-routing-policy.md +39 -0
  56. package/.agent-src/rules/think-before-action.md +1 -0
  57. package/.agent-src/rules/token-efficiency.md +1 -0
  58. package/.agent-src/rules/tool-safety.md +1 -0
  59. package/.agent-src/rules/ui-audit-gate.md +1 -0
  60. package/.agent-src/rules/upstream-proposal.md +1 -0
  61. package/.agent-src/rules/user-interaction.md +1 -0
  62. package/.agent-src/rules/verify-before-complete.md +1 -0
  63. package/.agent-src/skills/roadmap-management/SKILL.md +29 -4
  64. package/.agent-src/skills/verify-completion-evidence/SKILL.md +8 -1
  65. package/.agent-src/templates/agent-settings.md +16 -0
  66. package/.agent-src/templates/roadmaps.md +12 -3
  67. package/.agent-src/templates/scripts/work_engine/hook_bootstrap.py +9 -0
  68. package/.agent-src/templates/scripts/work_engine/hooks/__init__.py +4 -0
  69. package/.agent-src/templates/scripts/work_engine/hooks/builtin/__init__.py +4 -0
  70. package/.agent-src/templates/scripts/work_engine/hooks/builtin/decision_trace.py +163 -0
  71. package/.agent-src/templates/scripts/work_engine/hooks/builtin/memory_visibility.py +111 -0
  72. package/.agent-src/templates/scripts/work_engine/hooks/settings.py +36 -0
  73. package/.agent-src/templates/scripts/work_engine/scoring/decision_trace.py +141 -0
  74. package/.agent-src/templates/scripts/work_engine/scoring/memory_visibility.py +125 -0
  75. package/.claude-plugin/marketplace.json +1 -1
  76. package/CHANGELOG.md +97 -0
  77. package/README.md +20 -20
  78. package/config/agent-settings.template.yml +23 -0
  79. package/docs/architecture.md +1 -1
  80. package/docs/catalog.md +5 -2
  81. package/docs/contracts/adr-settings-sync-engine.md +127 -0
  82. package/docs/contracts/decision-trace-v1.md +146 -0
  83. package/docs/contracts/file-ownership-matrix.json +7 -0
  84. package/docs/contracts/hook-architecture-v1.md +213 -0
  85. package/docs/contracts/load-context-budget-model.md +80 -0
  86. package/docs/contracts/load-context-schema.md +20 -0
  87. package/docs/contracts/memory-visibility-v1.md +138 -0
  88. package/docs/contracts/one-off-script-lifecycle.md +109 -0
  89. package/docs/contracts/roadmap-complexity-standard.md +137 -0
  90. package/docs/contracts/rule-interactions.yml +22 -0
  91. package/docs/customization.md +1 -0
  92. package/docs/development.md +4 -1
  93. package/docs/guidelines/agent-infra/ask-when-uncertain-demos.md +134 -0
  94. package/docs/guidelines/agent-infra/direct-answers-demos.md +145 -0
  95. package/docs/guidelines/agent-infra/layered-settings.md +32 -13
  96. package/docs/guidelines/agent-infra/verify-before-complete-demos.md +128 -0
  97. package/package.json +1 -1
  98. package/scripts/agent-config +64 -0
  99. package/scripts/ai_council/bundler.py +3 -3
  100. package/scripts/ai_council/clients.py +24 -8
  101. package/scripts/ai_council/one_off_archive/2026-05/README.md +67 -0
  102. package/scripts/ai_council/one_off_archive/2026-05/_one_off_budget_v2_audit.py +206 -0
  103. package/scripts/ai_council/{_one_off_roundtrip.py → one_off_archive/2026-05/_one_off_roundtrip.py} +13 -8
  104. package/scripts/ai_council/one_off_archive/2026-05/_one_off_tier_retrofit.py +180 -0
  105. package/scripts/ai_council/session.py +92 -0
  106. package/scripts/build_rule_trigger_matrix.py +360 -0
  107. package/scripts/capture_showcase_session.py +361 -0
  108. package/scripts/chat_history.py +11 -1
  109. package/scripts/check_always_budget.py +46 -2
  110. package/scripts/check_one_off_location.py +81 -0
  111. package/scripts/check_references.py +6 -0
  112. package/scripts/compress.py +5 -2
  113. package/scripts/context_hygiene_hook.py +181 -0
  114. package/scripts/council_cli.py +357 -0
  115. package/scripts/hook_manifest.yaml +184 -0
  116. package/scripts/hooks/__init__.py +1 -0
  117. package/scripts/hooks/augment-context-hygiene.sh +55 -0
  118. package/scripts/hooks/augment-dispatcher.sh +72 -0
  119. package/scripts/hooks/augment-onboarding-gate.sh +55 -0
  120. package/scripts/hooks/cline-dispatcher.sh +86 -0
  121. package/scripts/hooks/cursor-dispatcher.sh +76 -0
  122. package/scripts/hooks/dispatch_hook.py +348 -0
  123. package/scripts/hooks/envelope.py +98 -0
  124. package/scripts/hooks/gemini-dispatcher.sh +117 -0
  125. package/scripts/hooks/state_io.py +122 -0
  126. package/scripts/hooks/windsurf-dispatcher.sh +123 -0
  127. package/scripts/hooks_status.py +146 -0
  128. package/scripts/install.py +728 -51
  129. package/scripts/install.sh +1 -1
  130. package/scripts/lint_examples.py +98 -0
  131. package/scripts/lint_hook_manifest.py +216 -0
  132. package/scripts/lint_one_off_age.py +184 -0
  133. package/scripts/lint_roadmap_complexity.py +127 -0
  134. package/scripts/lint_rule_tiers.py +78 -0
  135. package/scripts/lint_showcase_sessions.py +148 -0
  136. package/scripts/minimal_safe_diff_hook.py +245 -0
  137. package/scripts/onboarding_gate_hook.py +142 -0
  138. package/scripts/readme_linter.py +12 -3
  139. package/scripts/roadmap_progress_hook.py +5 -0
  140. package/scripts/schemas/rule.schema.json +5 -0
  141. package/scripts/sync_agent_settings.py +32 -129
  142. package/scripts/sync_yaml_rt.py +734 -0
  143. package/scripts/verify_before_complete_hook.py +216 -0
  144. /package/scripts/ai_council/{_one_off_2a4_acceptance.py → one_off_archive/2026-05/_one_off_2a4_acceptance.py} +0 -0
  145. /package/scripts/ai_council/{_one_off_context_layer_v1_estimate.py → one_off_archive/2026-05/_one_off_context_layer_v1_estimate.py} +0 -0
  146. /package/scripts/ai_council/{_one_off_context_layer_v1_review.py → one_off_archive/2026-05/_one_off_context_layer_v1_review.py} +0 -0
  147. /package/scripts/ai_council/{_one_off_followups_review.py → one_off_archive/2026-05/_one_off_followups_review.py} +0 -0
  148. /package/scripts/ai_council/{_one_off_nondestructive_inline_audit.py → one_off_archive/2026-05/_one_off_nondestructive_inline_audit.py} +0 -0
  149. /package/scripts/{_one_off_phase4_dispatch_latency.py → ai_council/one_off_archive/2026-05/_one_off_phase4_dispatch_latency.py} +0 -0
  150. /package/scripts/{_one_off_phase6_trigger_jaccard.py → ai_council/one_off_archive/2026-05/_one_off_phase6_trigger_jaccard.py} +0 -0
  151. /package/scripts/ai_council/{_one_off_phase_2a_budget_rebalance.py → one_off_archive/2026-05/_one_off_phase_2a_budget_rebalance.py} +0 -0
  152. /package/scripts/ai_council/{_one_off_phase_2a_post_revert.py → one_off_archive/2026-05/_one_off_phase_2a_post_revert.py} +0 -0
  153. /package/scripts/ai_council/{_one_off_rebalancing_audit.py → one_off_archive/2026-05/_one_off_rebalancing_audit.py} +0 -0
  154. /package/scripts/ai_council/{_one_off_rule_hardening_v1.py → one_off_archive/2026-05/_one_off_rule_hardening_v1.py} +0 -0
  155. /package/scripts/ai_council/{_one_off_structural_open_questions.py → one_off_archive/2026-05/_one_off_structural_open_questions.py} +0 -0
  156. /package/scripts/ai_council/{_one_off_structural_optimization.py → one_off_archive/2026-05/_one_off_structural_optimization.py} +0 -0
  157. /package/scripts/ai_council/{_one_off_structural_v3_gaps.py → one_off_archive/2026-05/_one_off_structural_v3_gaps.py} +0 -0
  158. /package/scripts/ai_council/{_one_off_structural_v3_review.py → one_off_archive/2026-05/_one_off_structural_v3_review.py} +0 -0
@@ -0,0 +1,181 @@
1
+ #!/usr/bin/env python3
2
+ """Platform-agnostic PostToolUse hook for the `context-hygiene` rule.
3
+
4
+ Maintains a deterministic state file the rule body cites for the
5
+ freshness threshold, the 3-failure stop, and tool-loop detection. The
6
+ agent's job shrinks from "remember three counters" to "read this file
7
+ before responding".
8
+
9
+ Output: `agents/state/context-hygiene.json`
10
+ {
11
+ "tool_calls": <int>, // running PostToolUse count
12
+ "consecutive_same_tool": <int>, // includes the latest call
13
+ "last_tool": "<name>",
14
+ "tool_history": [..., last 5 names],
15
+ "loop_detected": <bool>, // ≥ 3 same tool in a row
16
+ "freshness_threshold": <int|null>, // 20/40/60 milestone hit
17
+ "checked_at": "<iso8601>"
18
+ }
19
+
20
+ Exit code is always 0.
21
+
22
+ CLI:
23
+ python3 scripts/context_hygiene_hook.py [--platform NAME] [--verbose]
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import datetime as _dt
29
+ import json
30
+ import sys
31
+ from pathlib import Path
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
+
39
+ STATE_DIR = Path("agents") / "state"
40
+ STATE_FILE = STATE_DIR / "context-hygiene.json"
41
+
42
+ LOOP_THRESHOLD = 3 # 3+ consecutive same-tool calls
43
+ HISTORY_DEPTH = 5
44
+ FRESHNESS_MILESTONES = (20, 40, 60)
45
+
46
+
47
+ def _load_state(target: Path) -> dict:
48
+ if not target.is_file():
49
+ return {
50
+ "tool_calls": 0,
51
+ "consecutive_same_tool": 0,
52
+ "last_tool": None,
53
+ "tool_history": [],
54
+ "loop_detected": False,
55
+ "freshness_threshold": None,
56
+ }
57
+ try:
58
+ decoded = json.loads(target.read_text(encoding="utf-8"))
59
+ if isinstance(decoded, dict):
60
+ return decoded
61
+ except (OSError, json.JSONDecodeError):
62
+ pass
63
+ # Corrupt — start fresh, never block.
64
+ return {
65
+ "tool_calls": 0,
66
+ "consecutive_same_tool": 0,
67
+ "last_tool": None,
68
+ "tool_history": [],
69
+ "loop_detected": False,
70
+ "freshness_threshold": None,
71
+ }
72
+
73
+
74
+ def _extract_tool(payload: dict) -> str | None:
75
+ for key in ("tool_name", "toolName", "tool"):
76
+ v = payload.get(key)
77
+ if isinstance(v, str) and v:
78
+ return v
79
+ return None
80
+
81
+
82
+ def _milestone_hit(prev: int, curr: int) -> int | None:
83
+ """Return the milestone crossed by going from `prev` to `curr`, else None."""
84
+ for ms in FRESHNESS_MILESTONES:
85
+ if prev < ms <= curr:
86
+ return ms
87
+ return None
88
+
89
+
90
+ def _update(state: dict, tool: str | None) -> dict:
91
+ if tool is None:
92
+ # Non-tool event (e.g. malformed payload) — still mark we ran.
93
+ state["checked_at"] = _dt.datetime.now(_dt.timezone.utc).isoformat(
94
+ timespec="seconds")
95
+ return state
96
+
97
+ prev_count = int(state.get("tool_calls") or 0)
98
+ curr_count = prev_count + 1
99
+ state["tool_calls"] = curr_count
100
+
101
+ last = state.get("last_tool")
102
+ if last == tool:
103
+ state["consecutive_same_tool"] = int(
104
+ state.get("consecutive_same_tool") or 0) + 1
105
+ else:
106
+ state["consecutive_same_tool"] = 1
107
+ state["last_tool"] = tool
108
+
109
+ hist = state.get("tool_history") or []
110
+ if not isinstance(hist, list):
111
+ hist = []
112
+ hist.append(tool)
113
+ state["tool_history"] = hist[-HISTORY_DEPTH:]
114
+
115
+ state["loop_detected"] = (
116
+ state["consecutive_same_tool"] >= LOOP_THRESHOLD)
117
+
118
+ ms = _milestone_hit(prev_count, curr_count)
119
+ if ms is not None:
120
+ state["freshness_threshold"] = ms
121
+ state["checked_at"] = _dt.datetime.now(_dt.timezone.utc).isoformat(
122
+ timespec="seconds")
123
+ return state
124
+
125
+
126
+ def _write_state(consumer_root: Path, state: dict) -> None:
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)
130
+
131
+
132
+ def run(stdin_text: str, *, consumer_root: Path, verbose: bool = False) -> int:
133
+ payload: dict = {}
134
+ if stdin_text.strip():
135
+ try:
136
+ decoded = json.loads(stdin_text)
137
+ if isinstance(decoded, dict):
138
+ payload = decoded
139
+ except json.JSONDecodeError:
140
+ pass # silent no-op, never block
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
+
147
+ target = consumer_root / STATE_FILE
148
+ state = _load_state(target)
149
+ state = _update(state, _extract_tool(payload))
150
+
151
+ try:
152
+ _write_state(consumer_root, state)
153
+ except OSError:
154
+ if verbose:
155
+ print("context-hygiene-hook: state write failed",
156
+ file=sys.stderr)
157
+ return 0
158
+
159
+ if verbose:
160
+ print(
161
+ f"context-hygiene-hook: tool_calls={state.get('tool_calls')} "
162
+ f"loop={state.get('loop_detected')} "
163
+ f"threshold={state.get('freshness_threshold')}",
164
+ file=sys.stderr,
165
+ )
166
+ return 0
167
+
168
+
169
+ def main(argv: list[str] | None = None) -> int:
170
+ parser = argparse.ArgumentParser(description=__doc__)
171
+ parser.add_argument("--platform", default="generic",
172
+ help="informational platform tag")
173
+ parser.add_argument("--verbose", action="store_true",
174
+ help="emit one stderr line per invocation")
175
+ args = parser.parse_args(argv)
176
+ return run(sys.stdin.read(), consumer_root=Path.cwd(),
177
+ verbose=args.verbose)
178
+
179
+
180
+ if __name__ == "__main__": # pragma: no cover
181
+ sys.exit(main())
@@ -0,0 +1,357 @@
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
+ ) -> list[ExternalAIClient]:
66
+ """Construct enabled council members from settings.
67
+
68
+ Honours `ai_council.enabled` (master switch) and per-member
69
+ `enabled` flags. Raises `CouncilDisabledError` when the council is
70
+ off or no member is wired up.
71
+ """
72
+ ai = (settings.get("ai_council") or {}) if isinstance(settings, dict) else {}
73
+ if not ai.get("enabled"):
74
+ raise CouncilDisabledError(
75
+ "ai_council.enabled is false in .agent-settings.yml — "
76
+ "flip it on before invoking council:* commands."
77
+ )
78
+ members_cfg = ai.get("members") or {}
79
+ global_mode = ai.get("mode")
80
+ members: list[ExternalAIClient] = []
81
+ for name, cfg in members_cfg.items():
82
+ cfg = cfg or {}
83
+ if not cfg.get("enabled"):
84
+ continue
85
+ mode = resolve_mode(
86
+ name,
87
+ invocation_mode=invocation_mode,
88
+ member_settings=cfg,
89
+ global_mode=global_mode,
90
+ )
91
+ model = cfg.get("model")
92
+ if mode == "api" and name == "anthropic":
93
+ members.append(AnthropicClient(model=model or "claude-sonnet-4-5",
94
+ api_key=load_anthropic_key()))
95
+ elif mode == "api" and name == "openai":
96
+ members.append(OpenAIClient(model=model or "gpt-4o",
97
+ api_key=load_openai_key()))
98
+ elif mode == "manual":
99
+ members.append(ManualClient(name=name, model=model or "manual"))
100
+ elif mode == "playwright":
101
+ raise CouncilDisabledError(
102
+ f"member {name!r} resolves to mode=playwright (Phase 2c, not wired)."
103
+ )
104
+ else:
105
+ raise CouncilDisabledError(
106
+ f"member {name!r} has no transport — mode={mode}, name not in {{anthropic,openai}}."
107
+ )
108
+ if not members:
109
+ raise CouncilDisabledError(
110
+ "no council member has `enabled: true` — enable at least one in "
111
+ ".agent-settings.yml under ai_council.members.*."
112
+ )
113
+ return members
114
+
115
+
116
+ def build_question(
117
+ *,
118
+ input_path: Path,
119
+ input_mode: str,
120
+ max_tokens: int,
121
+ ) -> tuple[CouncilQuestion, str]:
122
+ """Bundle the input file. Returns (question, artefact_label)."""
123
+ if input_mode == "prompt":
124
+ text = input_path.read_text(encoding="utf-8")
125
+ ctx = bundle_prompt(text)
126
+ artefact = str(input_path)
127
+ elif input_mode == "roadmap":
128
+ ctx = bundle_roadmap(input_path)
129
+ artefact = str(input_path)
130
+ else:
131
+ raise ValueError(
132
+ f"unsupported input mode: {input_mode!r} (use prompt | roadmap)"
133
+ )
134
+ return CouncilQuestion(mode=ctx.mode, user_prompt=ctx.text,
135
+ max_tokens=max_tokens), artefact
136
+
137
+
138
+ def format_estimate_table(
139
+ members: list[ExternalAIClient],
140
+ estimates: list[Any],
141
+ ) -> str:
142
+ rows = [
143
+ f" {m.name}/{m.model}: "
144
+ f"~{e.input_tokens} in + {e.output_tokens} out = ${e.total_usd:.4f}"
145
+ for m, e in zip(members, estimates)
146
+ ]
147
+ total = sum(e.total_usd for e in estimates)
148
+ rows.append(f" TOTAL: ${total:.4f}")
149
+ return "\n".join(rows)
150
+
151
+
152
+ # ── subcommands ─────────────────────────────────────────────────────
153
+
154
+
155
+ def cmd_estimate(
156
+ args: argparse.Namespace,
157
+ *,
158
+ settings: dict[str, Any] | None = None,
159
+ members: list[ExternalAIClient] | None = None,
160
+ table: PriceTable | None = None,
161
+ ) -> int:
162
+ """Print per-member cost preview. No API calls."""
163
+ if settings is None:
164
+ settings = load_settings()
165
+ if members is None:
166
+ members = build_members(settings, invocation_mode=args.mode_override)
167
+ if table is None:
168
+ table = load_prices()
169
+ question, _ = build_question(
170
+ input_path=Path(args.question), input_mode=args.input_mode,
171
+ max_tokens=args.max_tokens,
172
+ )
173
+ project = detect_project_context(REPO_ROOT)
174
+ billable = [m for m in members if getattr(m, "billable", True)]
175
+ estimates = estimate(question, billable, table,
176
+ project=project, original_ask=args.original_ask)
177
+ sys.stdout.write(
178
+ f"council:estimate · mode={question.mode} · members={len(members)} "
179
+ f"(billable={len(billable)})\n"
180
+ )
181
+ sys.stdout.write(format_estimate_table(billable, estimates) + "\n")
182
+ return 0
183
+
184
+
185
+ def _serialise_responses(responses: list[CouncilResponse]) -> list[dict[str, Any]]:
186
+ out: list[dict[str, Any]] = []
187
+ for r in responses:
188
+ d = asdict(r)
189
+ # `metadata` may contain non-JSON types; coerce.
190
+ d["metadata"] = {k: str(v) for k, v in (d.get("metadata") or {}).items()}
191
+ out.append(d)
192
+ return out
193
+
194
+
195
+ def _deserialise_responses(items: list[dict[str, Any]]) -> list[CouncilResponse]:
196
+ out: list[CouncilResponse] = []
197
+ for d in items:
198
+ out.append(CouncilResponse(
199
+ provider=d.get("provider", ""),
200
+ model=d.get("model", ""),
201
+ text=d.get("text", ""),
202
+ input_tokens=int(d.get("input_tokens", 0) or 0),
203
+ output_tokens=int(d.get("output_tokens", 0) or 0),
204
+ latency_ms=int(d.get("latency_ms", 0) or 0),
205
+ error=d.get("error"),
206
+ metadata=dict(d.get("metadata") or {}),
207
+ ))
208
+ return out
209
+
210
+
211
+ def cmd_run(
212
+ args: argparse.Namespace,
213
+ *,
214
+ settings: dict[str, Any] | None = None,
215
+ members: list[ExternalAIClient] | None = None,
216
+ table: PriceTable | None = None,
217
+ ) -> int:
218
+ """Estimate, then run the council. Requires --confirm to spend."""
219
+ if settings is None:
220
+ settings = load_settings()
221
+ if members is None:
222
+ members = build_members(settings, invocation_mode=args.mode_override)
223
+ if table is None:
224
+ table = load_prices()
225
+ question, artefact = build_question(
226
+ input_path=Path(args.question), input_mode=args.input_mode,
227
+ max_tokens=args.max_tokens,
228
+ )
229
+ project = detect_project_context(REPO_ROOT)
230
+ billable = [m for m in members if getattr(m, "billable", True)]
231
+ estimates = estimate(question, billable, table,
232
+ project=project, original_ask=args.original_ask)
233
+ sys.stdout.write(
234
+ f"council:run · mode={question.mode} · members={len(members)} "
235
+ f"(billable={len(billable)})\n"
236
+ )
237
+ sys.stdout.write(format_estimate_table(billable, estimates) + "\n")
238
+
239
+ if not args.confirm:
240
+ sys.stdout.write(
241
+ "\nNo --confirm flag — estimate only. Re-run with --confirm to "
242
+ "invoke the council and write the response.\n"
243
+ )
244
+ return 0
245
+
246
+ cost_cfg = (settings.get("ai_council") or {}).get("cost_budget") or {}
247
+ budget = CostBudget(
248
+ max_input_tokens=int(cost_cfg.get("max_input_tokens", 50_000)),
249
+ max_output_tokens=int(cost_cfg.get("max_output_tokens", 20_000)),
250
+ max_calls=int(cost_cfg.get("max_calls", 10)),
251
+ max_total_usd=float(cost_cfg.get("max_total_usd", 0.0) or 0.0),
252
+ )
253
+ responses = consult(
254
+ members, question, budget,
255
+ table=table, project=project,
256
+ original_ask=args.original_ask, rounds=args.rounds,
257
+ )
258
+ estimated_total = sum(e.total_usd for e in estimates)
259
+ actual_total = 0.0
260
+ for r in responses:
261
+ if r.error:
262
+ continue
263
+ ce = estimate_cost(r.provider, r.model, r.input_tokens, r.output_tokens, table)
264
+ actual_total += ce.total_usd
265
+ payload = {
266
+ "schema_version": SCHEMA_VERSION,
267
+ "mode": question.mode,
268
+ "artefact": artefact,
269
+ "original_ask": args.original_ask,
270
+ "members": [f"{m.name}/{m.model}" for m in members],
271
+ "rounds": args.rounds,
272
+ "cost_usd_estimated": round(estimated_total, 6),
273
+ "cost_usd_actual": round(actual_total, 6),
274
+ "responses": _serialise_responses(responses),
275
+ }
276
+ out_path = Path(args.output)
277
+ out_path.parent.mkdir(parents=True, exist_ok=True)
278
+ out_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
279
+ sys.stdout.write(
280
+ f"\ncouncil:run · wrote {out_path} "
281
+ f"(estimated ${estimated_total:.4f} / actual ${actual_total:.4f})\n"
282
+ )
283
+ errors = [r for r in responses if r.error]
284
+ return 1 if errors and len(errors) == len(responses) else 0
285
+
286
+
287
+ def cmd_render(args: argparse.Namespace) -> int:
288
+ """Re-render a saved responses JSON to the markdown report."""
289
+ payload = json.loads(Path(args.responses).read_text(encoding="utf-8"))
290
+ items = payload.get("responses") or []
291
+ sys.stdout.write(render(_deserialise_responses(items)) + "\n")
292
+ return 0
293
+
294
+
295
+ # ── argparse + main ─────────────────────────────────────────────────
296
+
297
+
298
+ def _add_common_input_args(p: argparse.ArgumentParser) -> None:
299
+ p.add_argument("question", type=str,
300
+ help="Path to the question file (text or roadmap).")
301
+ p.add_argument("--input-mode", choices=["prompt", "roadmap"],
302
+ default="prompt",
303
+ help="How to bundle the file (default: prompt).")
304
+ p.add_argument("--max-tokens", type=int, default=1024,
305
+ help="Per-member output budget (default: 1024).")
306
+ p.add_argument("--mode-override", choices=["api", "manual"], default=None,
307
+ help="Override every member's transport mode.")
308
+ p.add_argument("--original-ask", default="",
309
+ help="The user's framing sentence (flows into handoff).")
310
+
311
+
312
+ def build_parser() -> argparse.ArgumentParser:
313
+ parser = argparse.ArgumentParser(
314
+ prog="agent-config council",
315
+ description="Non-interactive council orchestration.",
316
+ )
317
+ sub = parser.add_subparsers(dest="cmd", required=True)
318
+
319
+ p_est = sub.add_parser("estimate", help="Pre-call cost preview (no spend).")
320
+ _add_common_input_args(p_est)
321
+
322
+ p_run = sub.add_parser("run", help="Run the council; --confirm required to spend.")
323
+ _add_common_input_args(p_run)
324
+ p_run.add_argument("--output", required=True,
325
+ help="Path to write the responses JSON.")
326
+ p_run.add_argument("--confirm", action="store_true",
327
+ help="Required to actually invoke the council.")
328
+ p_run.add_argument("--rounds", type=int, default=1,
329
+ help="Number of debate rounds (1-3).")
330
+
331
+ p_ren = sub.add_parser("render", help="Re-render a saved responses JSON.")
332
+ p_ren.add_argument("responses",
333
+ help="Path to the JSON written by `council run`.")
334
+
335
+ return parser
336
+
337
+
338
+ def main(argv: list[str] | None = None) -> int:
339
+ args = build_parser().parse_args(argv)
340
+ try:
341
+ if args.cmd == "estimate":
342
+ return cmd_estimate(args)
343
+ if args.cmd == "run":
344
+ return cmd_run(args)
345
+ if args.cmd == "render":
346
+ return cmd_render(args)
347
+ except CouncilDisabledError as exc:
348
+ sys.stderr.write(f"❌ council:{args.cmd}: {exc}\n")
349
+ return 2
350
+ except (BundleTooLarge, InvalidModeError, FileNotFoundError) as exc:
351
+ sys.stderr.write(f"❌ council:{args.cmd}: {exc}\n")
352
+ return 2
353
+ return 1
354
+
355
+
356
+ if __name__ == "__main__":
357
+ raise SystemExit(main())