@event4u/agent-config 2.13.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 (64) hide show
  1. package/.agent-src/commands/memory/learn-low-impact.md +143 -0
  2. package/.agent-src/rules/ask-when-uncertain.md +10 -6
  3. package/.agent-src/rules/copilot-routing.md +1 -1
  4. package/.agent-src/rules/devcontainer-routing.md +1 -1
  5. package/.agent-src/rules/external-reference-deep-dive.md +1 -1
  6. package/.agent-src/rules/fast-path-marker-visibility.md +38 -0
  7. package/.agent-src/rules/low-impact-corpus-privacy-floor.md +74 -0
  8. package/.agent-src/rules/symfony-routing.md +1 -1
  9. package/.agent-src/skills/ai-council/SKILL.md +208 -8
  10. package/.agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  11. package/.claude-plugin/marketplace.json +2 -1
  12. package/CHANGELOG.md +299 -124
  13. package/README.md +6 -6
  14. package/config/gitignore-block.txt +6 -0
  15. package/docs/architecture.md +12 -12
  16. package/docs/archive/CHANGELOG-pre-2.11.0.md +141 -0
  17. package/docs/catalog.md +10 -7
  18. package/docs/contracts/adr-architectural-consensus-mechanism.md +4 -3
  19. package/docs/contracts/adr-level-6-productization.md +7 -9
  20. package/docs/contracts/ai-council-config.md +492 -20
  21. package/docs/contracts/command-clusters.md +1 -1
  22. package/docs/contracts/command-surface-tiers.md +3 -2
  23. package/docs/contracts/cost-profile-defaults.md +5 -0
  24. package/docs/contracts/decision-engine-gates.md +5 -0
  25. package/docs/contracts/decision-trace-v1.md +2 -2
  26. package/docs/contracts/file-ownership-matrix.json +1735 -72
  27. package/docs/contracts/installed-tools-lockfile.md +2 -1
  28. package/docs/contracts/low-impact-corpus-format.md +95 -0
  29. package/docs/contracts/mcp-beta-criteria.md +6 -5
  30. package/docs/contracts/mcp-cloud-scope.md +5 -4
  31. package/docs/contracts/multi-tool-projection-fidelity.md +8 -2
  32. package/docs/contracts/release-trunk-sync.md +4 -3
  33. package/docs/contracts/tier-3-contrib-plugin.md +5 -6
  34. package/docs/getting-started.md +2 -2
  35. package/docs/guidelines/agent-infra/installed-tools-manifest.md +2 -1
  36. package/docs/installation.md +32 -0
  37. package/package.json +1 -1
  38. package/scripts/_cli/cmd_doctor.py +134 -0
  39. package/scripts/ai_council/airgap.py +165 -0
  40. package/scripts/ai_council/cli_hints.py +123 -0
  41. package/scripts/ai_council/clients.py +787 -5
  42. package/scripts/ai_council/compile_corpus.py +178 -0
  43. package/scripts/ai_council/confidence_gate.py +156 -0
  44. package/scripts/ai_council/config.py +1007 -11
  45. package/scripts/ai_council/consensus.py +41 -2
  46. package/scripts/ai_council/events_log.py +137 -0
  47. package/scripts/ai_council/learn_low_impact_preview.py +252 -0
  48. package/scripts/ai_council/low_impact.py +714 -0
  49. package/scripts/ai_council/low_impact_corpus.py +466 -0
  50. package/scripts/ai_council/low_impact_intake.py +163 -0
  51. package/scripts/ai_council/modes.py +6 -1
  52. package/scripts/ai_council/necessity.py +782 -0
  53. package/scripts/ai_council/orchestrator.py +252 -14
  54. package/scripts/ai_council/probation_gate.py +152 -0
  55. package/scripts/ai_council/redact_low_impact_entry.py +155 -0
  56. package/scripts/ai_council/replay.py +155 -0
  57. package/scripts/ai_council/session.py +19 -1
  58. package/scripts/ai_council/shadow_dispatch.py +235 -0
  59. package/scripts/ai_council/solo_dispatch.py +226 -0
  60. package/scripts/audit_cloud_compatibility.py +74 -0
  61. package/scripts/audit_command_surface.py +363 -0
  62. package/scripts/check_council_layout.py +11 -0
  63. package/scripts/council_cli.py +1046 -15
  64. package/scripts/install.sh +12 -0
@@ -12,7 +12,8 @@ repository.
12
12
  - **Authoritative module:** [`scripts/_lib/installed_tools.py`](../../scripts/_lib/installed_tools.py)
13
13
  - **ADR:** [`docs/decisions/ADR-008-installed-tools-manifest.md`](../decisions/ADR-008-installed-tools-manifest.md)
14
14
  - **Workflow guide:** [`docs/guidelines/agent-infra/installed-tools-manifest.md`](../guidelines/agent-infra/installed-tools-manifest.md)
15
- - **Active roadmap:** P1.1 of [`agents/roadmaps/road-to-multi-package-coexistence.md`](../../agents/roadmaps/road-to-multi-package-coexistence.md)
15
+ - **Active roadmap:** P1.1 of the `road-to-multi-package-coexistence`
16
+ roadmap (see `agents/roadmaps/` for current status).
16
17
 
17
18
  ## Versions
18
19
 
@@ -0,0 +1,95 @@
1
+ ---
2
+ stability: beta
3
+ keep-beta-until: 2026-08-13
4
+ ---
5
+
6
+ # `low-impact-decisions.md` — corpus format contract (step-9 P4)
7
+
8
+ Parser-visible invariants for `agents/low-impact-decisions.md` and any
9
+ upstream seed at the same path. The hardened parser lives in
10
+ [`scripts/ai_council/low_impact_corpus.py`](../../scripts/ai_council/low_impact_corpus.py)
11
+ and ships in two modes:
12
+
13
+ | Mode | Entry point | Behaviour on drift |
14
+ |---|---|---|
15
+ | **Lenient** (routing hot-path) | `load_validated_phrases(path)` | Silently drops the offending line. Routing never blocks on a malformed corpus. |
16
+ | **Strict** (CI lint + intake) | `parse_corpus_strict(path)` | Raises `CorpusParseError` with a typed `reason` and 1-based `line` on the first anomaly. |
17
+
18
+ ## Required sections
19
+
20
+ ```
21
+ ## On Probation
22
+
23
+ <!-- intake-anchor: probation -->
24
+
25
+
26
+
27
+ ## Validated
28
+
29
+ <!-- intake-anchor: validated -->
30
+
31
+
32
+
33
+ ## Anti-Examples (Always Ask User)
34
+
35
+
36
+ ```
37
+
38
+ - Heading level **MUST** be `##` (two hashes). `###` → `heading_drift`.
39
+ - Trailing punctuation on a heading (`## Validated:`) → `heading_drift`.
40
+ - Sections may appear in any order; missing sections are tolerated by
41
+ both modes (an empty corpus is valid).
42
+ - The intake-anchor HTML comments **MUST** be present for the two
43
+ intake-bearing sections (`probation`, `validated`) once any section
44
+ body is present. Strict mode raises `missing_anchor` otherwise; the
45
+ lenient shim ignores anchors (anchors are for the intake writer, not
46
+ the routing reader).
47
+
48
+ ## Bullet shape
49
+
50
+ ```
51
+ - "<phrase>" — optional trailing metadata
52
+ ```
53
+
54
+ Strict invariants:
55
+
56
+ | Drift | `reason` | Example |
57
+ |---|---|---|
58
+ | Curly opening quote (`U+201C` / `U+2018`) | `curly_quotes` | `- “foo bar”` |
59
+ | Single-quoted phrase | `single_quotes` | `- 'foo bar'` |
60
+ | Non-dash list marker (`*`, `+`, `1.`) | `non_dash_bullet` | `* "foo bar"` |
61
+ | Opening `"` with no matching close on the same line | `unclosed_quote` | `- "foo bar — meta` |
62
+ | Phrase normalises to empty (whitespace / punctuation only) | `empty_phrase` | `- " …"` |
63
+
64
+ Trailing metadata (everything after the closing `"`) is preserved on
65
+ the `CorpusEntry.trailing_metadata` field but is **not** consulted for
66
+ routing — only the normalised phrase is.
67
+
68
+ ## Phrase normalisation
69
+
70
+ A phrase is normalised before equality / similarity comparison:
71
+
72
+ 1. Lowercase.
73
+ 2. Replace every non-`[\w\s]` character with a single space.
74
+ 3. Collapse runs of whitespace.
75
+ 4. Strip leading / trailing whitespace.
76
+
77
+ This normalisation runs in both modes and is stable across the
78
+ routing classifier (`classify_impact_with_corpus`), the intake module
79
+ (`low_impact_intake`), and the probation gate.
80
+
81
+ ## Failure-mode fixtures
82
+
83
+ The seven canonical failure cases ship as fixtures under
84
+ [`tests/fixtures/corpus-robust/`](../../tests/fixtures/corpus-robust/),
85
+ one file per `reason`. The strict-mode suite
86
+ [`tests/test_low_impact_corpus_robustness.py`](../../tests/test_low_impact_corpus_robustness.py)
87
+ asserts each fixture trips the matching `CorpusParseError.reason` and
88
+ that the lenient shim degrades silently for per-bullet drift.
89
+
90
+ ## Cross-references
91
+
92
+ - Privacy floor — `.augment/rules/low-impact-corpus-privacy-floor.md`
93
+ - Routing — `scripts/ai_council/necessity.py § classify_impact_with_corpus`
94
+ - Intake — `scripts/ai_council/low_impact_intake.py`
95
+ - Promotion / pruning — `scripts/ai_council/probation_gate.py`
@@ -7,8 +7,8 @@ mcp_scope: lite
7
7
 
8
8
  > **Status:** Active · governs the `experimental → beta` promotion for
9
9
  > the MCP surface (`scripts/mcp_server/` local stdio kernel + the
10
- > hosted `workers/mcp/` bridge). Owned by Phase 3 of
11
- > [`road-to-surface-discipline.md`](../../agents/roadmaps/road-to-surface-discipline.md).
10
+ > hosted `workers/mcp/` bridge). Owned by Phase 3 of the
11
+ > `road-to-surface-discipline` roadmap (see `agents/roadmaps/`).
12
12
  > Companion contract:
13
13
  > [`mcp-phase-1-scope.md`](mcp-phase-1-scope.md) (local) ·
14
14
  > [`mcp-cloud-scope.md`](mcp-cloud-scope.md) (hosted).
@@ -91,8 +91,9 @@ ping. Evidence: the workflow file (`.github/workflows/mcp-no-drift.yml`)
91
91
  1. Open a release-candidate branch named `release/mcp-beta-rcN`.
92
92
  2. Run `./agent-config doctor --check mcp-beta-readiness` — must
93
93
  print all six gates green.
94
- 3. Flip the wording in the **five** surfaces inventoried in
95
- [`road-to-surface-discipline.md` Phase 3 Step 1](../../agents/roadmaps/road-to-surface-discipline.md):
94
+ 3. Flip the wording in the **five** surfaces inventoried in the
95
+ `road-to-surface-discipline` roadmap (Phase 3 Step 1, under
96
+ `agents/roadmaps/`):
96
97
  `docs/mcp-server.md` (status banner + Remote-MCP sub-claim),
97
98
  `README.md` (pointer line), `scripts/mcp_server/server.py`
98
99
  (initialize-result `serverInfo.name`),
@@ -125,5 +126,5 @@ delta for Phase 3: ≤ 0.
125
126
  - [`STABILITY.md`](STABILITY.md) — stability tier definitions
126
127
  (`experimental` / `beta` / `stable`) and what wording each tier may
127
128
  use in user-visible surfaces.
128
- - [`road-to-surface-discipline.md`](../../agents/roadmaps/road-to-surface-discipline.md)
129
+ - The `road-to-surface-discipline` roadmap (under `agents/roadmaps/`)
129
130
  — Phase 3 acceptance criteria and step-level evidence pointers.
@@ -31,9 +31,9 @@ alongside auth).
31
31
 
32
32
  The package ships **two MCP surfaces** governed by named scopes. Every
33
33
  MCP-related doc, ADR, and code path carries `mcp_scope: lite|full|deferred`
34
- in its frontmatter (Phase 1 Step 6 of
35
- `agents/roadmaps/road-to-distribution-maturity.md`) so the boundary is
36
- machine-checkable, not prose-only.
34
+ in its frontmatter (Phase 1 Step 6 of the distribution-maturity roadmap,
35
+ under `agents/roadmaps/`) so the boundary is machine-checkable, not
36
+ prose-only.
37
37
 
38
38
  ### `mcp_scope: lite` — hosted, read-only knowledge surfaces
39
39
 
@@ -239,7 +239,8 @@ The README MCP section may **only** name modes that this `## Auth
239
239
  surface` section declares. This contract must declare every mode the
240
240
  README names. The drift test
241
241
  `tests/test_mcp_contract_readme_sync.py` enforces both directions
242
- per Phase 1 Step 4 of `agents/roadmaps/road-to-distribution-maturity.md`.
242
+ per Phase 1 Step 4 of the distribution-maturity roadmap (under
243
+ `agents/roadmaps/`).
243
244
 
244
245
  ## A0-cloud invariants
245
246
 
@@ -1,6 +1,12 @@
1
+ ---
2
+ stability: beta
3
+ keep-beta-until: 2026-08-13
4
+ ---
5
+
1
6
  # Multi-Tool Projection Fidelity Contract
2
7
 
3
- **Status:** beta · **Phase 4 of [step-1-v2-feedback-followup](../../agents/roadmaps/step-1-v2-feedback-followup.md)**
8
+ **Status:** beta · **Phase 4 of the `step-1-v2-feedback-followup`
9
+ roadmap** (under `agents/roadmaps/`).
4
10
 
5
11
  Names the **per-tool guarantees** the projection pipeline (`scripts/compress.py --sync` + `scripts/compress.py --generate-tools`) actually delivers. Byte-equivalence is not behaviour-fidelity — each consumer tool has its own frontmatter grammar, its own activation model, and its own surface for skills / rules / commands.
6
12
 
@@ -106,4 +112,4 @@ These are **architectural facts**, not regressions. They are documented so insta
106
112
  - [`augment-projection`](../architecture/augment-projection.md) — pipeline B (Augment-specific)
107
113
  - [`multi-tool-projection`](../architecture/multi-tool-projection.md) — pipeline C (the per-tool emitters)
108
114
  - [`rule-router`](rule-router.md) — the `triggers:` / `routes_to:` grammar this contract pins
109
- - [`agents/council-sessions/2026-05-14-v2-analysis/feedback/09-cross-tool-projection-fidelity.md`](../../agents/council-sessions/2026-05-14-v2-analysis/feedback/09-cross-tool-projection-fidelity.md) — origin council feedback
115
+ - [`agents/council-sessions/2026-05-14-v2-analysis/feedback/09-cross-tool-projection-fidelity.md`](../../agents/council-sessions/2026-05-14-v2-analysis/feedback/09-cross-tool-projection-fidelity.md) — origin council feedback <!-- council-ref-allowed: contract origin trace -->
@@ -11,7 +11,8 @@ keep-beta-until: 2026-08-12
11
11
  > points across the 2.x cycle. External readers landing on `main`
12
12
  > consistently saw stale README counts and missing skill catalogues
13
13
  > relative to the npm/Packagist artefact.
14
- > **Closes:** [Road to Productization](../../agents/roadmaps/road-to-productization.md) § P1.2.
14
+ > **Closes:** the `road-to-productization` roadmap § P1.2 (under
15
+ > `agents/roadmaps/`).
15
16
 
16
17
  ## Decision
17
18
 
@@ -100,5 +101,5 @@ not orphan the convention.
100
101
  - [`.github/workflows/release-guard.yml`](../../.github/workflows/release-guard.yml)
101
102
  — tag/version-file integrity gate (orthogonal: this contract handles
102
103
  trunk position, release-guard handles version-string integrity).
103
- - [`agents/roadmaps/road-to-productization.md`](../../agents/roadmaps/road-to-productization.md)
104
- § Phase 1.
104
+ - The `road-to-productization` roadmap § Phase 1 (under
105
+ `agents/roadmaps/`).
@@ -26,10 +26,9 @@ Last refreshed: 2026-05-12.
26
26
  | **Tier-2** | Shipped | Named in roadmaps + has plausible audience | Imperative bridge, same pattern as Tier-1 |
27
27
  | **Tier-3** | **Deferred** | Named in scoping/council but zero user demand | Manifest YAML in `agents/manifests/contrib/` (not yet implemented) |
28
28
 
29
- Phase 2.1 + 2.2 of
30
- [`road-to-global-first-install`](../../agents/roadmaps/road-to-global-first-install.md)
31
- closed Tier-1 and Tier-2 at **16 AIs**. Tier-3 is the explicit
32
- overflow bucket.
29
+ Phase 2.1 + 2.2 of the `road-to-global-first-install` roadmap
30
+ (under `agents/roadmaps/`) closed Tier-1 and Tier-2 at **16 AIs**.
31
+ Tier-3 is the explicit overflow bucket.
33
32
 
34
33
  ## Candidate list (frozen at proposal time)
35
34
 
@@ -126,5 +125,5 @@ not "preemptively scaffold" Tier-3 entries.
126
125
  - [`ADR-008`](../decisions/ADR-008-installed-tools-manifest.md) —
127
126
  `agents/installed-tools.lock` for per-project state, distinct
128
127
  from this maintainer-side contract.
129
- - [`road-to-global-first-install`](../../agents/roadmaps/road-to-global-first-install.md)
130
- Phase 2.6 — completion trigger for this contract.
128
+ - The `road-to-global-first-install` roadmap (under
129
+ `agents/roadmaps/`) Phase 2.6 — completion trigger for this contract.
@@ -106,7 +106,7 @@ Your agent is now:
106
106
  - **Respecting your codebase** — no conflicting patterns
107
107
  - **Following standards** — consistent code quality
108
108
 
109
- This is enforced automatically by 65 rules. No configuration needed.
109
+ This is enforced automatically by 67 rules. No configuration needed.
110
110
 
111
111
  ---
112
112
 
@@ -146,7 +146,7 @@ Your agent now understands slash commands:
146
146
  | `/quality-fix` | Run and fix all quality checks |
147
147
  | `/chat-history` | Inspect the persistent chat-history log (read-only `show`) |
148
148
 
149
- → [Browse all 108 active commands](../.agent-src/commands/)
149
+ → [Browse all 109 active commands](../.agent-src/commands/)
150
150
 
151
151
  ---
152
152
 
@@ -4,7 +4,8 @@ Project-committed bill of materials for AI tooling. Answers the
4
4
  question "which AIs does this project use, where do their bridges live,
5
5
  and is everyone on the team on the same set?". Canonical schema is
6
6
  ADR-008 ([`docs/decisions/ADR-008-installed-tools-manifest.md`](../../decisions/ADR-008-installed-tools-manifest.md)).
7
- Phase 3 of [`road-to-global-first-install`](../../../agents/roadmaps/road-to-global-first-install.md).
7
+ Delivered under the global-first-install roadmap (Phase 3) — see
8
+ `agents/roadmaps/` for current status.
8
9
 
9
10
  This file lives at **`agents/installed-tools.lock`** — committed,
10
11
  machine-managed, and orthogonal to `.agent-project-settings.yml`
@@ -646,6 +646,38 @@ the symlinks and regenerates derived files (`.windsurfrules`,
646
646
 
647
647
  ---
648
648
 
649
+ ## AI Council local state
650
+
651
+ The AI Council ([`docs/contracts/ai-council-config.md`](contracts/ai-council-config.md))
652
+ writes two local-only files outside the repo contract:
653
+
654
+ - `~/.event4u/agent-config/cli-calls.json` — per-day call counter for
655
+ `mode: cli` members. Daily UTC reset. Inspect with
656
+ `agent-config council quota`; clear today's counter for one provider
657
+ with `agent-config council quota --reset <provider> --confirm`.
658
+ - `agents/council-events.log` — JSONL audit trail. One line per
659
+ necessity-gate decision and per quota block. Gitignored by the
660
+ installer (managed `.gitignore` block); never committed.
661
+ `original_ask` is hashed `sha256[:12]` before write — the raw prompt
662
+ is never persisted.
663
+
664
+ Both are opt-in by construction: the quota counter only fires when a
665
+ provider has `cli_call_budget.max_calls_per_day.<provider>` set, and
666
+ the events log is purely additive (deletable at any time).
667
+
668
+ ### Kill-switches
669
+
670
+ Per-feature environment overrides for ephemeral worktrees, CI runners,
671
+ or sandbox testing:
672
+
673
+ - `AGENT_CONFIG_NO_EVENTS_LOG=1` — disables every write to
674
+ `agents/council-events.log` in-process. Quota counter and council
675
+ output stay untouched.
676
+ - `AGENT_CONFIG_LEGACY_ANCHOR=1` — opt back into the pre-step-7
677
+ legacy-anchor behaviour for `.agent-settings.yml` migration.
678
+
679
+ ---
680
+
649
681
  ## Windows
650
682
 
651
683
  Native Windows is not a first-class target. Use one of the following:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@event4u/agent-config",
3
- "version": "2.13.0",
3
+ "version": "2.14.0",
4
4
  "description": "Shared agent configuration \u2014 skills, rules, commands, guidelines, and templates for AI coding tools",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -36,6 +36,7 @@ import argparse
36
36
  import hashlib
37
37
  import json
38
38
  import re
39
+ import shutil
39
40
  import sys
40
41
  from pathlib import Path
41
42
  from typing import Any
@@ -287,6 +288,7 @@ CHECK_IDS = (
287
288
  "offline-readiness",
288
289
  "python-runtime",
289
290
  "tier-usage-readiness",
291
+ "council-cli",
290
292
  "unsupported-combos",
291
293
  )
292
294
 
@@ -675,6 +677,137 @@ def _check_tier_usage_readiness(project_root: Path) -> dict[str, Any]:
675
677
  }
676
678
 
677
679
 
680
+ #: Provider → (default binary, billable flag). Mirrors the
681
+ #: ``CliClient`` subclass attributes in ``scripts/ai_council/clients.py``
682
+ #: without importing them at module load time (keeps doctor lightweight
683
+ #: and robust if council deps fail to load).
684
+ _CLI_PROVIDER_META: dict[str, tuple[str, bool]] = {
685
+ "anthropic": ("claude", False),
686
+ "openai": ("codex", False),
687
+ "gemini": ("gemini", False),
688
+ "xai": ("grok", True),
689
+ "perplexity": ("perplexity", True),
690
+ }
691
+
692
+
693
+ def _check_council_cli(project_root: Path) -> dict[str, Any]:
694
+ """Health-probe each enabled ``mode: cli`` council member.
695
+
696
+ For every CLI member in ``agents/.ai-council.yml`` reports binary
697
+ presence (via ``shutil.which``), billable flag, daily-quota state
698
+ (used/cap or used/—), and rolls up to a single status icon.
699
+
700
+ No subprocess is spawned: ``--version`` probes would defeat the
701
+ cheap-by-default contract and surface flaky network paths (Codex
702
+ talks home on first run). Binary presence + the cached
703
+ ``cli-calls.json`` counter cover the actionable failure modes.
704
+
705
+ Status rules:
706
+
707
+ - ``ok`` — every enabled CLI member has its binary on PATH AND
708
+ (when capped) usage is below ``warn_at``.
709
+ - ``warn`` — at least one binary is missing OR usage crosses
710
+ ``warn_at`` for at least one capped member.
711
+ - returns ``ok`` with "no council config" if
712
+ ``agents/.ai-council.yml`` is absent (consumer project that
713
+ hasn't enabled the council yet).
714
+ """
715
+ council_path = project_root / "agents" / ".ai-council.yml"
716
+ if not council_path.exists():
717
+ return {
718
+ "id": "council-cli", "status": "ok",
719
+ "message": "no council config (agents/.ai-council.yml not present)",
720
+ "remedy": "",
721
+ }
722
+ try:
723
+ from scripts.ai_council.clients import load_cli_call_counts
724
+ from scripts.ai_council.config import load_council_config
725
+ except Exception as exc: # noqa: BLE001 — defensive: doctor must not crash
726
+ return {
727
+ "id": "council-cli", "status": "warn",
728
+ "message": f"council deps unavailable ({type(exc).__name__})",
729
+ "remedy": "install PyYAML and ensure scripts/ai_council is importable",
730
+ }
731
+ try:
732
+ cfg = load_council_config(council_path)
733
+ except Exception as exc: # noqa: BLE001
734
+ return {
735
+ "id": "council-cli", "status": "warn",
736
+ "message": f"council config invalid: {exc}",
737
+ "remedy": "fix agents/.ai-council.yml and re-run doctor",
738
+ }
739
+ cli_members: list[tuple[str, Any]] = [
740
+ (name, m) for name, m in cfg.members.items()
741
+ if m.enabled and m.mode == "cli" and name in _CLI_PROVIDER_META
742
+ ]
743
+ if not cli_members:
744
+ return {
745
+ "id": "council-cli", "status": "ok",
746
+ "message": "no enabled CLI-mode members",
747
+ "remedy": "",
748
+ }
749
+ counts = load_cli_call_counts()
750
+ caps = cfg.cli_call_budget.max_calls_per_day
751
+ warn_at = float(cfg.cli_call_budget.warn_at)
752
+ missing: list[str] = []
753
+ over_warn: list[str] = []
754
+ lines: list[str] = []
755
+ for name, member in cli_members:
756
+ default_bin, billable = _CLI_PROVIDER_META[name]
757
+ binary_name = member.binary or default_bin
758
+ resolved = shutil.which(binary_name)
759
+ binary_glyph = "✅" if resolved else "❌"
760
+ if resolved is None:
761
+ missing.append(name)
762
+ used = int(counts.get(name, 0))
763
+ cap = caps.get(name)
764
+ if cap is not None:
765
+ ratio = used / cap if cap > 0 else 0.0
766
+ quota_glyph = "⚠️" if ratio >= warn_at else "✅"
767
+ if ratio >= warn_at:
768
+ over_warn.append(name)
769
+ quota_str = f"{used}/{cap}"
770
+ else:
771
+ quota_glyph = "—"
772
+ quota_str = f"{used}/—"
773
+ billable_str = "billable" if billable else "subscription"
774
+ lines.append(
775
+ f"{name}: binary {binary_glyph} ({binary_name}) · "
776
+ f"quota {quota_glyph} {quota_str} · {billable_str}"
777
+ )
778
+ detail = " | ".join(lines)
779
+ if missing:
780
+ return {
781
+ "id": "council-cli", "status": "warn",
782
+ "message": (
783
+ f"{len(missing)}/{len(cli_members)} CLI member(s) missing binary "
784
+ f"({', '.join(missing)}) · {detail}"
785
+ ),
786
+ "remedy": (
787
+ "install the missing CLI(s) — see `council:estimate` pre-flight "
788
+ "for per-provider install hints, or flip "
789
+ "ai_council.members.<name>.mode to 'api'"
790
+ ),
791
+ }
792
+ if over_warn:
793
+ return {
794
+ "id": "council-cli", "status": "warn",
795
+ "message": (
796
+ f"{len(over_warn)}/{len(cli_members)} CLI member(s) at/over "
797
+ f"quota warn_at={warn_at} ({', '.join(over_warn)}) · {detail}"
798
+ ),
799
+ "remedy": (
800
+ "wait for UTC rollover or run "
801
+ "`python3 scripts/council_cli.py quota --reset` to clear the counter"
802
+ ),
803
+ }
804
+ return {
805
+ "id": "council-cli", "status": "ok",
806
+ "message": f"{len(cli_members)} CLI member(s) healthy · {detail}",
807
+ "remedy": "",
808
+ }
809
+
810
+
678
811
  def _check_unsupported_combos(manifest: dict[str, Any]) -> dict[str, Any]:
679
812
  """Flag tools whose ``scope`` violates the global-only or project-only rules."""
680
813
  global_only = {"droid", "qoder"}
@@ -720,6 +853,7 @@ def _run_checks(
720
853
  "offline-readiness": lambda: _check_offline_readiness(),
721
854
  "python-runtime": lambda: _check_python_runtime(),
722
855
  "tier-usage-readiness": lambda: _check_tier_usage_readiness(project_root),
856
+ "council-cli": lambda: _check_council_cli(project_root),
723
857
  "unsupported-combos": lambda: _check_unsupported_combos(manifest),
724
858
  }
725
859
  out: list[dict[str, Any]] = []
@@ -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:]))