ai-consultants 2.17.2 → 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 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.17.2
9
+ **Version**: 2.19.2
10
10
 
11
11
  ## Distribution
12
12
 
@@ -826,6 +826,37 @@ 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
+
829
860
  ### v2.17.2
830
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.
831
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.
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
- # AI Consultants v2.17.2
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
- [![Version](https://img.shields.io/badge/version-2.17.2-blue.svg)](https://github.com/matteoscurati/ai-consultants)
5
+ [![Version](https://img.shields.io/badge/version-2.19.2-blue.svg)](https://github.com/matteoscurati/ai-consultants)
6
6
  [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
7
7
  [![Claude Code Skill](https://img.shields.io/badge/Claude%20Code-Skill-orange.svg)](https://docs.anthropic.com/en/docs/claude-code/skills)
8
8
  [![GitHub stars](https://img.shields.io/github/stars/matteoscurati/ai-consultants?style=social)](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.17.2
8
+ version: 2.19.2
9
9
  ---
10
10
 
11
- # AI Consultants v2.17.2 - AI Expert Panel
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 |
@@ -1,4 +1,4 @@
1
- # Cost Rates - AI Consultants v2.17.2
1
+ # Cost Rates - AI Consultants v2.19.2
2
2
 
3
3
  This page documents the per-token rates used by the cost tracking system.
4
4
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.17.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,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.17.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/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.17.2"
890
+ AI_CONSULTANTS_VERSION="2.19.2"
@@ -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>&1 &
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>&1 &
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>&1 &
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 "**Successes**: $SUCCESS_COUNT/${#PIDS[@]} consultants"
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
@@ -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
@@ -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
- # Create file if it doesn't exist
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
- # Add session and update total
379
- jq --arg id "$session_id" \
380
- --arg cost "$cost" \
381
- --arg ts "$timestamp" \
382
- '.sessions += [{id: $id, cost: ($cost | tonumber), timestamp: $ts}] | .total_cost += ($cost | tonumber)' \
383
- "$COST_TRACKING_FILE" > "${COST_TRACKING_FILE}.tmp" && \
384
- mv "${COST_TRACKING_FILE}.tmp" "$COST_TRACKING_FILE"
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
@@ -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