@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.
Files changed (64) hide show
  1. package/.agent-src/commands/council/analysis.md +142 -0
  2. package/.agent-src/commands/council/debate.md +129 -0
  3. package/.agent-src/commands/council/default.md +8 -0
  4. package/.agent-src/commands/council/design.md +16 -12
  5. package/.agent-src/commands/council/optimize.md +16 -15
  6. package/.agent-src/commands/council/pr.md +12 -12
  7. package/.agent-src/commands/council.md +48 -2
  8. package/.agent-src/personas/advisors/contrarian.md +95 -0
  9. package/.agent-src/personas/advisors/executor.md +99 -0
  10. package/.agent-src/personas/advisors/expansionist.md +98 -0
  11. package/.agent-src/personas/advisors/first-principles.md +98 -0
  12. package/.agent-src/personas/advisors/outsider.md +102 -0
  13. package/.agent-src/rules/copilot-routing.md +19 -0
  14. package/.agent-src/rules/devcontainer-routing.md +20 -0
  15. package/.agent-src/rules/laravel-routing.md +20 -0
  16. package/.agent-src/rules/symfony-routing.md +20 -0
  17. package/.agent-src/skills/ai-council/SKILL.md +180 -2
  18. package/.agent-src/skills/copilot-config/SKILL.md +1 -1
  19. package/.agent-src/skills/devcontainer/SKILL.md +1 -1
  20. package/.agent-src/skills/laravel/SKILL.md +1 -1
  21. package/.agent-src/skills/project-analysis-core/SKILL.md +1 -1
  22. package/.agent-src/skills/project-analyzer/SKILL.md +1 -1
  23. package/.agent-src/skills/symfony-workflow/SKILL.md +1 -1
  24. package/.agent-src/skills/universal-project-analysis/SKILL.md +1 -1
  25. package/.agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  26. package/.claude-plugin/marketplace.json +3 -1
  27. package/AGENTS.md +1 -1
  28. package/CHANGELOG.md +47 -0
  29. package/CONTRIBUTING.md +5 -0
  30. package/README.md +3 -3
  31. package/config/agent-settings.template.yml +5 -93
  32. package/docs/architecture/multi-tool-projection.md +53 -0
  33. package/docs/architecture/{compression.md → source-projection.md} +21 -3
  34. package/docs/architecture.md +5 -5
  35. package/docs/catalog.md +21 -11
  36. package/docs/contracts/adr-architectural-consensus-mechanism.md +67 -0
  37. package/docs/contracts/ai-council-config.md +186 -0
  38. package/docs/contracts/command-clusters.md +57 -1
  39. package/docs/contracts/multi-tool-projection-fidelity.md +109 -0
  40. package/docs/getting-started.md +2 -2
  41. package/package.json +1 -1
  42. package/scripts/_archive/README.md +59 -0
  43. package/scripts/ai_council/_default_prices.py +10 -1
  44. package/scripts/ai_council/advisors.py +148 -0
  45. package/scripts/ai_council/clients.py +172 -0
  46. package/scripts/ai_council/config.py +368 -0
  47. package/scripts/ai_council/consensus.py +290 -0
  48. package/scripts/ai_council/orchestrator.py +628 -14
  49. package/scripts/ai_council/prompts.py +335 -0
  50. package/scripts/check_compressed_paths.py +6 -1
  51. package/scripts/ci_time_ratio.py +168 -0
  52. package/scripts/council_cli.py +973 -29
  53. package/scripts/measure_projection_bytes.py +159 -0
  54. package/scripts/measure_roadmap_trajectory.py +112 -0
  55. package/scripts/probe_projection_fidelity.py +202 -0
  56. package/scripts/score_skill_selection.py +198 -0
  57. package/scripts/skill_collision_clusters.py +162 -0
  58. /package/scripts/{_backfill_skill_domains.py → _archive/_backfill_skill_domains.py} +0 -0
  59. /package/scripts/{_bootstrap_tier_frontmatter.py → _archive/_bootstrap_tier_frontmatter.py} +0 -0
  60. /package/scripts/{_p43_bodies.py → _archive/_p43_bodies.py} +0 -0
  61. /package/scripts/{_p43_compress.py → _archive/_p43_compress.py} +0 -0
  62. /package/scripts/{_p4_migrate.py → _archive/_p4_migrate.py} +0 -0
  63. /package/scripts/{_phase2_shim_helper.py → _archive/_phase2_shim_helper.py} +0 -0
  64. /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