@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.
- 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/commands/memory/learn-low-impact.md +143 -0
- 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/ask-when-uncertain.md +10 -6
- package/.agent-src/rules/copilot-routing.md +19 -0
- package/.agent-src/rules/devcontainer-routing.md +20 -0
- package/.agent-src/rules/external-reference-deep-dive.md +1 -1
- package/.agent-src/rules/fast-path-marker-visibility.md +38 -0
- package/.agent-src/rules/laravel-routing.md +20 -0
- package/.agent-src/rules/low-impact-corpus-privacy-floor.md +74 -0
- package/.agent-src/rules/symfony-routing.md +20 -0
- package/.agent-src/skills/ai-council/SKILL.md +388 -10
- 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 +4 -1
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +346 -124
- package/CONTRIBUTING.md +5 -0
- package/README.md +6 -6
- package/config/agent-settings.template.yml +5 -93
- package/config/gitignore-block.txt +6 -0
- package/docs/architecture/multi-tool-projection.md +53 -0
- package/docs/architecture/{compression.md → source-projection.md} +21 -3
- package/docs/architecture.md +15 -15
- package/docs/archive/CHANGELOG-pre-2.11.0.md +141 -0
- package/docs/catalog.md +25 -12
- package/docs/contracts/adr-architectural-consensus-mechanism.md +68 -0
- package/docs/contracts/adr-level-6-productization.md +7 -9
- package/docs/contracts/ai-council-config.md +658 -0
- package/docs/contracts/command-clusters.md +58 -2
- package/docs/contracts/command-surface-tiers.md +3 -2
- package/docs/contracts/cost-profile-defaults.md +5 -0
- package/docs/contracts/decision-engine-gates.md +5 -0
- package/docs/contracts/decision-trace-v1.md +2 -2
- package/docs/contracts/file-ownership-matrix.json +1735 -72
- package/docs/contracts/installed-tools-lockfile.md +2 -1
- package/docs/contracts/low-impact-corpus-format.md +95 -0
- package/docs/contracts/mcp-beta-criteria.md +6 -5
- package/docs/contracts/mcp-cloud-scope.md +5 -4
- package/docs/contracts/multi-tool-projection-fidelity.md +115 -0
- package/docs/contracts/release-trunk-sync.md +4 -3
- package/docs/contracts/tier-3-contrib-plugin.md +5 -6
- package/docs/getting-started.md +2 -2
- package/docs/guidelines/agent-infra/installed-tools-manifest.md +2 -1
- package/docs/installation.md +32 -0
- package/package.json +1 -1
- package/scripts/_archive/README.md +59 -0
- package/scripts/_cli/cmd_doctor.py +134 -0
- package/scripts/ai_council/_default_prices.py +10 -1
- package/scripts/ai_council/advisors.py +148 -0
- package/scripts/ai_council/airgap.py +165 -0
- package/scripts/ai_council/cli_hints.py +123 -0
- package/scripts/ai_council/clients.py +959 -5
- package/scripts/ai_council/compile_corpus.py +178 -0
- package/scripts/ai_council/confidence_gate.py +156 -0
- package/scripts/ai_council/config.py +1364 -0
- package/scripts/ai_council/consensus.py +329 -0
- package/scripts/ai_council/events_log.py +137 -0
- package/scripts/ai_council/learn_low_impact_preview.py +252 -0
- package/scripts/ai_council/low_impact.py +714 -0
- package/scripts/ai_council/low_impact_corpus.py +466 -0
- package/scripts/ai_council/low_impact_intake.py +163 -0
- package/scripts/ai_council/modes.py +6 -1
- package/scripts/ai_council/necessity.py +782 -0
- package/scripts/ai_council/orchestrator.py +872 -20
- package/scripts/ai_council/probation_gate.py +152 -0
- package/scripts/ai_council/prompts.py +335 -0
- package/scripts/ai_council/redact_low_impact_entry.py +155 -0
- package/scripts/ai_council/replay.py +155 -0
- package/scripts/ai_council/session.py +19 -1
- package/scripts/ai_council/shadow_dispatch.py +235 -0
- package/scripts/ai_council/solo_dispatch.py +226 -0
- package/scripts/audit_cloud_compatibility.py +74 -0
- package/scripts/audit_command_surface.py +363 -0
- package/scripts/check_compressed_paths.py +6 -1
- package/scripts/check_council_layout.py +11 -0
- package/scripts/ci_time_ratio.py +168 -0
- package/scripts/council_cli.py +2005 -30
- package/scripts/install.sh +12 -0
- 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,148 @@
|
|
|
1
|
+
"""Thinking-style advisors — replace-mode call planning (Phase 6).
|
|
2
|
+
|
|
3
|
+
When `agents/.ai-council.yml` enables an advisor (e.g. `contrarian`
|
|
4
|
+
bound to `member: anthropic`), the orchestrator REPLACES the matching
|
|
5
|
+
plain-member call with an advisor-persona call on the same provider.
|
|
6
|
+
Same total call count as a plain run; bounded extra cost beyond the
|
|
7
|
+
persona-prompt token delta.
|
|
8
|
+
|
|
9
|
+
This module owns:
|
|
10
|
+
|
|
11
|
+
- `AdvisorPlan` — resolved swap for a single provider (persona text,
|
|
12
|
+
display name, optional model override).
|
|
13
|
+
- `plan_advisor_swap()` — walks the enabled advisors, reads their
|
|
14
|
+
persona files, and returns the per-provider plan map consumed by
|
|
15
|
+
`orchestrator.consult()` / `estimate()` and by the CLI.
|
|
16
|
+
- `resolve_persona_text()` — reads a persona file with compressed-tree
|
|
17
|
+
preference and frontmatter strip.
|
|
18
|
+
|
|
19
|
+
Cross-validation against the members block already ran at config load
|
|
20
|
+
(`config._build_config`); this module trusts that contract and only
|
|
21
|
+
enforces the **one-advisor-per-provider** rule (replace-mode invariant).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import re
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
import yaml
|
|
31
|
+
|
|
32
|
+
from scripts.ai_council.config import AdvisorConfig, CouncilConfigError
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class AdvisorPlan:
|
|
37
|
+
"""Resolved advisor swap for a single provider."""
|
|
38
|
+
|
|
39
|
+
name: str
|
|
40
|
+
display_name: str
|
|
41
|
+
member: str
|
|
42
|
+
persona_text: str
|
|
43
|
+
model_override: str | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
_FRONTMATTER_RE = re.compile(r"\A---\n(.*?)\n---\n", re.DOTALL)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _split_frontmatter(raw: str) -> tuple[dict, str]:
|
|
50
|
+
"""Return ``(frontmatter_dict, body)``. Missing frontmatter → ``({}, raw)``."""
|
|
51
|
+
match = _FRONTMATTER_RE.match(raw)
|
|
52
|
+
if not match:
|
|
53
|
+
return {}, raw
|
|
54
|
+
try:
|
|
55
|
+
meta = yaml.safe_load(match.group(1)) or {}
|
|
56
|
+
except yaml.YAMLError:
|
|
57
|
+
meta = {}
|
|
58
|
+
if not isinstance(meta, dict):
|
|
59
|
+
meta = {}
|
|
60
|
+
body = raw[match.end():]
|
|
61
|
+
return meta, body
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _display_name_from(advisor_name: str, frontmatter: dict) -> str:
|
|
65
|
+
"""Prefer frontmatter ``role``; fall back to titleized advisor key."""
|
|
66
|
+
role = frontmatter.get("role")
|
|
67
|
+
if isinstance(role, str) and role.strip():
|
|
68
|
+
return role.strip()
|
|
69
|
+
return advisor_name.replace("-", " ").replace("_", " ").title()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def resolve_persona_text(
|
|
73
|
+
persona_path: str,
|
|
74
|
+
repo_root: Path,
|
|
75
|
+
) -> tuple[str, dict]:
|
|
76
|
+
"""Read a persona file, returning ``(body, frontmatter)``.
|
|
77
|
+
|
|
78
|
+
Compressed tree (``.agent-src/``) wins so production runs match the
|
|
79
|
+
same projection the rest of the package consumes. Uncompressed tree
|
|
80
|
+
(``.agent-src.uncompressed/``) is the fallback for in-repo
|
|
81
|
+
development before ``task sync`` has projected the file.
|
|
82
|
+
"""
|
|
83
|
+
candidates = [
|
|
84
|
+
repo_root / ".agent-src" / persona_path,
|
|
85
|
+
repo_root / ".agent-src.uncompressed" / persona_path,
|
|
86
|
+
]
|
|
87
|
+
for candidate in candidates:
|
|
88
|
+
if candidate.exists():
|
|
89
|
+
raw = candidate.read_text(encoding="utf-8")
|
|
90
|
+
meta, body = _split_frontmatter(raw)
|
|
91
|
+
return body.strip(), meta
|
|
92
|
+
searched = "\n - ".join(str(c) for c in candidates)
|
|
93
|
+
raise CouncilConfigError(
|
|
94
|
+
f"Persona file not found for advisor (path={persona_path!r}). "
|
|
95
|
+
f"Searched:\n - {searched}"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def plan_advisor_swap(
|
|
100
|
+
advisors: dict[str, AdvisorConfig],
|
|
101
|
+
repo_root: Path,
|
|
102
|
+
) -> dict[str, AdvisorPlan]:
|
|
103
|
+
"""Return ``{provider_name: AdvisorPlan}`` for every ENABLED advisor.
|
|
104
|
+
|
|
105
|
+
Two enabled advisors targeting the same provider is a
|
|
106
|
+
``CouncilConfigError`` — replace-mode runs one advisor per provider
|
|
107
|
+
so the call plan never doubles up by accident.
|
|
108
|
+
"""
|
|
109
|
+
plans: dict[str, AdvisorPlan] = {}
|
|
110
|
+
for adv in advisors.values():
|
|
111
|
+
if not adv.enabled:
|
|
112
|
+
continue
|
|
113
|
+
if adv.member in plans:
|
|
114
|
+
existing = plans[adv.member].name
|
|
115
|
+
raise CouncilConfigError(
|
|
116
|
+
f"advisors.{adv.name} and advisors.{existing} both bind "
|
|
117
|
+
f"member={adv.member!r}; only one advisor per provider "
|
|
118
|
+
f"per run (replace-mode invariant)."
|
|
119
|
+
)
|
|
120
|
+
body, meta = resolve_persona_text(adv.persona, repo_root)
|
|
121
|
+
plans[adv.member] = AdvisorPlan(
|
|
122
|
+
name=adv.name,
|
|
123
|
+
display_name=_display_name_from(adv.name, meta),
|
|
124
|
+
member=adv.member,
|
|
125
|
+
persona_text=body,
|
|
126
|
+
model_override=adv.model,
|
|
127
|
+
)
|
|
128
|
+
return plans
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def build_persona_labels(
|
|
132
|
+
plans: dict[str, AdvisorPlan],
|
|
133
|
+
members: list,
|
|
134
|
+
) -> dict[str, str]:
|
|
135
|
+
"""Build the peer-review ``source → display_name`` map.
|
|
136
|
+
|
|
137
|
+
``source`` is the ``provider:model`` string the peer-review
|
|
138
|
+
pipeline uses for anonymisation; ``members`` is the post-swap
|
|
139
|
+
member list (model_override already applied), so the model field
|
|
140
|
+
matches what the response carries.
|
|
141
|
+
"""
|
|
142
|
+
labels: dict[str, str] = {}
|
|
143
|
+
for m in members:
|
|
144
|
+
plan = plans.get(m.name)
|
|
145
|
+
if plan is None:
|
|
146
|
+
continue
|
|
147
|
+
labels[f"{m.name}:{m.model}"] = plan.display_name
|
|
148
|
+
return labels
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Airgap detection for the AI Council installer / first-run (step-9 P11 · U1).
|
|
2
|
+
|
|
3
|
+
Probes DNS for the three primary council provider hosts with a short
|
|
4
|
+
timeout. If **all** probes fail the environment is treated as airgapped
|
|
5
|
+
and the installer is expected to seed ``defaults.member_mode: api`` (the
|
|
6
|
+
CLI default would otherwise launch ``codex``/``claude``/``gemini``
|
|
7
|
+
binaries that cannot reach their backends).
|
|
8
|
+
|
|
9
|
+
Why DNS, not HTTP:
|
|
10
|
+
- DNS is cheap (UDP, ~1 packet), HTTP probes are billable surface.
|
|
11
|
+
- A DNS hit is sufficient to disprove airgap; the actual reachability
|
|
12
|
+
of the host is checked at first use, not here.
|
|
13
|
+
- No auth required, no false negatives from corporate proxies that
|
|
14
|
+
block HTTPS but allow DNS.
|
|
15
|
+
|
|
16
|
+
Public surface:
|
|
17
|
+
- ``COUNCIL_PROBE_HOSTS`` — tuple of hosts to probe.
|
|
18
|
+
- ``probe_host(host, timeout)`` — single-host probe, returns bool.
|
|
19
|
+
- ``detect_airgap(*, hosts, timeout, resolver)`` — returns ``True`` iff
|
|
20
|
+
every host fails. ``resolver`` is injectable for tests.
|
|
21
|
+
- ``airgap_banner()`` — the one-liner the installer prints when airgap
|
|
22
|
+
is detected.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import socket
|
|
28
|
+
from collections.abc import Callable, Iterable
|
|
29
|
+
|
|
30
|
+
COUNCIL_PROBE_HOSTS: tuple[str, ...] = (
|
|
31
|
+
"api.anthropic.com",
|
|
32
|
+
"api.openai.com",
|
|
33
|
+
"generativelanguage.googleapis.com",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
DEFAULT_TIMEOUT_S: float = 1.0
|
|
37
|
+
|
|
38
|
+
# Banner string the installer prints when airgap is detected. Wording
|
|
39
|
+
# is part of the Phase 11 contract (roadmap step-9 line 147) and is
|
|
40
|
+
# asserted by tests/test_airgap_detection.py.
|
|
41
|
+
AIRGAP_BANNER: str = (
|
|
42
|
+
"airgapped environment detected — defaulting to mode: api"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def airgap_banner() -> str:
|
|
47
|
+
"""Return the canonical airgap banner (step-9 P11)."""
|
|
48
|
+
|
|
49
|
+
return AIRGAP_BANNER
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
Resolver = Callable[[str], None]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _default_resolver(host: str) -> None:
|
|
56
|
+
"""Resolve ``host`` via ``socket.getaddrinfo``.
|
|
57
|
+
|
|
58
|
+
Raises ``socket.gaierror`` / ``OSError`` on failure. The timeout is
|
|
59
|
+
enforced by the caller via ``socket.setdefaulttimeout`` because
|
|
60
|
+
``getaddrinfo`` itself has no ``timeout=`` kwarg.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
socket.getaddrinfo(host, None)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def probe_host(
|
|
67
|
+
host: str,
|
|
68
|
+
*,
|
|
69
|
+
timeout: float = DEFAULT_TIMEOUT_S,
|
|
70
|
+
resolver: Resolver | None = None,
|
|
71
|
+
) -> bool:
|
|
72
|
+
"""Return ``True`` iff ``host`` resolves within ``timeout``.
|
|
73
|
+
|
|
74
|
+
Any DNS / socket error is treated as unreachable. Test code can
|
|
75
|
+
inject ``resolver`` to simulate reachability without touching the
|
|
76
|
+
network.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
resolver = resolver or _default_resolver
|
|
80
|
+
previous = socket.getdefaulttimeout()
|
|
81
|
+
try:
|
|
82
|
+
socket.setdefaulttimeout(timeout)
|
|
83
|
+
try:
|
|
84
|
+
resolver(host)
|
|
85
|
+
except (socket.gaierror, OSError):
|
|
86
|
+
return False
|
|
87
|
+
return True
|
|
88
|
+
finally:
|
|
89
|
+
socket.setdefaulttimeout(previous)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def detect_airgap(
|
|
93
|
+
*,
|
|
94
|
+
hosts: Iterable[str] = COUNCIL_PROBE_HOSTS,
|
|
95
|
+
timeout: float = DEFAULT_TIMEOUT_S,
|
|
96
|
+
resolver: Resolver | None = None,
|
|
97
|
+
) -> bool:
|
|
98
|
+
"""Return ``True`` iff **every** host in ``hosts`` is unreachable.
|
|
99
|
+
|
|
100
|
+
A single reachable host is enough to disprove airgap — CLI members
|
|
101
|
+
only need one provider to be usable. Empty ``hosts`` is treated as
|
|
102
|
+
airgap by definition (no providers to reach).
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
hosts_list = list(hosts)
|
|
106
|
+
if not hosts_list:
|
|
107
|
+
return True
|
|
108
|
+
for host in hosts_list:
|
|
109
|
+
if probe_host(host, timeout=timeout, resolver=resolver):
|
|
110
|
+
return False
|
|
111
|
+
return True
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def recommended_member_mode(
|
|
115
|
+
*,
|
|
116
|
+
hosts: Iterable[str] = COUNCIL_PROBE_HOSTS,
|
|
117
|
+
timeout: float = DEFAULT_TIMEOUT_S,
|
|
118
|
+
resolver: Resolver | None = None,
|
|
119
|
+
) -> str:
|
|
120
|
+
"""Return ``"api"`` when airgapped, ``"cli"`` otherwise.
|
|
121
|
+
|
|
122
|
+
Convenience wrapper for the installer: matches the Phase 8 default
|
|
123
|
+
of ``cli`` and the Phase 11 airgap override of ``api``.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
return "api" if detect_airgap(
|
|
127
|
+
hosts=hosts, timeout=timeout, resolver=resolver,
|
|
128
|
+
) else "cli"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def main(argv: list[str] | None = None) -> int:
|
|
132
|
+
"""CLI entry-point: print recommended mode + banner if airgapped.
|
|
133
|
+
|
|
134
|
+
Used by the installer / first-run wrappers (step-9 P11): probe
|
|
135
|
+
the three provider hosts and exit ``0`` with the recommended mode
|
|
136
|
+
on stdout. When airgapped also emit the banner on stderr so the
|
|
137
|
+
installer can surface it without parsing stdout.
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
import argparse
|
|
141
|
+
import sys
|
|
142
|
+
|
|
143
|
+
parser = argparse.ArgumentParser(
|
|
144
|
+
description="Detect airgap state and print recommended member_mode."
|
|
145
|
+
)
|
|
146
|
+
parser.add_argument(
|
|
147
|
+
"--timeout",
|
|
148
|
+
type=float,
|
|
149
|
+
default=DEFAULT_TIMEOUT_S,
|
|
150
|
+
help=f"per-host DNS timeout in seconds (default: {DEFAULT_TIMEOUT_S})",
|
|
151
|
+
)
|
|
152
|
+
args = parser.parse_args(argv)
|
|
153
|
+
|
|
154
|
+
is_airgapped = detect_airgap(timeout=args.timeout)
|
|
155
|
+
mode = "api" if is_airgapped else "cli"
|
|
156
|
+
if is_airgapped:
|
|
157
|
+
print(AIRGAP_BANNER, file=sys.stderr)
|
|
158
|
+
print(mode)
|
|
159
|
+
return 0
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
if __name__ == "__main__":
|
|
163
|
+
import sys as _sys
|
|
164
|
+
|
|
165
|
+
raise SystemExit(main(_sys.argv[1:]))
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Per-provider CLI install hints for ``mode: cli`` members (step-9 P2).
|
|
2
|
+
|
|
3
|
+
When ``build_members`` cannot construct a ``mode: cli`` member because
|
|
4
|
+
the binary is missing on PATH, it records a skip entry of shape
|
|
5
|
+
``{"member": <provider>, "reason": "binary_missing", "detail": <msg>}``
|
|
6
|
+
in the caller's ``skipped`` list. This module turns that bookkeeping
|
|
7
|
+
into an actionable pre-flight banner — one line per skipped member —
|
|
8
|
+
so the user sees *which* CLI to install, *where* to get it, and
|
|
9
|
+
*how* to disable the CLI route as a fallback.
|
|
10
|
+
|
|
11
|
+
The table is intentionally small and stdlib-only (no HTTP fetch, no
|
|
12
|
+
new dependency): provider → ``(binary, docs_url, one_liner_install)``.
|
|
13
|
+
Surfaced by ``scripts/council_cli.py`` in ``cmd_estimate`` / ``cmd_ask``
|
|
14
|
+
/ ``cmd_debate`` immediately after the cost-estimate header so missing
|
|
15
|
+
CLIs are visible BEFORE the cost decision, not buried in stderr.
|
|
16
|
+
|
|
17
|
+
Closes PR #150 follow-up **C1** (Claude · UX). See
|
|
18
|
+
``agents/roadmaps/step-9-pr150-feedback-hardening.md`` Phase 2.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import Iterable, Mapping
|
|
23
|
+
|
|
24
|
+
#: Provider → ``(binary, docs_url, one_liner_install)``.
|
|
25
|
+
#:
|
|
26
|
+
#: - ``binary``: executable name the CLI client looks for via
|
|
27
|
+
#: ``shutil.which``. Matches ``default_binary`` on the
|
|
28
|
+
#: ``CliClient`` subclass in ``scripts/ai_council/clients.py``.
|
|
29
|
+
#: - ``docs_url``: canonical install page. Stable upstream URL —
|
|
30
|
+
#: when a vendor renames the page, update here.
|
|
31
|
+
#: - ``one_liner_install``: shortest copy-pasteable install
|
|
32
|
+
#: command. Plain shell, no curl-pipe-bash. Users with stricter
|
|
33
|
+
#: policies are expected to follow ``docs_url`` instead.
|
|
34
|
+
#:
|
|
35
|
+
#: Vendor-official transports (anthropic, openai, gemini) ship as
|
|
36
|
+
#: subscription-authed binaries — install once, ``billable=False``.
|
|
37
|
+
#: Community wrappers (xai, perplexity) consume an API key and stay
|
|
38
|
+
#: ``billable=True`` even on the CLI route — the hint links to the
|
|
39
|
+
#: community project so the user knows what they are installing.
|
|
40
|
+
INSTALL_HINTS: dict[str, tuple[str, str, str]] = {
|
|
41
|
+
"anthropic": (
|
|
42
|
+
"claude",
|
|
43
|
+
"https://docs.anthropic.com/en/docs/claude-code/quickstart",
|
|
44
|
+
"npm install -g @anthropic-ai/claude-code",
|
|
45
|
+
),
|
|
46
|
+
"openai": (
|
|
47
|
+
"codex",
|
|
48
|
+
"https://github.com/openai/codex",
|
|
49
|
+
"npm install -g @openai/codex",
|
|
50
|
+
),
|
|
51
|
+
"gemini": (
|
|
52
|
+
"gemini",
|
|
53
|
+
"https://github.com/google-gemini/gemini-cli",
|
|
54
|
+
"npm install -g @google/gemini-cli",
|
|
55
|
+
),
|
|
56
|
+
"xai": (
|
|
57
|
+
"grok",
|
|
58
|
+
"https://github.com/superagent-ai/grok-cli",
|
|
59
|
+
"npm install -g @superagent-ai/grok-cli",
|
|
60
|
+
),
|
|
61
|
+
"perplexity": (
|
|
62
|
+
"perplexity",
|
|
63
|
+
"https://github.com/perplexityai/perplexity-cli",
|
|
64
|
+
"npm install -g perplexity-cli",
|
|
65
|
+
),
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def hint_for(provider: str) -> tuple[str, str, str] | None:
|
|
70
|
+
"""Return ``(binary, docs_url, one_liner)`` for ``provider``, else ``None``.
|
|
71
|
+
|
|
72
|
+
Unknown providers (community additions not yet table-listed) return
|
|
73
|
+
``None`` so the caller can fall through to a generic message rather
|
|
74
|
+
than crashing the pre-flight banner.
|
|
75
|
+
"""
|
|
76
|
+
return INSTALL_HINTS.get(provider)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def format_install_hints(skipped: Iterable[Mapping[str, object]]) -> str:
|
|
80
|
+
"""Render the per-skip pre-flight banner.
|
|
81
|
+
|
|
82
|
+
``skipped`` is the list ``build_members`` populates — each entry
|
|
83
|
+
carries ``member`` (provider name), ``reason`` (``binary_missing``
|
|
84
|
+
or future variants), and ``detail`` (the raw ``CliClientError``
|
|
85
|
+
message). Output shape, one line per entry:
|
|
86
|
+
|
|
87
|
+
::
|
|
88
|
+
|
|
89
|
+
council:cli-skip · <provider> · binary not found · install: <one_liner> · docs: <url>
|
|
90
|
+
|
|
91
|
+
For providers with no entry in ``INSTALL_HINTS`` (community additions
|
|
92
|
+
not yet listed), falls back to the raw ``detail`` so the user still
|
|
93
|
+
sees the failure mode.
|
|
94
|
+
|
|
95
|
+
Returns ``""`` when ``skipped`` is empty so callers can write the
|
|
96
|
+
string unconditionally without a leading blank line.
|
|
97
|
+
|
|
98
|
+
Only ``reason == "binary_missing"`` entries get the install line —
|
|
99
|
+
other reasons (future: ``auth_expired``, ``parse_failed`` during
|
|
100
|
+
pre-flight probes) reuse the raw detail without an install hint.
|
|
101
|
+
"""
|
|
102
|
+
lines: list[str] = []
|
|
103
|
+
for entry in skipped:
|
|
104
|
+
name = str(entry.get("member", "?"))
|
|
105
|
+
reason = str(entry.get("reason", ""))
|
|
106
|
+
detail = str(entry.get("detail", ""))
|
|
107
|
+
if reason != "binary_missing":
|
|
108
|
+
lines.append(
|
|
109
|
+
f"council:cli-skip · {name} · {reason or 'unknown'} · {detail}"
|
|
110
|
+
)
|
|
111
|
+
continue
|
|
112
|
+
hint = hint_for(name)
|
|
113
|
+
if hint is None:
|
|
114
|
+
lines.append(
|
|
115
|
+
f"council:cli-skip · {name} · binary not found · {detail}"
|
|
116
|
+
)
|
|
117
|
+
continue
|
|
118
|
+
_binary, url, one_liner = hint
|
|
119
|
+
lines.append(
|
|
120
|
+
f"council:cli-skip · {name} · binary not found · "
|
|
121
|
+
f"install: {one_liner} · docs: {url}"
|
|
122
|
+
)
|
|
123
|
+
return "\n".join(lines)
|