@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
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""Council configuration loader — single source of truth.
|
|
2
|
+
|
|
3
|
+
Reads ``agents/.ai-council.yml`` per the contract in
|
|
4
|
+
``docs/contracts/ai-council-config.md``. Replaces the fragmented
|
|
5
|
+
``.agent-settings.yml`` ``ai_council`` block (Phase 0 migration).
|
|
6
|
+
|
|
7
|
+
Validation contract (7 rules, all enforced at load time):
|
|
8
|
+
|
|
9
|
+
1. ``enabled`` is a bool.
|
|
10
|
+
2. ``defaults.mode`` ∈ {``api``, ``manual``}; per-member mode same set.
|
|
11
|
+
3. ``members.<name>`` keys are restricted to the known provider set.
|
|
12
|
+
4. ``cost_budget.*`` numeric fields are >= 0.
|
|
13
|
+
5. Enabled members carry a non-empty ``model`` and ``api_key_ref``.
|
|
14
|
+
6. ``api_key_ref`` starts with ``file:`` or ``env:`` — raw keys are
|
|
15
|
+
refused even if syntactically plausible.
|
|
16
|
+
7. Resolved ``file:`` key paths must have mode 0o600 (delegated to
|
|
17
|
+
:func:`resolve_api_key`; runs at use-time, not parse-time).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import os
|
|
23
|
+
import stat
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
import yaml
|
|
29
|
+
|
|
30
|
+
from scripts._lib import user_global_paths
|
|
31
|
+
|
|
32
|
+
_VALID_PROVIDERS = frozenset({"anthropic", "openai", "gemini", "xai", "perplexity"})
|
|
33
|
+
_VALID_MODES = frozenset({"api", "manual"})
|
|
34
|
+
|
|
35
|
+
#: Prefixes that signal "this is a raw API key" so we refuse it loudly
|
|
36
|
+
#: even when the user accidentally inlined it in ``api_key_ref``.
|
|
37
|
+
_RAW_KEY_PREFIXES = ("sk-", "sk-ant-", "ya29.", "AIza", "xai-", "pplx-", "gsk_")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class CouncilConfigError(RuntimeError):
|
|
41
|
+
"""Raised when ``agents/.ai-council.yml`` violates the schema."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class DefaultsConfig:
|
|
46
|
+
mode: str = "api"
|
|
47
|
+
min_rounds: int = 2
|
|
48
|
+
deep_min_rounds: int = 3
|
|
49
|
+
max_output_tokens: int = 0
|
|
50
|
+
session_retention_days: int = 7
|
|
51
|
+
debate_max_rounds: int = 4
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class CostBudgetConfig:
|
|
56
|
+
max_input_tokens: int = 500_000
|
|
57
|
+
max_output_tokens: int = 200_000
|
|
58
|
+
max_calls: int = 50
|
|
59
|
+
max_total_usd: float = 20.0
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class MemberConfig:
|
|
64
|
+
name: str
|
|
65
|
+
enabled: bool
|
|
66
|
+
model: str
|
|
67
|
+
api_key_ref: str | None
|
|
68
|
+
mode: str | None = None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class AdvisorConfig:
|
|
73
|
+
"""Replace-mode advisor binding (Phase 6).
|
|
74
|
+
|
|
75
|
+
`member` names the provider whose plain call is replaced by this
|
|
76
|
+
advisor-persona call. `persona` is the path to the advisor persona
|
|
77
|
+
file (resolved relative to the package root). `model` is an
|
|
78
|
+
optional override of the bound member's plain model.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
name: str
|
|
82
|
+
enabled: bool
|
|
83
|
+
member: str
|
|
84
|
+
persona: str
|
|
85
|
+
model: str | None = None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class ConsensusScoringConfig:
|
|
90
|
+
"""Consensus-scoring round settings (Phase 4 / F3).
|
|
91
|
+
|
|
92
|
+
Only the `analysis` lens activates the scoring round today. Other
|
|
93
|
+
lenses see this as inert config. Thresholds are inclusive on the
|
|
94
|
+
`strong` side (> strong → strong bucket) and exclusive on the
|
|
95
|
+
`minority` side (≤ minority → minority bucket); the middle bucket
|
|
96
|
+
is `(minority, strong]`. Defaults mirror the roadmap (0.7 / 0.4).
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
enabled: bool = False
|
|
100
|
+
strong_threshold: float = 0.7
|
|
101
|
+
minority_threshold: float = 0.4
|
|
102
|
+
lenses: tuple[str, ...] = ("analysis",)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class CouncilConfig:
|
|
107
|
+
enabled: bool
|
|
108
|
+
defaults: DefaultsConfig
|
|
109
|
+
cost_budget: CostBudgetConfig
|
|
110
|
+
members: dict[str, MemberConfig]
|
|
111
|
+
advisors: dict[str, AdvisorConfig] = field(default_factory=dict)
|
|
112
|
+
consensus_scoring: ConsensusScoringConfig = field(
|
|
113
|
+
default_factory=ConsensusScoringConfig,
|
|
114
|
+
)
|
|
115
|
+
source_path: Path | None = None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def load_council_config(path: Path) -> CouncilConfig:
|
|
119
|
+
"""Load and validate the council YAML at ``path``."""
|
|
120
|
+
if not path.exists():
|
|
121
|
+
raise CouncilConfigError(
|
|
122
|
+
f"Council config not found at {path}. "
|
|
123
|
+
f"Create it per docs/contracts/ai-council-config.md."
|
|
124
|
+
)
|
|
125
|
+
try:
|
|
126
|
+
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
127
|
+
except yaml.YAMLError as exc:
|
|
128
|
+
raise CouncilConfigError(f"{path}: invalid YAML — {exc}") from exc
|
|
129
|
+
if not isinstance(raw, dict):
|
|
130
|
+
raise CouncilConfigError(f"{path}: top-level must be a mapping.")
|
|
131
|
+
return _build_config(raw, source_path=path)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _build_config(raw: dict[str, Any], *, source_path: Path) -> CouncilConfig:
|
|
135
|
+
enabled = raw.get("enabled", False)
|
|
136
|
+
if not isinstance(enabled, bool):
|
|
137
|
+
raise CouncilConfigError("`enabled` must be a bool.")
|
|
138
|
+
|
|
139
|
+
defaults = _build_defaults(raw.get("defaults") or {})
|
|
140
|
+
cost_budget = _build_cost_budget(raw.get("cost_budget") or {})
|
|
141
|
+
|
|
142
|
+
members_raw = raw.get("members") or {}
|
|
143
|
+
if not isinstance(members_raw, dict):
|
|
144
|
+
raise CouncilConfigError("`members` must be a mapping.")
|
|
145
|
+
members: dict[str, MemberConfig] = {}
|
|
146
|
+
for name, cfg in members_raw.items():
|
|
147
|
+
members[name] = _build_member(name, cfg or {})
|
|
148
|
+
|
|
149
|
+
advisors_raw = raw.get("advisors") or {}
|
|
150
|
+
if not isinstance(advisors_raw, dict):
|
|
151
|
+
raise CouncilConfigError("`advisors` must be a mapping.")
|
|
152
|
+
advisors: dict[str, AdvisorConfig] = {}
|
|
153
|
+
for adv_name, adv_cfg in advisors_raw.items():
|
|
154
|
+
advisors[adv_name] = _build_advisor(adv_name, adv_cfg or {})
|
|
155
|
+
|
|
156
|
+
# Cross-validate enabled advisors against the members block. An
|
|
157
|
+
# advisor referencing a missing or disabled member is a hard error
|
|
158
|
+
# — never a silent skip — so a typo never costs the user money on
|
|
159
|
+
# an unintended call plan.
|
|
160
|
+
for adv in advisors.values():
|
|
161
|
+
if not adv.enabled:
|
|
162
|
+
continue
|
|
163
|
+
bound = members.get(adv.member)
|
|
164
|
+
if bound is None:
|
|
165
|
+
raise CouncilConfigError(
|
|
166
|
+
f"advisors.{adv.name}.member={adv.member!r}: no such "
|
|
167
|
+
f"member in the `members` block."
|
|
168
|
+
)
|
|
169
|
+
if not bound.enabled:
|
|
170
|
+
raise CouncilConfigError(
|
|
171
|
+
f"advisors.{adv.name}.member={adv.member!r}: member "
|
|
172
|
+
f"exists but is disabled. Enable the member or disable "
|
|
173
|
+
f"the advisor."
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
consensus = _build_consensus_scoring(raw.get("consensus_scoring") or {})
|
|
177
|
+
|
|
178
|
+
return CouncilConfig(
|
|
179
|
+
enabled=enabled,
|
|
180
|
+
defaults=defaults,
|
|
181
|
+
cost_budget=cost_budget,
|
|
182
|
+
members=members,
|
|
183
|
+
advisors=advisors,
|
|
184
|
+
consensus_scoring=consensus,
|
|
185
|
+
source_path=source_path,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _build_consensus_scoring(d: dict[str, Any]) -> ConsensusScoringConfig:
|
|
190
|
+
if not isinstance(d, dict):
|
|
191
|
+
raise CouncilConfigError("`consensus_scoring` must be a mapping.")
|
|
192
|
+
strong = float(d.get("strong_threshold", 0.7))
|
|
193
|
+
minority = float(d.get("minority_threshold", 0.4))
|
|
194
|
+
if not 0.0 <= minority <= strong <= 1.0:
|
|
195
|
+
raise CouncilConfigError(
|
|
196
|
+
f"consensus_scoring thresholds broken: require "
|
|
197
|
+
f"0 <= minority ({minority}) <= strong ({strong}) <= 1."
|
|
198
|
+
)
|
|
199
|
+
lenses_raw = d.get("lenses", ["analysis"])
|
|
200
|
+
if not isinstance(lenses_raw, list) or not all(isinstance(x, str) for x in lenses_raw):
|
|
201
|
+
raise CouncilConfigError("`consensus_scoring.lenses` must be a list of strings.")
|
|
202
|
+
return ConsensusScoringConfig(
|
|
203
|
+
enabled=bool(d.get("enabled", False)),
|
|
204
|
+
strong_threshold=strong,
|
|
205
|
+
minority_threshold=minority,
|
|
206
|
+
lenses=tuple(lenses_raw),
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _build_defaults(d: dict[str, Any]) -> DefaultsConfig:
|
|
211
|
+
if not isinstance(d, dict):
|
|
212
|
+
raise CouncilConfigError("`defaults` must be a mapping.")
|
|
213
|
+
mode = d.get("mode", "api")
|
|
214
|
+
if mode not in _VALID_MODES:
|
|
215
|
+
raise CouncilConfigError(
|
|
216
|
+
f"defaults.mode={mode!r} not in {sorted(_VALID_MODES)}."
|
|
217
|
+
)
|
|
218
|
+
return DefaultsConfig(
|
|
219
|
+
mode=mode,
|
|
220
|
+
min_rounds=int(d.get("min_rounds", 2)),
|
|
221
|
+
deep_min_rounds=int(d.get("deep_min_rounds", 3)),
|
|
222
|
+
max_output_tokens=int(d.get("max_output_tokens", 0)),
|
|
223
|
+
session_retention_days=int(d.get("session_retention_days", 7)),
|
|
224
|
+
debate_max_rounds=int(d.get("debate_max_rounds", 4)),
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _build_cost_budget(d: dict[str, Any]) -> CostBudgetConfig:
|
|
229
|
+
if not isinstance(d, dict):
|
|
230
|
+
raise CouncilConfigError("`cost_budget` must be a mapping.")
|
|
231
|
+
cb = CostBudgetConfig(
|
|
232
|
+
max_input_tokens=int(d.get("max_input_tokens", 500_000)),
|
|
233
|
+
max_output_tokens=int(d.get("max_output_tokens", 200_000)),
|
|
234
|
+
max_calls=int(d.get("max_calls", 50)),
|
|
235
|
+
max_total_usd=float(d.get("max_total_usd", 20.0)),
|
|
236
|
+
)
|
|
237
|
+
for fname in ("max_input_tokens", "max_output_tokens", "max_calls", "max_total_usd"):
|
|
238
|
+
val = getattr(cb, fname)
|
|
239
|
+
if val < 0:
|
|
240
|
+
raise CouncilConfigError(
|
|
241
|
+
f"cost_budget.{fname} must be >= 0 (got {val!r})."
|
|
242
|
+
)
|
|
243
|
+
return cb
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _build_member(name: str, cfg: dict[str, Any]) -> MemberConfig:
|
|
247
|
+
if name not in _VALID_PROVIDERS:
|
|
248
|
+
raise CouncilConfigError(
|
|
249
|
+
f"members.{name}: unknown provider; valid: {sorted(_VALID_PROVIDERS)}."
|
|
250
|
+
)
|
|
251
|
+
member_enabled = bool(cfg.get("enabled", False))
|
|
252
|
+
model = cfg.get("model") or ""
|
|
253
|
+
api_key_ref = cfg.get("api_key_ref")
|
|
254
|
+
if member_enabled:
|
|
255
|
+
if not model:
|
|
256
|
+
raise CouncilConfigError(
|
|
257
|
+
f"members.{name}: enabled members require a non-empty `model`."
|
|
258
|
+
)
|
|
259
|
+
if not api_key_ref:
|
|
260
|
+
raise CouncilConfigError(
|
|
261
|
+
f"members.{name}: enabled members require an `api_key_ref`."
|
|
262
|
+
)
|
|
263
|
+
if api_key_ref is not None:
|
|
264
|
+
_validate_api_key_ref(f"members.{name}", api_key_ref)
|
|
265
|
+
member_mode = cfg.get("mode")
|
|
266
|
+
if member_mode is not None and member_mode not in _VALID_MODES:
|
|
267
|
+
raise CouncilConfigError(
|
|
268
|
+
f"members.{name}.mode={member_mode!r} not in {sorted(_VALID_MODES)}."
|
|
269
|
+
)
|
|
270
|
+
return MemberConfig(
|
|
271
|
+
name=name,
|
|
272
|
+
enabled=member_enabled,
|
|
273
|
+
model=model,
|
|
274
|
+
api_key_ref=api_key_ref,
|
|
275
|
+
mode=member_mode,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _build_advisor(name: str, cfg: dict[str, Any]) -> AdvisorConfig:
|
|
280
|
+
if not isinstance(cfg, dict):
|
|
281
|
+
raise CouncilConfigError(f"advisors.{name}: must be a mapping.")
|
|
282
|
+
member = cfg.get("member")
|
|
283
|
+
if member not in _VALID_PROVIDERS:
|
|
284
|
+
raise CouncilConfigError(
|
|
285
|
+
f"advisors.{name}.member={member!r} not a valid provider; "
|
|
286
|
+
f"valid: {sorted(_VALID_PROVIDERS)}."
|
|
287
|
+
)
|
|
288
|
+
# `persona` may be set explicitly; otherwise default to the
|
|
289
|
+
# convention path so the YAML stays terse.
|
|
290
|
+
persona = cfg.get("persona") or f"personas/advisors/{name}.md"
|
|
291
|
+
model = cfg.get("model")
|
|
292
|
+
if model is not None and not isinstance(model, str):
|
|
293
|
+
raise CouncilConfigError(
|
|
294
|
+
f"advisors.{name}.model must be a string when set."
|
|
295
|
+
)
|
|
296
|
+
return AdvisorConfig(
|
|
297
|
+
name=name,
|
|
298
|
+
enabled=bool(cfg.get("enabled", False)),
|
|
299
|
+
member=member,
|
|
300
|
+
persona=persona,
|
|
301
|
+
model=model,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _validate_api_key_ref(scope: str, ref: Any) -> None:
|
|
306
|
+
if not isinstance(ref, str) or not ref:
|
|
307
|
+
raise CouncilConfigError(f"{scope}.api_key_ref must be a non-empty string.")
|
|
308
|
+
if any(ref.startswith(prefix) for prefix in _RAW_KEY_PREFIXES):
|
|
309
|
+
raise CouncilConfigError(
|
|
310
|
+
f"{scope}.api_key_ref looks like a raw API key. "
|
|
311
|
+
f"Use `file:<path>` (0600) or `env:<VAR>` — never inline secrets."
|
|
312
|
+
)
|
|
313
|
+
if ref.startswith("file:"):
|
|
314
|
+
if not ref[len("file:"):].strip():
|
|
315
|
+
raise CouncilConfigError(f"{scope}.api_key_ref `file:` ref missing path.")
|
|
316
|
+
return
|
|
317
|
+
if ref.startswith("env:"):
|
|
318
|
+
if not ref[len("env:"):].strip():
|
|
319
|
+
raise CouncilConfigError(f"{scope}.api_key_ref `env:` ref missing variable name.")
|
|
320
|
+
return
|
|
321
|
+
raise CouncilConfigError(
|
|
322
|
+
f"{scope}.api_key_ref must start with `file:` or `env:` (got {ref!r})."
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def resolve_api_key(ref: str, *, scope: str = "api_key_ref") -> str:
|
|
327
|
+
"""Resolve ``file:<path>`` or ``env:<VAR>`` to the raw key string.
|
|
328
|
+
|
|
329
|
+
``file:`` — relative paths resolve under the user-global namespace
|
|
330
|
+
(``~/.event4u/agent-config/`` today, with the pre-2.4
|
|
331
|
+
``~/.config/agent-config/`` tree read as a fallback). Mode must be
|
|
332
|
+
0o600. ``env:`` — reads from ``os.environ``; empty/missing is a
|
|
333
|
+
hard error. Never echoes the value.
|
|
334
|
+
"""
|
|
335
|
+
_validate_api_key_ref(scope, ref)
|
|
336
|
+
if ref.startswith("env:"):
|
|
337
|
+
var = ref[len("env:"):].strip()
|
|
338
|
+
if not var:
|
|
339
|
+
raise CouncilConfigError(f"{scope}: `env:` ref missing variable name.")
|
|
340
|
+
value = os.environ.get(var, "").strip()
|
|
341
|
+
if not value:
|
|
342
|
+
raise CouncilConfigError(f"{scope}: env var {var!r} is unset or empty.")
|
|
343
|
+
return value
|
|
344
|
+
spec = ref[len("file:"):].strip()
|
|
345
|
+
if not spec:
|
|
346
|
+
raise CouncilConfigError(f"{scope}: `file:` ref missing path.")
|
|
347
|
+
path = Path(spec).expanduser()
|
|
348
|
+
if not path.is_absolute():
|
|
349
|
+
found = user_global_paths.resolve_with_fallback(spec)
|
|
350
|
+
if found is None:
|
|
351
|
+
target = user_global_paths.write_target(spec)
|
|
352
|
+
raise CouncilConfigError(
|
|
353
|
+
f"{scope}: key file not found at {target} (or legacy fallback)."
|
|
354
|
+
)
|
|
355
|
+
path = found
|
|
356
|
+
if not path.exists():
|
|
357
|
+
raise CouncilConfigError(f"{scope}: key file does not exist at {path}.")
|
|
358
|
+
mode = stat.S_IMODE(path.stat().st_mode)
|
|
359
|
+
if mode != 0o600:
|
|
360
|
+
raise CouncilConfigError(
|
|
361
|
+
f"{scope}: unsafe permissions on {path}: got {oct(mode)}, expected 0o600. "
|
|
362
|
+
f"Fix: chmod 600 {path}"
|
|
363
|
+
)
|
|
364
|
+
value = path.read_text(encoding="utf-8").strip()
|
|
365
|
+
if not value:
|
|
366
|
+
raise CouncilConfigError(f"{scope}: key file at {path} is empty.")
|
|
367
|
+
return value
|
|
368
|
+
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""Consensus scoring for the analysis lens (Phase 4 / F3).
|
|
2
|
+
|
|
3
|
+
After the final deliberation round, members score each other's
|
|
4
|
+
findings. The renderer ranks findings by consensus and surfaces a
|
|
5
|
+
"Minority Views" section for sub-threshold items so they remain
|
|
6
|
+
audit-trail signal rather than silent drop.
|
|
7
|
+
|
|
8
|
+
Schema (Opus's machine-readable contract):
|
|
9
|
+
|
|
10
|
+
Finding — `{id: str, source: str, text: str}`
|
|
11
|
+
FindingScore — `{finding_id: str, scorer: str, score: 1..10,
|
|
12
|
+
agree: bool, reason: str}`
|
|
13
|
+
ConsensusMetadata — per-finding aggregate:
|
|
14
|
+
`{finding_id, consensus_strength: 0..1,
|
|
15
|
+
dissent_count, scorers, mean_score}`
|
|
16
|
+
|
|
17
|
+
Threshold bucketing (Phase 4 Step 3):
|
|
18
|
+
|
|
19
|
+
consensus_strength > strong → Strong Consensus
|
|
20
|
+
minority < strength <= strong → Findings (default body)
|
|
21
|
+
strength <= minority → Minority Views
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import re
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from typing import Iterable
|
|
30
|
+
|
|
31
|
+
_JSON_BLOCK = re.compile(r"```(?:json)?\s*(\[.*?\])\s*```", re.DOTALL)
|
|
32
|
+
_BARE_ARRAY = re.compile(r"(\[\s*\{.*?\}\s*\])", re.DOTALL)
|
|
33
|
+
|
|
34
|
+
# Defaults mirror the roadmap (Phase 4 Step 4). The .agent-settings.yml
|
|
35
|
+
# block overrides them at run time.
|
|
36
|
+
DEFAULT_STRONG_THRESHOLD: float = 0.7
|
|
37
|
+
DEFAULT_MINORITY_THRESHOLD: float = 0.4
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class Finding:
|
|
42
|
+
"""One finding extracted from a member's deliberation output."""
|
|
43
|
+
|
|
44
|
+
id: str
|
|
45
|
+
source: str # provider/model that authored the finding
|
|
46
|
+
text: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class FindingScore:
|
|
51
|
+
"""One scorer's vote on one finding."""
|
|
52
|
+
|
|
53
|
+
finding_id: str
|
|
54
|
+
scorer: str
|
|
55
|
+
score: int # 1..10
|
|
56
|
+
agree: bool
|
|
57
|
+
reason: str
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class ConsensusMetadata:
|
|
62
|
+
"""Aggregate consensus stats for a single finding."""
|
|
63
|
+
|
|
64
|
+
finding_id: str
|
|
65
|
+
consensus_strength: float # 0..1
|
|
66
|
+
dissent_count: int
|
|
67
|
+
scorers: tuple[str, ...]
|
|
68
|
+
mean_score: float
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class ConsensusBucket:
|
|
73
|
+
"""Threshold-bucketed findings ready for renderer sectioning."""
|
|
74
|
+
|
|
75
|
+
strong: list[tuple[Finding, ConsensusMetadata]] = field(default_factory=list)
|
|
76
|
+
findings: list[tuple[Finding, ConsensusMetadata]] = field(default_factory=list)
|
|
77
|
+
minority: list[tuple[Finding, ConsensusMetadata]] = field(default_factory=list)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def aggregate_scores(
|
|
81
|
+
findings: Iterable[Finding],
|
|
82
|
+
scores: Iterable[FindingScore],
|
|
83
|
+
) -> dict[str, ConsensusMetadata]:
|
|
84
|
+
"""Aggregate per-finding scores into ConsensusMetadata.
|
|
85
|
+
|
|
86
|
+
`consensus_strength` = mean(score) / 10 * agreement_rate.
|
|
87
|
+
|
|
88
|
+
A finding's *own author* is never expected to score it; we drop
|
|
89
|
+
self-scores defensively to keep the aggregate honest. Missing
|
|
90
|
+
findings get zero scorers (strength=0, dissent_count=0).
|
|
91
|
+
"""
|
|
92
|
+
by_id: dict[str, list[FindingScore]] = {f.id: [] for f in findings}
|
|
93
|
+
sources: dict[str, str] = {f.id: f.source for f in findings}
|
|
94
|
+
for s in scores:
|
|
95
|
+
if s.finding_id not in by_id:
|
|
96
|
+
continue
|
|
97
|
+
if s.scorer == sources[s.finding_id]:
|
|
98
|
+
continue # ignore self-scores
|
|
99
|
+
by_id[s.finding_id].append(s)
|
|
100
|
+
out: dict[str, ConsensusMetadata] = {}
|
|
101
|
+
for fid, fs in by_id.items():
|
|
102
|
+
if not fs:
|
|
103
|
+
out[fid] = ConsensusMetadata(
|
|
104
|
+
finding_id=fid, consensus_strength=0.0,
|
|
105
|
+
dissent_count=0, scorers=(), mean_score=0.0,
|
|
106
|
+
)
|
|
107
|
+
continue
|
|
108
|
+
mean = sum(s.score for s in fs) / len(fs)
|
|
109
|
+
agree_rate = sum(1 for s in fs if s.agree) / len(fs)
|
|
110
|
+
strength = (mean / 10.0) * agree_rate
|
|
111
|
+
dissent = sum(1 for s in fs if not s.agree)
|
|
112
|
+
scorers = tuple(s.scorer for s in fs)
|
|
113
|
+
out[fid] = ConsensusMetadata(
|
|
114
|
+
finding_id=fid, consensus_strength=round(strength, 3),
|
|
115
|
+
dissent_count=dissent, scorers=scorers,
|
|
116
|
+
mean_score=round(mean, 2),
|
|
117
|
+
)
|
|
118
|
+
return out
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def bucket_by_threshold(
|
|
122
|
+
findings: Iterable[Finding],
|
|
123
|
+
metadata: dict[str, ConsensusMetadata],
|
|
124
|
+
*,
|
|
125
|
+
strong: float = DEFAULT_STRONG_THRESHOLD,
|
|
126
|
+
minority: float = DEFAULT_MINORITY_THRESHOLD,
|
|
127
|
+
) -> ConsensusBucket:
|
|
128
|
+
"""Split findings into Strong / Findings / Minority buckets.
|
|
129
|
+
|
|
130
|
+
`strong` and `minority` are the thresholds from
|
|
131
|
+
`.agent-settings.yml::ai_council.consensus_threshold_*`. Findings
|
|
132
|
+
with no metadata (no scorers) fall into the Minority bucket — they
|
|
133
|
+
were uncontested but unsupported.
|
|
134
|
+
"""
|
|
135
|
+
if not 0.0 <= minority <= strong <= 1.0:
|
|
136
|
+
raise ValueError(
|
|
137
|
+
f"Threshold ordering broken: 0 <= {minority} <= {strong} <= 1 required.",
|
|
138
|
+
)
|
|
139
|
+
bucket = ConsensusBucket()
|
|
140
|
+
for f in findings:
|
|
141
|
+
m = metadata.get(f.id)
|
|
142
|
+
if m is None:
|
|
143
|
+
m = ConsensusMetadata(
|
|
144
|
+
finding_id=f.id, consensus_strength=0.0,
|
|
145
|
+
dissent_count=0, scorers=(), mean_score=0.0,
|
|
146
|
+
)
|
|
147
|
+
if m.consensus_strength > strong:
|
|
148
|
+
bucket.strong.append((f, m))
|
|
149
|
+
elif m.consensus_strength > minority:
|
|
150
|
+
bucket.findings.append((f, m))
|
|
151
|
+
else:
|
|
152
|
+
bucket.minority.append((f, m))
|
|
153
|
+
# Strongest first inside each bucket.
|
|
154
|
+
for lst in (bucket.strong, bucket.findings, bucket.minority):
|
|
155
|
+
lst.sort(key=lambda pair: pair[1].consensus_strength, reverse=True)
|
|
156
|
+
return bucket
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def parse_findings_response(text: str, *, source: str) -> list[Finding]:
|
|
160
|
+
"""Parse a member's structured-findings response into Finding objects.
|
|
161
|
+
|
|
162
|
+
Accepts either a fenced ```json``` block or a bare JSON array. Each
|
|
163
|
+
item must be `{id: str, text: str}` (the `source` is set from the
|
|
164
|
+
`source` arg so we can attribute findings to their author). Items
|
|
165
|
+
missing required keys are skipped silently — extraction is best-
|
|
166
|
+
effort, never raises.
|
|
167
|
+
"""
|
|
168
|
+
array = _extract_json_array(text)
|
|
169
|
+
if not array:
|
|
170
|
+
return []
|
|
171
|
+
try:
|
|
172
|
+
parsed = json.loads(array)
|
|
173
|
+
except json.JSONDecodeError:
|
|
174
|
+
return []
|
|
175
|
+
if not isinstance(parsed, list):
|
|
176
|
+
return []
|
|
177
|
+
out: list[Finding] = []
|
|
178
|
+
for item in parsed:
|
|
179
|
+
if not isinstance(item, dict):
|
|
180
|
+
continue
|
|
181
|
+
fid = item.get("id")
|
|
182
|
+
txt = item.get("text")
|
|
183
|
+
if not fid or not txt:
|
|
184
|
+
continue
|
|
185
|
+
out.append(Finding(id=str(fid), source=source, text=str(txt).strip()))
|
|
186
|
+
return out
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def parse_scores_response(text: str, *, scorer: str) -> list[FindingScore]:
|
|
190
|
+
"""Parse a member's scoring response into FindingScore objects.
|
|
191
|
+
|
|
192
|
+
Each item must be `{finding_id, score, agree, reason}`. Scores are
|
|
193
|
+
clamped to 1..10; non-numeric scores or out-of-range values cause
|
|
194
|
+
the item to be skipped (defensive — never poison aggregates).
|
|
195
|
+
"""
|
|
196
|
+
array = _extract_json_array(text)
|
|
197
|
+
if not array:
|
|
198
|
+
return []
|
|
199
|
+
try:
|
|
200
|
+
parsed = json.loads(array)
|
|
201
|
+
except json.JSONDecodeError:
|
|
202
|
+
return []
|
|
203
|
+
if not isinstance(parsed, list):
|
|
204
|
+
return []
|
|
205
|
+
out: list[FindingScore] = []
|
|
206
|
+
for item in parsed:
|
|
207
|
+
if not isinstance(item, dict):
|
|
208
|
+
continue
|
|
209
|
+
fid = item.get("finding_id") or item.get("id")
|
|
210
|
+
score = item.get("score")
|
|
211
|
+
if not fid or not isinstance(score, (int, float)):
|
|
212
|
+
continue
|
|
213
|
+
score_int = int(score)
|
|
214
|
+
if not 1 <= score_int <= 10:
|
|
215
|
+
continue
|
|
216
|
+
out.append(FindingScore(
|
|
217
|
+
finding_id=str(fid), scorer=scorer, score=score_int,
|
|
218
|
+
agree=bool(item.get("agree", True)),
|
|
219
|
+
reason=str(item.get("reason", "")).strip(),
|
|
220
|
+
))
|
|
221
|
+
return out
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _extract_json_array(text: str) -> str:
|
|
225
|
+
"""Best-effort JSON-array extraction from a model response."""
|
|
226
|
+
if not text:
|
|
227
|
+
return ""
|
|
228
|
+
fenced = _JSON_BLOCK.search(text)
|
|
229
|
+
if fenced:
|
|
230
|
+
return fenced.group(1)
|
|
231
|
+
bare = _BARE_ARRAY.search(text)
|
|
232
|
+
if bare:
|
|
233
|
+
return bare.group(1)
|
|
234
|
+
return ""
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def anonymize_findings(findings: list[Finding]) -> dict[str, Finding]:
|
|
238
|
+
"""Return `{anon_label: Finding}` map so scorers see neutral labels.
|
|
239
|
+
|
|
240
|
+
Labels are `Finding-A`, `Finding-B`, … in input order. The author
|
|
241
|
+
mapping must be kept out of the prompt — keep it server-side only.
|
|
242
|
+
"""
|
|
243
|
+
out: dict[str, Finding] = {}
|
|
244
|
+
for idx, f in enumerate(findings):
|
|
245
|
+
label = f"Finding-{chr(ord('A') + idx)}"
|
|
246
|
+
out[label] = f
|
|
247
|
+
return out
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def anonymize_responses(
|
|
251
|
+
responses: Iterable[tuple[str, str]],
|
|
252
|
+
*,
|
|
253
|
+
persona_labels: dict[str, str] | None = None,
|
|
254
|
+
) -> tuple[dict[str, str], dict[str, str]]:
|
|
255
|
+
"""Anonymize deliberation responses for the peer-review round (Phase 5).
|
|
256
|
+
|
|
257
|
+
`responses` is an iterable of ``(source, text)`` pairs where ``source``
|
|
258
|
+
is the canonical `provider:model` identifier. Returns:
|
|
259
|
+
|
|
260
|
+
- ``anon_text``: ``{Response-A: <body>}`` map fed into the prompt.
|
|
261
|
+
- ``label_to_source``: ``{Response-A: provider:model}`` map kept
|
|
262
|
+
server-side so the orchestrator can de-anonymize at synthesis time.
|
|
263
|
+
|
|
264
|
+
Empty / whitespace-only texts are skipped — they leak nothing and
|
|
265
|
+
would clutter the prompt. Input order is preserved so determinism
|
|
266
|
+
holds for tests (Iron-Law neutrality §peer-review: anonymization
|
|
267
|
+
strips identity, not order; deterministic A/B labels avoid
|
|
268
|
+
accidental cross-run reidentification when the same artefact is
|
|
269
|
+
re-run).
|
|
270
|
+
|
|
271
|
+
Phase 6 Step 3a wires `persona_labels` so advisor-mode runs render
|
|
272
|
+
as ``Response A (Contrarian)`` while provider identity stays
|
|
273
|
+
stripped. ``persona_labels`` maps ``source`` → ``persona`` (e.g.
|
|
274
|
+
``"anthropic:claude-opus-4-1" -> "Contrarian"``); sources missing
|
|
275
|
+
from the map render as bare ``Response A``. Plain-member runs pass
|
|
276
|
+
``persona_labels=None`` and behave exactly like today.
|
|
277
|
+
"""
|
|
278
|
+
anon_text: dict[str, str] = {}
|
|
279
|
+
label_to_source: dict[str, str] = {}
|
|
280
|
+
idx = 0
|
|
281
|
+
for source, text in responses:
|
|
282
|
+
if not text or not text.strip():
|
|
283
|
+
continue
|
|
284
|
+
base = f"Response-{chr(ord('A') + idx)}"
|
|
285
|
+
persona = (persona_labels or {}).get(source)
|
|
286
|
+
label = f"{base} ({persona})" if persona else base
|
|
287
|
+
anon_text[label] = text.strip()
|
|
288
|
+
label_to_source[label] = source
|
|
289
|
+
idx += 1
|
|
290
|
+
return anon_text, label_to_source
|