Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ A coding-agent plugin that orchestrates automated skill-driven workflows using h
- `*Def` — static definition of a registered entity (e.g., `HookDef`, `PackDef`, `FeatureDef`, `RuleDef`). Typically a `NamedTuple` or `@dataclass(frozen=True)`, used as elements in a registry or lookup table. Typically lives in `core/`; stdlib-only types importable from hook scripts may live at the package root (e.g., `HookDef` in `hook_registry.py`).
- `*Spec` — behavioral specification or validation rule (e.g., `ExperimentTypeSpec`, `WriteBehaviorSpec`). Typically a `@dataclass` or `TypedDict` configuring a pipeline or validation stage. Typically lives in `recipe/` or domain layers; `*Spec` types used by IL-0 core protocols live in `core/` (e.g., `WriteBehaviorSpec` in `core/types/_type_results.py`).
* **Commit discipline**: Always create NEW commits. Never use `git commit --amend`, `--fixup`, or `--squash` unless the active recipe or SKILL.md explicitly requires it. This applies to all session types including headless sessions.
* **Multi-part plan green-gate invariant**: Every part of a multi-part plan must independently pass `task test-check`. If a part's changes invalidate a pre-existing test, that test must be updated, removed, or marked `xfail(strict=True)` in the same part. The `xfail(strict=True)` bridge is the canonical mechanism: `check_test_passed` ignores xfailed counts; `strict=True` forces cleanup when the xfail condition is resolved.
* **Multi-part plan green-gate invariant**: Every part of a multi-part plan must independently pass `task test-check`. If a part's changes invalidate a pre-existing test, that test must be updated, removed, or marked `xfail(strict=True)` in the same part. The `xfail(strict=True)` bridge is the canonical mechanism: `check_test_passed` ignores xfailed counts; `strict=True` forces cleanup when the xfail condition is resolved. A bridge's `reason` must cite the open tracking issue (`#NNNN`) for the deferred work — enforced by an architectural guard. A bridge whose stated exit condition is satisfied within the same PR must be removed in that same PR.

#### **3.1.a. Pre-commit Hooks**

Expand Down
11 changes: 7 additions & 4 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,14 +82,17 @@ Run health checks on your setup.
**Flags:**
- `--output-json` — Output results as JSON

Runs 28 checks (up to 33 with fleet enabled) enumerated by `run_doctor` in
`cli/_doctor.py` — 23 numbered plus lettered sub-checks `2b`, `2c`, `2d`,
`4b`, and `7b`. The checks cover stale MCP servers, plugin registration, PATH,
Runs 38 checks (up to 44 with fleet enabled) enumerated by `run_doctor` in
`cli/doctor/__init__.py` — numbered plus lettered sub-checks `2b`, `2c`, `2d`,
`2e`, `4b`, `7b`, `7c`, and `31b`. The checks cover stale MCP servers, plugin registration, PATH,
project config, secrets placement, version consistency, hook health, hook
registration, hook registry drift, recipe version health, gitignore
completeness, secret-scanning hook, editable install source, stale entry
points, source drift, quota cache schema, process state, install classification,
update dismissal state, ambient env leaks, and feature gate consistency. See
update dismissal state, ambient env leaks, feature gate consistency, codex version,
script binary, claude binary, codex MCP timeouts, codex graduation, CLI conformance,
codex NDJSON drift, codex model-alias staleness, standing backend pin feasibility,
and local recipe validity (including the 6 fleet checks 24-29). See
[installation.md](installation.md#post-install-verification) for the full table.

---
Expand Down
13 changes: 7 additions & 6 deletions src/autoskillit/cli/doctor/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@
from ._doctor_config import (
_check_config_layers_for_secrets,
_check_gitignore_completeness,
_check_local_recipe_validity,
_check_project_config,
_check_secret_scanning_hook,
_check_standing_backend_pins_feasibility,
)
from ._doctor_env import (
_check_ambient_campaign_id,
Expand Down Expand Up @@ -143,22 +145,16 @@ def run_doctor(*, output_json: bool = False) -> None:

# Check 11: Editable install source directory still exists
results.append(_check_editable_install_source_exists())

# Check 12: No stale autoskillit entry points outside ~/.local/bin
results.append(_check_stale_entry_points())

# Check 13: Source version drift (network, with disk-cache TTL fallback)
results.append(_check_source_version_drift())

# Check 14: Quota cache schema version
results.append(_check_quota_cache_schema())

# Check 15: claude process state breakdown
results.append(_check_claude_process_state_breakdown(backend=_backend))

# Check 16: Install classification from direct_url.json
results.append(_check_install_classification())

# Check 17: Update-prompt dismissal state
results.append(_check_update_dismissal_state())

Expand Down Expand Up @@ -228,6 +224,11 @@ def run_doctor(*, output_json: bool = False) -> None:
# Check 36: Codex model-alias staleness
results.append(_check_codex_model_alias_staleness())

# Check 37: Standing backend pin feasibility
results.extend(_check_standing_backend_pins_feasibility())
# Check 38: Local recipe validity
results.extend(_check_local_recipe_validity())

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

from pathlib import Path
from typing import Any

from autoskillit.core import Severity, get_logger

Expand Down Expand Up @@ -128,3 +129,245 @@ def _check_secret_scanning_hook(project_dir: Path) -> DoctorResult:
f"({scanners}). Add one to prevent credential leaks."
)
return DoctorResult(Severity.ERROR, "secret_scanning_hook", msg)


def _iter_backend_pins(data: dict[str, Any]) -> list[tuple[str, str, str]]:
"""Yield (recipe_name, step_name, backend_name) tuples for every pin in a config layer.

``recipe_name`` is empty for global ``step_overrides`` pins, which are not
scoped to a single recipe.
"""
agent_backend = data.get("agent_backend")
if not isinstance(agent_backend, dict):
return []
pins: list[tuple[str, str, str]] = []
recipe_overrides = agent_backend.get("recipe_overrides")
if isinstance(recipe_overrides, dict):
for recipe_name, step_map in recipe_overrides.items():
if not isinstance(step_map, dict):
continue
for step_name, backend_name in step_map.items():
if isinstance(backend_name, str):
pins.append((str(recipe_name), str(step_name), backend_name))
step_overrides = agent_backend.get("step_overrides")
if isinstance(step_overrides, dict):
for step_name, backend_name in step_overrides.items():
if isinstance(backend_name, str):
pins.append(("", str(step_name), backend_name))
return pins


def _check_standing_backend_pins_feasibility(
project_dir: Path | None = None,
) -> list[DoctorResult]:
"""Check that every standing agent_backend pin targets a capability-feasible backend.

Re-reads each config.yaml layer individually (mirrors
``_check_config_layers_for_secrets``) and resolves every ``recipe_overrides``
and ``step_overrides`` pin against the step's skill capabilities. A pin that
forces a backend lacking a hard capability the step's skill requires is
reported as an ERROR since it would fail at dispatch time.
"""
from autoskillit.core import (
YAMLError,
describe_capability_mismatches,
load_yaml,
unsatisfied_backend_capabilities,
)
from autoskillit.execution import get_backend
from autoskillit.recipe import find_recipe_by_name, load_recipe
from autoskillit.workspace import DefaultSkillResolver

root = project_dir or Path.cwd()
config_paths = [
Path.home() / ".autoskillit" / "config.yaml",
root / ".autoskillit" / "config.yaml",
]
resolver = DefaultSkillResolver()
results: list[DoctorResult] = []

for config_path in config_paths:
if not config_path.is_file():
continue
try:
data = load_yaml(config_path) or {}
except YAMLError as exc:
results.append(
DoctorResult(
Severity.WARNING,
"standing_backend_pins_feasibility",
f"Could not parse {str(config_path)!r} as YAML: {exc}",
)
)
continue
if not isinstance(data, dict):
continue

for recipe_name, step_name, backend_name in _iter_backend_pins(data):
is_recipe_pin = bool(recipe_name)
dotted_key = (
f"agent_backend.recipe_overrides.{recipe_name}.{step_name}"
if is_recipe_pin
else f"agent_backend.step_overrides.{step_name}"
)

try:
backend = get_backend(backend_name)
except ValueError:
results.append(
DoctorResult(
Severity.WARNING,
"standing_backend_pins_feasibility",
f"{config_path}: {dotted_key} references unknown backend "
f"{backend_name!r}.",
)
)
continue

if not is_recipe_pin:
# Global step_overrides pins are not tied to a single recipe's step
# definition, so there is no skill to resolve capabilities from.
continue

recipe_info = find_recipe_by_name(recipe_name, root)
if recipe_info is None:
results.append(
DoctorResult(
Severity.WARNING,
"standing_backend_pins_feasibility",
f"{config_path}: {dotted_key} references unknown recipe {recipe_name!r}.",
)
)
continue

try:
recipe = load_recipe(recipe_info.path)
except (OSError, ValueError, YAMLError) as exc:
results.append(
DoctorResult(
Severity.WARNING,
"standing_backend_pins_feasibility",
f"{config_path}: {dotted_key} could not load recipe "
f"{recipe_name!r}: {exc}",
)
)
continue

step = recipe.steps.get(step_name)
if step_name != "*" and step is None:
results.append(
DoctorResult(
Severity.WARNING,
"standing_backend_pins_feasibility",
f"{config_path}: {dotted_key} references unknown step "
f"{step_name!r} in recipe {recipe_name!r}.",
)
)
continue

steps_to_check = [step] if step is not None else list(recipe.steps.values())
for target_step in steps_to_check:
skill_name = target_step.skill_name
if not skill_name:
continue
skill_info = resolver.resolve(skill_name)
if skill_info is None:
results.append(
DoctorResult(
Severity.WARNING,
"standing_backend_pins_feasibility",
f"{config_path}: {dotted_key} references skill "
f"{skill_name!r} (step {target_step.name!r}) which could "
"not be resolved.",
)
)
continue

mismatches = unsatisfied_backend_capabilities(
skill_info.uses_capabilities, backend.capabilities
)
if mismatches:
results.append(
DoctorResult(
Severity.ERROR,
"standing_backend_pins_feasibility",
f"{config_path}: {dotted_key} pins backend {backend_name!r} "
f"for step {target_step.name!r}, but "
f"{describe_capability_mismatches(mismatches)}. "
"Remove or update this pin, or choose a backend that "
"satisfies the step's required capabilities.",
)
)

if not results:
results.append(
DoctorResult(
Severity.OK,
"standing_backend_pins_feasibility",
"All standing agent_backend pins are capability-feasible.",
)
)
return results


def _check_local_recipe_validity(project_dir: Path | None = None) -> list[DoctorResult]:
"""Check every recipe in .autoskillit/recipes/ passes semantic validation.

Local project recipes are not covered by the bundled recipe test suite, so
a broken local recipe would otherwise only surface when it is dispatched.
"""
from autoskillit.core import YAMLError
from autoskillit.recipe import load_recipe, run_semantic_rules

root = project_dir or Path.cwd()
recipes_dir = root / ".autoskillit" / "recipes"
if not recipes_dir.is_dir():
return [DoctorResult(Severity.OK, "local_recipe_validity", "No local recipes directory")]

results: list[DoctorResult] = []
for yaml_path in sorted(recipes_dir.glob("*.yaml")):
try:
recipe = load_recipe(yaml_path)
except (OSError, ValueError, YAMLError) as exc:
results.append(
DoctorResult(
Severity.ERROR,
"local_recipe_validity",
f"{yaml_path}: failed to load: {exc}",
)
)
continue

try:
findings = run_semantic_rules(recipe)
except Exception as exc: # noqa: BLE001 - doctor must never crash
logger.warning("local_recipe_validation_error", path=str(yaml_path), error=str(exc))
results.append(
DoctorResult(
Severity.WARNING,
"local_recipe_validity",
f"{yaml_path}: semantic validation could not run: {exc}",
)
)
continue

error_findings = [f for f in findings if f.severity == Severity.ERROR]
if error_findings:
rule_names = ", ".join(sorted({f.rule for f in error_findings}))
results.append(
DoctorResult(
Severity.ERROR,
"local_recipe_validity",
f"{yaml_path}: failed semantic validation ({rule_names}).",
)
)

if not results:
results.append(
DoctorResult(
Severity.OK,
"local_recipe_validity",
"All local recipes pass semantic validation.",
)
)
return results
2 changes: 1 addition & 1 deletion src/autoskillit/cli/fleet/_fleet_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ async def _execute_fleet_run(
skill_resolver=ctx.skill_resolver,
config_backend=ctx.config.agent_backend,
)
_effective_backend_map = _compute_effective_backend_map(
_effective_backend_map, _backend_origin_map = _compute_effective_backend_map(
_raw_steps,
dispatch_backend.name if dispatch_backend else None,
ctx.config.providers,
Expand Down
1 change: 1 addition & 0 deletions src/autoskillit/core/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,7 @@ from .types import WriteExpectedResolver as WriteExpectedResolver
from .types import assert_prompt_sentinel as assert_prompt_sentinel
from .types import closure_authority_spec_from_args as closure_authority_spec_from_args
from .types import compute_remaining as compute_remaining
from .types import describe_capability_mismatches as describe_capability_mismatches
from .types import extract_path_arg as extract_path_arg
from .types import extract_positional_args as extract_positional_args
from .types import extract_skill_name as extract_skill_name
Expand Down
14 changes: 14 additions & 0 deletions src/autoskillit/core/types/_type_constants_registries.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"SkillCapabilityDef",
"HardCapabilityMismatch",
"SKILL_CAPABILITY_REGISTRY",
"describe_capability_mismatches",
"unsatisfied_backend_capabilities",
]

Expand Down Expand Up @@ -485,6 +486,19 @@ class HardCapabilityMismatch(NamedTuple):
)


def describe_capability_mismatches(
mismatches: tuple[HardCapabilityMismatch, ...] | list[HardCapabilityMismatch],
) -> str:
"""Format capability mismatches into a human-readable string."""
parts = []
for m in mismatches:
parts.append(
f"{m.property_name}=True required (via '{m.capability}') "
f"but backend has {m.property_name}={m.actual_value!r}"
)
return "; ".join(parts)


def unsatisfied_backend_capabilities(
uses_capabilities: frozenset[str],
backend_capabilities: BackendCapabilities,
Expand Down
2 changes: 2 additions & 0 deletions src/autoskillit/core/types/_type_protocols_recipe.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ def load_and_validate(
backend_name: str | None = None,
effective_backend_map: dict[str, str] | None = None,
backend_capabilities_map: dict[str, BackendCapabilities] | None = None,
backend_origin_map: dict[str, str] | None = None,
) -> dict[str, Any]:
"""Load and validate a recipe.

Expand All @@ -83,6 +84,7 @@ def validate_from_path(
ingredient_overrides: dict[str, str] | None = None,
effective_backend_map: dict[str, str] | None = None,
backend_capabilities_map: dict[str, BackendCapabilities] | None = None,
backend_origin_map: dict[str, str] | None = None,
) -> dict[str, Any]: ...

def list_all(
Expand Down
Loading
Loading