@event4u/agent-config 2.12.0 → 2.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/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/copilot-routing.md +19 -0
- package/.agent-src/rules/devcontainer-routing.md +20 -0
- package/.agent-src/rules/laravel-routing.md +20 -0
- package/.agent-src/rules/symfony-routing.md +20 -0
- package/.agent-src/skills/ai-council/SKILL.md +180 -2
- 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 +3 -1
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +47 -0
- package/CONTRIBUTING.md +5 -0
- package/README.md +3 -3
- package/config/agent-settings.template.yml +5 -93
- package/docs/architecture/multi-tool-projection.md +53 -0
- package/docs/architecture/{compression.md → source-projection.md} +21 -3
- package/docs/architecture.md +5 -5
- package/docs/catalog.md +21 -11
- package/docs/contracts/adr-architectural-consensus-mechanism.md +67 -0
- package/docs/contracts/ai-council-config.md +186 -0
- package/docs/contracts/command-clusters.md +57 -1
- package/docs/contracts/multi-tool-projection-fidelity.md +109 -0
- package/docs/getting-started.md +2 -2
- package/package.json +1 -1
- package/scripts/_archive/README.md +59 -0
- package/scripts/ai_council/_default_prices.py +10 -1
- package/scripts/ai_council/advisors.py +148 -0
- package/scripts/ai_council/clients.py +172 -0
- package/scripts/ai_council/config.py +368 -0
- package/scripts/ai_council/consensus.py +290 -0
- package/scripts/ai_council/orchestrator.py +628 -14
- package/scripts/ai_council/prompts.py +335 -0
- package/scripts/check_compressed_paths.py +6 -1
- package/scripts/ci_time_ratio.py +168 -0
- package/scripts/council_cli.py +973 -29
- 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,109 @@
|
|
|
1
|
+
# Multi-Tool Projection Fidelity Contract
|
|
2
|
+
|
|
3
|
+
**Status:** beta · **Phase 4 of [step-1-v2-feedback-followup](../../agents/roadmaps/step-1-v2-feedback-followup.md)**
|
|
4
|
+
|
|
5
|
+
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
|
+
|
|
7
|
+
## Source of truth
|
|
8
|
+
|
|
9
|
+
Every projection starts from `.agent-src/` (compressed) which is generated from `.agent-src.uncompressed/`. The projection layer **never** writes to source; it only reads.
|
|
10
|
+
|
|
11
|
+
## Per-tool projection map
|
|
12
|
+
|
|
13
|
+
| Tool | Rules surface | Skills surface | Commands surface | Frontmatter grammar |
|
|
14
|
+
|---|---|---|---|---|
|
|
15
|
+
| **Augment** (host) | `.augment/rules/*.md` (copies; symlink opt-in via `augment.rules_use_symlinks`) | `.augment/skills/<name>/SKILL.md` (symlink → `.agent-src/skills/`) | `.augment/commands/*.md` | full source frontmatter preserved |
|
|
16
|
+
| **Claude** (Code + Desktop) | `.claude/rules/*.md` | `.claude/skills/<name>/SKILL.md` | `.claude/skills/<name>/SKILL.md` (commands rendered as skills) | full source frontmatter preserved |
|
|
17
|
+
| **Cursor** | `.cursor/rules/*.mdc` + legacy `.md` symlinks (130 files = 65 × 2) | **not projected** | `.cursor/commands/*.md` | `description`, `globs`, `alwaysApply` only — `triggers`, `routes_to`, `tier`, `type` are **dropped** |
|
|
18
|
+
| **Windsurf** | `.windsurfrules` (single concatenated file) + `.windsurf/rules/*.md` (per-rule) | **not projected** | `.windsurf/workflows/*.md` | concatenated body; per-rule frontmatter only retained in `.windsurf/rules/`, not in the legacy `.windsurfrules` single-file |
|
|
19
|
+
| **Cline** | `.clinerules/*.md` | **not projected** | **not projected** | full router frontmatter preserved (`type`, `tier`, `description`, `triggers`, `routes_to`) |
|
|
20
|
+
| **Gemini** | `GEMINI.md` (single-file digest) | embedded inline | embedded inline | digest only — no per-rule frontmatter |
|
|
21
|
+
| **Copilot** | `AGENTS.md` / `copilot-instructions.md` | embedded inline | embedded inline | digest only |
|
|
22
|
+
|
|
23
|
+
`AGENTS.md` is the **tool-agnostic root pointer** and exists at workspace root regardless of which projections are enabled.
|
|
24
|
+
|
|
25
|
+
## Fidelity guarantees per axis
|
|
26
|
+
|
|
27
|
+
### 1. Rule body fidelity
|
|
28
|
+
|
|
29
|
+
| Tool | Body identical to source? |
|
|
30
|
+
|---|---|
|
|
31
|
+
| Augment | yes (copy or symlink) |
|
|
32
|
+
| Claude | yes (copy) |
|
|
33
|
+
| Cursor `.mdc` | yes |
|
|
34
|
+
| Cline | yes |
|
|
35
|
+
| Windsurf single-file | concatenated, separator `---` between rules |
|
|
36
|
+
| Windsurf per-rule | yes |
|
|
37
|
+
| Gemini / Copilot digest | summarised — **no fidelity guarantee** |
|
|
38
|
+
|
|
39
|
+
### 2. Trigger fidelity (`triggers:` keyword / `path_prefix`)
|
|
40
|
+
|
|
41
|
+
| Tool | `triggers:` preserved? |
|
|
42
|
+
|---|---|
|
|
43
|
+
| Augment, Claude, Cline, Windsurf-per-rule | **yes** — the host LLM sees the trigger set verbatim |
|
|
44
|
+
| Cursor `.mdc` | **no** — Cursor's frontmatter grammar does not honour `triggers:`; activation falls back to `globs:` + `alwaysApply: <bool>` + description match |
|
|
45
|
+
| Windsurf single-file `.windsurfrules` | **no** — concatenated body strips per-rule frontmatter |
|
|
46
|
+
| Gemini, Copilot | **no** — digest format |
|
|
47
|
+
|
|
48
|
+
**Consequence:** rules that depend on `triggers:` for activation (tier-2a path-prefix routing, tier-3 keyword routing) **silently degrade on Cursor and on the Windsurf single-file**. They still appear in body, but the host must infer activation from prose.
|
|
49
|
+
|
|
50
|
+
### 3. `routes_to:` fidelity
|
|
51
|
+
|
|
52
|
+
Same matrix as `triggers:` — preserved on Augment, Claude, Cline, Windsurf-per-rule; **dropped** on Cursor `.mdc` and Windsurf single-file.
|
|
53
|
+
|
|
54
|
+
**Consequence:** the four tier-3 routing rules (`laravel-routing`, `symfony-routing`, `copilot-routing`, `devcontainer-routing`) added in Phase 3.3 will route deterministically on Augment / Claude / Cline; on Cursor / Windsurf-single-file the host must rely on description matching alone.
|
|
55
|
+
|
|
56
|
+
### 4. Skill surface
|
|
57
|
+
|
|
58
|
+
Cursor, Windsurf, Cline, Gemini, Copilot have **no native skill surface**. Skills are projected only for Augment and Claude. Consumers on the other tools see skill content only indirectly (via rule bodies that cite skills, or via the catalogue in `AGENTS.md`).
|
|
59
|
+
|
|
60
|
+
### 5. Command surface
|
|
61
|
+
|
|
62
|
+
| Tool | Where commands appear |
|
|
63
|
+
|---|---|
|
|
64
|
+
| Augment | `.augment/commands/*.md` (native slash-command surface) |
|
|
65
|
+
| Claude | `.claude/skills/<command>/SKILL.md` (commands rendered as skills with `disable-model-invocation: true`) |
|
|
66
|
+
| Cursor | `.cursor/commands/*.md` (106 files) |
|
|
67
|
+
| Windsurf | `.windsurf/workflows/*.md` (106 files) |
|
|
68
|
+
| Cline | none |
|
|
69
|
+
| Gemini, Copilot | listed only inside `AGENTS.md` / `GEMINI.md` digest |
|
|
70
|
+
|
|
71
|
+
## Automated probe — `task lint-projection-fidelity`
|
|
72
|
+
|
|
73
|
+
`scripts/probe_projection_fidelity.py` reads `tests/fixtures/projection_fidelity/fixtures.yml` and asserts the per-tool guarantees above against the actual projected trees. The fixture covers five representative artefacts:
|
|
74
|
+
|
|
75
|
+
| Fixture entry | Tier | Stress-tests |
|
|
76
|
+
|---|---|---|
|
|
77
|
+
| `rule:non-destructive-by-default` | kernel | always-active body fidelity across all five rule surfaces |
|
|
78
|
+
| `rule:laravel-translations` | tier-2a | `path_prefix:` trigger preservation (Cline) vs drop (Cursor) |
|
|
79
|
+
| `rule:laravel-routing` | tier-3 | `routes_to:` preservation (Cline) vs drop (Cursor, Windsurf-single) |
|
|
80
|
+
| `skill:laravel` | skill | Augment + Claude only; rationale for absence on others |
|
|
81
|
+
| `command:commit` | command | per-tool command surface divergence |
|
|
82
|
+
|
|
83
|
+
Run: `python3 scripts/probe_projection_fidelity.py` — exits non-zero on any divergence. Report at `agents/reports/projection-fidelity.json`.
|
|
84
|
+
|
|
85
|
+
## Known divergences (do not file as bugs)
|
|
86
|
+
|
|
87
|
+
These are **architectural facts**, not regressions. They are documented so installers and consumers know what to expect.
|
|
88
|
+
|
|
89
|
+
1. **Cursor `.mdc` drops router metadata.** Cursor's third-party rule format only honours `description`, `globs`, `alwaysApply`. Adding `triggers:` or `routes_to:` to a Cursor rule has no effect at activation time. The body still loads when the description matches; the deterministic routing layer does not.
|
|
90
|
+
2. **Windsurf single-file (`.windsurfrules`) strips per-rule frontmatter.** Legacy compatibility surface. The new `.windsurf/rules/*.md` per-rule files preserve the full frontmatter — consumers should prefer those.
|
|
91
|
+
3. **Skills do not project to Cursor / Windsurf / Cline / Gemini / Copilot.** These tools have no native skill loader. Skill content reaches consumers indirectly via rule bodies and the `AGENTS.md` catalogue.
|
|
92
|
+
4. **Augment historically did not load symlinked rules.** Default is to **copy** rules into `.augment/rules/`. Opt into symlinks via `augment.rules_use_symlinks: true` in `.agent-settings.yml`.
|
|
93
|
+
5. **`task generate-tools` does not refresh `.augment/rules/`.** Only `task sync` (== `scripts/compress.py --sync`) copies rules into the Augment tree. Investigators who edit a rule, run only `generate-tools`, and then `ls .augment/rules/` will see stale state.
|
|
94
|
+
|
|
95
|
+
## Acceptance criteria for this contract
|
|
96
|
+
|
|
97
|
+
- [x] Fixture under `tests/fixtures/projection_fidelity/`
|
|
98
|
+
- [x] Probe script under `scripts/probe_projection_fidelity.py`
|
|
99
|
+
- [x] Report under `agents/reports/projection-fidelity.json`
|
|
100
|
+
- [x] Per-tool guarantee table above
|
|
101
|
+
- [x] Known-divergence list above
|
|
102
|
+
|
|
103
|
+
## Related
|
|
104
|
+
|
|
105
|
+
- [`source-projection`](../architecture/source-projection.md) — pipeline A (source compression)
|
|
106
|
+
- [`augment-projection`](../architecture/augment-projection.md) — pipeline B (Augment-specific)
|
|
107
|
+
- [`multi-tool-projection`](../architecture/multi-tool-projection.md) — pipeline C (the per-tool emitters)
|
|
108
|
+
- [`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
|
package/docs/getting-started.md
CHANGED
|
@@ -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
|
|
109
|
+
This is enforced automatically by 65 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
|
|
149
|
+
→ [Browse all 108 active commands](../.agent-src/commands/)
|
|
150
150
|
|
|
151
151
|
---
|
|
152
152
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Script Archive — One-Shot Migrations
|
|
2
|
+
|
|
3
|
+
This directory preserves migration / bootstrap / back-fill scripts that have
|
|
4
|
+
already run to completion and are no longer invoked by any productive code
|
|
5
|
+
path. They are kept as forensic reference, mirroring the archival convention
|
|
6
|
+
used by [`agents/roadmaps/archive/`](../../agents/roadmaps/archive/).
|
|
7
|
+
|
|
8
|
+
**Do not run these scripts.** They are one-shot transformations whose
|
|
9
|
+
target state is already the working tree. Re-running them on the current
|
|
10
|
+
codebase is undefined behaviour.
|
|
11
|
+
|
|
12
|
+
## Provenance
|
|
13
|
+
|
|
14
|
+
Archived 2026-05-14 as part of [`agents/roadmaps/step-1-v2-feedback-followup.md`](../../agents/roadmaps/step-1-v2-feedback-followup.md)
|
|
15
|
+
Phase 1 Step 3, addressing audit finding F3 / council finding C3 from
|
|
16
|
+
[`agents/council-sessions/2026-05-14-v2-analysis/feedback/03-migration-scripts-archival.md`](../../agents/council-sessions/2026-05-14-v2-analysis/feedback/03-migration-scripts-archival.md).
|
|
17
|
+
|
|
18
|
+
## Inventory
|
|
19
|
+
|
|
20
|
+
| Script | Migration / phase served | What it did |
|
|
21
|
+
|---|---|---|
|
|
22
|
+
| [`_backfill_skill_domains.py`](_backfill_skill_domains.py) | B3 domain back-fill | Injected `domain:` frontmatter into every `SKILL.md`. Source of truth now lives in each skill's frontmatter directly. |
|
|
23
|
+
| [`_bootstrap_tier_frontmatter.py`](_bootstrap_tier_frontmatter.py) | Tier-frontmatter bootstrap | Injected `tier: N` frontmatter into every slash command during the kernel / tier-1 / tier-2 routing introduction. |
|
|
24
|
+
| [`_p43_bodies.py`](_p43_bodies.py) | Phase 4.3 — rule-body compression | Wrote compressed rule bodies after `_p43_compress.py` produced the manifest. Paired with `_p43_compress.py`. |
|
|
25
|
+
| [`_p43_compress.py`](_p43_compress.py) | Phase 4.3 — rule-body compression | Surgical compression of 22 `compress-and-keep` auto-rules; produced the manifest consumed by `_p43_bodies.py`. |
|
|
26
|
+
| [`_p4_migrate.py`](_p4_migrate.py) | Phase 4.1 + 4.2 — rule reclassification | Migrated rules into the skill / guideline / command / contract-stub split that the package ships today. |
|
|
27
|
+
| [`_phase2_shim_helper.py`](_phase2_shim_helper.py) | Phase 2 — deprecation shim | One-shot helper that injected `superseded_by:` + `deprecated_in:` + deprecation warning into rules retired during Phase 2. |
|
|
28
|
+
| [`_pilot_council_question.py`](_pilot_council_question.py) | Phase 1 pilot — kernel-membership council prep | Built the Phase-1 council question file used for the kernel-membership R1/R2 cross-check. The resulting council artefacts live under `agents/council-sessions/20260506T*`. |
|
|
29
|
+
|
|
30
|
+
## Why these stayed live and were NOT archived
|
|
31
|
+
|
|
32
|
+
The 2026-05-14 audit (F3) listed 9 candidate scripts. Two of those turn out
|
|
33
|
+
to have productive (non-incestuous) references and remain in `scripts/`:
|
|
34
|
+
|
|
35
|
+
- **`scripts/_emit_domain_table.py`** — cited as the regeneration command in
|
|
36
|
+
[`docs/contracts/skill-domains.md`](../../docs/contracts/skill-domains.md)
|
|
37
|
+
("regenerate via `python3 scripts/_emit_domain_table.py`"). The
|
|
38
|
+
domain-table snapshot is a derived view that the contract doc explicitly
|
|
39
|
+
expects to be regenerable from this script.
|
|
40
|
+
- **`scripts/_pilot_measure.py`** — cited by
|
|
41
|
+
[`docs/contracts/kernel-membership.md`](../../docs/contracts/kernel-membership.md)
|
|
42
|
+
as the reproducibility-verification command for the kernel pilot SHAs, and
|
|
43
|
+
its algorithm is mirrored by [`scripts/iron_law_sha.py`](../iron_law_sha.py).
|
|
44
|
+
Both productive paths assume the script remains in place.
|
|
45
|
+
|
|
46
|
+
The audit's F3 framing ("zero productive references") was correct for the 7
|
|
47
|
+
archived scripts and wrong for these 2. Recorded here so the F3 finding is
|
|
48
|
+
not re-litigated without context.
|
|
49
|
+
|
|
50
|
+
## How to restore one (if a future migration needs it)
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
git mv scripts/_archive/<script>.py scripts/
|
|
54
|
+
git commit -m "chore(scripts): restore <script> for <reason>"
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Restoration should come with an issue / PR explaining why the historical
|
|
58
|
+
one-shot is being reused — by construction these scripts assume their
|
|
59
|
+
pre-migration starting state.
|
|
@@ -17,7 +17,7 @@ from __future__ import annotations
|
|
|
17
17
|
|
|
18
18
|
# YYYY-MM-DD of when this table was last hand-edited. Keep in sync with
|
|
19
19
|
# the test_default_prices freshness assertion if you bump this.
|
|
20
|
-
LAST_UPDATED = "2026-
|
|
20
|
+
LAST_UPDATED = "2026-05-14"
|
|
21
21
|
|
|
22
22
|
# (provider, model) -> (input_per_1m_usd, output_per_1m_usd)
|
|
23
23
|
DEFAULT_PRICES: dict[tuple[str, str], tuple[float, float]] = {
|
|
@@ -30,6 +30,15 @@ DEFAULT_PRICES: dict[tuple[str, str], tuple[float, float]] = {
|
|
|
30
30
|
("openai", "gpt-4o-mini"): (0.15, 0.60),
|
|
31
31
|
("openai", "o1"): (15.00, 60.00),
|
|
32
32
|
("openai", "o3-mini"): (1.10, 4.40),
|
|
33
|
+
# ── Google Gemini ────────────────────────────────────────────────
|
|
34
|
+
("gemini", "gemini-2.5-pro"): (1.25, 10.00),
|
|
35
|
+
("gemini", "gemini-2.5-flash"): (0.30, 2.50),
|
|
36
|
+
# ── xAI Grok ─────────────────────────────────────────────────────
|
|
37
|
+
("xai", "grok-4"): (3.00, 15.00),
|
|
38
|
+
("xai", "grok-3-mini"): (0.30, 0.50),
|
|
39
|
+
# ── Perplexity ───────────────────────────────────────────────────
|
|
40
|
+
("perplexity", "sonar-pro"): (3.00, 15.00),
|
|
41
|
+
("perplexity", "sonar"): (1.00, 1.00),
|
|
33
42
|
}
|
|
34
43
|
|
|
35
44
|
|
|
@@ -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
|
|
@@ -52,6 +52,15 @@ def _resolve_key_path(filename: str) -> Path:
|
|
|
52
52
|
|
|
53
53
|
DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-5"
|
|
54
54
|
DEFAULT_OPENAI_MODEL = "gpt-4o"
|
|
55
|
+
DEFAULT_GEMINI_MODEL = "gemini-2.5-pro"
|
|
56
|
+
DEFAULT_XAI_MODEL = "grok-4"
|
|
57
|
+
DEFAULT_PERPLEXITY_MODEL = "sonar-pro"
|
|
58
|
+
|
|
59
|
+
#: OpenAI-API-compatible endpoints. xAI and Perplexity both expose the
|
|
60
|
+
#: ``/v1/chat/completions`` shape, so their clients reuse the ``openai``
|
|
61
|
+
#: SDK with a custom ``base_url``. Gemini has its own SDK (``google-genai``).
|
|
62
|
+
XAI_BASE_URL = "https://api.x.ai/v1"
|
|
63
|
+
PERPLEXITY_BASE_URL = "https://api.perplexity.ai"
|
|
55
64
|
|
|
56
65
|
#: Per-call output budget when no caller-supplied value reaches `ask()`.
|
|
57
66
|
#: The CLI resolves the live default from `ai_council.max_output_tokens`
|
|
@@ -269,6 +278,169 @@ class OpenAIClient(ExternalAIClient):
|
|
|
269
278
|
)
|
|
270
279
|
|
|
271
280
|
|
|
281
|
+
# ── Gemini / xAI / Perplexity (Phase 0 — Step 6) ─────────────────────
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
class GeminiClient(ExternalAIClient):
|
|
285
|
+
"""Google Gemini via the ``google-genai`` SDK.
|
|
286
|
+
|
|
287
|
+
Lazy-imports ``google.genai`` on first ``ask()`` so disabled
|
|
288
|
+
members do not require the SDK to be installed. Tests inject a
|
|
289
|
+
mock client shaped like ``genai.Client(api_key=...)`` —
|
|
290
|
+
``self._client.models.generate_content(...)`` returns an object
|
|
291
|
+
with ``.text`` and ``.usage_metadata.{prompt_token_count,
|
|
292
|
+
candidates_token_count}``.
|
|
293
|
+
"""
|
|
294
|
+
|
|
295
|
+
name = "gemini"
|
|
296
|
+
billable = True
|
|
297
|
+
|
|
298
|
+
def __init__(
|
|
299
|
+
self,
|
|
300
|
+
model: str = DEFAULT_GEMINI_MODEL,
|
|
301
|
+
client: object = None,
|
|
302
|
+
api_key: str | None = None,
|
|
303
|
+
):
|
|
304
|
+
self.model = model
|
|
305
|
+
if client is not None:
|
|
306
|
+
self._client = client
|
|
307
|
+
return
|
|
308
|
+
if api_key is None:
|
|
309
|
+
raise RuntimeError(
|
|
310
|
+
"GeminiClient requires explicit api_key or injected client. "
|
|
311
|
+
"Use `api_key_ref: env:GEMINI_API_KEY` in agents/.ai-council.yml."
|
|
312
|
+
)
|
|
313
|
+
try:
|
|
314
|
+
from google import genai # type: ignore[import-not-found]
|
|
315
|
+
except ImportError as exc: # pragma: no cover - exercised only with real SDK
|
|
316
|
+
raise RuntimeError(
|
|
317
|
+
"google-genai package not installed. `pip install google-genai`."
|
|
318
|
+
) from exc
|
|
319
|
+
self._client = genai.Client(api_key=api_key)
|
|
320
|
+
|
|
321
|
+
def ask(self, system_prompt: str, user_prompt: str, max_tokens: int = DEFAULT_MAX_TOKENS) -> CouncilResponse:
|
|
322
|
+
t0 = time.monotonic()
|
|
323
|
+
contents = f"{system_prompt}\n\n---\n\n{user_prompt}"
|
|
324
|
+
try:
|
|
325
|
+
response = self._client.models.generate_content(
|
|
326
|
+
model=self.model,
|
|
327
|
+
contents=contents,
|
|
328
|
+
config={"max_output_tokens": max_tokens},
|
|
329
|
+
)
|
|
330
|
+
except Exception as exc: # noqa: BLE001 - normalise all SDK errors
|
|
331
|
+
return CouncilResponse(
|
|
332
|
+
provider=self.name, model=self.model, text="",
|
|
333
|
+
latency_ms=int((time.monotonic() - t0) * 1000),
|
|
334
|
+
error=f"{type(exc).__name__}: {exc}",
|
|
335
|
+
)
|
|
336
|
+
latency_ms = int((time.monotonic() - t0) * 1000)
|
|
337
|
+
text = getattr(response, "text", "") or ""
|
|
338
|
+
usage = getattr(response, "usage_metadata", None)
|
|
339
|
+
return CouncilResponse(
|
|
340
|
+
provider=self.name, model=self.model, text=text,
|
|
341
|
+
input_tokens=getattr(usage, "prompt_token_count", 0) if usage else 0,
|
|
342
|
+
output_tokens=getattr(usage, "candidates_token_count", 0) if usage else 0,
|
|
343
|
+
latency_ms=latency_ms,
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
class _OpenAICompatibleClient(ExternalAIClient):
|
|
348
|
+
"""Shared shape for OpenAI-API-compatible providers (xAI, Perplexity).
|
|
349
|
+
|
|
350
|
+
Both vendors implement ``/v1/chat/completions`` and accept the
|
|
351
|
+
``openai`` Python SDK with a custom ``base_url``. The reasoning-
|
|
352
|
+
model branch from :class:`OpenAIClient` is intentionally omitted —
|
|
353
|
+
neither xAI nor Perplexity ships a reasoning model that requires
|
|
354
|
+
``max_completion_tokens`` as of 2026-05-14.
|
|
355
|
+
"""
|
|
356
|
+
|
|
357
|
+
billable = True
|
|
358
|
+
base_url: str = ""
|
|
359
|
+
|
|
360
|
+
def __init__(
|
|
361
|
+
self,
|
|
362
|
+
model: str,
|
|
363
|
+
client: object = None,
|
|
364
|
+
api_key: str | None = None,
|
|
365
|
+
):
|
|
366
|
+
self.model = model
|
|
367
|
+
if client is not None:
|
|
368
|
+
self._client = client
|
|
369
|
+
return
|
|
370
|
+
if api_key is None:
|
|
371
|
+
raise RuntimeError(
|
|
372
|
+
f"{type(self).__name__} requires explicit api_key or injected client."
|
|
373
|
+
)
|
|
374
|
+
try:
|
|
375
|
+
import openai # type: ignore[import-not-found]
|
|
376
|
+
except ImportError as exc: # pragma: no cover - exercised only with real SDK
|
|
377
|
+
raise RuntimeError(
|
|
378
|
+
"openai package not installed. `pip install openai`."
|
|
379
|
+
) from exc
|
|
380
|
+
self._client = openai.OpenAI(api_key=api_key, base_url=self.base_url)
|
|
381
|
+
|
|
382
|
+
def ask(self, system_prompt: str, user_prompt: str, max_tokens: int = DEFAULT_MAX_TOKENS) -> CouncilResponse:
|
|
383
|
+
t0 = time.monotonic()
|
|
384
|
+
try:
|
|
385
|
+
response = self._client.chat.completions.create(
|
|
386
|
+
model=self.model,
|
|
387
|
+
max_tokens=max_tokens,
|
|
388
|
+
messages=[
|
|
389
|
+
{"role": "system", "content": system_prompt},
|
|
390
|
+
{"role": "user", "content": user_prompt},
|
|
391
|
+
],
|
|
392
|
+
)
|
|
393
|
+
except Exception as exc: # noqa: BLE001 - normalise all SDK errors
|
|
394
|
+
return CouncilResponse(
|
|
395
|
+
provider=self.name, model=self.model, text="",
|
|
396
|
+
latency_ms=int((time.monotonic() - t0) * 1000),
|
|
397
|
+
error=f"{type(exc).__name__}: {exc}",
|
|
398
|
+
)
|
|
399
|
+
latency_ms = int((time.monotonic() - t0) * 1000)
|
|
400
|
+
text = ""
|
|
401
|
+
choices = getattr(response, "choices", None)
|
|
402
|
+
if choices:
|
|
403
|
+
msg = getattr(choices[0], "message", None)
|
|
404
|
+
text = getattr(msg, "content", "") if msg else ""
|
|
405
|
+
usage = getattr(response, "usage", None)
|
|
406
|
+
return CouncilResponse(
|
|
407
|
+
provider=self.name, model=self.model, text=text or "",
|
|
408
|
+
input_tokens=getattr(usage, "prompt_tokens", 0) if usage else 0,
|
|
409
|
+
output_tokens=getattr(usage, "completion_tokens", 0) if usage else 0,
|
|
410
|
+
latency_ms=latency_ms,
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
class XAIClient(_OpenAICompatibleClient):
|
|
415
|
+
"""xAI Grok via the OpenAI-compatible endpoint at api.x.ai/v1."""
|
|
416
|
+
|
|
417
|
+
name = "xai"
|
|
418
|
+
base_url = XAI_BASE_URL
|
|
419
|
+
|
|
420
|
+
def __init__(
|
|
421
|
+
self,
|
|
422
|
+
model: str = DEFAULT_XAI_MODEL,
|
|
423
|
+
client: object = None,
|
|
424
|
+
api_key: str | None = None,
|
|
425
|
+
):
|
|
426
|
+
super().__init__(model=model, client=client, api_key=api_key)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
class PerplexityClient(_OpenAICompatibleClient):
|
|
430
|
+
"""Perplexity via the OpenAI-compatible endpoint at api.perplexity.ai."""
|
|
431
|
+
|
|
432
|
+
name = "perplexity"
|
|
433
|
+
base_url = PERPLEXITY_BASE_URL
|
|
434
|
+
|
|
435
|
+
def __init__(
|
|
436
|
+
self,
|
|
437
|
+
model: str = DEFAULT_PERPLEXITY_MODEL,
|
|
438
|
+
client: object = None,
|
|
439
|
+
api_key: str | None = None,
|
|
440
|
+
):
|
|
441
|
+
super().__init__(model=model, client=client, api_key=api_key)
|
|
442
|
+
|
|
443
|
+
|
|
272
444
|
# ── Manual mode (Phase 2b) ───────────────────────────────────────────
|
|
273
445
|
|
|
274
446
|
|