@event4u/agent-config 2.12.0 → 2.14.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 (107) 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/commands/memory/learn-low-impact.md +143 -0
  9. package/.agent-src/personas/advisors/contrarian.md +95 -0
  10. package/.agent-src/personas/advisors/executor.md +99 -0
  11. package/.agent-src/personas/advisors/expansionist.md +98 -0
  12. package/.agent-src/personas/advisors/first-principles.md +98 -0
  13. package/.agent-src/personas/advisors/outsider.md +102 -0
  14. package/.agent-src/rules/ask-when-uncertain.md +10 -6
  15. package/.agent-src/rules/copilot-routing.md +19 -0
  16. package/.agent-src/rules/devcontainer-routing.md +20 -0
  17. package/.agent-src/rules/external-reference-deep-dive.md +1 -1
  18. package/.agent-src/rules/fast-path-marker-visibility.md +38 -0
  19. package/.agent-src/rules/laravel-routing.md +20 -0
  20. package/.agent-src/rules/low-impact-corpus-privacy-floor.md +74 -0
  21. package/.agent-src/rules/symfony-routing.md +20 -0
  22. package/.agent-src/skills/ai-council/SKILL.md +388 -10
  23. package/.agent-src/skills/copilot-config/SKILL.md +1 -1
  24. package/.agent-src/skills/devcontainer/SKILL.md +1 -1
  25. package/.agent-src/skills/laravel/SKILL.md +1 -1
  26. package/.agent-src/skills/project-analysis-core/SKILL.md +1 -1
  27. package/.agent-src/skills/project-analyzer/SKILL.md +1 -1
  28. package/.agent-src/skills/symfony-workflow/SKILL.md +1 -1
  29. package/.agent-src/skills/universal-project-analysis/SKILL.md +1 -1
  30. package/.agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  31. package/.claude-plugin/marketplace.json +4 -1
  32. package/AGENTS.md +1 -1
  33. package/CHANGELOG.md +346 -124
  34. package/CONTRIBUTING.md +5 -0
  35. package/README.md +6 -6
  36. package/config/agent-settings.template.yml +5 -93
  37. package/config/gitignore-block.txt +6 -0
  38. package/docs/architecture/multi-tool-projection.md +53 -0
  39. package/docs/architecture/{compression.md → source-projection.md} +21 -3
  40. package/docs/architecture.md +15 -15
  41. package/docs/archive/CHANGELOG-pre-2.11.0.md +141 -0
  42. package/docs/catalog.md +25 -12
  43. package/docs/contracts/adr-architectural-consensus-mechanism.md +68 -0
  44. package/docs/contracts/adr-level-6-productization.md +7 -9
  45. package/docs/contracts/ai-council-config.md +658 -0
  46. package/docs/contracts/command-clusters.md +58 -2
  47. package/docs/contracts/command-surface-tiers.md +3 -2
  48. package/docs/contracts/cost-profile-defaults.md +5 -0
  49. package/docs/contracts/decision-engine-gates.md +5 -0
  50. package/docs/contracts/decision-trace-v1.md +2 -2
  51. package/docs/contracts/file-ownership-matrix.json +1735 -72
  52. package/docs/contracts/installed-tools-lockfile.md +2 -1
  53. package/docs/contracts/low-impact-corpus-format.md +95 -0
  54. package/docs/contracts/mcp-beta-criteria.md +6 -5
  55. package/docs/contracts/mcp-cloud-scope.md +5 -4
  56. package/docs/contracts/multi-tool-projection-fidelity.md +115 -0
  57. package/docs/contracts/release-trunk-sync.md +4 -3
  58. package/docs/contracts/tier-3-contrib-plugin.md +5 -6
  59. package/docs/getting-started.md +2 -2
  60. package/docs/guidelines/agent-infra/installed-tools-manifest.md +2 -1
  61. package/docs/installation.md +32 -0
  62. package/package.json +1 -1
  63. package/scripts/_archive/README.md +59 -0
  64. package/scripts/_cli/cmd_doctor.py +134 -0
  65. package/scripts/ai_council/_default_prices.py +10 -1
  66. package/scripts/ai_council/advisors.py +148 -0
  67. package/scripts/ai_council/airgap.py +165 -0
  68. package/scripts/ai_council/cli_hints.py +123 -0
  69. package/scripts/ai_council/clients.py +959 -5
  70. package/scripts/ai_council/compile_corpus.py +178 -0
  71. package/scripts/ai_council/confidence_gate.py +156 -0
  72. package/scripts/ai_council/config.py +1364 -0
  73. package/scripts/ai_council/consensus.py +329 -0
  74. package/scripts/ai_council/events_log.py +137 -0
  75. package/scripts/ai_council/learn_low_impact_preview.py +252 -0
  76. package/scripts/ai_council/low_impact.py +714 -0
  77. package/scripts/ai_council/low_impact_corpus.py +466 -0
  78. package/scripts/ai_council/low_impact_intake.py +163 -0
  79. package/scripts/ai_council/modes.py +6 -1
  80. package/scripts/ai_council/necessity.py +782 -0
  81. package/scripts/ai_council/orchestrator.py +872 -20
  82. package/scripts/ai_council/probation_gate.py +152 -0
  83. package/scripts/ai_council/prompts.py +335 -0
  84. package/scripts/ai_council/redact_low_impact_entry.py +155 -0
  85. package/scripts/ai_council/replay.py +155 -0
  86. package/scripts/ai_council/session.py +19 -1
  87. package/scripts/ai_council/shadow_dispatch.py +235 -0
  88. package/scripts/ai_council/solo_dispatch.py +226 -0
  89. package/scripts/audit_cloud_compatibility.py +74 -0
  90. package/scripts/audit_command_surface.py +363 -0
  91. package/scripts/check_compressed_paths.py +6 -1
  92. package/scripts/check_council_layout.py +11 -0
  93. package/scripts/ci_time_ratio.py +168 -0
  94. package/scripts/council_cli.py +2005 -30
  95. package/scripts/install.sh +12 -0
  96. package/scripts/measure_projection_bytes.py +159 -0
  97. package/scripts/measure_roadmap_trajectory.py +112 -0
  98. package/scripts/probe_projection_fidelity.py +202 -0
  99. package/scripts/score_skill_selection.py +198 -0
  100. package/scripts/skill_collision_clusters.py +162 -0
  101. /package/scripts/{_backfill_skill_domains.py → _archive/_backfill_skill_domains.py} +0 -0
  102. /package/scripts/{_bootstrap_tier_frontmatter.py → _archive/_bootstrap_tier_frontmatter.py} +0 -0
  103. /package/scripts/{_p43_bodies.py → _archive/_p43_bodies.py} +0 -0
  104. /package/scripts/{_p43_compress.py → _archive/_p43_compress.py} +0 -0
  105. /package/scripts/{_p4_migrate.py → _archive/_p4_migrate.py} +0 -0
  106. /package/scripts/{_phase2_shim_helper.py → _archive/_phase2_shim_helper.py} +0 -0
  107. /package/scripts/{_pilot_council_question.py → _archive/_pilot_council_question.py} +0 -0
@@ -0,0 +1,714 @@
1
+ """Lightweight-QA fast-path resolver (Phase 11).
2
+
3
+ When ``decision_resolution.classes.low_impact.mode = council`` fires,
4
+ this module narrows the standard council fan-out to the opted-in
5
+ members, caps the spend at the ``decision_resolution.fast_path``
6
+ budget, and stamps a transparency marker on the result so the host
7
+ agent can surface that the answer came from the lightweight path.
8
+
9
+ The fast-path is a strict subset of the standard ``consult()`` flow:
10
+
11
+ - members filtered to ``participate_low_impact = True`` (and ``enabled``);
12
+ - list truncated to ``LowImpactFastPathConfig.max_members`` (1 or 2);
13
+ - ``CostBudget.max_calls = max_members``;
14
+ - ``CostBudget.max_total_usd = max_cost_usd``;
15
+ - token caps tightened to ``max_tokens`` (split 60 / 40 in / out);
16
+ - ``rounds`` locked to 1 — multi-round debate defeats the purpose.
17
+
18
+ Iron Law (Phase 10) is unaffected: ``high_impact`` and ``user_required``
19
+ never reach this module — they route to ``user`` at the config layer.
20
+ This module is only consulted for the ``low_impact`` class.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import re
26
+ from dataclasses import dataclass, field
27
+ from datetime import datetime, timezone
28
+ from typing import Callable, Literal
29
+
30
+ from scripts.ai_council.clients import CouncilResponse, ExternalAIClient
31
+ from scripts.ai_council.config import (
32
+ LowImpactFastPathConfig,
33
+ MemberConfig,
34
+ )
35
+ from scripts.ai_council.orchestrator import CostBudget
36
+
37
+
38
+ #: Token split ratio between input prompt and output budget when the
39
+ #: fast-path caps the total. 60 / 40 mirrors the empirical mix observed
40
+ #: for short Q&A — Q is long-ish, A is terse. Tunable; kept private so
41
+ #: the contract surface stays at ``max_tokens``.
42
+ _INPUT_RATIO = 0.6
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class FastPathPlan:
47
+ """Resolved fast-path execution plan (Phase 11).
48
+
49
+ Attributes:
50
+ members: Ordered list of member configs to invoke. Empty when
51
+ no member opted in — the caller must fall back to the
52
+ standard council path or escalate.
53
+ budget: ``CostBudget`` pre-sized to the fast-path caps. Safe
54
+ to pass directly to :func:`orchestrator.consult`.
55
+ marker: One-line transparency string for the rendered output
56
+ (e.g. ``"[fast-path: 2 members · cap $0.05]"``). Surface
57
+ it to the user so fast-path resolutions are distinguishable
58
+ from standard council runs.
59
+ reason: Diagnostic string explaining the plan shape — used by
60
+ the CLI and tests. Empty when ``members`` is non-empty.
61
+ """
62
+
63
+ members: tuple[MemberConfig, ...]
64
+ budget: CostBudget
65
+ marker: str
66
+ reason: str = ""
67
+
68
+ @property
69
+ def is_resolvable(self) -> bool:
70
+ """True when at least one opted-in member is available."""
71
+ return bool(self.members)
72
+
73
+
74
+ def select_fast_path_members(
75
+ members: dict[str, MemberConfig],
76
+ cfg: LowImpactFastPathConfig,
77
+ ) -> tuple[MemberConfig, ...]:
78
+ """Filter and order opted-in members for the fast-path.
79
+
80
+ Selection rules:
81
+
82
+ - member must be ``enabled``;
83
+ - member must have ``participate_low_impact = True``;
84
+ - alphabetical by provider name → deterministic, easy to test,
85
+ no hidden cost-rank heuristic to debug;
86
+ - truncate to ``cfg.max_members`` (1 or 2 per schema).
87
+
88
+ No price-table lookup here — the standard council path already
89
+ runs the full cost-disclosure flow and the per-call cap in
90
+ ``CostBudget`` is the structural backstop.
91
+ """
92
+ candidates = [
93
+ m for m in members.values()
94
+ if m.enabled and m.participate_low_impact
95
+ ]
96
+ candidates.sort(key=lambda m: m.name)
97
+ return tuple(candidates[: cfg.max_members])
98
+
99
+
100
+ def build_fast_path_budget(cfg: LowImpactFastPathConfig) -> CostBudget:
101
+ """Translate the ``fast_path`` config into a runnable ``CostBudget``.
102
+
103
+ The 60 / 40 input / output split is a heuristic — callers that
104
+ need an exact ceiling can override the returned ``CostBudget``
105
+ fields. ``max_calls`` matches ``max_members`` so the orchestrator
106
+ short-circuits as soon as the fast-path quota is exhausted.
107
+ """
108
+ max_in = max(1, int(cfg.max_tokens * _INPUT_RATIO))
109
+ max_out = max(1, cfg.max_tokens - max_in)
110
+ return CostBudget(
111
+ max_input_tokens=max_in,
112
+ max_output_tokens=max_out,
113
+ max_calls=cfg.max_members,
114
+ max_total_usd=cfg.max_cost_usd,
115
+ )
116
+
117
+
118
+ def plan_fast_path(
119
+ members: dict[str, MemberConfig],
120
+ cfg: LowImpactFastPathConfig,
121
+ ) -> FastPathPlan:
122
+ """Build the full execution plan for a ``low_impact`` resolution.
123
+
124
+ Returns a :class:`FastPathPlan`. When no member opted in, the
125
+ plan's ``members`` tuple is empty and ``reason`` explains why —
126
+ the caller must fall back (standard council) or escalate (user).
127
+ """
128
+ selected = select_fast_path_members(members, cfg)
129
+ if not selected:
130
+ return FastPathPlan(
131
+ members=(),
132
+ budget=build_fast_path_budget(cfg),
133
+ marker="",
134
+ reason=(
135
+ "no member has `participate_low_impact: true` — "
136
+ "fast-path unavailable, fall back to standard council "
137
+ "or escalate to user."
138
+ ),
139
+ )
140
+ names = ", ".join(m.name for m in selected)
141
+ marker = (
142
+ f"[fast-path: {len(selected)} member"
143
+ f"{'s' if len(selected) > 1 else ''} ({names}) · "
144
+ f"cap ${cfg.max_cost_usd:.2f} · {cfg.max_tokens} tokens]"
145
+ )
146
+ return FastPathPlan(
147
+ members=selected,
148
+ budget=build_fast_path_budget(cfg),
149
+ marker=marker,
150
+ )
151
+
152
+
153
+ # --- Phase 11 Step 2-3: fast-path executor + transparency marker ----------
154
+
155
+ #: Status of a :class:`FastPathResolution`. ``resolved`` = one or
156
+ #: matching answers, returned to caller; ``split`` = members disagreed,
157
+ #: caller must escalate to user with both opinions; ``aborted`` =
158
+ #: hard cap hit or all members failed, caller must escalate;
159
+ #: ``unavailable`` = plan had no opted-in members (caller never even
160
+ #: called the executor — included so the status enum is exhaustive).
161
+ FastPathStatus = Literal["resolved", "split", "aborted", "unavailable"]
162
+
163
+
164
+ #: System prompt for fast-path members. Deliberately terse — the
165
+ #: standard advisor + Karpathy peer-review machinery is bypassed by
166
+ #: design (Phase 11 contract). One sentence of rationale is asked
167
+ #: explicitly so the user-visible marker can surface a "why" without
168
+ #: a second round.
169
+ _FAST_PATH_SYSTEM = (
170
+ "You are a fast-path council member answering a low-impact "
171
+ "development question. Reply with: (1) a short, direct answer; "
172
+ "(2) one sentence of rationale. No preamble, no caveats, no "
173
+ "alternative options — just answer + rationale."
174
+ )
175
+
176
+
177
+ @dataclass(frozen=True)
178
+ class MemberAnswer:
179
+ """One fast-path member's normalised answer.
180
+
181
+ Attributes:
182
+ member: Member name (e.g. ``"anthropic"``).
183
+ text: Raw response text. ``""`` when the call errored.
184
+ normalized: Lowercase + punctuation-stripped form used for
185
+ agreement detection. ``""`` mirrors ``text``.
186
+ cost_usd: Estimated spend in USD for this single call. ``0.0``
187
+ for non-billable transports (manual / vendor-CLI).
188
+ error: Provider-side error string, ``None`` on success.
189
+ """
190
+
191
+ member: str
192
+ text: str
193
+ normalized: str
194
+ cost_usd: float = 0.0
195
+ error: str | None = None
196
+
197
+ @property
198
+ def ok(self) -> bool:
199
+ return self.error is None and bool(self.text.strip())
200
+
201
+
202
+ @dataclass(frozen=True)
203
+ class FastPathResolution:
204
+ """End-to-end outcome of a low-impact fast-path resolution.
205
+
206
+ Attributes:
207
+ status: One of :data:`FastPathStatus`.
208
+ answer: Final user-visible answer text. Empty when ``status``
209
+ is ``split``, ``aborted``, or ``unavailable``.
210
+ marker: Transparency marker line — either the plan marker
211
+ (resolved) or a status-specific escalation marker.
212
+ answers: Per-member normalised answers, in call order.
213
+ total_cost_usd: Sum of per-call costs.
214
+ session_log_line: One-line append for the session artefact
215
+ under ``low-impact-resolutions.md``. Empty when status
216
+ is ``unavailable`` (no call happened).
217
+ """
218
+
219
+ status: FastPathStatus
220
+ answer: str
221
+ marker: str
222
+ answers: tuple[MemberAnswer, ...] = ()
223
+ total_cost_usd: float = 0.0
224
+ session_log_line: str = ""
225
+
226
+
227
+ _PUNCT_RE = re.compile(r"[^\w\s]+")
228
+ _WS_RE = re.compile(r"\s+")
229
+
230
+
231
+ def _normalize(text: str) -> str:
232
+ """Lowercase, strip punctuation, collapse whitespace.
233
+
234
+ Used to detect agreement between two fast-path answers without
235
+ fuzzy / embedding match — keeps the agreement test auditable.
236
+ """
237
+ lowered = (text or "").lower()
238
+ stripped = _PUNCT_RE.sub(" ", lowered)
239
+ return _WS_RE.sub(" ", stripped).strip()
240
+
241
+
242
+ def _build_user_prompt(question_text: str) -> str:
243
+ return (
244
+ f"Question: {question_text.strip()}\n\n"
245
+ "Reply with: answer on line 1, one sentence rationale on line 2."
246
+ )
247
+
248
+
249
+ def _aborted_marker(reason: str, members: tuple[MemberConfig, ...]) -> str:
250
+ """Build the normative ``aborted`` marker.
251
+
252
+ Wording is fixed by ``fast-path-marker-visibility.md`` Iron Law:
253
+ ``> Low-impact council aborted (<reason>) — escalating to user:``.
254
+ The members-tried list trails on the same prefix so downstream
255
+ pattern matchers can still extract who was called.
256
+ """
257
+ names = ", ".join(m.name for m in members) if members else "no members"
258
+ return (
259
+ f"> Low-impact council aborted ({reason}) — escalating to user: "
260
+ f"members tried: {names}."
261
+ )
262
+
263
+
264
+ def _split_marker(answers: tuple[MemberAnswer, ...]) -> str:
265
+ """Build the normative ``split`` marker.
266
+
267
+ Wording is fixed by ``fast-path-marker-visibility.md`` Iron Law:
268
+ ``> Low-impact council split — escalating to user (<m1>: X / <m2>: Y):``.
269
+ """
270
+ parts = " / ".join(
271
+ f"{a.member}: {a.text.splitlines()[0].strip()[:80]}"
272
+ for a in answers if a.ok
273
+ )
274
+ return f"> Low-impact council split — escalating to user ({parts}):"
275
+
276
+
277
+ def _unavailable_marker() -> str:
278
+ """Build the normative ``unavailable`` marker.
279
+
280
+ Wording is fixed by ``fast-path-marker-visibility.md`` Iron Law:
281
+ ``> Low-impact council unavailable (no opted-in members) — escalating to user.``.
282
+ """
283
+ return (
284
+ "> Low-impact council unavailable (no opted-in members) — "
285
+ "escalating to user."
286
+ )
287
+
288
+
289
+ def _resolved_marker(ok_answers: tuple[MemberAnswer, ...]) -> str:
290
+ """Build the normative ``resolved`` marker.
291
+
292
+ Wording is fixed by ``fast-path-marker-visibility.md`` Iron Law:
293
+ ``> Resolved via low-impact council fast-path: <verdict>.``. The
294
+ verdict short-string distinguishes single-member from 2-member
295
+ consensus so the host agent can preserve provenance without
296
+ paraphrasing the answer body.
297
+ """
298
+ if len(ok_answers) <= 1:
299
+ verdict = "single-member answer"
300
+ else:
301
+ verdict = f"{len(ok_answers)}-member consensus"
302
+ return f"> Resolved via low-impact council fast-path: {verdict}."
303
+
304
+
305
+ def _answer_line(text: str) -> str:
306
+ """Extract the answer portion (line 1) from a fast-path response."""
307
+ for line in text.splitlines():
308
+ stripped = line.strip()
309
+ if stripped:
310
+ return stripped
311
+ return ""
312
+
313
+
314
+ def _build_member_answer(
315
+ member: str,
316
+ response: CouncilResponse,
317
+ price_table: "object | None",
318
+ ) -> MemberAnswer:
319
+ """Normalise one provider call into a :class:`MemberAnswer`."""
320
+ if response.error:
321
+ return MemberAnswer(
322
+ member=member, text="", normalized="",
323
+ cost_usd=0.0, error=response.error,
324
+ )
325
+ text = (response.text or "").strip()
326
+ if not text:
327
+ return MemberAnswer(
328
+ member=member, text="", normalized="",
329
+ cost_usd=0.0, error="empty response",
330
+ )
331
+ cost = _compute_cost(response, price_table)
332
+ return MemberAnswer(
333
+ member=member,
334
+ text=text,
335
+ normalized=_normalize(_answer_line(text)),
336
+ cost_usd=cost,
337
+ )
338
+
339
+
340
+ def _compute_cost(
341
+ response: CouncilResponse,
342
+ price_table: "object | None",
343
+ ) -> float:
344
+ """Estimate USD cost for one response.
345
+
346
+ Uses the injected ``price_table`` when available; falls back to
347
+ ``0.0`` for non-billable transports (manual / vendor-CLI) and for
348
+ unknown models. Never raises — cost is an observability signal,
349
+ not a gate (the budget check is structural).
350
+ """
351
+ if price_table is None:
352
+ return 0.0
353
+ lookup = getattr(price_table, "lookup", None)
354
+ if lookup is None:
355
+ return 0.0
356
+ price = lookup(response.provider, response.model)
357
+ if price is None:
358
+ return 0.0
359
+ in_usd = (response.input_tokens / 1_000_000) * price.input_per_1m_usd
360
+ out_usd = (response.output_tokens / 1_000_000) * price.output_per_1m_usd
361
+ return in_usd + out_usd
362
+
363
+
364
+ def _session_log_line(
365
+ question_text: str,
366
+ status: FastPathStatus,
367
+ answers: tuple[MemberAnswer, ...],
368
+ total_cost: float,
369
+ now: "datetime | None" = None,
370
+ ) -> str:
371
+ """Build a one-line append for the session artefact.
372
+
373
+ Format: ``ISO8601 | status | members(ok/total) | $cost | Q…``
374
+ Question is truncated to 120 chars so the log stays scannable.
375
+ """
376
+ ts = (now or datetime.now(timezone.utc)).strftime("%Y-%m-%dT%H:%M:%SZ")
377
+ ok = sum(1 for a in answers if a.ok)
378
+ total = len(answers)
379
+ q = question_text.strip().replace("\n", " ")
380
+ if len(q) > 120:
381
+ q = q[:117] + "..."
382
+ names = ", ".join(a.member for a in answers)
383
+ members_tag = f" members({names})" if names else ""
384
+ return (
385
+ f"{ts} | {status} | members={ok}/{total} |{members_tag} "
386
+ f"cost=${total_cost:.4f} | Q={q}"
387
+ )
388
+
389
+
390
+ def resolve_low_impact(
391
+ question_text: str,
392
+ plan: FastPathPlan,
393
+ clients: dict[str, ExternalAIClient],
394
+ price_table: "object | None" = None,
395
+ now: "Callable[[], datetime] | None" = None,
396
+ ) -> FastPathResolution:
397
+ """Execute the fast-path plan and return a :class:`FastPathResolution`.
398
+
399
+ Contract:
400
+
401
+ - One round only — each opted-in member is called exactly once.
402
+ - Per-call ``max_tokens`` is taken from ``plan.budget.max_output_tokens``.
403
+ - The USD cap (``plan.budget.max_total_usd``) is a hard stop — when
404
+ the running total would exceed it after a call, the executor
405
+ aborts and escalates to the user (never silently truncates).
406
+ - Provider failures never block — a failed member is recorded and
407
+ the executor continues with the remaining member (if any).
408
+ - Consensus rule (2 members): normalised answer-line equality. No
409
+ embedding match, no LLM-judge — keeps the agreement test
410
+ auditable. Disagreement → ``status = "split"``, caller escalates.
411
+
412
+ Args:
413
+ question_text: The low-impact question being routed.
414
+ plan: Output of :func:`plan_fast_path`.
415
+ clients: Provider name → instantiated client. Missing entries
416
+ are treated as a member-side failure (error recorded,
417
+ other members proceed).
418
+ price_table: Optional pricing table for cost estimation. When
419
+ ``None``, ``cost_usd`` fields stay at ``0.0`` (the structural
420
+ budget check still fires on token counts).
421
+ now: Optional clock injector for deterministic tests.
422
+
423
+ Returns:
424
+ :class:`FastPathResolution` with status, answer, marker, and
425
+ session log line. Caller renders the marker, surfaces the
426
+ answer (if any), and appends ``session_log_line`` to the
427
+ session artefact.
428
+ """
429
+ if not plan.is_resolvable:
430
+ return FastPathResolution(
431
+ status="unavailable",
432
+ answer="",
433
+ marker=_unavailable_marker(),
434
+ )
435
+
436
+ user_prompt = _build_user_prompt(question_text)
437
+ answers: list[MemberAnswer] = []
438
+ total_cost = 0.0
439
+
440
+ for member in plan.members:
441
+ client = clients.get(member.name)
442
+ if client is None:
443
+ answers.append(MemberAnswer(
444
+ member=member.name, text="", normalized="",
445
+ cost_usd=0.0, error="no client instantiated",
446
+ ))
447
+ continue
448
+ try:
449
+ response = client.ask(
450
+ _FAST_PATH_SYSTEM,
451
+ user_prompt,
452
+ max_tokens=plan.budget.max_output_tokens,
453
+ )
454
+ except Exception as exc: # noqa: BLE001 — surface as member error
455
+ answers.append(MemberAnswer(
456
+ member=member.name, text="", normalized="",
457
+ cost_usd=0.0, error=f"client raised: {exc!r}",
458
+ ))
459
+ continue
460
+ ans = _build_member_answer(member.name, response, price_table)
461
+ # Hard cap — refuse to add an over-budget answer to the result.
462
+ projected = total_cost + ans.cost_usd
463
+ if projected > plan.budget.max_total_usd and ans.ok:
464
+ answers.append(MemberAnswer(
465
+ member=member.name, text="", normalized="",
466
+ cost_usd=ans.cost_usd,
467
+ error=(
468
+ f"would exceed fast-path cap "
469
+ f"${plan.budget.max_total_usd:.2f} "
470
+ f"(projected ${projected:.4f})"
471
+ ),
472
+ ))
473
+ break
474
+ answers.append(ans)
475
+ total_cost = projected
476
+
477
+ answers_t = tuple(answers)
478
+ ok_answers = tuple(a for a in answers_t if a.ok)
479
+
480
+ if not ok_answers:
481
+ marker = _aborted_marker("all members failed", plan.members)
482
+ return FastPathResolution(
483
+ status="aborted", answer="", marker=marker,
484
+ answers=answers_t, total_cost_usd=total_cost,
485
+ session_log_line=_session_log_line(
486
+ question_text, "aborted", answers_t, total_cost,
487
+ now=now() if now else None,
488
+ ),
489
+ )
490
+
491
+ if len(ok_answers) == 1:
492
+ return FastPathResolution(
493
+ status="resolved",
494
+ answer=ok_answers[0].text,
495
+ marker=_resolved_marker(ok_answers),
496
+ answers=answers_t,
497
+ total_cost_usd=total_cost,
498
+ session_log_line=_session_log_line(
499
+ question_text, "resolved", answers_t, total_cost,
500
+ now=now() if now else None,
501
+ ),
502
+ )
503
+
504
+ # Two members → quick consensus on normalised answer line.
505
+ if ok_answers[0].normalized == ok_answers[1].normalized:
506
+ return FastPathResolution(
507
+ status="resolved",
508
+ answer=ok_answers[0].text,
509
+ marker=_resolved_marker(ok_answers),
510
+ answers=answers_t,
511
+ total_cost_usd=total_cost,
512
+ session_log_line=_session_log_line(
513
+ question_text, "resolved", answers_t, total_cost,
514
+ now=now() if now else None,
515
+ ),
516
+ )
517
+
518
+ return FastPathResolution(
519
+ status="split",
520
+ answer="",
521
+ marker=_split_marker(ok_answers),
522
+ answers=answers_t,
523
+ total_cost_usd=total_cost,
524
+ session_log_line=_session_log_line(
525
+ question_text, "split", answers_t, total_cost,
526
+ now=now() if now else None,
527
+ ),
528
+ )
529
+
530
+
531
+ # --- Phase 11 Step 5: low-impact stats over session log -------------------
532
+
533
+
534
+ @dataclass(frozen=True)
535
+ class LowImpactStats:
536
+ """Aggregate summary of one session's low-impact resolutions.
537
+
538
+ Attributes:
539
+ total: Total number of fast-path attempts in the session.
540
+ by_status: Count per status (``resolved``/``split``/``aborted``).
541
+ by_member: Count per member name (sum across all attempts;
542
+ a 2-member call increments both entries).
543
+ total_cost_usd: Sum of per-attempt cost across the session.
544
+ """
545
+
546
+ total: int
547
+ by_status: dict[str, int]
548
+ by_member: dict[str, int]
549
+ total_cost_usd: float
550
+
551
+
552
+ _LOG_LINE_RE = re.compile(
553
+ r"^(?P<ts>\S+)\s*\|\s*(?P<status>\w+)\s*\|\s*members=(?P<ok>\d+)/"
554
+ r"(?P<tot>\d+)\s*\|.*?cost=\$(?P<cost>[\d.]+)\s*\|\s*Q=",
555
+ )
556
+
557
+
558
+ def parse_low_impact_log(text: str) -> LowImpactStats:
559
+ """Parse a ``low-impact-resolutions.md`` body into stats.
560
+
561
+ Lines that do not match the canonical ``_session_log_line`` shape
562
+ are skipped silently — keeps the parser tolerant of free-form
563
+ section headers the artefact may grow over time. Returns a
564
+ :class:`LowImpactStats` with the aggregated counts.
565
+ """
566
+ by_status: dict[str, int] = {}
567
+ by_member: dict[str, int] = {}
568
+ total = 0
569
+ total_cost = 0.0
570
+ member_section_re = re.compile(r"members\((?P<names>[^)]+)\)")
571
+ for raw in text.splitlines():
572
+ m = _LOG_LINE_RE.match(raw.strip())
573
+ if not m:
574
+ continue
575
+ total += 1
576
+ status = m.group("status")
577
+ by_status[status] = by_status.get(status, 0) + 1
578
+ try:
579
+ total_cost += float(m.group("cost"))
580
+ except ValueError:
581
+ pass
582
+ # Optional ``members(name, name)`` tag emitted by the renderer.
583
+ names_m = member_section_re.search(raw)
584
+ if names_m:
585
+ for name in names_m.group("names").split(","):
586
+ name = name.strip()
587
+ if name:
588
+ by_member[name] = by_member.get(name, 0) + 1
589
+ return LowImpactStats(
590
+ total=total,
591
+ by_status=dict(sorted(by_status.items())),
592
+ by_member=dict(sorted(by_member.items())),
593
+ total_cost_usd=round(total_cost, 4),
594
+ )
595
+
596
+
597
+ def render_low_impact_stats(stats: LowImpactStats) -> str:
598
+ """Render :class:`LowImpactStats` as a short stdout summary block."""
599
+ lines = ["# Low-impact fast-path · session summary", ""]
600
+ lines.append(f"- attempts: {stats.total}")
601
+ if stats.by_status:
602
+ parts = " · ".join(
603
+ f"{k}={v}" for k, v in stats.by_status.items()
604
+ )
605
+ lines.append(f"- status: {parts}")
606
+ else:
607
+ lines.append("- status: (none)")
608
+ if stats.by_member:
609
+ parts = " · ".join(
610
+ f"{k}={v}" for k, v in stats.by_member.items()
611
+ )
612
+ lines.append(f"- members: {parts}")
613
+ lines.append(f"- total cost: ${stats.total_cost_usd:.4f}")
614
+ return "\n".join(lines) + "\n"
615
+
616
+
617
+ # --- step-9 P5: fuzzy corpus match with safety vetoes -------------------
618
+
619
+ def classify_impact_with_corpus_fuzzy(
620
+ question_text: str,
621
+ corpus_paths: "tuple[object, ...] | None" = None,
622
+ *,
623
+ threshold: float = 0.92,
624
+ ):
625
+ """Fuzzy variant of :func:`necessity.classify_impact_with_corpus`.
626
+
627
+ Uses :class:`difflib.SequenceMatcher` to match near-paraphrases of
628
+ ``Validated`` corpus entries while preserving the Iron Law:
629
+
630
+ - **Iron Law (precedence)**: the base verdict from
631
+ :func:`classify_impact` runs first. If the base class is in
632
+ ``LOCKED_IMPACT_CLASSES`` (``high_impact`` / ``user_required``),
633
+ the fuzzy lookup is skipped entirely.
634
+ - **High-impact-veto**: any whole-word token from
635
+ :data:`IMPACT_TRIGGERS["high_impact"]` in the (lowered) query
636
+ short-circuits to the base verdict regardless of similarity.
637
+ Catches paraphrases that escaped the trigger-bucket vote.
638
+ - **Anti-example-veto**: if the maximum similarity to any
639
+ ``Anti-Examples`` phrase is ``>=`` the maximum similarity to any
640
+ ``Validated`` phrase, the fuzzy match is rejected. Prevents
641
+ ratio-driven drift onto bullets the corpus has explicitly
642
+ flagged as user-required.
643
+
644
+ Returns the base verdict on every reject path so the caller gets
645
+ consistent semantics with the exact-match classifier.
646
+ """
647
+ import difflib
648
+ import re as _re
649
+
650
+ from scripts.ai_council.low_impact_corpus import (
651
+ load_anti_example_phrases,
652
+ load_validated_phrases,
653
+ )
654
+ from scripts.ai_council.necessity import (
655
+ IMPACT_TRIGGERS,
656
+ ImpactVerdict,
657
+ LOCKED_IMPACT_CLASSES,
658
+ classify_impact,
659
+ )
660
+
661
+ base = classify_impact(question_text)
662
+ if base.impact_class in LOCKED_IMPACT_CLASSES:
663
+ return base
664
+ if not corpus_paths or not (0.0 < threshold <= 1.0):
665
+ return base
666
+
667
+ norm_q = _re.sub(r"[^\w\s]", " ", (question_text or "").lower())
668
+ norm_q = _re.sub(r"\s+", " ", norm_q).strip()
669
+ if not norm_q:
670
+ return base
671
+
672
+ # High-impact-veto: a paraphrase carrying a security-class trigger
673
+ # wins the Iron Law regardless of corpus similarity. Whole-word
674
+ # match against the lowered query, mirroring `_count_matches`.
675
+ high_triggers = IMPACT_TRIGGERS.get("high_impact", ())
676
+ lowered_q = (question_text or "").lower()
677
+ for trig in high_triggers:
678
+ pattern = r"\b" + _re.escape(trig.lower()) + r"\b"
679
+ if _re.search(pattern, lowered_q):
680
+ return base
681
+
682
+ validated: list[str] = []
683
+ anti: list[str] = []
684
+ for path in corpus_paths:
685
+ validated.extend(load_validated_phrases(path))
686
+ anti.extend(load_anti_example_phrases(path))
687
+
688
+ if not validated:
689
+ return base
690
+
691
+ def _ratio(a: str, b: str) -> float:
692
+ return difflib.SequenceMatcher(a=a, b=b).ratio()
693
+
694
+ best_validated = max((_ratio(norm_q, p) for p in validated), default=0.0)
695
+ if best_validated < threshold:
696
+ return base
697
+
698
+ best_anti = max((_ratio(norm_q, p) for p in anti), default=0.0)
699
+ # Anti-example-veto: if the query is at least as close to an
700
+ # anti-example as to a validated phrase, the corpus has actively
701
+ # flagged this shape — don't shortcut.
702
+ if anti and best_anti >= best_validated:
703
+ return base
704
+
705
+ return ImpactVerdict(
706
+ impact_class="low_impact",
707
+ confidence=round(min(0.9, best_validated), 4),
708
+ rationale=(
709
+ f"Fuzzy match against Validated corpus "
710
+ f"(ratio={best_validated:.3f} ≥ {threshold:.2f}) — routing "
711
+ "as `low_impact` (step-9 P5)."
712
+ ),
713
+ category="corpus_validated_fuzzy",
714
+ )