Skip to content
Merged
10 changes: 8 additions & 2 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,8 @@ projects need.

autoskillit doctor

Doctor runs 28 checks (23 numbered + 5 lettered sub-checks: `2b`, `2c`, `2d`, `4b`, `7b`); up to 33 with the fleet feature enabled.
Enumerated by `run_doctor` in `src/autoskillit/cli/_doctor.py`:
Doctor runs 33 checks (23 numbered + 5 lettered sub-checks: `2b`, `2c`, `2d`, `4b`, `7b` + 5 backend runtime probes, checks 30–34); up to 39 with the fleet feature enabled.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[warning] cohesion: prose says "Doctor runs 33 checks" but src/autoskillit/cli/doctor/AGENTS.md says "34 checks" — one count must be wrong. Verify whether the base (non-fleet) total is 33 (23 numbered + 5 lettered + 5 backend runtime probes 30–34) or 34, and update both files to agree.

Enumerated by `run_doctor` in `src/autoskillit/cli/doctor/__init__.py`:

| # | Check | What it verifies |
|---|-------|------------------|
Expand Down Expand Up @@ -101,6 +101,12 @@ Enumerated by `run_doctor` in `src/autoskillit/cli/_doctor.py`:
| 22 | Feature dependency consistency | Enabled features satisfy their declared dependencies |
| 23 | Feature registry import consistency | All feature gate modules import without errors |
| 24–28 | Fleet infrastructure | Sous-chef skill, dispatch guard, stale state, onboarding, clone collisions (fleet feature only) |
| 29 | Fleet state schema | Fleet state schema version drift (fleet feature only) |
| 30 | Codex version | Codex CLI version meets minimum requirement |
| 31 | script(1) binary | PTY binary availability with -qefc support |
| 32 | MCP timeouts | Codex MCP tool_timeout_sec coherence |
| 33 | Codex graduation | Multi-criteria graduation readiness (version, probe, matrix, smoke) |
| 34 | CLI conformance | Backend CLI accepts minimal TOML config probe |

See **[Hooks](safety/hooks.md)** for what each PreToolUse / PostToolUse /
SessionStart hook actually enforces.
Expand Down
2 changes: 1 addition & 1 deletion src/autoskillit/cli/doctor/AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# doctor/

Diagnostic health checks for the autoskillit installation (33 checks).
Diagnostic health checks for the autoskillit installation (34 checks).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[warning] cohesion: AGENTS.md says 34 checks but docs/installation.md prose says 33. These two authoritative sources give different totals — whichever is wrong must be corrected.


## Files

Expand Down
4 changes: 4 additions & 0 deletions src/autoskillit/cli/doctor/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
)
from ._doctor_runtime import (
_check_claude_process_state_breakdown,
_check_cli_conformance_probes,
_check_codex_graduation,
_check_codex_version,
_check_quota_cache_schema,
Expand Down Expand Up @@ -217,6 +218,9 @@ def run_doctor(*, output_json: bool = False) -> None:
# Check 33: Codex graduation criteria
results.append(_check_codex_graduation(backend=_backend))

# Check 34: CLI config-acceptance probe
results.append(_check_cli_conformance_probes(backend=_backend))

# Output
if output_json:
print(
Expand Down
47 changes: 47 additions & 0 deletions src/autoskillit/cli/doctor/_doctor_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,17 @@
from __future__ import annotations

import json
import shlex
import subprocess
import tempfile
from pathlib import Path

import regex as re

from autoskillit.core import (
CodingAgentBackend,
Severity,
atomic_write,
default_log_dir,
get_logger,
)
Expand Down Expand Up @@ -270,3 +273,47 @@ def _check_codex_graduation(
summary += f" — EXPERIMENTAL hold: {pending} of 4 criteria pending"

return DoctorResult(Severity.INFO, check_name, summary)


def _check_cli_conformance_probes(*, backend: CodingAgentBackend | None = None) -> DoctorResult:
check_name = "cli_conformance_probes"

if backend is None or not backend.capabilities.version_check_command:
return DoctorResult(Severity.OK, check_name, "Skipped (no version check command)")

cli_argv = shlex.split(backend.capabilities.version_check_command)
try:
subprocess.run(cli_argv, capture_output=True, text=True, timeout=5)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[warning] defense: OSError from the version-check subprocess.run is silently swallowed with a generic "CLI unavailable" message, discarding diagnostic context. A permission error or bad binary is indistinguishable from "not installed". Log the exception at debug level before returning so operators can distinguish failure modes.

except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
return DoctorResult(Severity.OK, check_name, "CLI unavailable; skipping config probe")

cli_binary = cli_argv[0]
result = None
with tempfile.TemporaryDirectory() as tmpdir:
probe_path = Path(tmpdir) / "probe.toml"
atomic_write(probe_path, 'model = "test-model"\n')
try:
result = subprocess.run(
[cli_binary, "-c", str(probe_path), "--version"],
capture_output=True,
text=True,
timeout=10,
)
except subprocess.TimeoutExpired:
return DoctorResult(
Severity.OK,
check_name,
f"{cli_binary} config probe timed out; skipping",
)
except (FileNotFoundError, OSError):
return DoctorResult(Severity.OK, check_name, "CLI unavailable; skipping config probe")

if result is not None and result.returncode == 0:
return DoctorResult(Severity.OK, check_name, f"{cli_binary} accepted minimal config probe")
if result is None:
return DoctorResult(Severity.OK, check_name, "CLI unavailable; skipping config probe")
return DoctorResult(
Severity.WARNING,
check_name,
f"{cli_binary} rejected config probe (exit {result.returncode})",
)
114 changes: 114 additions & 0 deletions tests/cli/test_doctor_backend_guards.py
Original file line number Diff line number Diff line change
Expand Up @@ -691,3 +691,117 @@ def test_absent_files_yield_not_yet_run(
assert "probe=not-yet-run" in result.message
assert "matrix=not-yet-run" in result.message
assert "smoke=not-found" in result.message


class TestCheckCliConformanceProbes:
"""Tests for _check_cli_conformance_probes doctor check (Check 34)."""

def test_skip_for_none_backend(self) -> None:
from autoskillit.cli.doctor._doctor_runtime import _check_cli_conformance_probes
from autoskillit.core import Severity

result = _check_cli_conformance_probes(backend=None)
assert result.severity == Severity.OK
assert result.check == "cli_conformance_probes"
assert "skipped" in result.message.lower()

def test_skip_for_backend_without_version_check_command(self) -> None:
from types import SimpleNamespace

from autoskillit.cli.doctor._doctor_runtime import _check_cli_conformance_probes
from autoskillit.core import Severity

stub = SimpleNamespace(capabilities=SimpleNamespace(version_check_command=""))
result = _check_cli_conformance_probes(backend=stub)
assert result.severity == Severity.OK
assert "skipped" in result.message.lower()

def test_skip_when_cli_unavailable(self, monkeypatch: pytest.MonkeyPatch) -> None:
from autoskillit.cli.doctor._doctor_runtime import _check_cli_conformance_probes
from autoskillit.core import Severity
from autoskillit.execution.backends.codex import CodexBackend

def _raise(*a, **kw):
raise FileNotFoundError("codex")

monkeypatch.setattr("autoskillit.cli.doctor._doctor_runtime.subprocess.run", _raise)
result = _check_cli_conformance_probes(backend=CodexBackend())
assert result.severity == Severity.OK
assert "unavailable" in result.message.lower()

def test_skip_when_cli_times_out(self, monkeypatch: pytest.MonkeyPatch) -> None:
import subprocess

from autoskillit.cli.doctor._doctor_runtime import _check_cli_conformance_probes
from autoskillit.core import Severity
from autoskillit.execution.backends.codex import CodexBackend

call_count = 0

def _stateful_fake(*a, **kw):
nonlocal call_count
call_count += 1
if call_count == 1:
return type(
"CompletedProcess", (), {"returncode": 0, "stdout": "", "stderr": ""}
)()
raise subprocess.TimeoutExpired(cmd="codex", timeout=10)

monkeypatch.setattr(
"autoskillit.cli.doctor._doctor_runtime.subprocess.run", _stateful_fake
)
result = _check_cli_conformance_probes(backend=CodexBackend())
assert result.severity == Severity.OK
assert "timed out" in result.message.lower()

def test_ok_when_config_accepted(self, monkeypatch: pytest.MonkeyPatch) -> None:
import subprocess

from autoskillit.cli.doctor._doctor_runtime import _check_cli_conformance_probes
from autoskillit.core import Severity
from autoskillit.execution.backends.codex import CodexBackend

calls = []

def _fake_run(*a, **kw):
calls.append(a)
return type(
"CompletedProcess",
(),
{"returncode": 0, "stdout": "codex 0.130.0\n", "stderr": ""},
)()

monkeypatch.setattr(subprocess, "run", _fake_run)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[warning] tests: monkeypatch.setattr(subprocess, "run", _fake_run) patches the global subprocess module object instead of the module-scoped path "autoskillit.cli.doctor._doctor_runtime.subprocess.run" used by every other test in this class (L727, L750). This patch may not intercept the production code's call, making test_ok_when_config_accepted unreliable. Change to monkeypatch.setattr("autoskillit.cli.doctor._doctor_runtime.subprocess.run", _fake_run).

result = _check_cli_conformance_probes(backend=CodexBackend())
assert result.severity == Severity.OK
assert "accepted" in result.message.lower()
assert len(calls) == 2

def test_warning_when_config_rejected(self, monkeypatch: pytest.MonkeyPatch) -> None:
import subprocess

from autoskillit.cli.doctor._doctor_runtime import _check_cli_conformance_probes
from autoskillit.core import Severity
from autoskillit.execution.backends.codex import CodexBackend

call_count = 0

def _fake_run(*a, **kw):
nonlocal call_count
call_count += 1
if call_count == 1:
return type(
"CompletedProcess",
(),
{"returncode": 0, "stdout": "codex 0.130.0\n", "stderr": ""},
)()
return type(
"CompletedProcess",
(),
{"returncode": 1, "stdout": "", "stderr": "unknown flag"},
)()

monkeypatch.setattr(subprocess, "run", _fake_run)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[warning] tests: Same monkeypatch scope bug as L774 — monkeypatch.setattr(subprocess, "run", _fake_run) patches globally instead of "autoskillit.cli.doctor._doctor_runtime.subprocess.run". The production code's subprocess.run calls may not be intercepted, and the broad patch risks interference with concurrent tests under pytest-xdist.

result = _check_cli_conformance_probes(backend=CodexBackend())
assert result.severity == Severity.WARNING
assert "rejected" in result.message.lower() or "exit 1" in result.message

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[warning] tests: Weak disjunctive assertion — assert "rejected" in result.message.lower() or "exit 1" in result.message lets either clause pass independently, masking a regression where the exit code is dropped from the message. Assert both conditions separately: assert "rejected" in result.message.lower() and assert "exit 1" in result.message.

15 changes: 7 additions & 8 deletions tests/docs/test_doc_counts.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,16 +237,15 @@ def test_quota_thresholds_defaults() -> None:
assert long_ == pytest.approx(95.0)


def test_doctor_check_count_is_40() -> None:
# 40 total = 17 numbered base + 7 lettered sub-checks (2b–2e, 4b, 7b, 7c)
# + 4 ambient env (18–21) + 2 feature (22–23) + 6 gated franchise (24–29)
# + 1 codex version (30) + 1 script binary (31) + 1 MCP timeouts (32)
# + 1 codex graduation (33).
# The docs claim 17 user-visible checks; the gap is intentional (Check 2/4/7
# split into sub-markers here but appear as single entries in docs).
def test_doctor_check_count_is_41() -> None:
# 41 total = 23 numbered (1–17 base + 18–21 ambient env + 22–23 feature)
# + 7 lettered sub-checks (2b–2e, 4b, 7b, 7c)
# + 6 gated fleet (24–29) + 5 backend runtime (30–34).
# Docs list 33 user-visible checks (23 numbered + 5 documented lettered +
# 5 backend runtime); 2e and 7c are internal-only sub-checks not shown in docs.
# Update both tests whenever a new doctor check is added.
count = _count_doctor_checks()
assert count == 40, f"Expected 40 doctor checks; found {count}"
assert count == 41, f"Expected 41 doctor checks; found {count}"


def test_bundled_recipe_count_is_15() -> None:
Expand Down
Loading