@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,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)