ai-consultants 2.17.0 → 2.19.2
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/CLAUDE.md +45 -1
- package/README.md +6 -3
- package/SKILL.md +6 -3
- package/docs/COST_RATES.md +1 -1
- package/docs/cost_rates.json +1 -1
- package/docs/releases/v2.17.1.md +46 -0
- package/docs/releases/v2.17.2.md +51 -0
- package/docs/releases/v2.18.0.md +63 -0
- package/docs/releases/v2.19.0.md +66 -0
- package/docs/releases/v2.19.1.md +44 -0
- package/docs/releases/v2.19.2.md +54 -0
- package/package.json +1 -1
- package/scripts/build_context.sh +39 -6
- package/scripts/config.sh +30 -1
- package/scripts/consult_all.sh +104 -4
- package/scripts/doctor.sh +71 -0
- package/scripts/lib/common.sh +79 -3
- package/scripts/lib/costs.sh +66 -9
- package/scripts/test_context_optimization.sh +35 -2
- package/scripts/test_suite.sh +38 -0
package/CLAUDE.md
CHANGED
|
@@ -6,7 +6,7 @@ AI Consultants is a multi-model AI deliberation system that queries up to 15 AI
|
|
|
6
6
|
|
|
7
7
|
**Self-Exclusion**: The invoking agent is automatically excluded from the panel to prevent self-consultation. Claude Code won't query Claude, Codex CLI won't query Codex, etc.
|
|
8
8
|
|
|
9
|
-
**Version**: 2.
|
|
9
|
+
**Version**: 2.19.2
|
|
10
10
|
|
|
11
11
|
## Distribution
|
|
12
12
|
|
|
@@ -826,6 +826,50 @@ curl -fsSL https://raw.githubusercontent.com/matteoscurati/ai-consultants/main/s
|
|
|
826
826
|
|
|
827
827
|
## Changelog
|
|
828
828
|
|
|
829
|
+
### v2.19.2
|
|
830
|
+
- **Cost tracking silently lost on a fresh install — fixed.** `lib/costs.sh::track_session_cost` wrote to `$COST_TRACKING_FILE` (`$XDG_DATA_HOME/ai-consultants/costs.json`, i.e. `~/.local/share/...`) without ever creating the parent directory. That dir doesn't exist until something makes it, and `costs.json` is the only artifact stored there — so on a fresh install every session's cost write failed with "No such file or directory" (swallowed under `set -e` in the caller) and cumulative cost tracking never accumulated. Fix: `mkdir -p "$(dirname "$COST_TRACKING_FILE")"` up front; on failure, `log_warn` + `return 0`.
|
|
831
|
+
- **Function split for lock discipline**: `track_session_cost` (outer) now owns dir creation + locking and delegates the read-modify-write to a new inner `_track_session_cost_update`, which runs with the lock held and never propagates failure. Rationale documented inline: the caller in `consult_all.sh` runs under `set -e` *after* every consultant has already been queried and billed, so a bookkeeping failure must degrade to a warning, never abort the run.
|
|
832
|
+
- **Concurrency**: the RMW of `costs.json` was unguarded, so parallel consultations could interleave and lose records or fail the `mv`. Added a portable `mkdir`-based lock (flock is unavailable on macOS) with a bounded wait (50 × 0.1s = 5s); if the lock stays busy it proceeds unlocked with a warning rather than blocking or aborting. Writes now go through `mktemp "${COST_TRACKING_FILE}.XXXXXX"` instead of a fixed `.tmp` sibling, so an unlocked concurrent writer can't clobber another run's temp file (lost record / failed `mv`).
|
|
833
|
+
- **Corrupt-file self-heal**: a corrupt `costs.json` (truncated write, interleaved update) previously failed every future `jq` update forever and never recovered. Now `jq empty` gates the file; on failure it's moved aside to `${COST_TRACKING_FILE}.corrupt` and reset, so tracking recovers on the next session.
|
|
834
|
+
- **Tests**: `scripts/test_suite.sh::test_cost_tracking_resilience` (5 assertions, wired into `main()`) — costs.json created under a missing nested parent dir (fresh-install path), second session accumulates on the existing file (0.25 → 0.75, both exact in binary float), a corrupt costs.json is reset with the new session recorded, and the corrupt original is preserved as `.corrupt`. Calls guarded with `|| true` so a regression surfaces as a FAIL assertion, not a `set -e` suite abort. 8 suites pass; the new assertions are green; shellcheck clean.
|
|
835
|
+
- **No behavioral change for existing installs** whose data dir already exists; the fix only affects the first-run/absent-dir, concurrent, and corrupt-file paths. Cost bookkeeping remains best-effort by design.
|
|
836
|
+
|
|
837
|
+
### v2.19.1
|
|
838
|
+
- Cleanup from `/code-review max` on v2.19.0 (the workflow ran degraded under rate limits but surfaced these once it completed; all confirmed inline):
|
|
839
|
+
- **Report-table mangling fixed**: a failure reason containing a `|` (e.g. a CLI error mentioning a piped command) broke the "Diagnosed Failures" markdown row. The new `lib/common.sh::render_diagnosed_failure <entry> [console|table]` escapes `|`→`\|` in table mode. (Not a security injection — the text is the user's own CLI stderr — but a real rendering bug.)
|
|
840
|
+
- **DRY**: that helper is now the single source of the `name|reason` decode; the console log (quorum FAILED branch) and the report table both call it, so a future delimiter/encoding change touches one place instead of two.
|
|
841
|
+
- **Health gate is cache-aware**: it no longer pings a consultant whose response is already cached (Round 1 would serve it free via `check_cache`) — it keeps the cached consultant without a billed probe. Avoids the opt-in gate defeating the semantic cache.
|
|
842
|
+
- **`ping_consultant` takes the already-lowercased id** (callers compute it for the out/err paths anyway), dropping a redundant `to_lower` fork per consultant on both call sites (`doctor --live`, health gate).
|
|
843
|
+
- **Documented** the health gate's inherent pre-Round-1 startup latency (serial with the run by definition; up to `HEALTH_GATE_TIMEOUT`) in `config.sh` — it's the cost of pruning up front; opt-in + tunable.
|
|
844
|
+
- Tests: `test_render_diagnosed_failure` (4 assertions incl. pipe-escaping); `test_ping_consultant` updated for the lowercased-id signature. 8 suites pass; shellcheck clean. Smoke-verified: report table renders correctly, health gate still prunes + the min-2 guard fires on the pruned count.
|
|
845
|
+
|
|
846
|
+
### v2.19.0
|
|
847
|
+
- **Quorum grading + "Diagnosed Failures" report** (the #1 pick from the `/workflows` CLI-reliability investigation — chosen for zero transport/billing risk over the CLI→API fallback, which the workflow's adversarial critique showed would emit persona-less confidence-5 stubs that poison voting). After the round-1 collect loop, `grade_quorum <success> <attempted> <min>` (new pure helper in `lib/common.sh`) classifies the run **MET / DEGRADED / FAILED** vs `QUORUM_MIN` (default 2). The report gains an `**Outcome**:` banner and a **## Diagnosed Failures** table that surfaces each dropped consultant's reason (the v2.18.0 `.err` capture) — so a panel that silently shrank to 2/7 is visibly DEGRADED instead of presenting as authoritative. `QUORUM_ACTION=stop` aborts below quorum; default `warn` continues with the banner. Failures are collected in `_surface_consultant_error` (DRY — same call that already logs the reason).
|
|
848
|
+
- **Health gate (`ENABLE_HEALTH_GATE`, opt-in)** — before the run, ping every selected consultant **in parallel** (new `lib/common.sh::ping_consultant`, `HEALTH_GATE_TIMEOUT` default 30s) and drop the non-responsive ones, so installed-but-unauthenticated CLIs (Cursor/Kimi/stale installs) are pruned up front and the quorum/budget checks see the genuinely-working panel. Script-less custom API agents (no `query_*.sh`) return code 2 and are kept (not probeable). Opt-in because it costs one extra tiny query per consultant; it prunes, it does not switch transport.
|
|
849
|
+
- **DRY**: `doctor --live` refactored to use the shared `ping_consultant`; both surfaces now share one probe implementation.
|
|
850
|
+
- **Explicitly NOT done** (per the workflow synthesis): CLI→API automatic fallback (persona-loss vote poisoning; Gemini already auto-resolves), cold-start retry-timeout escalation (stretches the whole round's worst case), warm-up calls, and the transport-abstraction circuit breaker (racy on-disk state, highest blast radius).
|
|
851
|
+
- **Tests**: `test_functions.sh` — `test_grade_quorum` (5 assertions: MET/DEGRADED/FAILED + boundaries) and `test_ping_consultant` (3: valid→0, no-output→fail, missing-script→2, via stub query scripts). 8 suites pass; shellcheck clean. End-to-end smoke verified: a forced CLI failure yields FAILED outcome + Diagnosed Failures table with the real reason, and the health gate prunes the dead consultant pre-run.
|
|
852
|
+
|
|
853
|
+
### v2.18.0
|
|
854
|
+
- **Failures are now diagnosable, not silent (Fix A).** `consult_all.sh` launched every consultant with `> /dev/null 2>&1` — discarding stderr — so a failed consultant produced only a bare `Failed`/`Empty response`, with no way to tell *not installed* vs *not authenticated* vs *transient* (this is exactly what led a real session to mis-attribute failures to a timeout). Now each consultant's stderr is captured to `$OUTPUT_DIR/<consultant>.err`, and on FAILED/EMPTY the run surfaces a one-line reason via the new `lib/common.sh::get_consultant_error_reason` helper (ANSI-stripped, prefers an explicit error line, drops orchestration status noise, falls back to "no error captured — CLI likely missing or not authenticated").
|
|
855
|
+
- **`run_query` now embeds the CLI's real error in its failure log.** The per-attempt CLI stderr (`${output_file}.err`, an unpredictable mktemp path the orchestrator can't locate) was only `log_debug`'d. Its first line is now appended to the `Error (code: N)` and `All N attempts failed` warnings, so the actual reason (e.g. `401 Unauthorized`, `command not found`) reaches the captured stderr and the surfaced message.
|
|
856
|
+
- **`doctor.sh --live` (Fix B): real ping per consultant.** The static checks only verify a CLI is installed (`--version`), so `doctor` reports a consultant healthy even when it errors at query time (unauthenticated) — a real source of "All systems healthy" while 3 consultants silently fail. `--live` sends a minimal real query to each *enabled* (and not self-excluded) consultant with a short timeout (`DOCTOR_LIVE_TIMEOUT`, default 45s), and reports ✓/✗ with the captured reason; failures become `doctor` issues (exit 1). Opt-in (costs one tiny query each). Standalone short-circuit mode like `--suggest-preset`.
|
|
857
|
+
- **Tests**: `test_functions.sh::test_get_consultant_error_reason` (5 assertions: explicit-error line, embedded-auth reason, orchestration-noise-not-mistaken, empty/missing file). 8 suites pass; shellcheck clean (CI invocation).
|
|
858
|
+
- **Not fixable in code (documented for users)**: the underlying CLI auth/install state, and cold-start/warm-up retry effects, are environment issues — `--live` *surfaces* them but can't authenticate a CLI for you.
|
|
859
|
+
|
|
860
|
+
### v2.17.2
|
|
861
|
+
- **SECURITY: revert v2.17.1's `allow_absolute=true` for context files — it opened a secret-exfiltration surface (caught in `/code-review max`).** v2.17.1 fixed the macOS `/private/tmp` drop by letting `build_context.sh` accept *any* absolute context path behind `validate_file_path`'s prefix-only blocklist (`/etc /root /var/log /proc /sys /dev`). That blocklist covers no home secrets, so a context arg of `~/.ssh/id_rsa`, `~/.aws/credentials`, `~/.netrc`, or `~/.config/gh/hosts.yml` was read verbatim and **sent to the external AI providers**. It also matched literal non-canonical prefixes, so `/private/etc/master.passwd` (the real `/etc` on macOS, via the same symlink aliasing) bypassed the `/etc` rule.
|
|
862
|
+
- **Fix (correct altitude — allowlist, not blocklist)**: context files are now accepted only when (a) relative and in-tree (`validate_file_path ... false` — rejects absolute and `..`), or (b) under a recognized temp root via the new `_is_temp_path` helper (`/tmp`, `/private/tmp`, `$TMPDIR`, and the `/private`-prefixed macOS alias of `$TMPDIR`; `..` rejected so a temp prefix can't traverse out). Still fixes the original macOS scratch-file regression (Claude Code writes `/private/tmp/...`) **without** widening to the whole filesystem. Verified: `/tmp`, `/private/tmp`, `$TMPDIR/...`, relative in-tree → accepted; `~/.ssh/id_rsa`, `/etc/passwd`, `/private/etc/master.passwd`, `/tmp/../etc/passwd` → rejected.
|
|
863
|
+
- **`build_context.sh` OUTPUT_FILE**: removed the `/tmp/*` short-circuit that jumped past `validate_file_path` (so `/tmp/../etc/x` bypassed the `..` check). Output paths now always run through `validate_file_path "$OUTPUT_FILE" "true"` — absolute allowed (output lives under the XDG cache or `/tmp`), traversal + sensitive-path guards enforced. The two validation sites are intentionally different now (OUTPUT is tool-chosen; context files are untrusted input) and the comments say so.
|
|
864
|
+
- **Test**: rewrote `test_context_optimization.sh` Test 15 as a *boundary* test — temp-root accepted, `~/.ssh/id_rsa` rejected (exfiltration blocked), `/etc/passwd` rejected. Uses the auto-cleaned `$_TMPDIR` (no `$HOME` litter) and `QUESTION_CATEGORY=SECURITY` to skip the project-tree `find` the assertions don't need. Fixed the stale suite-header comment that contradicted the new behavior. 8 suites pass; shellcheck clean.
|
|
865
|
+
- **Note**: v2.17.1 was tagged and GitHub-released but **never published to npm** (npm stayed at 2.17.0), so no npm user received the vulnerable version. Publish **2.17.2**, not 2.17.1.
|
|
866
|
+
|
|
867
|
+
### v2.17.1
|
|
868
|
+
- **Fix: context files at absolute paths outside `/tmp` were silently dropped.** `build_context.sh`'s context-file gate accepted only relative paths or a literal `/tmp/*` prefix (`if [[ "$_PARSED_PATH" == /tmp/* ]] || validate_file_path "$_PARSED_PATH" "false"`). On macOS `/tmp` is a symlink to `/private/tmp`, so Claude Code scratch files arrive as `/private/tmp/...` and matched neither branch → `log_warn "Skipping invalid file path"` and the file was excluded, with `build_context.sh` falling back to a generic repo auto-context. Net effect: a consultation looked like it ran with the user's context but the consultants never received it. (Reported from a real session where the passed context was dropped and an auto-context substituted.)
|
|
869
|
+
- **Fix**: the gate now uses `validate_file_path "$_PARSED_PATH" "true"` (allow absolute), mirroring the OUTPUT_FILE handling a few lines above. Context files are explicitly user/agent-provided, so absolute paths are legitimate; the sensitive-path blocklist (`/etc /root /var/log /proc /sys /dev`), path-traversal (`..`), and null-byte guards in `validate_file_path` still apply. Verified: `/private/tmp/...`, `/tmp/...`, relative, and `$HOME/...` paths accepted; `/etc/passwd`, `/var/log/...`, `../../etc/...` still rejected.
|
|
870
|
+
- **Test**: `test_context_optimization.sh` Test 15 — an absolute context path outside `/tmp` (a `$HOME` file) is now included in the built context, and `/etc/passwd` is still skipped. 8 suites pass; shellcheck clean.
|
|
871
|
+
- **Note (not a code change)**: the same session also showed Gemini/Codex failing immediately — because the *installed* skill at `~/.claude/skills/ai-consultants` was **v2.10.0** (pre-agy migration), so Gemini called the deprecated `gemini` binary. Updating the installed skill to ≥v2.15 (now v2.17.1) resolves that; it's an install-staleness issue, not a current-code bug.
|
|
872
|
+
|
|
829
873
|
### v2.17.0
|
|
830
874
|
- **Model catalog refresh (June 2026)** across all three tiers + cost rates, for every agent. Source of truth stays `config.sh::get_model_for_tier` (+ default `*_MODEL` vars) mirrored by `docs/cost_rates.json` (`model_tiers` + `consultant_fallbacks` + per-1K `models` rates). CLI-addressed models verified by querying the installed binaries (`agy models`, `agent --list-models`, kimi config); API/provider models + pricing researched against official sources. Superseded IDs kept in `cost_rates.json` for historical/pinned lookups.
|
|
831
875
|
- **Changed models** (premium / standard / economy):
|
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
# AI Consultants v2.
|
|
1
|
+
# AI Consultants v2.19.2
|
|
2
2
|
|
|
3
3
|
> **A harness for every question.** A panel of up to 15 frontier models that writes its own playbook per question — fan out, debate to convergence, cross-examine under adversarial review, or run a tournament — and checks its work before it reaches you.
|
|
4
4
|
|
|
5
|
-
[](https://github.com/matteoscurati/ai-consultants)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
[](https://docs.anthropic.com/en/docs/claude-code/skills)
|
|
8
8
|
[](https://github.com/matteoscurati/ai-consultants)
|
|
@@ -574,13 +574,16 @@ Full reference (~150 vars): [`references/configuration.md`](references/configura
|
|
|
574
574
|
Diagnose, suggest, and fix:
|
|
575
575
|
|
|
576
576
|
```bash
|
|
577
|
-
ai-consultants doctor # 22 health checks
|
|
577
|
+
ai-consultants doctor # 22 health checks (CLI installed?)
|
|
578
578
|
ai-consultants doctor --fix # Auto-fix common issues
|
|
579
579
|
ai-consultants doctor --json # JSON for automation
|
|
580
|
+
ai-consultants doctor --live # Real ping per consultant — catches installed-but-unauthenticated CLIs
|
|
580
581
|
ai-consultants doctor --suggest-config # Print recommended ENABLE_* based on detected CLIs
|
|
581
582
|
ai-consultants doctor --suggest-preset --question "..." # Recommend preset + strategy for a question
|
|
582
583
|
```
|
|
583
584
|
|
|
585
|
+
> When a consultant fails mid-consultation, the run surfaces the captured reason (e.g. `CLI not found`, `401 Unauthorized`) instead of a bare "Failed", so you can tell *not installed* from *not authenticated* from *transient*.
|
|
586
|
+
|
|
584
587
|
---
|
|
585
588
|
|
|
586
589
|
## How It Works
|
package/SKILL.md
CHANGED
|
@@ -5,10 +5,10 @@ license: MIT
|
|
|
5
5
|
compatibility: Requires bash, jq, and at least 2 AI CLI tools (agy, codex, vibe, etc.). macOS and Linux.
|
|
6
6
|
metadata:
|
|
7
7
|
author: matteoscurati
|
|
8
|
-
version: 2.
|
|
8
|
+
version: 2.19.2
|
|
9
9
|
---
|
|
10
10
|
|
|
11
|
-
# AI Consultants v2.
|
|
11
|
+
# AI Consultants v2.19.2 - AI Expert Panel
|
|
12
12
|
|
|
13
13
|
**A harness for every question.** Convene a panel of AI "consultants" for coding questions — and let the engine pick *how* they deliberate: a quick read, a convergence loop, an adversarial refutation gate, a tournament, or an exhaustive sweep. Each consultant has a **configurable persona** that shapes its analysis.
|
|
14
14
|
|
|
@@ -130,11 +130,14 @@ See [Full Configuration Reference](references/configuration.md) for all variable
|
|
|
130
130
|
Diagnose and fix issues:
|
|
131
131
|
|
|
132
132
|
```bash
|
|
133
|
-
./scripts/doctor.sh # Full check
|
|
133
|
+
./scripts/doctor.sh # Full check (CLI installed?)
|
|
134
134
|
./scripts/doctor.sh --fix # Auto-fix
|
|
135
135
|
./scripts/doctor.sh --json # JSON output
|
|
136
|
+
./scripts/doctor.sh --live # Real ping per consultant — catches installed-but-unauthenticated CLIs
|
|
136
137
|
```
|
|
137
138
|
|
|
139
|
+
When a consultant fails during a consultation, the run prints the captured reason (e.g. "CLI not found", "401 Unauthorized"). The run is also **graded** MET/DEGRADED/FAILED by how many responded (`QUORUM_MIN`), with a "Diagnosed Failures" section in the report. Set `ENABLE_HEALTH_GATE=true` to ping consultants and drop the dead ones *before* the run.
|
|
140
|
+
|
|
138
141
|
## Interpreting Results
|
|
139
142
|
|
|
140
143
|
| Scenario | Recommendation |
|
package/docs/COST_RATES.md
CHANGED
package/docs/cost_rates.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "2.
|
|
2
|
+
"version": "2.19.2",
|
|
3
3
|
"updated": "2026-06-24",
|
|
4
4
|
"models": {
|
|
5
5
|
"_comment_units": "All rates are USD per 1,000 tokens (per-1K). lib/costs.sh computes cost as (token_count / 1000) * rate, so every value here MUST be a per-1K rate = (provider $/1M price) / 1000. Do NOT paste $/MTok figures directly.",
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Release v2.17.1
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-06-25
|
|
4
|
+
**Type:** Patch — context-file path fix
|
|
5
|
+
**Previous:** v2.17.0
|
|
6
|
+
|
|
7
|
+
## Highlights
|
|
8
|
+
|
|
9
|
+
- **Fixes a silent failure where context files you passed were dropped** and replaced with a generic auto-context, so consultants never saw the context you intended.
|
|
10
|
+
|
|
11
|
+
## What's New
|
|
12
|
+
|
|
13
|
+
### Absolute context paths are now accepted
|
|
14
|
+
|
|
15
|
+
`build_context.sh` (which assembles the context bundle sent to the consultants) only accepted context files at **relative paths** or under a literal **`/tmp/`** prefix. Any other absolute path was rejected with `Skipping invalid file path` and excluded — and the script quietly fell back to building a generic context from the repository.
|
|
16
|
+
|
|
17
|
+
The most common way to hit this: on macOS, `/tmp` is a symlink to `/private/tmp`, so scratch files (e.g. the ones Claude Code writes) arrive as `/private/tmp/...` and missed the `/tmp/` check. The result looked like a normal consultation, but the consultants received auto-generated repo context instead of what you passed.
|
|
18
|
+
|
|
19
|
+
Context files are explicitly provided by you (or the calling agent), so absolute paths are now accepted. The safety guards are unchanged: the sensitive-path blocklist (`/etc`, `/root`, `/var/log`, `/proc`, `/sys`, `/dev`), path-traversal (`..`), and null-byte checks still reject dangerous paths.
|
|
20
|
+
|
|
21
|
+
## Breaking Changes
|
|
22
|
+
|
|
23
|
+
None.
|
|
24
|
+
|
|
25
|
+
## Upgrade Guide
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
# If installed via git clone
|
|
29
|
+
cd ~/.claude/skills/ai-consultants && git pull
|
|
30
|
+
|
|
31
|
+
# If installed via curl | bash
|
|
32
|
+
curl -fsSL https://raw.githubusercontent.com/matteoscurati/ai-consultants/main/scripts/install.sh | bash
|
|
33
|
+
|
|
34
|
+
# Or via npx (always latest)
|
|
35
|
+
npx ai-consultants@latest install
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
> If your installed skill is well behind (e.g. v2.10.x), update it: pre-v2.15 versions still call the now-deprecated `gemini` CLI, which is why Gemini (and possibly Codex) fail immediately. v2.15+ uses the Antigravity CLI (`agy`).
|
|
39
|
+
|
|
40
|
+
## Commits
|
|
41
|
+
|
|
42
|
+
- `_______` fix(context): accept absolute context-file paths (macOS /private/tmp was dropped)
|
|
43
|
+
|
|
44
|
+
## Contributors
|
|
45
|
+
|
|
46
|
+
- @matteoscurati
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Release v2.17.2
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-06-25
|
|
4
|
+
**Type:** Patch — security fix (supersedes v2.17.1)
|
|
5
|
+
**Previous:** v2.17.1
|
|
6
|
+
|
|
7
|
+
## Highlights
|
|
8
|
+
|
|
9
|
+
- **Security fix.** v2.17.1's context-path change was too broad: it let a context file argument point at **any** absolute path — including secrets like `~/.ssh/id_rsa`, `~/.aws/credentials`, or `~/.netrc` — whose contents would then be sent to the external AI consultants. v2.17.2 closes that.
|
|
10
|
+
- The original bug v2.17.1 set out to fix (macOS `/private/tmp` scratch files being dropped) is still fixed — just with a safe allowlist instead of an open door.
|
|
11
|
+
|
|
12
|
+
> ⚠️ **v2.17.1 was tagged and GitHub-released but never published to npm** (npm stayed at 2.17.0), so no npm user received the vulnerable build. Install **2.17.2**.
|
|
13
|
+
|
|
14
|
+
## What's New
|
|
15
|
+
|
|
16
|
+
### Context files: allowlist instead of blocklist
|
|
17
|
+
|
|
18
|
+
`build_context.sh` assembles the files you pass and sends their contents to the consultant models. v2.17.1 accepted any absolute path and relied on a small blocklist (`/etc`, `/root`, …) to keep dangerous paths out — but that list covers no home-directory secrets, and it matched non-canonical prefixes (so `/private/etc/master.passwd`, the real `/etc` on macOS, slipped through).
|
|
19
|
+
|
|
20
|
+
v2.17.2 restricts context files to:
|
|
21
|
+
- **relative, in-tree paths** (absolute paths and `..` traversal rejected), or
|
|
22
|
+
- **recognized temp roots**: `/tmp`, `/private/tmp`, and `$TMPDIR` (plus the `/private`-prefixed macOS alias), with `..` rejected so a temp prefix can't traverse out.
|
|
23
|
+
|
|
24
|
+
Everything else — `~/.ssh/id_rsa`, `/etc/passwd`, `/private/etc/master.passwd`, `/tmp/../etc/passwd` — is rejected. The macOS scratch-file case that v2.17.1 targeted (`/private/tmp/...`) still works.
|
|
25
|
+
|
|
26
|
+
### Output-path hardening
|
|
27
|
+
|
|
28
|
+
`build_context.sh`'s output path no longer short-circuits past the traversal/sensitive-path guards for `/tmp` outputs; all output paths run through `validate_file_path` uniformly.
|
|
29
|
+
|
|
30
|
+
## Breaking Changes
|
|
31
|
+
|
|
32
|
+
If you relied on passing an **absolute context path outside a temp directory** (introduced for one patch in v2.17.1), it is now rejected. Pass the file by a relative path from the project root, or place it under a temp dir. This is the same boundary as v2.17.0 and earlier.
|
|
33
|
+
|
|
34
|
+
## Upgrade Guide
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
# git clone
|
|
38
|
+
cd ~/.claude/skills/ai-consultants && git pull
|
|
39
|
+
# curl | bash
|
|
40
|
+
curl -fsSL https://raw.githubusercontent.com/matteoscurati/ai-consultants/main/scripts/install.sh | bash
|
|
41
|
+
# npx
|
|
42
|
+
npx ai-consultants@latest install
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Commits
|
|
46
|
+
|
|
47
|
+
- `_______` fix(security): restrict context files to relative/temp paths (revert v2.17.1 allow-absolute)
|
|
48
|
+
|
|
49
|
+
## Contributors
|
|
50
|
+
|
|
51
|
+
- @matteoscurati
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Release v2.18.0
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-06-26
|
|
4
|
+
**Type:** Minor — observability (diagnosable failures + live doctor)
|
|
5
|
+
**Previous:** v2.17.2
|
|
6
|
+
|
|
7
|
+
## Highlights
|
|
8
|
+
|
|
9
|
+
- **When a consultant fails, you now see *why*** — "CLI not found", "401 Unauthorized", "Timeout", etc. — instead of a bare "Failed".
|
|
10
|
+
- **`doctor.sh --live`** sends a real ping to each enabled consultant, catching CLIs that are installed but not authenticated (which the static check reports as healthy).
|
|
11
|
+
|
|
12
|
+
## What's New
|
|
13
|
+
|
|
14
|
+
### Failures tell you the reason
|
|
15
|
+
|
|
16
|
+
Previously the orchestrator launched every consultant with its output **and stderr** sent to `/dev/null`, so a failing consultant produced only `Failed` / `Empty response` with no detail — making it impossible to distinguish *not installed* from *not authenticated* from a *transient* error without digging through temp files. (In a real session this led to a wrong diagnosis — blaming a timeout for what were actually auth failures.)
|
|
17
|
+
|
|
18
|
+
Now each consultant's stderr is captured, and on failure the run prints a one-line reason:
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
✗ Cursor: Failed
|
|
22
|
+
↳ Cursor: [ERROR] Cursor CLI not found (command: agent)
|
|
23
|
+
✗ GLM: Failed
|
|
24
|
+
↳ GLM: All 2 attempts failed: 401 Unauthorized
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
If nothing diagnostic was captured, it says so plainly ("no output and no error captured — CLI likely missing or not authenticated").
|
|
28
|
+
|
|
29
|
+
### `doctor.sh --live`
|
|
30
|
+
|
|
31
|
+
The normal `doctor.sh` only checks that a consultant's CLI is *installed* (`--version`). `--live` goes further: it sends a minimal real query to each **enabled** consultant and reports which actually respond.
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
./scripts/doctor.sh --live
|
|
35
|
+
# ✓ Gemini: responded
|
|
36
|
+
# ✗ Cursor: no valid response — Cursor CLI not found (command: agent)
|
|
37
|
+
# ✗ Kimi: no valid response
|
|
38
|
+
# Live result: 1 responding, 2 failing
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
It's opt-in (one small query per consultant) and honors `DOCTOR_LIVE_TIMEOUT` (default 45s). Failures are reported as `doctor` issues (exit 1).
|
|
42
|
+
|
|
43
|
+
## Breaking Changes
|
|
44
|
+
|
|
45
|
+
None.
|
|
46
|
+
|
|
47
|
+
## Not fixable in code
|
|
48
|
+
|
|
49
|
+
`--live` and the surfaced reasons *diagnose* a consultant that's missing or unauthenticated — but installing/authenticating that CLI is up to you. Cold-start/warm-up retry effects are also environmental.
|
|
50
|
+
|
|
51
|
+
## Upgrade Guide
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
cd ~/.claude/skills/ai-consultants && git pull # or: npx ai-consultants@latest install
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Commits
|
|
58
|
+
|
|
59
|
+
- `_______` feat: diagnosable consultant failures + doctor --live (observability)
|
|
60
|
+
|
|
61
|
+
## Contributors
|
|
62
|
+
|
|
63
|
+
- @matteoscurati
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Release v2.19.0
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-06-26
|
|
4
|
+
**Type:** Minor — reliability (quorum grading + health gate)
|
|
5
|
+
**Previous:** v2.18.0
|
|
6
|
+
|
|
7
|
+
## Highlights
|
|
8
|
+
|
|
9
|
+
- **A consultation that quietly ran with fewer panelists no longer looks authoritative.** It's now graded **MET / DEGRADED / FAILED**, and the report lists exactly which consultants dropped and why.
|
|
10
|
+
- **Opt-in health gate** prunes non-responsive consultants *before* the run, so you don't spend the full consultation on CLIs that are installed but not authenticated.
|
|
11
|
+
|
|
12
|
+
These were chosen from a multi-agent investigation of the "flaky CLI" problem; the adversarial review explicitly steered us *away* from an automatic CLI→API fallback (it would emit persona-less, confidence-5 stubs that poison the vote).
|
|
13
|
+
|
|
14
|
+
## What's New
|
|
15
|
+
|
|
16
|
+
### Quorum grading + Diagnosed Failures
|
|
17
|
+
|
|
18
|
+
After the panel responds, the run is graded by how many consultants actually answered, relative to `QUORUM_MIN` (default 2):
|
|
19
|
+
|
|
20
|
+
- **MET** — everyone responded
|
|
21
|
+
- **DEGRADED** — some failed, but ≥ quorum
|
|
22
|
+
- **FAILED** — below quorum
|
|
23
|
+
|
|
24
|
+
The report now opens with an `Outcome:` line and, when anything dropped, a **Diagnosed Failures** table:
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
**Outcome**: DEGRADED (5/7 responded, quorum=2)
|
|
28
|
+
|
|
29
|
+
## Diagnosed Failures
|
|
30
|
+
| Consultant | Reason |
|
|
31
|
+
|------------|--------|
|
|
32
|
+
| Cursor | Cursor CLI not found (command: agent) |
|
|
33
|
+
| Kimi | no output and no error captured — CLI likely missing or not authenticated |
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`QUORUM_ACTION=stop` aborts a below-quorum run; the default `warn` continues with the banner so nothing silently passes as a full-panel result.
|
|
37
|
+
|
|
38
|
+
### Health gate (opt-in)
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
ENABLE_HEALTH_GATE=true ai-consultants "your question"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Pings every selected consultant **in parallel** with a tiny real query (`HEALTH_GATE_TIMEOUT`, default 30s) and drops the ones that don't respond before the real run starts — catching installed-but-unauthenticated CLIs and stale installs. It prunes; it does not switch transport. Custom API agents without a query script are kept (not probeable).
|
|
45
|
+
|
|
46
|
+
## Breaking Changes
|
|
47
|
+
|
|
48
|
+
None. Both features are additive; `QUORUM_ACTION` defaults to `warn` (banner only) and the health gate is off by default.
|
|
49
|
+
|
|
50
|
+
## Not included (deliberately)
|
|
51
|
+
|
|
52
|
+
The investigation rejected, for now: automatic CLI→API fallback (vote-poisoning persona loss), cold-start retry-timeout escalation (stretches the whole round's worst case), warm-up calls (extra billing/OAuth contention), and a transport-abstraction circuit breaker (racy on-disk state).
|
|
53
|
+
|
|
54
|
+
## Upgrade Guide
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
cd ~/.claude/skills/ai-consultants && git pull # or: npx ai-consultants@latest install
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Commits
|
|
61
|
+
|
|
62
|
+
- `_______` feat: quorum grading + diagnosed-failures report + opt-in health gate
|
|
63
|
+
|
|
64
|
+
## Contributors
|
|
65
|
+
|
|
66
|
+
- @matteoscurati
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Release v2.19.1
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-06-27
|
|
4
|
+
**Type:** Patch — code-review cleanup for v2.19.0
|
|
5
|
+
**Previous:** v2.19.0
|
|
6
|
+
|
|
7
|
+
## Highlights
|
|
8
|
+
|
|
9
|
+
- **Fixes the "Diagnosed Failures" report table breaking** when a consultant's error reason contains a `|`.
|
|
10
|
+
- **The opt-in health gate no longer wastes a ping** on consultants whose answer is already cached.
|
|
11
|
+
|
|
12
|
+
## What's New
|
|
13
|
+
|
|
14
|
+
### Report table escapes pipes
|
|
15
|
+
|
|
16
|
+
A failure reason containing a `|` (e.g. a CLI error that mentions a piped command) used to mangle the markdown row in the **Diagnosed Failures** table. The reason is now escaped (`|` → `\|`) in table mode. (This was a rendering bug, not a security issue — the text is your own CLI's stderr.)
|
|
17
|
+
|
|
18
|
+
### Cache-aware health gate
|
|
19
|
+
|
|
20
|
+
When `ENABLE_HEALTH_GATE=true` and semantic caching is on, a consultant whose response is already cached is kept **without** a ping — Round 1 would serve it for free, so probing it would defeat the cache. The gate only pings consultants it would actually run live.
|
|
21
|
+
|
|
22
|
+
### Internal cleanup (from `/code-review max`)
|
|
23
|
+
|
|
24
|
+
- A single `render_diagnosed_failure` helper now renders both the console log and the report table — the `name|reason` decode lives in one place.
|
|
25
|
+
- `ping_consultant` takes the already-lowercased consultant id (callers compute it anyway), removing a redundant `to_lower` fork per consultant.
|
|
26
|
+
- The health gate's inherent pre-run startup latency (it's serial with the consultation by design, up to `HEALTH_GATE_TIMEOUT`) is now documented in `config.sh`.
|
|
27
|
+
|
|
28
|
+
## Breaking Changes
|
|
29
|
+
|
|
30
|
+
None.
|
|
31
|
+
|
|
32
|
+
## Upgrade Guide
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
cd ~/.claude/skills/ai-consultants && git pull # or: npx ai-consultants@latest install
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Commits
|
|
39
|
+
|
|
40
|
+
- `_______` fix: escape pipes in diagnosed-failures table + cache-aware health gate + review cleanup
|
|
41
|
+
|
|
42
|
+
## Contributors
|
|
43
|
+
|
|
44
|
+
- @matteoscurati
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Release v2.19.2
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-07-11
|
|
4
|
+
**Type:** Patch — cost-tracking resilience (fresh-install fix + concurrency + corruption self-heal)
|
|
5
|
+
**Previous:** v2.19.1
|
|
6
|
+
|
|
7
|
+
## Highlights
|
|
8
|
+
|
|
9
|
+
- **Fresh-install cost tracking is fixed** — sessions no longer fail to record their cost the first time you run the tool.
|
|
10
|
+
- **A corrupt `costs.json` now self-heals** instead of permanently wedging every future cost update.
|
|
11
|
+
- **Concurrent consultations can't lose cost records** — the update is serialized and uses a unique temp file.
|
|
12
|
+
- **Cost bookkeeping can never abort a run** — every failure path degrades to a warning.
|
|
13
|
+
- No configuration changes; no impact on existing installs whose data dir already exists.
|
|
14
|
+
|
|
15
|
+
## What's New
|
|
16
|
+
|
|
17
|
+
### Cost tracking resilience (`lib/costs.sh`)
|
|
18
|
+
|
|
19
|
+
`track_session_cost` records each consultation's cost to `costs.json` under the XDG data dir (`~/.local/share/ai-consultants/`). Four problems are fixed:
|
|
20
|
+
|
|
21
|
+
| Problem | Before | After |
|
|
22
|
+
|---|---|---|
|
|
23
|
+
| **Fresh install** | Wrote to `costs.json` without creating its parent dir → every session failed with "No such file or directory"; cost tracking silently never accumulated | `mkdir -p` the parent dir on demand |
|
|
24
|
+
| **Corrupt file** | A malformed `costs.json` failed every future `jq` update, forever | Detected via `jq empty`, moved aside to `costs.json.corrupt`, and reset |
|
|
25
|
+
| **Concurrency** | Unguarded read-modify-write; parallel runs could interleave and lose records | Serialized with a portable `mkdir` lock (5s bounded wait, then proceeds unlocked) |
|
|
26
|
+
| **Temp file** | Fixed `.tmp` sibling shared across runs → clobbered writes / failed `mv` | Unique `mktemp` temp file per write |
|
|
27
|
+
|
|
28
|
+
All failure paths now log a warning and return success: cost bookkeeping is best-effort and can never abort a consultation. This matters because the caller in `consult_all.sh` runs under `set -e` *after* every consultant has already been queried and billed — a bookkeeping hiccup must not throw away the run's results.
|
|
29
|
+
|
|
30
|
+
The function is split into an outer `track_session_cost` (directory creation + lock management) and an inner `_track_session_cost_update` (the read-modify-write, run with the lock held).
|
|
31
|
+
|
|
32
|
+
### Tests
|
|
33
|
+
|
|
34
|
+
New `test_cost_tracking_resilience` in `scripts/test_suite.sh` (5 assertions, wired into `main()`) covers the fresh-install path (costs.json created under a missing nested parent dir), cross-session accumulation (0.25 → 0.75), and the corrupt-file self-heal including the `.corrupt` backup. All 8 suites pass; shellcheck clean.
|
|
35
|
+
|
|
36
|
+
## Breaking Changes
|
|
37
|
+
|
|
38
|
+
None. Backwards-compatible. Installs whose data dir already exists see no behavioral change.
|
|
39
|
+
|
|
40
|
+
## Upgrade Guide
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
cd ~/.claude/skills/ai-consultants && git pull # or: npx ai-consultants@latest install
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
No configuration changes required.
|
|
47
|
+
|
|
48
|
+
## Commits
|
|
49
|
+
|
|
50
|
+
- `_______` fix(costs): harden track_session_cost against fresh-install, concurrency, and corruption
|
|
51
|
+
|
|
52
|
+
## Contributors
|
|
53
|
+
|
|
54
|
+
- @matteoscurati
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-consultants",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.19.2",
|
|
4
4
|
"description": "A harness for every question - a panel of up to 15 AI models that picks its own deliberation strategy per question (convergence, adversarial verification, tournament) and checks its work before answering",
|
|
5
5
|
"bin": {
|
|
6
6
|
"ai-consultants": "bin/ai-consultants"
|
package/scripts/build_context.sh
CHANGED
|
@@ -475,10 +475,31 @@ shift
|
|
|
475
475
|
QUERY="$1"
|
|
476
476
|
shift
|
|
477
477
|
|
|
478
|
-
#
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
478
|
+
# Returns 0 if PATH is under a recognized temp root: /tmp, /private/tmp (macOS:
|
|
479
|
+
# /tmp is a symlink to /private/tmp), $TMPDIR, or the /private-prefixed macOS
|
|
480
|
+
# alias of $TMPDIR. Rejects any path containing ".." so a temp prefix cannot be
|
|
481
|
+
# used to traverse out (e.g. /tmp/../etc/...).
|
|
482
|
+
_is_temp_path() {
|
|
483
|
+
local p="$1"
|
|
484
|
+
[[ "$p" == *".."* ]] && return 1
|
|
485
|
+
case "$p" in
|
|
486
|
+
/tmp/*|/private/tmp/*) return 0 ;;
|
|
487
|
+
esac
|
|
488
|
+
if [[ -n "${TMPDIR:-}" ]]; then
|
|
489
|
+
local t="${TMPDIR%/}"
|
|
490
|
+
[[ "$p" == "$t"/* ]] && return 0
|
|
491
|
+
[[ "$p" == /private"$t"/* ]] && return 0
|
|
492
|
+
fi
|
|
493
|
+
return 1
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
# Validate output file path. OUTPUT_FILE is tool-chosen (consult_all picks the
|
|
497
|
+
# output dir — under the XDG cache or /tmp), so absolute is allowed, but the
|
|
498
|
+
# traversal + sensitive-path guards in validate_file_path must still apply.
|
|
499
|
+
# (Previously a literal "/tmp/*" prefix short-circuited past those guards, so an
|
|
500
|
+
# output path like /tmp/../etc/x bypassed the ".." check — fixed by routing all
|
|
501
|
+
# output paths through validate_file_path uniformly.)
|
|
502
|
+
if ! validate_file_path "$OUTPUT_FILE" "true"; then
|
|
482
503
|
log_error "Invalid output file path: $OUTPUT_FILE"
|
|
483
504
|
exit 1
|
|
484
505
|
fi
|
|
@@ -492,8 +513,20 @@ while [[ $# -gt 0 ]]; do
|
|
|
492
513
|
file_arg="$1"
|
|
493
514
|
shift
|
|
494
515
|
_parse_tagged_path "$file_arg"
|
|
495
|
-
#
|
|
496
|
-
|
|
516
|
+
# SECURITY: build_context sends file contents to EXTERNAL AI providers, so a
|
|
517
|
+
# context arg must NOT be able to read arbitrary absolute paths (e.g.
|
|
518
|
+
# ~/.ssh/id_rsa, ~/.aws/credentials, ~/.netrc). Accept only:
|
|
519
|
+
# (a) relative, in-tree paths — validate_file_path with allow_absolute=false
|
|
520
|
+
# rejects absolute paths and ".." traversal, keeping them under cwd; or
|
|
521
|
+
# (b) files under a recognized temp root (_is_temp_path).
|
|
522
|
+
# This fixes the macOS scratch-file regression (Claude Code writes
|
|
523
|
+
# /private/tmp/...; /tmp is a symlink to /private/tmp, and $TMPDIR is
|
|
524
|
+
# /var/folders/...) WITHOUT widening to the whole filesystem.
|
|
525
|
+
# NOTE: v2.17.1 briefly used allow_absolute=true here, which let any absolute
|
|
526
|
+
# path through a prefix-only blocklist — a secret-exfiltration surface. Do not
|
|
527
|
+
# reintroduce it. (OUTPUT_FILE above is tool-chosen, not user input, so it is
|
|
528
|
+
# validated differently — the two sites are intentionally not identical.)
|
|
529
|
+
if _is_temp_path "$_PARSED_PATH" || validate_file_path "$_PARSED_PATH" "false" 2>/dev/null; then
|
|
497
530
|
FILES+=("$_PARSED_PATH")
|
|
498
531
|
FILE_TAGS+=("$_PARSED_TAG")
|
|
499
532
|
else
|
package/scripts/config.sh
CHANGED
|
@@ -574,6 +574,35 @@ CONVERGENCE_STALL_EPSILON="${CONVERGENCE_STALL_EPSILON:-5}"
|
|
|
574
574
|
# and runs anonymous peer review as a refutation gate before synthesis.
|
|
575
575
|
ENABLE_ADVERSARIAL_VERIFY="${ENABLE_ADVERSARIAL_VERIFY:-true}"
|
|
576
576
|
|
|
577
|
+
# =============================================================================
|
|
578
|
+
# QUORUM GRADING (v2.19.0)
|
|
579
|
+
# =============================================================================
|
|
580
|
+
# Grade the consultation outcome by the number of consultants that actually
|
|
581
|
+
# responded, so a run that silently shrank to a few panelists is reported as
|
|
582
|
+
# DEGRADED/FAILED instead of presenting as authoritative. Failures are listed
|
|
583
|
+
# with their diagnosed reason (from the v2.18.0 .err capture) in the report.
|
|
584
|
+
# Minimum successful responses below which the outcome is FAILED.
|
|
585
|
+
QUORUM_MIN="${QUORUM_MIN:-2}"
|
|
586
|
+
# warn (default: banner + continue) | stop (abort if below quorum)
|
|
587
|
+
QUORUM_ACTION="${QUORUM_ACTION:-warn}"
|
|
588
|
+
|
|
589
|
+
# =============================================================================
|
|
590
|
+
# HEALTH GATE (v2.19.0, opt-in)
|
|
591
|
+
# =============================================================================
|
|
592
|
+
# Before the consultation, send a cheap real "ping" query to each selected
|
|
593
|
+
# consultant in parallel and drop the non-responsive ones (installed-but-
|
|
594
|
+
# unauthenticated CLIs, stale installs), so the panel only spends the full run
|
|
595
|
+
# on consultants that actually work. Opt-in: it costs one tiny extra query per
|
|
596
|
+
# consultant. Prunes; it does not switch transport. Consultants whose response
|
|
597
|
+
# is already cached are kept WITHOUT a ping (cache-aware).
|
|
598
|
+
#
|
|
599
|
+
# TRADE-OFF: the gate runs BEFORE Round 1 and is serial with it (a pre-flight
|
|
600
|
+
# probe can't overlap the real run by definition), so it adds up to
|
|
601
|
+
# HEALTH_GATE_TIMEOUT of blocking startup latency when a consultant is slow/dead.
|
|
602
|
+
# That's the cost of pruning up front; keep it opt-in and tune the timeout.
|
|
603
|
+
ENABLE_HEALTH_GATE="${ENABLE_HEALTH_GATE:-false}"
|
|
604
|
+
HEALTH_GATE_TIMEOUT="${HEALTH_GATE_TIMEOUT:-30}"
|
|
605
|
+
|
|
577
606
|
# --- Debate Optimization ---
|
|
578
607
|
# Optimize debate rounds for token efficiency: skip debate when consensus is
|
|
579
608
|
# already high (confidence spread below DEBATE_CONFIDENCE_SPREAD_THRESHOLD).
|
|
@@ -858,4 +887,4 @@ EOF
|
|
|
858
887
|
# VERSION
|
|
859
888
|
# =============================================================================
|
|
860
889
|
|
|
861
|
-
AI_CONSULTANTS_VERSION="2.
|
|
890
|
+
AI_CONSULTANTS_VERSION="2.19.2"
|
package/scripts/consult_all.sh
CHANGED
|
@@ -369,6 +369,45 @@ else
|
|
|
369
369
|
_discover_custom_api_agents
|
|
370
370
|
fi
|
|
371
371
|
|
|
372
|
+
# --- Health Gate (v2.19.0, opt-in) ---
|
|
373
|
+
# Ping each selected consultant in parallel and drop the non-responsive ones
|
|
374
|
+
# (installed-but-unauthenticated CLIs, stale installs) BEFORE the real run, so
|
|
375
|
+
# the quorum/budget checks below see the genuinely-working panel. Opt-in: costs
|
|
376
|
+
# one tiny extra query per consultant. Prunes; does not switch transport.
|
|
377
|
+
if [[ "${ENABLE_HEALTH_GATE:-false}" == "true" && ${#SELECTED_CONSULTANTS[@]} -gt 0 ]]; then
|
|
378
|
+
HG_DIR=$(mktemp -d)
|
|
379
|
+
declare -a HG_PIDS=() HG_NAMES=() RESPONSIVE=()
|
|
380
|
+
for _c in "${SELECTED_CONSULTANTS[@]}"; do
|
|
381
|
+
_cl=$(to_lower "$_c")
|
|
382
|
+
# Cache-aware: a consultant whose response is already cached costs nothing
|
|
383
|
+
# in Round 1 (check_cache short-circuits), so keep it without a billed ping.
|
|
384
|
+
if is_cache_enabled && [[ -n "$(check_cache "$QUERY" "$QUESTION_CATEGORY" "$_cl" "$CONTEXT_FILE" 2>/dev/null || echo "")" ]]; then
|
|
385
|
+
RESPONSIVE+=("$_c")
|
|
386
|
+
log_debug " Health gate: $_c cached, kept (not pinged)"
|
|
387
|
+
continue
|
|
388
|
+
fi
|
|
389
|
+
( ping_consultant "$_cl" "$SCRIPT_DIR" "${HEALTH_GATE_TIMEOUT:-30}" "$HG_DIR/${_cl}.json" "$HG_DIR/${_cl}.err" ) &
|
|
390
|
+
HG_PIDS+=("$!"); HG_NAMES+=("$_c")
|
|
391
|
+
done
|
|
392
|
+
[[ ${#HG_PIDS[@]} -gt 0 ]] && log_info "Health gate: pinging ${#HG_PIDS[@]} consultants in parallel..."
|
|
393
|
+
for _i in "${!HG_PIDS[@]}"; do
|
|
394
|
+
if wait "${HG_PIDS[$_i]}" 2>/dev/null; then _rc=0; else _rc=$?; fi
|
|
395
|
+
_nm="${HG_NAMES[$_i]}"
|
|
396
|
+
if [[ $_rc -eq 0 || $_rc -eq 2 ]]; then
|
|
397
|
+
RESPONSIVE+=("$_nm")
|
|
398
|
+
[[ $_rc -eq 2 ]] && log_debug " Health gate: $_nm not probeable (no query script), kept"
|
|
399
|
+
else
|
|
400
|
+
_cl=$(to_lower "$_nm")
|
|
401
|
+
_reason=$(get_consultant_error_reason "$HG_DIR/${_cl}.err")
|
|
402
|
+
log_warn " Health gate dropped $_nm${_reason:+: $_reason}"
|
|
403
|
+
fi
|
|
404
|
+
done
|
|
405
|
+
rm -rf "$HG_DIR"
|
|
406
|
+
_hg_total=${#SELECTED_CONSULTANTS[@]}
|
|
407
|
+
SELECTED_CONSULTANTS=(${RESPONSIVE[@]+"${RESPONSIVE[@]}"})
|
|
408
|
+
log_info "Health gate: ${#SELECTED_CONSULTANTS[@]} of ${_hg_total} usable"
|
|
409
|
+
fi
|
|
410
|
+
|
|
372
411
|
if [[ ${#SELECTED_CONSULTANTS[@]} -eq 0 ]]; then
|
|
373
412
|
log_error "No consultant enabled or selected"
|
|
374
413
|
exit 1
|
|
@@ -421,6 +460,9 @@ declare -a CACHE_HITS=()
|
|
|
421
460
|
for consultant in "${SELECTED_CONSULTANTS[@]}"; do
|
|
422
461
|
consultant_lower=$(to_lower "$consultant")
|
|
423
462
|
local_output_file="$OUTPUT_DIR/${consultant_lower}.json"
|
|
463
|
+
# Capture each consultant's stderr so a failure (auth/CLI-missing/transient)
|
|
464
|
+
# can be surfaced instead of silently discarded (was `2>&1` to /dev/null).
|
|
465
|
+
local_err_file="$OUTPUT_DIR/${consultant_lower}.err"
|
|
424
466
|
OUTPUT_FILES+=("$local_output_file")
|
|
425
467
|
NAMES+=("$consultant")
|
|
426
468
|
|
|
@@ -456,21 +498,21 @@ for consultant in "${SELECTED_CONSULTANTS[@]}"; do
|
|
|
456
498
|
apply_launch_stagger
|
|
457
499
|
export "$model_var=$local_model"
|
|
458
500
|
"$query_script" "" "$CONTEXT_FILE" "$local_output_file"
|
|
459
|
-
) > /dev/null 2
|
|
501
|
+
) > /dev/null 2>"$local_err_file" &
|
|
460
502
|
PIDS+=($!)
|
|
461
503
|
update_progress "$consultant" 10 "running"
|
|
462
504
|
continue
|
|
463
505
|
fi
|
|
464
506
|
fi
|
|
465
507
|
# Standard launch (inherits current environment models)
|
|
466
|
-
( apply_launch_stagger; "$query_script" "" "$CONTEXT_FILE" "$local_output_file" ) > /dev/null 2
|
|
508
|
+
( apply_launch_stagger; "$query_script" "" "$CONTEXT_FILE" "$local_output_file" ) > /dev/null 2>"$local_err_file" &
|
|
467
509
|
else
|
|
468
510
|
# Fallback: custom API agent via generic API query
|
|
469
511
|
(
|
|
470
512
|
apply_launch_stagger
|
|
471
513
|
source "$SCRIPT_DIR/lib/api_query.sh"
|
|
472
514
|
run_api_consultant "$consultant" "" "$CONTEXT_FILE" "$local_output_file"
|
|
473
|
-
) > /dev/null 2
|
|
515
|
+
) > /dev/null 2>"$local_err_file" &
|
|
474
516
|
fi
|
|
475
517
|
|
|
476
518
|
PIDS+=($!)
|
|
@@ -482,10 +524,25 @@ if [[ ${#CACHE_HITS[@]} -gt 0 ]]; then
|
|
|
482
524
|
log_info "Cache hits: ${CACHE_HITS[*]}"
|
|
483
525
|
fi
|
|
484
526
|
|
|
527
|
+
# Surface the captured stderr reason for a consultant that produced no usable
|
|
528
|
+
# output, so the user sees WHY (auth error, CLI missing, transient) instead of a
|
|
529
|
+
# bare "Failed". Derives the .err path from the .json output path.
|
|
530
|
+
_surface_consultant_error() {
|
|
531
|
+
local cname="$1" out="$2"
|
|
532
|
+
local ef="${out%.json}.err"
|
|
533
|
+
local reason
|
|
534
|
+
reason=$(get_consultant_error_reason "$ef")
|
|
535
|
+
local detail="${reason:-no output and no error captured — CLI likely missing or not authenticated (run doctor.sh --live)}"
|
|
536
|
+
log_warn " ↳ ${cname}: ${detail}"
|
|
537
|
+
# Record for the report's "Diagnosed Failures" section (quorum grading, v2.19.0).
|
|
538
|
+
DIAGNOSED_FAILURES+=("${cname}|${detail}")
|
|
539
|
+
}
|
|
540
|
+
|
|
485
541
|
# --- Wait and Collect Results ---
|
|
486
542
|
log_info "Waiting for responses from ${#PIDS[@]} consultants..."
|
|
487
543
|
|
|
488
544
|
declare -a RESULTS=()
|
|
545
|
+
declare -a DIAGNOSED_FAILURES=()
|
|
489
546
|
SUCCESS_COUNT=0
|
|
490
547
|
|
|
491
548
|
for i in "${!PIDS[@]}"; do
|
|
@@ -521,11 +578,13 @@ for i in "${!PIDS[@]}"; do
|
|
|
521
578
|
fi
|
|
522
579
|
else
|
|
523
580
|
log_warn " $name: Empty response"
|
|
581
|
+
_surface_consultant_error "$name" "$output_file"
|
|
524
582
|
RESULTS+=("$name:EMPTY")
|
|
525
583
|
update_progress "$name" 100 "failed"
|
|
526
584
|
fi
|
|
527
585
|
else
|
|
528
586
|
log_error " $name: Failed"
|
|
587
|
+
_surface_consultant_error "$name" "$output_file"
|
|
529
588
|
RESULTS+=("$name:FAILED")
|
|
530
589
|
update_progress "$name" 100 "failed"
|
|
531
590
|
fi
|
|
@@ -533,6 +592,27 @@ done
|
|
|
533
592
|
|
|
534
593
|
echo "" >&2
|
|
535
594
|
|
|
595
|
+
# --- Quorum Grading (v2.19.0) ---
|
|
596
|
+
# Grade the run by how many consultants actually responded, so a silently-shrunk
|
|
597
|
+
# panel is reported as DEGRADED/FAILED instead of presenting as authoritative.
|
|
598
|
+
QUORUM_ATTEMPTED=${#PIDS[@]}
|
|
599
|
+
QUORUM_MIN_EFF="${QUORUM_MIN:-2}"
|
|
600
|
+
QUORUM_OUTCOME=$(grade_quorum "$SUCCESS_COUNT" "$QUORUM_ATTEMPTED" "$QUORUM_MIN_EFF")
|
|
601
|
+
|
|
602
|
+
if [[ "$QUORUM_OUTCOME" == "DEGRADED" ]]; then
|
|
603
|
+
log_warn "Quorum: DEGRADED — ${SUCCESS_COUNT}/${QUORUM_ATTEMPTED} consultants responded"
|
|
604
|
+
elif [[ "$QUORUM_OUTCOME" == "FAILED" ]]; then
|
|
605
|
+
log_error "Quorum: FAILED — only ${SUCCESS_COUNT}/${QUORUM_ATTEMPTED} responded (need >= ${QUORUM_MIN_EFF})"
|
|
606
|
+
if [[ ${#DIAGNOSED_FAILURES[@]} -gt 0 ]]; then
|
|
607
|
+
log_error "Diagnosed failures:"
|
|
608
|
+
for _df in "${DIAGNOSED_FAILURES[@]}"; do log_error "$(render_diagnosed_failure "$_df")"; done
|
|
609
|
+
fi
|
|
610
|
+
if [[ "${QUORUM_ACTION:-warn}" == "stop" ]]; then
|
|
611
|
+
log_error "Aborting below quorum (QUORUM_ACTION=stop). Set QUORUM_ACTION=warn to continue."
|
|
612
|
+
exit 1
|
|
613
|
+
fi
|
|
614
|
+
fi
|
|
615
|
+
|
|
536
616
|
# --- Budget Check: After Round 1 ---
|
|
537
617
|
CURRENT_COST=0
|
|
538
618
|
if [[ "$ENABLE_COST_TRACKING" == "true" && $SUCCESS_COUNT -gt 0 ]]; then
|
|
@@ -792,9 +872,29 @@ REPORT_FILE="$OUTPUT_DIR/report.md"
|
|
|
792
872
|
echo "**Directory**: $(pwd)"
|
|
793
873
|
echo "**Version**: AI Consultants v${AI_CONSULTANTS_VERSION}"
|
|
794
874
|
echo "**Category**: $QUESTION_CATEGORY"
|
|
795
|
-
echo "**
|
|
875
|
+
echo "**Outcome**: ${QUORUM_OUTCOME:-MET} (${SUCCESS_COUNT}/${QUORUM_ATTEMPTED:-${#PIDS[@]}} responded, quorum=${QUORUM_MIN_EFF:-2})"
|
|
796
876
|
echo "**Consensus**: ${CONSENSUS_SCORE}% ($CONSENSUS_LEVEL)"
|
|
797
877
|
echo ""
|
|
878
|
+
|
|
879
|
+
# Diagnosed failures (quorum grading, v2.19.0) — surface WHY consultants
|
|
880
|
+
# dropped, so a degraded panel is visible instead of silently authoritative.
|
|
881
|
+
if [[ ${#DIAGNOSED_FAILURES[@]} -gt 0 ]]; then
|
|
882
|
+
echo "## Diagnosed Failures"
|
|
883
|
+
echo ""
|
|
884
|
+
if [[ "${QUORUM_OUTCOME:-MET}" == "FAILED" ]]; then
|
|
885
|
+
echo "> ⚠️ Below quorum (${SUCCESS_COUNT}/${QUORUM_MIN_EFF:-2}). Treat the recommendation as low-confidence."
|
|
886
|
+
elif [[ "${QUORUM_OUTCOME:-MET}" == "DEGRADED" ]]; then
|
|
887
|
+
echo "> Some consultants did not respond — the panel ran with ${SUCCESS_COUNT}/${QUORUM_ATTEMPTED:-${#PIDS[@]}}."
|
|
888
|
+
fi
|
|
889
|
+
echo ""
|
|
890
|
+
echo "| Consultant | Reason |"
|
|
891
|
+
echo "|------------|--------|"
|
|
892
|
+
for _df in "${DIAGNOSED_FAILURES[@]}"; do
|
|
893
|
+
echo "$(render_diagnosed_failure "$_df" table)"
|
|
894
|
+
done
|
|
895
|
+
echo ""
|
|
896
|
+
fi
|
|
897
|
+
|
|
798
898
|
echo "---"
|
|
799
899
|
echo ""
|
|
800
900
|
|
package/scripts/doctor.sh
CHANGED
|
@@ -32,6 +32,7 @@ VERBOSE=false
|
|
|
32
32
|
QUICK_MODE=false
|
|
33
33
|
SUGGEST_CONFIG=false
|
|
34
34
|
SUGGEST_PRESET=false
|
|
35
|
+
LIVE_MODE=false
|
|
35
36
|
QUESTION=""
|
|
36
37
|
|
|
37
38
|
while [[ $# -gt 0 ]]; do
|
|
@@ -60,6 +61,10 @@ while [[ $# -gt 0 ]]; do
|
|
|
60
61
|
SUGGEST_PRESET=true
|
|
61
62
|
shift
|
|
62
63
|
;;
|
|
64
|
+
--live)
|
|
65
|
+
LIVE_MODE=true
|
|
66
|
+
shift
|
|
67
|
+
;;
|
|
63
68
|
--question)
|
|
64
69
|
QUESTION="${2:-}"
|
|
65
70
|
shift 2
|
|
@@ -78,6 +83,9 @@ while [[ $# -gt 0 ]]; do
|
|
|
78
83
|
echo " --suggest-config Print recommended ENABLE_* configuration"
|
|
79
84
|
echo " --suggest-preset Recommend preset + strategy for a question"
|
|
80
85
|
echo " --question \"...\" Question text for --suggest-preset"
|
|
86
|
+
echo " --live Send a real ping query to each enabled consultant"
|
|
87
|
+
echo " (catches installed-but-unauthenticated CLIs that the"
|
|
88
|
+
echo " static check reports as healthy). Costs a tiny query each."
|
|
81
89
|
exit 0
|
|
82
90
|
;;
|
|
83
91
|
*)
|
|
@@ -1080,6 +1088,62 @@ suggest_preset() {
|
|
|
1080
1088
|
# MAIN
|
|
1081
1089
|
# =============================================================================
|
|
1082
1090
|
|
|
1091
|
+
# --- Live consultant ping (--live) ---------------------------------------------
|
|
1092
|
+
# The static checks above only verify a CLI is installed (`--version`), so they
|
|
1093
|
+
# report a consultant as healthy even when its CLI errors at query time (e.g.
|
|
1094
|
+
# not authenticated). This sends a real, minimal query to each ENABLED consultant
|
|
1095
|
+
# and reports pass/fail with the captured error reason. Opt-in (costs one tiny
|
|
1096
|
+
# query per consultant).
|
|
1097
|
+
check_live_consultants() {
|
|
1098
|
+
print_section "Live Consultant Check (real ping query)"
|
|
1099
|
+
|
|
1100
|
+
local timeout_s="${DOCTOR_LIVE_TIMEOUT:-45}"
|
|
1101
|
+
local tmpdir
|
|
1102
|
+
tmpdir=$(mktemp -d)
|
|
1103
|
+
|
|
1104
|
+
# name|enable-flag — mirrors consult_all's selection set.
|
|
1105
|
+
local entries=(
|
|
1106
|
+
"Gemini|ENABLE_GEMINI" "Codex|ENABLE_CODEX" "Mistral|ENABLE_MISTRAL"
|
|
1107
|
+
"Kilo|ENABLE_KILO" "Cursor|ENABLE_CURSOR" "Aider|ENABLE_AIDER"
|
|
1108
|
+
"Amp|ENABLE_AMP" "Kimi|ENABLE_KIMI" "Claude|ENABLE_CLAUDE"
|
|
1109
|
+
"Qwen3|ENABLE_QWEN3" "GLM|ENABLE_GLM" "Grok|ENABLE_GROK"
|
|
1110
|
+
"DeepSeek|ENABLE_DEEPSEEK" "MiniMax|ENABLE_MINIMAX" "Ollama|ENABLE_OLLAMA"
|
|
1111
|
+
)
|
|
1112
|
+
|
|
1113
|
+
local live_pass=0 live_fail=0 e name flagvar lower out err reason rc
|
|
1114
|
+
for e in "${entries[@]}"; do
|
|
1115
|
+
name="${e%%|*}"; flagvar="${e##*|}"
|
|
1116
|
+
if [[ "${!flagvar:-false}" != "true" ]]; then
|
|
1117
|
+
[[ "$VERBOSE" == "true" ]] && _print " ○ $name: disabled"
|
|
1118
|
+
continue
|
|
1119
|
+
fi
|
|
1120
|
+
if should_skip_consultant "$name" 2>/dev/null; then
|
|
1121
|
+
_print " ○ $name: excluded (invoking agent)"
|
|
1122
|
+
continue
|
|
1123
|
+
fi
|
|
1124
|
+
lower=$(to_lower "$name")
|
|
1125
|
+
out="$tmpdir/${lower}.json"; err="$tmpdir/${lower}.err"
|
|
1126
|
+
if ping_consultant "$lower" "$SCRIPT_DIR" "$timeout_s" "$out" "$err"; then rc=0; else rc=$?; fi
|
|
1127
|
+
if [[ $rc -eq 2 ]]; then
|
|
1128
|
+
_print " ✗ $name: query script missing"
|
|
1129
|
+
add_issue "live" "$name query script missing" "reinstall the skill"
|
|
1130
|
+
check_fail; live_fail=$((live_fail + 1))
|
|
1131
|
+
elif [[ $rc -eq 0 ]]; then
|
|
1132
|
+
_print " ✓ $name: responded"
|
|
1133
|
+
check_pass; live_pass=$((live_pass + 1))
|
|
1134
|
+
else
|
|
1135
|
+
reason=$(get_consultant_error_reason "$err")
|
|
1136
|
+
_print " ✗ $name: no valid response${reason:+ — $reason}"
|
|
1137
|
+
add_issue "live" "$name failed live ping${reason:+: $reason}" "check that the $name CLI is installed and authenticated"
|
|
1138
|
+
check_fail; live_fail=$((live_fail + 1))
|
|
1139
|
+
fi
|
|
1140
|
+
done
|
|
1141
|
+
|
|
1142
|
+
rm -rf "$tmpdir"
|
|
1143
|
+
_print ""
|
|
1144
|
+
_print " Live result: ${live_pass} responding, ${live_fail} failing"
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1083
1147
|
main() {
|
|
1084
1148
|
print_header
|
|
1085
1149
|
check_dependencies
|
|
@@ -1112,4 +1176,11 @@ if [[ "$SUGGEST_PRESET" == "true" ]]; then
|
|
|
1112
1176
|
exit 0
|
|
1113
1177
|
fi
|
|
1114
1178
|
|
|
1179
|
+
if [[ "$LIVE_MODE" == "true" ]]; then
|
|
1180
|
+
print_header
|
|
1181
|
+
check_live_consultants
|
|
1182
|
+
print_summary
|
|
1183
|
+
[[ ${#ISSUES[@]} -gt 0 ]] && exit 1 || exit 0
|
|
1184
|
+
fi
|
|
1185
|
+
|
|
1115
1186
|
main
|
package/scripts/lib/common.sh
CHANGED
|
@@ -192,11 +192,17 @@ run_query() {
|
|
|
192
192
|
local error_msg=""
|
|
193
193
|
[[ -f "$error_file" ]] && error_msg=$(head -5 "$error_file" 2>/dev/null)
|
|
194
194
|
|
|
195
|
+
# First line of the CLI's own stderr — the actual reason (auth error,
|
|
196
|
+
# command not found, etc.). Kept for the final failure message so it
|
|
197
|
+
# reaches the orchestrator's captured stderr (the per-CLI error_file is a
|
|
198
|
+
# mktemp path the caller can't locate).
|
|
199
|
+
local error_first=""
|
|
200
|
+
[[ -n "$error_msg" ]] && error_first=$(printf '%s\n' "$error_msg" | grep -v '^[[:space:]]*$' | head -1 | cut -c1-200)
|
|
201
|
+
|
|
195
202
|
if [[ $exit_code -eq 124 ]]; then
|
|
196
203
|
log_warn "[$consultant_name] Timeout after ${timeout_seconds}s"
|
|
197
204
|
else
|
|
198
|
-
log_warn "[$consultant_name] Error (code: $exit_code)"
|
|
199
|
-
[[ -n "$error_msg" ]] && log_debug "Details: $error_msg"
|
|
205
|
+
log_warn "[$consultant_name] Error (code: $exit_code)${error_first:+: $error_first}"
|
|
200
206
|
fi
|
|
201
207
|
|
|
202
208
|
((attempt++)) || true
|
|
@@ -206,7 +212,7 @@ run_query() {
|
|
|
206
212
|
fi
|
|
207
213
|
done
|
|
208
214
|
|
|
209
|
-
log_error "[$consultant_name] All $MAX_RETRIES attempts failed"
|
|
215
|
+
log_error "[$consultant_name] All $MAX_RETRIES attempts failed${error_first:+: $error_first}"
|
|
210
216
|
return 1
|
|
211
217
|
}
|
|
212
218
|
|
|
@@ -923,6 +929,76 @@ build_error_response() {
|
|
|
923
929
|
# fallback-only: if the input already parses as JSON it is returned unchanged
|
|
924
930
|
# (a real fence makes the text invalid JSON, so the gate reliably detects it).
|
|
925
931
|
# Usage: cleaned=$(strip_json_fence "$raw")
|
|
932
|
+
# Extract a concise, ANSI-stripped failure reason from a consultant's captured
|
|
933
|
+
# stderr (.err) file, for surfacing WHY a consultant produced no output (auth
|
|
934
|
+
# error, CLI not installed, transient init failure, ...) instead of a bare
|
|
935
|
+
# "Failed". Prefers an explicit error-ish line; falls back to the last non-empty
|
|
936
|
+
# line. Returns empty string if the file is missing/empty.
|
|
937
|
+
# Usage: reason=$(get_consultant_error_reason "$err_file")
|
|
938
|
+
get_consultant_error_reason() {
|
|
939
|
+
local ef="$1"
|
|
940
|
+
[[ -s "$ef" ]] || return 0
|
|
941
|
+
local esc cleaned line
|
|
942
|
+
esc=$(printf '\033')
|
|
943
|
+
# Strip ANSI, blank lines, AND our own orchestration status logs first — those
|
|
944
|
+
# carry no failure info and would otherwise be mis-picked (e.g. the
|
|
945
|
+
# "Consulting X (timeout: 180s...)" header matches the 'timeout' pattern below).
|
|
946
|
+
cleaned=$(sed "s/${esc}\[[0-9;]*m//g" "$ef" 2>/dev/null \
|
|
947
|
+
| grep -v '^[[:space:]]*$' \
|
|
948
|
+
| grep -ivE '\[INFO\].*(Consulting|Response received|Waiting .* before|Attempt [0-9]|Querying)')
|
|
949
|
+
# Prefer a line that names an actual error; else the last remaining line.
|
|
950
|
+
line=$(printf '%s\n' "$cleaned" | grep -iE 'error|fail|not found|no such|denied|unauthor|invalid|timed? ?out|missing|command not found|exit code' | tail -1)
|
|
951
|
+
[[ -z "$line" ]] && line=$(printf '%s\n' "$cleaned" | tail -1)
|
|
952
|
+
printf '%s' "$line" | cut -c1-200
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
# Render one DIAGNOSED_FAILURES entry ("name|reason") for a given surface.
|
|
956
|
+
# Single source of the "name|reason" decode (used by both the console log and the
|
|
957
|
+
# report table), and the table mode escapes any '|' in the reason so a CLI error
|
|
958
|
+
# containing a pipe doesn't mangle the markdown row.
|
|
959
|
+
# Usage: render_diagnosed_failure "<name>|<reason>" [console|table]
|
|
960
|
+
render_diagnosed_failure() {
|
|
961
|
+
local entry="$1" mode="${2:-console}" name reason
|
|
962
|
+
name="${entry%%|*}"
|
|
963
|
+
reason="${entry#*|}"
|
|
964
|
+
if [[ "$mode" == "table" ]]; then
|
|
965
|
+
printf '| %s | %s |' "$name" "${reason//|/\\|}"
|
|
966
|
+
else
|
|
967
|
+
printf ' - %s: %s' "$name" "$reason"
|
|
968
|
+
fi
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
# Grade a consultation by how many consultants responded (v2.19.0).
|
|
972
|
+
# Usage: grade_quorum <success_count> <attempted_count> <min_quorum>
|
|
973
|
+
# Echoes: FAILED (< min) | DEGRADED (>= min but some failed) | MET (all responded)
|
|
974
|
+
grade_quorum() {
|
|
975
|
+
local success="$1" attempted="$2" min="$3"
|
|
976
|
+
if [[ "$success" -lt "$min" ]]; then
|
|
977
|
+
echo "FAILED"
|
|
978
|
+
elif [[ "$success" -lt "$attempted" ]]; then
|
|
979
|
+
echo "DEGRADED"
|
|
980
|
+
else
|
|
981
|
+
echo "MET"
|
|
982
|
+
fi
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
# Send a trivial real "ping" query to a consultant's query script and report
|
|
986
|
+
# whether it produced a structurally valid response. Shared by `doctor --live`
|
|
987
|
+
# and the consult_all health gate. Persona is disabled (we only need a parseable
|
|
988
|
+
# envelope, not a full analysis). Caller provides scratch out/err paths.
|
|
989
|
+
# <id> is the ALREADY-lowercased consultant id. Callers compute the lowercase
|
|
990
|
+
# form anyway (for the out/err paths), so we take it directly instead of forking
|
|
991
|
+
# to_lower again per consultant.
|
|
992
|
+
# Usage: ping_consultant <id> <scripts_dir> <timeout_s> <out_file> <err_file>
|
|
993
|
+
# Returns: 0 = responded, 1 = failed/empty, 2 = no query script (not probeable)
|
|
994
|
+
ping_consultant() {
|
|
995
|
+
local id="$1" scripts_dir="$2" timeout_s="$3" out="$4" err="$5"
|
|
996
|
+
local qs="$scripts_dir/query_${id}.sh"
|
|
997
|
+
[[ -x "$qs" ]] || return 2
|
|
998
|
+
ENABLE_PERSONA=false run_with_timeout "$timeout_s" "$qs" "Reply with exactly: OK" "" "$out" >/dev/null 2>"$err" || true
|
|
999
|
+
[[ -s "$out" ]] && jq -e '.response' "$out" >/dev/null 2>&1
|
|
1000
|
+
}
|
|
1001
|
+
|
|
926
1002
|
strip_json_fence() {
|
|
927
1003
|
local text="$1"
|
|
928
1004
|
if echo "$text" | jq -e '.' > /dev/null 2>&1; then
|
package/scripts/lib/costs.sh
CHANGED
|
@@ -365,23 +365,80 @@ COST_TRACKING_FILE="${COST_TRACKING_FILE:-${_AI_CONSULTANTS_XDG_DATA:-/tmp/ai_co
|
|
|
365
365
|
|
|
366
366
|
# Record session cost
|
|
367
367
|
# Usage: track_session_cost <session_id> <cost>
|
|
368
|
+
# Best-effort: cost tracking is bookkeeping. The caller in consult_all.sh runs
|
|
369
|
+
# under set -e *after* every consultant has already been queried and billed,
|
|
370
|
+
# so failures here degrade to a warning instead of aborting the run.
|
|
368
371
|
track_session_cost() {
|
|
369
372
|
local session_id="$1"
|
|
370
373
|
local cost="$2"
|
|
374
|
+
|
|
375
|
+
# The XDG data dir may not exist yet on a fresh install — costs.json is
|
|
376
|
+
# the only artifact stored there
|
|
377
|
+
if ! mkdir -p "$(dirname "$COST_TRACKING_FILE")" 2>/dev/null; then
|
|
378
|
+
log_warn "Cost tracking skipped: cannot create $(dirname "$COST_TRACKING_FILE")"
|
|
379
|
+
return 0
|
|
380
|
+
fi
|
|
381
|
+
|
|
382
|
+
# Serialize the read-modify-write against concurrent consultations with a
|
|
383
|
+
# portable mkdir lock (flock is unavailable on macOS). Bounded wait, then
|
|
384
|
+
# proceed unlocked: bookkeeping must never block or abort the run.
|
|
385
|
+
local lock_dir="${COST_TRACKING_FILE}.lock" locked=false _i
|
|
386
|
+
for _i in {1..50}; do
|
|
387
|
+
if mkdir "$lock_dir" 2>/dev/null; then locked=true; break; fi
|
|
388
|
+
sleep 0.1
|
|
389
|
+
done
|
|
390
|
+
if [[ "$locked" != "true" ]]; then
|
|
391
|
+
log_warn "Cost tracking lock busy for 5s (stale ${lock_dir}?), proceeding unlocked"
|
|
392
|
+
fi
|
|
393
|
+
|
|
394
|
+
_track_session_cost_update "$session_id" "$cost" || true
|
|
395
|
+
|
|
396
|
+
if [[ "$locked" == "true" ]]; then
|
|
397
|
+
rmdir "$lock_dir" 2>/dev/null || true
|
|
398
|
+
fi
|
|
399
|
+
return 0
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
# Inner update for track_session_cost — runs with the lock held.
|
|
403
|
+
# Never propagates failure; logs a warning and returns instead.
|
|
404
|
+
_track_session_cost_update() {
|
|
405
|
+
local session_id="$1"
|
|
406
|
+
local cost="$2"
|
|
371
407
|
local timestamp=$(date -Iseconds)
|
|
372
408
|
|
|
373
|
-
#
|
|
409
|
+
# A corrupt file (truncated write, interleaved concurrent update) would
|
|
410
|
+
# fail every future jq update and never self-heal: set it aside and reset
|
|
411
|
+
if [[ -f "$COST_TRACKING_FILE" ]]; then
|
|
412
|
+
if ! jq empty "$COST_TRACKING_FILE" 2>/dev/null; then
|
|
413
|
+
log_warn "Cost tracking file corrupt, resetting (backup: ${COST_TRACKING_FILE}.corrupt)"
|
|
414
|
+
mv -f "$COST_TRACKING_FILE" "${COST_TRACKING_FILE}.corrupt" 2>/dev/null || true
|
|
415
|
+
fi
|
|
416
|
+
fi
|
|
374
417
|
if [[ ! -f "$COST_TRACKING_FILE" ]]; then
|
|
375
|
-
echo '{"sessions": [], "total_cost": 0}' > "$COST_TRACKING_FILE"
|
|
418
|
+
if ! { echo '{"sessions": [], "total_cost": 0}' > "$COST_TRACKING_FILE"; } 2>/dev/null; then
|
|
419
|
+
log_warn "Cost tracking skipped: cannot write $COST_TRACKING_FILE"
|
|
420
|
+
return 0
|
|
421
|
+
fi
|
|
376
422
|
fi
|
|
377
423
|
|
|
378
|
-
#
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
424
|
+
# Unique temp file: even an unlocked writer must not share a fixed .tmp
|
|
425
|
+
# sibling with other runs (lost records, failed mv)
|
|
426
|
+
local tmp_file
|
|
427
|
+
if ! tmp_file=$(mktemp "${COST_TRACKING_FILE}.XXXXXX" 2>/dev/null); then
|
|
428
|
+
log_warn "Cost tracking skipped: cannot create temp file for $COST_TRACKING_FILE"
|
|
429
|
+
return 0
|
|
430
|
+
fi
|
|
431
|
+
if jq --arg id "$session_id" \
|
|
432
|
+
--arg cost "$cost" \
|
|
433
|
+
--arg ts "$timestamp" \
|
|
434
|
+
'.sessions += [{id: $id, cost: ($cost | tonumber), timestamp: $ts}] | .total_cost += ($cost | tonumber)' \
|
|
435
|
+
"$COST_TRACKING_FILE" > "$tmp_file" 2>/dev/null; then
|
|
436
|
+
mv -f "$tmp_file" "$COST_TRACKING_FILE" 2>/dev/null || rm -f "$tmp_file"
|
|
437
|
+
else
|
|
438
|
+
rm -f "$tmp_file"
|
|
439
|
+
log_warn "Cost tracking update failed for session $session_id"
|
|
440
|
+
fi
|
|
441
|
+
return 0
|
|
385
442
|
}
|
|
386
443
|
|
|
387
444
|
# Get total tracked cost
|
|
@@ -9,8 +9,9 @@ set -uo pipefail
|
|
|
9
9
|
|
|
10
10
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
11
11
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
12
|
-
#
|
|
13
|
-
#
|
|
12
|
+
# Fixtures are referenced by RELATIVE path: build_context.sh's context gate
|
|
13
|
+
# accepts only relative in-tree paths or temp-root paths (/tmp, /private/tmp,
|
|
14
|
+
# $TMPDIR) and rejects arbitrary absolute paths (see Test 15 for the boundary).
|
|
14
15
|
FIXTURES_REL="scripts/test_fixtures/context"
|
|
15
16
|
|
|
16
17
|
# Always invoke build_context.sh from the project root so relative fixture
|
|
@@ -282,6 +283,37 @@ test_legacy_inlined_query() {
|
|
|
282
283
|
# Suite runner
|
|
283
284
|
# -----------------------------------------------------------------------------
|
|
284
285
|
echo "test_context_optimization.sh — v2.14 context handoff regression suite"
|
|
286
|
+
# -----------------------------------------------------------------------------
|
|
287
|
+
# Test 15: context-file path boundary (v2.17.2)
|
|
288
|
+
# -----------------------------------------------------------------------------
|
|
289
|
+
# build_context.sh sends file contents to external AI providers, so the context
|
|
290
|
+
# gate must (a) ACCEPT temp-root paths — fixing the macOS /private/tmp scratch-file
|
|
291
|
+
# regression — and (b) REJECT arbitrary absolute paths so secrets like ~/.ssh/id_rsa
|
|
292
|
+
# can't be exfiltrated. ($_TMPDIR is itself a temp root: /var/folders/... on macOS,
|
|
293
|
+
# /tmp/... on Linux — the exact accept case, auto-cleaned, no $HOME litter.)
|
|
294
|
+
# QUESTION_CATEGORY=SECURITY skips the project-tree find these assertions don't need.
|
|
295
|
+
test_context_path_boundary() {
|
|
296
|
+
local tmpfile="$_TMPDIR/aic_ctx_abs.txt"
|
|
297
|
+
printf 'UNIQUE_CTX_MARKER_42 lorem ipsum\n' > "$tmpfile"
|
|
298
|
+
QUESTION_CATEGORY=SECURITY "$SCRIPT_DIR/build_context.sh" "$_TMPDIR/ctx_tmp.md" "summarize this" "$tmpfile" >/dev/null 2>&1
|
|
299
|
+
local content
|
|
300
|
+
content=$(cat "$_TMPDIR/ctx_tmp.md" 2>/dev/null)
|
|
301
|
+
assert_match "UNIQUE_CTX_MARKER_42" "$content" \
|
|
302
|
+
"temp-root context path is included (macOS /private/tmp regression class)"
|
|
303
|
+
|
|
304
|
+
# Arbitrary absolute path outside the temp roots must be REJECTED (no secret
|
|
305
|
+
# exfiltration). Path need not exist — rejection happens at validation.
|
|
306
|
+
local stderr
|
|
307
|
+
stderr=$(QUESTION_CATEGORY=SECURITY "$SCRIPT_DIR/build_context.sh" "$_TMPDIR/ctx_sec.md" "q" "$HOME/.ssh/id_rsa" 2>&1)
|
|
308
|
+
assert_match "Skipping invalid file path: $HOME/.ssh/id_rsa" "$stderr" \
|
|
309
|
+
"absolute path outside temp roots is rejected (secret exfiltration blocked)"
|
|
310
|
+
|
|
311
|
+
# Sensitive system path still rejected.
|
|
312
|
+
stderr=$(QUESTION_CATEGORY=SECURITY "$SCRIPT_DIR/build_context.sh" "$_TMPDIR/ctx_etc.md" "q" "/etc/passwd" 2>&1)
|
|
313
|
+
assert_match "Skipping invalid file path: /etc/passwd" "$stderr" \
|
|
314
|
+
"sensitive /etc path is still rejected"
|
|
315
|
+
}
|
|
316
|
+
|
|
285
317
|
run_test "Test 1: @TAG defaults to PRIMARY" test_tag_default_primary
|
|
286
318
|
run_test "Test 2: @TAG honored when explicit" test_tag_explicit
|
|
287
319
|
run_test "Test 3: @TAG unknown falls back to PRIMARY with warning" test_tag_unknown_fallback
|
|
@@ -296,5 +328,6 @@ run_test "Test 11: --query-file is parsed correctly" test_query_file_flag
|
|
|
296
328
|
run_test "Test 12: --query-file with missing file is rejected" test_query_file_missing
|
|
297
329
|
run_test "Test 13: --query-file conflicts with positional query" test_query_file_conflict
|
|
298
330
|
run_test "Test 14: Legacy no-FILES path still works" test_legacy_inlined_query
|
|
331
|
+
run_test "Test 15: context-file path boundary (v2.17.2)" test_context_path_boundary
|
|
299
332
|
|
|
300
333
|
test_summary "test_context_optimization.sh"
|
package/scripts/test_suite.sh
CHANGED
|
@@ -1011,6 +1011,43 @@ test_cost_per1k_contract() {
|
|
|
1011
1011
|
if [[ -n "$saved" ]]; then COST_RATES_FILE="$saved"; else unset COST_RATES_FILE; fi
|
|
1012
1012
|
}
|
|
1013
1013
|
|
|
1014
|
+
# Fresh-install regression: track_session_cost wrote to $COST_TRACKING_FILE
|
|
1015
|
+
# without creating its parent directory, so on a fresh install (XDG data dir
|
|
1016
|
+
# ~/.local/share/ai-consultants absent) every session ended with
|
|
1017
|
+
# "No such file or directory" and cumulative cost tracking was lost.
|
|
1018
|
+
# Also covers the corrupt-file self-heal path (a bad costs.json previously
|
|
1019
|
+
# wedged every future update).
|
|
1020
|
+
test_cost_tracking_resilience() {
|
|
1021
|
+
suite "costs.sh: track_session_cost resilience (fresh-install + corrupt-file)"
|
|
1022
|
+
|
|
1023
|
+
local saved="$COST_TRACKING_FILE"
|
|
1024
|
+
COST_TRACKING_FILE="$TEST_TMPDIR/cost_tracking/nonexistent/nested/costs.json"
|
|
1025
|
+
|
|
1026
|
+
# Calls are guarded with || true: a recurrence of the guarded bugs must
|
|
1027
|
+
# surface as FAIL assertions below, not abort the suite under set -e
|
|
1028
|
+
track_session_cost "test_session_1" "0.25" 2>/dev/null || true
|
|
1029
|
+
|
|
1030
|
+
# 0.25 and 0.5 are exact in binary floating point, so the jq sum is exact
|
|
1031
|
+
assert_exit_code_success "costs.json created under a missing parent dir" test -f "$COST_TRACKING_FILE"
|
|
1032
|
+
assert_equals "0.25" "$(get_total_tracked_cost)" "session cost recorded on fresh install"
|
|
1033
|
+
|
|
1034
|
+
# Second call must append, not re-initialize
|
|
1035
|
+
track_session_cost "test_session_2" "0.5" 2>/dev/null || true
|
|
1036
|
+
assert_equals "0.75" "$(get_total_tracked_cost)" "second session accumulates on existing file"
|
|
1037
|
+
|
|
1038
|
+
# A corrupt costs.json must be set aside and reset, not wedge every
|
|
1039
|
+
# future update. The dir is created explicitly: this block tests the
|
|
1040
|
+
# self-heal path, not dir creation, and the setup write must not be able
|
|
1041
|
+
# to abort the suite under set -e
|
|
1042
|
+
mkdir -p "$(dirname "$COST_TRACKING_FILE")"
|
|
1043
|
+
echo '{"sessions": [' > "$COST_TRACKING_FILE"
|
|
1044
|
+
track_session_cost "test_session_3" "0.5" 2>/dev/null || true
|
|
1045
|
+
assert_equals "0.5" "$(get_total_tracked_cost)" "corrupt costs.json is reset and the new session recorded"
|
|
1046
|
+
assert_exit_code_success "corrupt file preserved as .corrupt backup" test -f "${COST_TRACKING_FILE}.corrupt"
|
|
1047
|
+
|
|
1048
|
+
COST_TRACKING_FILE="$saved"
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1014
1051
|
# =============================================================================
|
|
1015
1052
|
# TESTS: costs.sh - Cost formatting
|
|
1016
1053
|
# =============================================================================
|
|
@@ -1616,6 +1653,7 @@ main() {
|
|
|
1616
1653
|
test_cost_rates
|
|
1617
1654
|
test_cost_estimation
|
|
1618
1655
|
test_cost_per1k_contract
|
|
1656
|
+
test_cost_tracking_resilience
|
|
1619
1657
|
test_cost_formatting
|
|
1620
1658
|
test_budget_checking
|
|
1621
1659
|
test_query_complexity
|