From 0de31aa2bb7d25d1de3a9d867de45299b63d9697 Mon Sep 17 00:00:00 2001 From: Trecek Date: Wed, 22 Jul 2026 22:51:55 -0700 Subject: [PATCH 01/62] test: define skill capability contract immunity --- tests/arch/test_doc_fence_filter.py | 23 +++ tests/arch/test_skill_backend_annotations.py | 181 ++++++++++++++---- tests/arch/test_skill_capability_registry.py | 40 +++- tests/cli/test_l3_orchestrator_prompt.py | 53 ++--- tests/config/test_skills_config.py | 19 ++ .../test_discipline_delivery_matrix.py | 10 + .../test_target_skill_invocability.py | 40 ++++ tests/core/AGENTS.md | 1 + tests/core/test_skill_contract_types.py | 37 ++++ tests/docs/test_doc_counts.py | 28 +++ tests/execution/AGENTS.md | 1 + .../test_process_session_id_callback.py | 63 ++++++ .../test_skill_session_contract_store.py | 113 +++++++++++ tests/fleet/test_food_truck_prompt.py | 17 ++ tests/server/test_tools_execution_routing.py | 60 ++++++ .../workspace/test_project_local_overrides.py | 116 +++++++++++ ...t_session_skills_allow_only_and_closure.py | 112 +++++++++++ tests/workspace/test_session_skills_codex.py | 60 +++++- tests/workspace/test_session_skills_deps.py | 35 ++++ .../workspace/test_session_skills_provider.py | 112 +++++++++++ tests/workspace/test_skill_format.py | 43 +++-- tests/workspace/test_skills.py | 81 +++++++- 22 files changed, 1162 insertions(+), 83 deletions(-) create mode 100644 tests/core/test_skill_contract_types.py create mode 100644 tests/execution/test_skill_session_contract_store.py diff --git a/tests/arch/test_doc_fence_filter.py b/tests/arch/test_doc_fence_filter.py index a2edf6c4b5..f938fe4173 100644 --- a/tests/arch/test_doc_fence_filter.py +++ b/tests/arch/test_doc_fence_filter.py @@ -50,6 +50,17 @@ def test_step_2a_heading_recognized(): assert "git commit" in _strip_doc_fenced_blocks(body) +def test_nested_part_heading_is_executable(): + body = ( + "### Step 2: Inspect history\n" + "#### Part A — Locate Claude sessions\n" + "```bash\n" + 'find "$PROJECT_ROOT/.claude/projects" -name "*.jsonl"\n' + "```\n" + ) + assert ".claude/projects" in _strip_doc_fenced_blocks(body) + + def test_detect_capabilities_ignores_doc_fence_git_commit(): body = ( "## Conflict-Resolution Plan Requirements\n" @@ -66,3 +77,15 @@ def test_detect_capabilities_finds_step_fence_git_commit(): body = '### Step 4: Apply Fixes\n```bash\ngit commit -m "fix: resolve issue"\n```\n' detected = _detect_capabilities(body, "test-skill") assert "git_metadata_write" in detected + + +def test_detect_capabilities_finds_nested_part_fence_claude_dir(): + body = ( + "### Step 2: Inspect history\n" + "#### Part A — Read session artifacts\n" + "```bash\n" + 'find "$PROJECT_ROOT/.claude/projects" -name "*.jsonl"\n' + "```\n" + ) + detected = _detect_capabilities(body, "test-skill") + assert "claude_dir" in detected diff --git a/tests/arch/test_skill_backend_annotations.py b/tests/arch/test_skill_backend_annotations.py index 3f7288556b..cac0f6e68d 100644 --- a/tests/arch/test_skill_backend_annotations.py +++ b/tests/arch/test_skill_backend_annotations.py @@ -1,9 +1,8 @@ -"""Bidirectional backend annotation enforcement with capability-aware detection. +"""Bidirectional capability annotation enforcement with semantic evidence detection. Forward: skill content using a capability → uses_capabilities must declare it. -Reverse: backend_requirements present → at least one capability justifies it. -Co-requirement: backend_requirements non-empty → uses_capabilities must also exist. -Derivation: backend_requirements == union of required_backends from uses_capabilities. +Reverse: uses_capabilities declarations → genuine self-initiated operation evidence. +Derivation: backend_requirements remains runtime-only. """ from __future__ import annotations @@ -21,9 +20,9 @@ _CAPABILITY_PATTERNS: dict[str, list[re.Pattern[str]]] = { "agent_subagent": [re.compile(r"Agent\(\s*subagent_type\s*=")], "agent_model": [re.compile(r"Agent\(\s*model\s*=")], - "open_kitchen": [re.compile(r"\bopen_kitchen\b"), re.compile(r"\bclose_kitchen\b")], - "run_skill": [re.compile(r"\brun_skill\b")], - "test_check": [re.compile(r"\btest_check\b")], + "open_kitchen": [], + "run_skill": [], + "test_check": [], "claude_dir": [re.compile(r"\.claude/")], "commit_files": [re.compile(r"\bcommit_files\s*\(")], "git_metadata_write": [ @@ -41,6 +40,56 @@ ], } +_SELF_INITIATED_TOOL_CAPABILITIES: dict[str, tuple[str, ...]] = { + "open_kitchen": ("open_kitchen", "close_kitchen"), + "run_skill": ("run_skill",), + "test_check": ("test_check",), +} + +_NON_OPERATION_CONTEXT = re.compile( + r"\b(?:" + r"called by|calls this via|invoked by|launched by|" + r"do not|don't|never|must not|cannot|can't|without|skip|" + r"returns?|returned|result|output|response|warning|denied|blocked|" + r"configuration|config key|frontmatter|documentation|artifact|" + r"gated behind|generated recipe" + r")\b", + re.IGNORECASE, +) + + +def _has_self_initiated_tool_operation(filtered: str, tool_name: str) -> bool: + """Detect an outbound tool operation, excluding transport and documentary prose.""" + tool = re.escape(tool_name) + direct_call = re.compile(rf"\b{tool}\s*\(") + imperative = re.compile( + rf"\b(?:call|run|invoke|use|execute|retry|re-run|test it)\b" + rf"[^\n]{{0,100}}\b{tool}\b", + re.IGNORECASE, + ) + configuration = re.compile( + rf"(?:\buses_capabilities\s*:|\btool\s*:|\b{tool}\.(?:command|commands)\b)", + re.IGNORECASE, + ) + + for raw_line in filtered.splitlines(): + line = raw_line.strip().strip("`") + if not line or line.startswith("#"): + continue + if configuration.search(line) or _NON_OPERATION_CONTEXT.search(line): + continue + if direct_call.search(line) or imperative.search(line): + return True + return False + + +def _has_graphql_mutation_operation(filtered: str) -> bool: + """Default-POST ``gh api graphql`` is a write when its query is a mutation.""" + return bool( + re.search(r"\bgh\s+api\s+graphql\b", filtered) + and re.search(r"\bmutation\b", filtered, re.IGNORECASE) + ) + _EXCLUDED_SECTION_HEADINGS = ( "Related Skills", @@ -296,6 +345,11 @@ def _detect_capabilities(body: str, skill_name: str) -> set[str]: if pat.search(filtered): detected.add(cap_name) break + for cap_name, tool_names in _SELF_INITIATED_TOOL_CAPABILITIES.items(): + if any(_has_self_initiated_tool_operation(filtered, tool) for tool in tool_names): + detected.add(cap_name) + if _has_graphql_mutation_operation(filtered): + detected.add("github_api_write") if _detect_cross_skill_ref(filtered, skill_name): detected.add("cross_skill_ref") return detected @@ -332,46 +386,97 @@ def test_forward_check_capabilities_declared(): ) -def test_reverse_check_annotation_justified(): - """Skills with backend_requirements must have at least one capability justifying it.""" +def test_reverse_check_capability_declarations_are_genuine(): + """Every declared capability must have genuine self-initiated evidence.""" violations: list[str] = [] for name, skill_md in _iter_skill_dirs(): + content = skill_md.read_text(encoding="utf-8") + body = _strip_frontmatter(content) + detected = _detect_capabilities(body, name) fm = _read_skill_frontmatter(skill_md) - backend_reqs = set(fm.get("backend_requirements", [])) - if not backend_reqs: - continue - uses_caps = list(fm.get("uses_capabilities", [])) - if not uses_caps: - violations.append(f"{name}: has backend_requirements but no uses_capabilities") - continue - derived: set[str] = set() - for cap_name in uses_caps: - cap_def = SKILL_CAPABILITY_REGISTRY.get(cap_name) - if cap_def: - derived |= cap_def.required_backends - for req in backend_reqs: - if req not in derived: - violations.append( - f"{name}: backend_requirements includes '{req}' " - f"but no declared capability requires it" - ) + declared = set(fm.get("uses_capabilities", [])) + unsupported = declared - detected + if unsupported: + violations.append( + f"{name}: declared={sorted(declared)}, without_evidence={sorted(unsupported)}" + ) assert not violations, ( - f"{len(violations)} annotation(s) not justified by capabilities:\n" + f"{len(violations)} skill(s) declare capabilities without genuine evidence:\n" + "\n".join(f" {v}" for v in violations) ) -def test_co_requirement_backend_requires_capabilities(): - """Non-empty backend_requirements → uses_capabilities must also be declared.""" - violations: list[str] = [] - for name, skill_md in _iter_skill_dirs(): - fm = _read_skill_frontmatter(skill_md) - if fm.get("backend_requirements") and not fm.get("uses_capabilities"): - violations.append(name) - assert not violations, ( - f"{len(violations)} skill(s) have backend_requirements without uses_capabilities:\n" - + "\n".join(f" {v}" for v in violations) +@pytest.mark.parametrize( + ("body", "expected"), + [ + ('### Step 1\nrun_skill("/autoskillit:investigate report.md")', {"run_skill"}), + ("### Step 1\nRun `test_check` on the worktree.", {"test_check"}), + ("### Step 1\nCall `open_kitchen()` with no arguments.", {"open_kitchen"}), + ( + '### Step 1\nQUERY="mutation { closeIssue(input: $input) { issue { id } } }"\n' + 'gh api graphql -f query="$QUERY"', + {"github_api_write"}, + ), + ], +) +def test_semantic_capability_evidence_positive(body: str, expected: set[str]) -> None: + assert expected <= _detect_capabilities(body, "test-skill") + + +@pytest.mark.parametrize( + "body", + [ + "The parent orchestrator calls this skill via `run_skill`.", + "Never call `run_skill`; the parent owns dispatch.", + "When `run_skill` returns, copy its result.", + "Read the configured `test_check.command` value.", + "```yaml\ntool: run_skill\nskill: /autoskillit:investigate\n```", + "## Requirements\n```python\nrun_skill('/autoskillit:investigate')\n```", + "The artifact documents the `run_skill` request and response.", + "uses_capabilities: [run_skill, test_check]", + ], +) +def test_semantic_capability_evidence_negative(body: str) -> None: + detected = _detect_capabilities(body, "test-skill") + assert detected.isdisjoint({"run_skill", "test_check"}), ( + f"documentary/transport prose was misclassified: {sorted(detected)}" + ) + + +def test_genuine_run_skill_inventory_is_exact() -> None: + inventory = { + name + for name, skill_md in _iter_skill_dirs() + if "run_skill" + in _detect_capabilities(_strip_frontmatter(skill_md.read_text(encoding="utf-8")), name) + } + assert inventory == {"process-issues", "sous-chef"} + + +def test_genuine_test_check_inventory_is_exact() -> None: + inventory = { + name + for name, skill_md in _iter_skill_dirs() + if "test_check" + in _detect_capabilities(_strip_frontmatter(skill_md.read_text(encoding="utf-8")), name) + } + assert inventory == {"resolve-failures", "sous-chef"} + + +def test_enrich_issues_does_not_have_open_kitchen_evidence() -> None: + skill = next(skill_md for name, skill_md in _iter_skill_dirs() if name == "enrich-issues") + detected = _detect_capabilities( + _strip_frontmatter(skill.read_text(encoding="utf-8")), "enrich-issues" + ) + assert "open_kitchen" not in detected + + +def test_graphql_default_post_mutation_implies_github_api_write() -> None: + body = ( + 'MUTATION_QUERY="mutation { resolveReviewThread(input: $input) { thread { id } } }"\n' + 'gh api graphql -f query="${MUTATION_QUERY}"' ) + assert "github_api_write" in _detect_capabilities(body, "test-skill") def test_derivation_backend_requirements_match_capabilities(): diff --git a/tests/arch/test_skill_capability_registry.py b/tests/arch/test_skill_capability_registry.py index cabd1b00fa..83deaf99e1 100644 --- a/tests/arch/test_skill_capability_registry.py +++ b/tests/arch/test_skill_capability_registry.py @@ -5,7 +5,7 @@ import pytest from autoskillit.core.types._type_constants_registries import SKILL_CAPABILITY_REGISTRY -from autoskillit.core.types._type_enums import SessionType +from autoskillit.core.types._type_enums import SessionType, SkillExecutionRole from autoskillit.workspace.skills import _read_skill_frontmatter from tests.arch._helpers import _iter_skill_dirs @@ -28,6 +28,44 @@ def test_all_capability_keys_are_consumed(): ) +def test_every_capability_def_declares_exact_allowed_execution_roles() -> None: + """Role ownership must be explicit at every registry construction site.""" + import ast + import inspect + + import autoskillit.core.types._type_constants_registries as registries + + tree = ast.parse(inspect.getsource(registries)) + definitions = [ + node + for node in ast.walk(tree) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "SkillCapabilityDef" + ] + assert len(definitions) == len(SKILL_CAPABILITY_REGISTRY) + missing = [ + node.lineno + for node in definitions + if "allowed_execution_roles" not in {kw.arg for kw in node.keywords} + ] + assert not missing, ( + "Every SkillCapabilityDef must explicitly declare allowed_execution_roles; " + f"missing at source lines {missing}" + ) + all_roles = frozenset(SkillExecutionRole) + for name, capability in SKILL_CAPABILITY_REGISTRY.items(): + assert isinstance(capability.allowed_execution_roles, frozenset) + assert capability.allowed_execution_roles + assert capability.allowed_execution_roles <= all_roles, name + + +def test_run_skill_is_owned_by_exact_orchestrator_role() -> None: + assert SKILL_CAPABILITY_REGISTRY["run_skill"].allowed_execution_roles == frozenset( + {SkillExecutionRole.ORCHESTRATOR} + ) + + def test_backend_requirements_derivable_from_capabilities(): from autoskillit.workspace.skills import DefaultSkillResolver diff --git a/tests/cli/test_l3_orchestrator_prompt.py b/tests/cli/test_l3_orchestrator_prompt.py index 5003769fd5..e6a2f75aae 100644 --- a/tests/cli/test_l3_orchestrator_prompt.py +++ b/tests/cli/test_l3_orchestrator_prompt.py @@ -3,12 +3,9 @@ from __future__ import annotations -import pathlib - import pytest from autoskillit.cli._mcp_names import DIRECT_PREFIX, MARKETPLACE_PREFIX -from autoskillit.core.types._type_constants import SOUS_CHEF_MANDATORY_SECTIONS from autoskillit.recipe.schema import CampaignDispatch, Recipe, RecipeKind pytestmark = [pytest.mark.layer("cli"), pytest.mark.small, pytest.mark.feature("fleet")] @@ -77,30 +74,27 @@ def test_all_parameters_interpolated(self) -> None: assert _MANIFEST_YAML.strip() in prompt -# --- K-2: TestL3SousChefDiscipline --- +# --- K-2: TestL3NativeDiscipline --- -class TestL3AdmiralDiscipline: - def test_full_admiral_content_appended(self) -> None: +class TestL3NativeDiscipline: + def test_l2_sous_chef_document_is_not_injected(self) -> None: prompt = _build() - for header in SOUS_CHEF_MANDATORY_SECTIONS: - assert header in prompt, f"Missing admiral section: {header}" - - def test_graceful_degradation_on_missing_skill_md( - self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path - ) -> None: - from autoskillit.cli import _prompts + assert "name: sous-chef" not in prompt + assert "uses_capabilities:" not in prompt + assert "execution_role:" not in prompt + assert "backend_requirements:" not in prompt - monkeypatch.setattr(_prompts, "pkg_root", lambda: tmp_path) - result = _build() - assert isinstance(result, str) - assert len(result) > 0 - # Non-sous-chef structural sections must survive degradation - assert "CAMPAIGN OVERVIEW" in result - assert "DISPATCH MANIFEST" in result - assert "FAILURE RECOVERY" in result - assert "QUOTA RETRY" in result - assert "INTERRUPT/CLEANUP SEQUENCE" in result + def test_native_fleet_sections_remain_without_sous_chef(self) -> None: + prompt = _build() + for section in ( + "CAMPAIGN OVERVIEW", + "DISPATCH MANIFEST", + "FAILURE RECOVERY", + "QUOTA RETRY", + "INTERRUPT/CLEANUP SEQUENCE", + ): + assert section in prompt # --- K-3: TestCampaignOverviewSection --- @@ -381,9 +375,16 @@ def test_open_kitchen_in_forbidden_list(self) -> None: def test_uses_dispatch_food_truck_not_run_skill(self) -> None: prompt = _build() assert f"{DIRECT_PREFIX}dispatch_food_truck" in prompt - # run_skill must not be described as the dispatch mechanism - assert "dispatch via run_skill" not in prompt - assert "dispatch through run_skill" not in prompt + affirmative_run_skill_lines = [ + line + for line in prompt.splitlines() + if "run_skill" in line + and not any( + denial in line.lower() + for denial in ("forbidden", "never", "do not", "must not", "not callable") + ) + ] + assert affirmative_run_skill_lines == [] # --- K-13: TestL3NoBootstrapSequence --- diff --git a/tests/config/test_skills_config.py b/tests/config/test_skills_config.py index 1b491428bb..65628eb9b5 100644 --- a/tests/config/test_skills_config.py +++ b/tests/config/test_skills_config.py @@ -51,6 +51,25 @@ def test_load_config_populates_skills_tiers(self, tmp_path) -> None: assert "make-plan" in cfg.skills.tier2 assert "compose-pr" in cfg.skills.tier3 + def test_process_issues_is_role_derived_not_user_tiered(self) -> None: + from autoskillit.config import load_config + + cfg = load_config() + assert "process-issues" not in cfg.skills.tier1 + assert "process-issues" not in cfg.skills.tier2 + assert "process-issues" not in cfg.skills.tier3 + + def test_process_issues_cannot_be_readded_to_session_tier(self, tmp_path) -> None: + from autoskillit.config import load_config + from autoskillit.core import SkillContractError + + config_dir = tmp_path / ".autoskillit" + config_dir.mkdir() + (config_dir / "config.yaml").write_text("skills:\n tier2:\n - process-issues\n") + + with pytest.raises(SkillContractError, match="process-issues|ORCHESTRATOR"): + load_config(tmp_path) + def test_file_audit_issues_in_tier3(self) -> None: from autoskillit.config import load_config diff --git a/tests/contracts/test_discipline_delivery_matrix.py b/tests/contracts/test_discipline_delivery_matrix.py index b29d1e9aae..221894e6c0 100644 --- a/tests/contracts/test_discipline_delivery_matrix.py +++ b/tests/contracts/test_discipline_delivery_matrix.py @@ -135,11 +135,17 @@ def test_session_type_skill_in_env(self, backend) -> None: class TestSousChefDelivery: def test_sous_chef_in_orchestrator_prompt(self) -> None: from autoskillit.cli._prompts import _build_orchestrator_prompt, _read_full_sous_chef + from autoskillit.execution import codex_recipe_delivery_calling_contract sous_chef = _read_full_sous_chef() assert sous_chef, "_read_full_sous_chef must return non-empty content" prompt = _build_orchestrator_prompt("test-recipe", "mcp__autoskillit__") assert sous_chef[:80] in prompt + assert "uses_capabilities:" not in sous_chef + assert "execution_role:" not in sous_chef + assert "backend_requirements:" not in sous_chef + calling_contract = codex_recipe_delivery_calling_contract(mcp_prefix="mcp__autoskillit__") + assert prompt.count(calling_contract) == 1 def test_sous_chef_in_open_kitchen_prompt(self) -> None: from autoskillit.cli._prompts import _build_open_kitchen_prompt, _read_full_sous_chef @@ -148,6 +154,9 @@ def test_sous_chef_in_open_kitchen_prompt(self) -> None: assert sous_chef, "_read_full_sous_chef must return non-empty content" prompt = _build_open_kitchen_prompt("mcp__autoskillit__") assert sous_chef[:80] in prompt + assert "uses_capabilities:" not in prompt + assert "execution_role:" not in prompt + assert "backend_requirements:" not in prompt def test_sous_chef_not_in_fleet_dispatch_prompt(self) -> None: from autoskillit.cli._prompts import _build_fleet_dispatch_prompt, _read_full_sous_chef @@ -156,3 +165,4 @@ def test_sous_chef_not_in_fleet_dispatch_prompt(self) -> None: assert sous_chef, "_read_full_sous_chef must return non-empty content" prompt = _build_fleet_dispatch_prompt("mcp__autoskillit__") assert sous_chef[:80] not in prompt + assert "name: sous-chef" not in prompt diff --git a/tests/contracts/test_target_skill_invocability.py b/tests/contracts/test_target_skill_invocability.py index b20d718146..2001408c67 100644 --- a/tests/contracts/test_target_skill_invocability.py +++ b/tests/contracts/test_target_skill_invocability.py @@ -131,6 +131,46 @@ def test_non_slash_command_passes_through(self) -> None: assert resolved == "Fix the bug" +class TestRoleDerivedInvocability: + def test_process_issues_only_appears_in_orchestrator_catalog(self) -> None: + from autoskillit.core import SkillExecutionRole + + resolver = DefaultSkillResolver() + session_names = { + skill.name + for skill in resolver.list_effective(_PROJECT_ROOT, SkillExecutionRole.SESSION).skills + } + orchestrator_names = { + skill.name + for skill in resolver.list_effective( + _PROJECT_ROOT, SkillExecutionRole.ORCHESTRATOR + ).skills + } + + assert "process-issues" not in session_names + assert "process-issues" in orchestrator_names + + def test_direct_session_invocation_cannot_target_process_issues(self) -> None: + from autoskillit.core import SkillContractError, SkillExecutionRole + + resolver = DefaultSkillResolver() + with pytest.raises(SkillContractError, match="process-issues|ORCHESTRATOR"): + resolver.resolve_invocation( + "process-issues", + _PROJECT_ROOT, + SkillExecutionRole.SESSION, + ) + + assert ( + resolver.resolve_invocation( + "process-issues", + _PROJECT_ROOT, + SkillExecutionRole.ORCHESTRATOR, + ) + is not None + ) + + class TestDepSkillsNotGatedAfterActivation: """After activating a target with activate_deps, dependency skills are also ungated.""" diff --git a/tests/core/AGENTS.md b/tests/core/AGENTS.md index 06685ee395..6982427f6b 100644 --- a/tests/core/AGENTS.md +++ b/tests/core/AGENTS.md @@ -40,6 +40,7 @@ Core layer (IL-0) unit tests — paths, IO, types, feature flags. | `test_session_registry.py` | Tests for core/session_registry.py | | `test_session_type.py` | Tests for SessionType resolver and constants | | `test_skill_command_parsing.py` | Unit tests for extract_path_arg in core._type_helpers | +| `test_skill_contract_types.py` | Tests for skill execution-role mappings and effective source enum exhaustiveness | | `test_stamp_constants.py` | Tests for DRY_WALKTHROUGH_VERIFIED_MARKER constant | | `test_tool_sequence_analysis.py` | Tool sequence analysis tests | | `test_type_helpers.py` | Tests for extract_positional_args helper | diff --git a/tests/core/test_skill_contract_types.py b/tests/core/test_skill_contract_types.py new file mode 100644 index 0000000000..9e58204652 --- /dev/null +++ b/tests/core/test_skill_contract_types.py @@ -0,0 +1,37 @@ +"""Typed skill machine-contract invariants.""" + +from __future__ import annotations + +import pytest + +from autoskillit.core import ( + SessionType, + SkillExecutionRole, + SkillSource, + session_type_for_skill_execution_role, +) + +pytestmark = [pytest.mark.layer("core"), pytest.mark.small] + + +def test_skill_execution_roles_are_closed_and_map_explicitly() -> None: + expected = { + SkillExecutionRole.SESSION: SessionType.SKILL, + SkillExecutionRole.ORCHESTRATOR: SessionType.ORCHESTRATOR, + SkillExecutionRole.FLEET: SessionType.FLEET, + } + assert set(SkillExecutionRole) == set(expected) + assert { + role: session_type_for_skill_execution_role(role) for role in SkillExecutionRole + } == expected + + +def test_skill_sources_cover_effective_origins_exactly() -> None: + assert {source.value for source in SkillSource} == { + "bundled", + "bundled_extended", + "project_local", + "third_party", + } + assert SkillSource.PROJECT_LOCAL == "project_local" + assert SkillSource.THIRD_PARTY == "third_party" diff --git a/tests/docs/test_doc_counts.py b/tests/docs/test_doc_counts.py index 98ac9b079a..78752517b2 100644 --- a/tests/docs/test_doc_counts.py +++ b/tests/docs/test_doc_counts.py @@ -105,6 +105,11 @@ def _count_skills_total() -> int: return tier1 + tier23 +def _configured_skill_tier_counts() -> tuple[int, int, int]: + skills = load_yaml(SRC_DIR / "config" / "defaults.yaml")["skills"] + return len(skills["tier1"]), len(skills["tier2"]), len(skills["tier3"]) + + def _count_arch_lens_skills() -> int: return sum( 1 @@ -317,6 +322,29 @@ def test_skill_visibility_states_142_skills() -> None: _assert_doc_states_number(DOCS_DIR / "skills" / "visibility.md", "skills total", 142) +def test_skill_visibility_tier_counts_match_defaults() -> None: + tier1, tier2, tier3 = _configured_skill_tier_counts() + text = _read(DOCS_DIR / "skills" / "visibility.md") + assert f"**Default members** ({tier2} total)" in text + assert f"**Default members** ({tier3} total)" in text + assert "Tier 1" in text and str(tier1) in text + + +def test_process_issues_is_documented_as_role_derived_not_tiered() -> None: + for path in ( + DOCS_DIR / "skills" / "visibility.md", + DOCS_DIR / "skills" / "catalog.md", + ): + text = _read(path) + tier2_start = text.index("## Tier 2") + tier3_start = text.index("## Tier 3", tier2_start) + assert "`process-issues`" not in text[tier2_start:tier3_start] + assert re.search( + r"(?is)(process-issues.{0,200}orchestrat|orchestrat.{0,200}process-issues)", + text, + ), f"{path} must document process-issues as role-derived orchestration" + + def test_safety_hook_counts_match_registry() -> None: counts = _count_hooks_by_event() text = _read(DOCS_DIR / "safety" / "hooks.md") diff --git a/tests/execution/AGENTS.md b/tests/execution/AGENTS.md index 42f1ed1566..adc4d40b65 100644 --- a/tests/execution/AGENTS.md +++ b/tests/execution/AGENTS.md @@ -111,6 +111,7 @@ Subprocess integration, headless session, process lifecycle, and session result | `test_session_content.py` | Tests for session content validation and token normalization (`_normalize_model_output`) | | `test_session_debug_logging.py` | Tests for debug logging instrumentation in session.py | | `test_session_env_contracts.py` | Cross-backend env contract tests: every build_*_cmd method must inject required env vars | +| `test_skill_session_contract_store.py` | Tests for persisted effective skill contracts, projected snapshots, and backend session-ID binding | | `test_session_state_persistence.py` | Tests for persist_session_state, read_session_state, and clear_session_state | | `test_session_index_roundtrip.py` | Tests verifying sessions.jsonl keys match SessionIndexEntry annotations | | `test_session_log_backend_source.py` | Tests verifying backend_override_source is recorded in sessions.jsonl (REQ-EVD-001) | diff --git a/tests/execution/test_process_session_id_callback.py b/tests/execution/test_process_session_id_callback.py index b7215cfc31..8697b7d1d6 100644 --- a/tests/execution/test_process_session_id_callback.py +++ b/tests/execution/test_process_session_id_callback.py @@ -53,6 +53,69 @@ def on_sid(sid: str) -> None: assert acc.stdout_session_id == session_id assert captured == [session_id] + @pytest.mark.anyio + async def test_callback_is_a_repeated_candidate_signal(self, tmp_path: Path) -> None: + """Provider attempts may report different candidates through the same callback.""" + import anyio + + from autoskillit.execution.process._process_race import ( + RaceAccumulator, + _extract_stdout_session_id, + ) + + captured: list[str] = [] + for attempt, session_id in enumerate(("attempt-one", "attempt-two")): + stdout_path = tmp_path / f"stdout-{attempt}.jsonl" + stdout_path.write_text( + json.dumps({"type": "system", "subtype": "init", "session_id": session_id}) + "\n" + ) + await _extract_stdout_session_id( + stdout_path, + RaceAccumulator(), + anyio.Event(), + on_session_id_resolved=captured.append, + ) + + assert captured == ["attempt-one", "attempt-two"] + + @pytest.mark.anyio + async def test_channel_b_session_id_fires_candidate_without_stdout( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Channel B identity is observable when stdout never contains an init record.""" + import anyio + + from autoskillit.core import ChannelBStatus + from autoskillit.execution.process import _process_race + from autoskillit.execution.process._process_monitor import SessionMonitorResult + + async def fake_monitor(*args: Any, **kwargs: Any) -> SessionMonitorResult: + return SessionMonitorResult(ChannelBStatus.DIR_MISSING, "channel-b-only") + + monkeypatch.setattr(_process_race, "_session_log_monitor", fake_monitor) + acc = _process_race.RaceAccumulator() + captured: list[str] = [] + + await _process_race._watch_session_log( + tmp_path, + "", + 1.0, + 0.0, + frozenset({"result"}), + 1, + 0.0, + acc, + anyio.Event(), + anyio.Event(), + 0.01, + 0.01, + 0.01, + on_session_id_resolved=captured.append, + ) + + assert acc.channel_b_session_id == "channel-b-only" + assert captured == ["channel-b-only"] + @pytest.mark.anyio async def test_callback_not_called_when_no_session_id_found(self, tmp_path: Path) -> None: """When no init record is present, callback is never invoked.""" diff --git a/tests/execution/test_skill_session_contract_store.py b/tests/execution/test_skill_session_contract_store.py new file mode 100644 index 0000000000..cb6cd03b98 --- /dev/null +++ b/tests/execution/test_skill_session_contract_store.py @@ -0,0 +1,113 @@ +"""Always-on persistence contracts for resumable projected skill sessions.""" + +from __future__ import annotations + +import hashlib +from pathlib import Path + +import pytest + +pytestmark = [pytest.mark.layer("execution"), pytest.mark.small] + + +def _contract(tmp_path: Path, projected_text: str): + from autoskillit.core import SkillExecutionRole + from autoskillit.execution.session import SkillSessionContract + + projected_digest = hashlib.sha256(projected_text.encode()).hexdigest() + return SkillSessionContract( + root_name="root", + execution_role=SkillExecutionRole.SESSION, + source_refs={"root": "project_local:.claude/skills/root/SKILL.md"}, + closure=("root",), + capability_union=frozenset({"github_api_write"}), + canonical_digests={"root": "a" * 64}, + projected_digests={"root": projected_digest}, + projection_version=1, + project_root=str(tmp_path / "project"), + execution_cwd=str(tmp_path / "worktree"), + backend="claude-code", + resolved_command="/root do work", + ) + + +def test_store_round_trip_preserves_machine_contract_and_projected_snapshot( + tmp_path: Path, +) -> None: + from autoskillit.execution.session import DefaultSkillSessionContractStore + + root = tmp_path / "contracts" + text = "---\nname: root\n---\nprojected body\n" + contract = _contract(tmp_path, text) + store = DefaultSkillSessionContractStore(root=root) + + correlation_key = store.create_provisional( + contract=contract, + snapshot={".claude/skills/root/SKILL.md": text}, + ) + store.observe_candidate(correlation_key, "provider-attempt-1") + store.finalize(correlation_key, "backend/session:final") + stored = store.load("backend/session:final") + + assert stored.contract == contract + assert stored.raw_session_id == "backend/session:final" + assert (stored.snapshot_dir / ".claude/skills/root/SKILL.md").read_text() == text + assert stored.snapshot_dir.resolve().is_relative_to(root.resolve()) + + +def test_store_keys_are_distinct_contained_collision_safe_and_final_only(tmp_path: Path) -> None: + from autoskillit.execution.session import DefaultSkillSessionContractStore + + root = tmp_path / "contracts" + text = "projected\n" + store = DefaultSkillSessionContractStore(root=root) + first = store.create_provisional( + contract=_contract(tmp_path, text), + snapshot={".claude/skills/root/SKILL.md": text}, + ) + second = store.create_provisional( + contract=_contract(tmp_path, text), + snapshot={".claude/skills/root/SKILL.md": text}, + ) + assert first != second + + store.observe_candidate(first, "../../superseded") + store.observe_candidate(first, "../../superseded") + store.finalize(first, "a/b") + store.finalize(second, "a_b") + + with pytest.raises((FileNotFoundError, KeyError)): + store.load("../../superseded") + slash = store.load("a/b") + underscore = store.load("a_b") + assert slash.snapshot_dir != underscore.snapshot_dir + assert slash.snapshot_dir.resolve().is_relative_to(root.resolve()) + assert underscore.snapshot_dir.resolve().is_relative_to(root.resolve()) + + with pytest.raises(ValueError, match="session"): + store.load("") + + +def test_store_rejects_projected_digest_tampering_and_deletes_only_explicitly( + tmp_path: Path, +) -> None: + from autoskillit.execution.session import DefaultSkillSessionContractStore + + root = tmp_path / "contracts" + text = "projected\n" + store = DefaultSkillSessionContractStore(root=root) + correlation_key = store.create_provisional( + contract=_contract(tmp_path, text), + snapshot={".claude/skills/root/SKILL.md": text}, + ) + store.finalize(correlation_key, "resumable") + stored = store.load("resumable") + projected = stored.snapshot_dir / ".claude/skills/root/SKILL.md" + projected.write_text("tampered\n") + + with pytest.raises(ValueError, match="digest"): + store.load("resumable") + + store.delete("resumable") + with pytest.raises((FileNotFoundError, KeyError)): + store.load("resumable") diff --git a/tests/fleet/test_food_truck_prompt.py b/tests/fleet/test_food_truck_prompt.py index 439627303a..fadd49f9ad 100644 --- a/tests/fleet/test_food_truck_prompt.py +++ b/tests/fleet/test_food_truck_prompt.py @@ -40,6 +40,23 @@ def test_food_truck_prompt_contains_hook_denial_compliance(): assert "HOOK DENIAL" in prompt.upper() +def test_l2_food_truck_retains_run_skill_guidance_without_machine_frontmatter(): + prompt = _build_food_truck_prompt( + recipe="process-issues", + task="Process the issue batches", + ingredients={}, + mcp_prefix=DIRECT_PREFIX, + dispatch_id="test-dispatch", + campaign_id="test-campaign", + l3_timeout_sec=300, + ) + assert "run_skill" in prompt + assert "retry the EXACT same run_skill call" in prompt + assert "uses_capabilities:" not in prompt + assert "execution_role:" not in prompt + assert "backend_requirements:" not in prompt + + def test_fleet_prompt_contains_budget_exceeded_routing(): """L3 food truck prompt must contain QUOTA WAIT REQUIRED and QUOTA BUDGET EXCEEDED routing.""" prompt = _build_food_truck_prompt( diff --git a/tests/server/test_tools_execution_routing.py b/tests/server/test_tools_execution_routing.py index 5387ee53d3..05b13f0f4c 100644 --- a/tests/server/test_tools_execution_routing.py +++ b/tests/server/test_tools_execution_routing.py @@ -726,3 +726,63 @@ async def test_run_skill_no_inspector_outside_dispatch(tool_ctx_kitchen_open, mo assert len(executor.calls) == 1 assert executor.calls[0].inspector_eligible is False assert executor.calls[0].inspector_model == "" + + +@pytest.mark.anyio +@pytest.mark.parametrize("session_type", ["skill", "fleet"]) +async def test_run_skill_exact_role_denial_precedes_all_downstream_work( + tool_ctx_kitchen_open, monkeypatch, tmp_path, session_type +) -> None: + """L1 and L3 are denied before resolution, materialization, or execution.""" + from unittest.mock import MagicMock + + from tests.fakes import InMemoryHeadlessExecutor + + monkeypatch.setenv("AUTOSKILLIT_HEADLESS", "1") + monkeypatch.setenv("AUTOSKILLIT_SESSION_TYPE", session_type) + resolver = MagicMock() + manager = MagicMock() + executor = InMemoryHeadlessExecutor() + tool_ctx_kitchen_open.skill_resolver = resolver + tool_ctx_kitchen_open.session_skill_manager = manager + tool_ctx_kitchen_open.executor = executor + monkeypatch.setattr("autoskillit.server._ctx", tool_ctx_kitchen_open) + + result = await run_skill("/autoskillit:root work", str(tmp_path)) + + assert __import__("json").loads(result)["subtype"] == "headless_error" + resolver.resolve_effective.assert_not_called() + resolver.resolve_invocation.assert_not_called() + manager.init_session.assert_not_called() + manager.activate_skill_deps.assert_not_called() + assert executor.calls == [] + + +@pytest.mark.anyio +async def test_fresh_dispatch_constructs_default_project_aware_resolver_before_writes( + tool_ctx_kitchen_open, monkeypatch, tmp_path +) -> None: + """A missing injected resolver is not an authorization bypass or unrestricted launch.""" + from autoskillit.workspace.skills import DefaultSkillResolver + from tests.fakes import InMemoryHeadlessExecutor + + monkeypatch.setenv("AUTOSKILLIT_HEADLESS", "1") + monkeypatch.setenv("AUTOSKILLIT_SESSION_TYPE", "orchestrator") + calls: list[tuple[str, object, object]] = [] + + def unresolved(self, name, project_root, execution_role): + calls.append((name, project_root, execution_role)) + return None + + monkeypatch.setattr(DefaultSkillResolver, "resolve_invocation", unresolved) + executor = InMemoryHeadlessExecutor() + tool_ctx_kitchen_open.skill_resolver = None + tool_ctx_kitchen_open.executor = executor + monkeypatch.setattr("autoskillit.server._ctx", tool_ctx_kitchen_open) + + result = await run_skill("/autoskillit:not-installed work", str(tmp_path)) + + assert calls and calls[0][0] == "not-installed" + assert calls[0][1] == tmp_path + assert __import__("json").loads(result)["success"] is False + assert executor.calls == [] diff --git a/tests/workspace/test_project_local_overrides.py b/tests/workspace/test_project_local_overrides.py index 34b93c0b78..58117632d1 100644 --- a/tests/workspace/test_project_local_overrides.py +++ b/tests/workspace/test_project_local_overrides.py @@ -182,6 +182,122 @@ def test_detect_project_local_overrides_claude_code_backend_scoping(tmp_path): ) +def _write_effective_skill( + root, + name, + *, + capabilities: tuple[str, ...], + execution_role: str, + body: str, +): + skill_path = root / name / "SKILL.md" + skill_path.parent.mkdir(parents=True, exist_ok=True) + skill_path.write_text( + "\n".join( + ( + "---", + f"name: {name}", + "description: Effective source fixture.", + f"uses_capabilities: [{', '.join(capabilities)}]", + f"execution_role: {execution_role}", + "---", + body, + "", + ) + ) + ) + return skill_path + + +def test_resolve_effective_observes_new_override_without_cross_dispatch_cache( + tmp_path, monkeypatch +): + """A higher-priority source created between fresh dispatches is immediately effective.""" + from autoskillit.workspace.skills import DefaultSkillResolver + + bundled = tmp_path / "bundled" + extended = tmp_path / "extended" + project = tmp_path / "project" + bundled.mkdir() + extended.mkdir() + project.mkdir() + bundled_path = _write_effective_skill( + bundled, + "target", + capabilities=("github_api_write", "agent_model"), + execution_role="session", + body="bundled body", + ) + + resolver = DefaultSkillResolver() + monkeypatch.setattr(resolver, "_dir", bundled) + monkeypatch.setattr(resolver, "_extended_dir", extended) + + first = resolver.resolve_effective("target", project) + assert first is not None + assert first.path == bundled_path + assert first.uses_capabilities == frozenset({"github_api_write", "agent_model"}) + + override_path = _write_effective_skill( + project / ".claude" / "skills", + "target", + capabilities=("git_metadata_write", "run_skill"), + execution_role="orchestrator", + body="fresh override body", + ) + second = resolver.resolve_effective("target", project) + + assert second is not None + assert second is not first + assert second.path == override_path + assert second.source.value == "project_local" + assert second.uses_capabilities == frozenset({"git_metadata_write", "run_skill"}) + assert second.execution_role.value == "orchestrator" + + +def test_resolve_effective_uses_one_first_match_for_policy_and_identity(tmp_path, monkeypatch): + """Source precedence cannot mix policy metadata with bytes from a lower-priority source.""" + from autoskillit.workspace.skills import DefaultSkillResolver + + bundled = tmp_path / "bundled" + extended = tmp_path / "extended" + project = tmp_path / "project" + bundled.mkdir() + extended.mkdir() + project.mkdir() + _write_effective_skill( + bundled, + "target", + capabilities=("agent_model",), + execution_role="session", + body="bundled", + ) + claude_path = _write_effective_skill( + project / ".claude" / "skills", + "target", + capabilities=("github_api_write",), + execution_role="session", + body="first match", + ) + _write_effective_skill( + project / ".autoskillit" / "skills", + "target", + capabilities=("git_metadata_write",), + execution_role="session", + body="lower priority", + ) + + resolver = DefaultSkillResolver() + monkeypatch.setattr(resolver, "_dir", bundled) + monkeypatch.setattr(resolver, "_extended_dir", extended) + effective = resolver.resolve_effective("target", project) + + assert effective is not None + assert effective.path == claude_path + assert effective.path.read_text().endswith("first match\n") + assert effective.uses_capabilities == frozenset({"github_api_write"}) + + # --------------------------------------------------------------------------- # T-OVR-007..011: init_session() — project_dir override filtering # --------------------------------------------------------------------------- diff --git a/tests/workspace/test_session_skills_allow_only_and_closure.py b/tests/workspace/test_session_skills_allow_only_and_closure.py index d3c69f51aa..f398d2e88c 100644 --- a/tests/workspace/test_session_skills_allow_only_and_closure.py +++ b/tests/workspace/test_session_skills_allow_only_and_closure.py @@ -467,6 +467,118 @@ def test_closure_pack_dep_with_no_members_returns_only_target(self, tmp_path: Pa assert compute_skill_closure("target", provider) == frozenset({"target"}) +def _write_invocation_skill( + root: Path, + name: str, + *, + capabilities: tuple[str, ...] = (), + execution_role: str = "session", + deps: tuple[str, ...] = (), + categories: tuple[str, ...] = (), +) -> None: + skill_path = root / name / "SKILL.md" + skill_path.parent.mkdir(parents=True, exist_ok=True) + frontmatter = [ + f"name: {name}", + f"description: Synthetic {name} invocation contract.", + f"execution_role: {execution_role}", + ] + if capabilities: + frontmatter.append(f"uses_capabilities: [{', '.join(capabilities)}]") + if deps: + frontmatter.append(f"activate_deps: [{', '.join(deps)}]") + if categories: + frontmatter.append(f"categories: [{', '.join(categories)}]") + skill_path.write_text("---\n" + "\n".join(frontmatter) + "\n---\nbody\n") + + +def _make_effective_resolver(tmp_path: Path, monkeypatch, skills: dict[str, dict]): + from autoskillit.workspace.skills import DefaultSkillResolver + + bundled = tmp_path / "bundled" + extended = tmp_path / "extended" + bundled.mkdir() + extended.mkdir() + for name, spec in skills.items(): + _write_invocation_skill(extended, name, **spec) + resolver = DefaultSkillResolver() + monkeypatch.setattr(resolver, "_dir", bundled) + monkeypatch.setattr(resolver, "_extended_dir", extended) + return resolver + + +class TestEffectiveInvocationClosurePolicy: + """The complete direct/pack closure supplies one validated capability contract.""" + + def test_capability_union_includes_direct_and_pack_expanded_dependencies( + self, tmp_path: Path, monkeypatch + ) -> None: + from autoskillit.core import SkillExecutionRole + + resolver = _make_effective_resolver( + tmp_path, + monkeypatch, + { + "root": {"deps": ("direct", "audit")}, + "direct": {"capabilities": ("github_api_write",)}, + "pack-member": { + "capabilities": ("git_metadata_write",), + "categories": ("audit",), + }, + }, + ) + project_root = tmp_path / "project" + project_root.mkdir() + + invocation = resolver.resolve_invocation("root", project_root, SkillExecutionRole.SESSION) + + assert invocation.root.name == "root" + assert {member.name for member in invocation.closure} == { + "root", + "direct", + "pack-member", + } + assert invocation.capability_union == frozenset({"github_api_write", "git_metadata_write"}) + assert invocation.project_root == project_root.resolve() + + @pytest.mark.parametrize( + ("dependency", "root_deps", "categories"), + [ + pytest.param("direct", ("direct",), (), id="direct"), + pytest.param("pack-member", ("audit",), ("audit",), id="pack-expanded"), + ], + ) + def test_orchestrator_dependency_rejected_before_materialization( + self, + tmp_path: Path, + monkeypatch, + dependency: str, + root_deps: tuple[str, ...], + categories: tuple[str, ...], + ) -> None: + from autoskillit.core import SkillExecutionRole + + resolver = _make_effective_resolver( + tmp_path, + monkeypatch, + { + "root": {"deps": root_deps}, + dependency: { + "execution_role": "orchestrator", + "capabilities": ("run_skill",), + "categories": categories, + }, + }, + ) + project_root = tmp_path / "project" + project_root.mkdir() + + with pytest.raises( + ValueError, match=rf"{dependency}.*orchestrator|orchestrator.*{dependency}" + ): + resolver.resolve_invocation("root", project_root, SkillExecutionRole.SESSION) + + class TestParseWritePaths: """Unit tests for _parse_write_paths frontmatter parser.""" diff --git a/tests/workspace/test_session_skills_codex.py b/tests/workspace/test_session_skills_codex.py index 5ab647df1e..33572904ad 100644 --- a/tests/workspace/test_session_skills_codex.py +++ b/tests/workspace/test_session_skills_codex.py @@ -113,14 +113,24 @@ def test_codex_init_session_raises_when_pre_launch_fails( mgr.init_session("sid", cook_session=True, backend=codex_env.backend) -def test_profile_skills_symlinked_into_session_dir(tmp_path, monkeypatch) -> None: - """_materialize_profile_skills symlinks ~/.codex/skills/ into session_dir/skills/.""" # noqa: E501 +def test_profile_skills_are_projected_into_session_dir(tmp_path, monkeypatch) -> None: + """Codex profile skills are copied as projections, never linked to raw sources.""" + from autoskillit.core.io import load_yaml from autoskillit.execution.backends.codex import _materialize_profile_skills fake_home = tmp_path / "fake_home" profile_skill = fake_home / ".codex" / "skills" / "my-skill" profile_skill.mkdir(parents=True) - (profile_skill / "SKILL.md").write_text("# MY SKILL") + (profile_skill / "SKILL.md").write_text( + "---\n" + "name: my-skill\n" + "description: Public profile description.\n" + "uses_capabilities: [agent_model]\n" + "execution_role: session\n" + "backend_requirements: [codex]\n" + "---\n" + "# MY SKILL\n" + ) session_dir = tmp_path / "session" (session_dir / "skills").mkdir(parents=True) @@ -129,8 +139,17 @@ def test_profile_skills_symlinked_into_session_dir(tmp_path, monkeypatch) -> Non count = _materialize_profile_skills(session_dir) target = session_dir / "skills" / "my-skill" - assert target.is_symlink() or target.is_dir() - assert (target / "SKILL.md").read_text() == "# MY SKILL" + assert target.is_dir() + assert not target.is_symlink() + content = (target / "SKILL.md").read_text() + frontmatter = load_yaml(content.split("---\n", 2)[1]) + assert { + "uses_capabilities", + "execution_role", + "backend_requirements", + }.isdisjoint(frontmatter) + assert frontmatter["description"] == "Public profile description." + assert content.endswith("# MY SKILL\n") assert count == 1 @@ -198,3 +217,34 @@ def test_codex_session_cook_mode_still_writes_all_skills( skills_base = session_path / ClaudeDirectoryConventions.PLUGIN_DIR_SKILLS_SUBDIR skill_files = list(skills_base.glob("*/SKILL.md")) assert len(skill_files) > 0, "Cook sessions must write all skills regardless of backend" + + +@pytest.mark.parametrize("backend_kind", ["claude-code", "codex"]) +def test_session_projection_is_agent_safe_for_each_backend( + make_session_skill_manager, + codex_env, + backend_kind: str, +) -> None: + from autoskillit.core.io import load_yaml + + backend = codex_env.backend if backend_kind == "codex" else None + manager = make_session_skill_manager() + session_path = manager.init_session( + f"projection-{backend_kind}", + cook_session=True, + backend=backend, + allow_only=frozenset({"make-arch-diag"}), + ) + skills_subdir = ( + ClaudeDirectoryConventions.PLUGIN_DIR_SKILLS_SUBDIR + if backend is not None + else ClaudeDirectoryConventions.ADD_DIR_SKILLS_SUBDIR + ) + content = (session_path / skills_subdir / "make-arch-diag" / "SKILL.md").read_text() + frontmatter = load_yaml(content.split("---\n", 2)[1]) + assert { + "uses_capabilities", + "execution_role", + "backend_requirements", + }.isdisjoint(frontmatter) + assert frontmatter["name"] == "make-arch-diag" diff --git a/tests/workspace/test_session_skills_deps.py b/tests/workspace/test_session_skills_deps.py index 4907fe91f4..c1013bf3ad 100644 --- a/tests/workspace/test_session_skills_deps.py +++ b/tests/workspace/test_session_skills_deps.py @@ -341,6 +341,41 @@ def test_activate_deps_preserves_marker_in_root_skill(self, tmp_path: Path) -> N class TestCopyOnActivate: + def test_transitive_dependency_materialisation_uses_agent_safe_projection( + self, tmp_path: Path + ) -> None: + from autoskillit.core.io import load_yaml + from autoskillit.workspace.session_skills import SkillsDirectoryProvider + + session_id = "test-projected-dependency" + _write_skill_md( + tmp_path, + session_id, + "root-skill", + "---\nname: root-skill\nactivate_deps: [make-arch-diag]\n" + "disable-model-invocation: true\n---\n# Root", + ) + provider = SkillsDirectoryProvider() + raw = provider.resolver.resolve("make-arch-diag") + assert raw is not None + assert "uses_capabilities:" in raw.path.read_text() + + manager = DefaultSessionSkillManager(provider, ephemeral_root=tmp_path) + assert manager.activate_skill_deps(session_id, "root-skill") is True + + dependency = ( + tmp_path / session_id / ".claude" / "skills" / "make-arch-diag" / "SKILL.md" + ).read_text() + frontmatter_text = dependency.split("---\n", 2)[1] + frontmatter = load_yaml(frontmatter_text) + assert { + "uses_capabilities", + "execution_role", + "backend_requirements", + }.isdisjoint(frontmatter) + assert frontmatter["name"] == "make-arch-diag" + assert "# Architecture Diagram Generator" in dependency + def test_copy_on_activate_single_absent_skill(self, tmp_path: Path) -> None: """Absence of SKILL.md triggers provider fetch; content is ungated after materialisation.""" diff --git a/tests/workspace/test_session_skills_provider.py b/tests/workspace/test_session_skills_provider.py index f877cc2411..2288d80df3 100644 --- a/tests/workspace/test_session_skills_provider.py +++ b/tests/workspace/test_session_skills_provider.py @@ -21,6 +21,22 @@ pytestmark = [pytest.mark.layer("workspace"), pytest.mark.small] +_MACHINE_ONLY_FRONTMATTER_KEYS = { + "uses_capabilities", + "execution_role", + "backend_requirements", +} + + +def _frontmatter(content: str) -> dict[str, object]: + match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL) + assert match, "Content must have YAML frontmatter" + return load_yaml(match.group(1)) + + +def _assert_agent_safe(content: str) -> None: + assert _MACHINE_ONLY_FRONTMATTER_KEYS.isdisjoint(_frontmatter(content)) + def test_resolve_ephemeral_root_returns_writable_dir( tmp_path: Path, monkeypatch: pytest.MonkeyPatch @@ -76,6 +92,65 @@ def test_provider_does_not_inject_for_cook_session() -> None: assert fm.get("disable-model-invocation") is not True +def test_agent_skill_projector_preserves_public_document_and_stable_digest( + tmp_path: Path, +) -> None: + from autoskillit.core import SkillSource + from autoskillit.workspace import ( + AgentSkillDocument, + SkillProjectionContext, + project_agent_skill_document, + ) + from autoskillit.workspace.skills import SkillInfo + + skill_md = tmp_path / "SKILL.md" + skill_md.write_text( + "---\n" + "name: projected-skill\n" + "description: Public description.\n" + "uses_capabilities: [agent_model]\n" + "execution_role: session\n" + "backend_requirements: [claude-code]\n" + "metadata:\n" + " public-key: public-value\n" + "---\n" + "# Public body\n\n" + "Keep this body byte-for-byte.\n" + ) + skill_info = SkillInfo( + name="projected-skill", + source=SkillSource.BUNDLED_EXTENDED, + path=skill_md, + ) + context = SkillProjectionContext(execution_cwd=tmp_path) + + first = project_agent_skill_document(skill_info, context) + second = project_agent_skill_document(skill_info, context) + + assert isinstance(first, AgentSkillDocument) + _assert_agent_safe(first.content) + frontmatter = _frontmatter(first.content) + assert frontmatter["name"] == "projected-skill" + assert frontmatter["description"] == "Public description." + assert frontmatter["metadata"] == {"public-key": "public-value"} + assert first.content.endswith("# Public body\n\nKeep this body byte-for-byte.\n") + assert first.projected_digest == second.projected_digest + assert first.content == second.content + + +def test_provider_string_api_returns_unified_agent_safe_projection() -> None: + provider = SkillsDirectoryProvider() + raw = provider.resolver.resolve("make-arch-diag") + assert raw is not None + assert "uses_capabilities:" in raw.path.read_text() + + content = provider.get_skill_content("make-arch-diag", gated=False) + + _assert_agent_safe(content) + assert _frontmatter(content)["name"] == "make-arch-diag" + assert "# Architecture Diagram Generator" in content + + @pytest.mark.parametrize( ("backend_name", "skills_subdir"), [ @@ -320,6 +395,43 @@ def test_init_session_tier2_skill_present_when_in_allow_only(tmp_path: Path) -> assert not (skills_base / "make-plan").exists() +def test_init_session_projects_project_local_override_instead_of_raw_copy( + tmp_path: Path, +) -> None: + project_dir = tmp_path / "project" + override_dir = project_dir / ".claude" / "skills" / "local-safe" + override_dir.mkdir(parents=True) + (override_dir / "SKILL.md").write_text( + "---\n" + "name: local-safe\n" + "description: Public local description.\n" + "uses_capabilities: [agent_model]\n" + "execution_role: session\n" + "backend_requirements: [claude-code]\n" + "---\n" + "# Local override body\n" + ) + session_root = tmp_path / "sessions" + manager = DefaultSessionSkillManager( + SkillsDirectoryProvider(), + ephemeral_root=session_root, + ) + + session_path = manager.init_session( + "local-projection", + cook_session=True, + project_dir=project_dir, + allow_only=frozenset({"local-safe"}), + ) + + projected = ( + session_path / ClaudeDirectoryConventions.ADD_DIR_SKILLS_SUBDIR / "local-safe" / "SKILL.md" + ).read_text() + _assert_agent_safe(projected) + assert _frontmatter(projected)["description"] == "Public local description." + assert projected.endswith("# Local override body\n") + + def test_cleanup_stale_removes_old_dirs(tmp_path: Path) -> None: provider = SkillsDirectoryProvider() mgr = DefaultSessionSkillManager(provider, ephemeral_root=tmp_path) diff --git a/tests/workspace/test_skill_format.py b/tests/workspace/test_skill_format.py index 8938f7c183..d29615b831 100644 --- a/tests/workspace/test_skill_format.py +++ b/tests/workspace/test_skill_format.py @@ -5,7 +5,9 @@ import pytest from autoskillit.workspace.skill_format import ( + SkillFrontmatterParseResult, parse_frontmatter_content, + read_skill_frontmatter, validate_skill_frontmatter, ) @@ -84,18 +86,37 @@ class TestParseFrontmatterContent: def test_parse_frontmatter_content_valid(self) -> None: content = "---\nname: my-skill\ndescription: A skill\n---\nBody" result = parse_frontmatter_content(content) - assert result["name"] == "my-skill" - assert result["description"] == "A skill" - - def test_parse_frontmatter_content_no_frontmatter(self) -> None: - content = "Just plain text without frontmatter" - result = parse_frontmatter_content(content) - assert result == {} - - def test_parse_frontmatter_content_malformed_yaml(self) -> None: - content = "---\n: invalid\n---\nBody" + assert isinstance(result, SkillFrontmatterParseResult) + assert result.is_valid + assert result.error is None + assert result.data == {"name": "my-skill", "description": "A skill"} + assert result.frontmatter_text == "name: my-skill\ndescription: A skill" + assert result.body == "Body" + assert result.content == content + + @pytest.mark.parametrize( + ("content", "error"), + [ + ("Just plain text without frontmatter", "missing_opening_delimiter"), + ("---\nname: my-skill\nBody", "missing_closing_delimiter"), + ("---\nname: [unterminated\n---\nBody", "malformed_yaml"), + ("---\n- one\n- two\n---\nBody", "non_mapping"), + ], + ) + def test_invalid_frontmatter_has_typed_failure(self, content: str, error: str) -> None: result = parse_frontmatter_content(content) - assert result == {} + assert isinstance(result, SkillFrontmatterParseResult) + assert not result.is_valid + assert result.error == error + assert result.data is None + assert result.content == content + + def test_unreadable_frontmatter_is_distinct(self, tmp_path) -> None: + result = read_skill_frontmatter(tmp_path / "missing" / "SKILL.md") + assert isinstance(result, SkillFrontmatterParseResult) + assert not result.is_valid + assert result.error == "unreadable" + assert result.data is None class TestWritePathsValidation: diff --git a/tests/workspace/test_skills.py b/tests/workspace/test_skills.py index 16a53d9732..bd580f7b7c 100644 --- a/tests/workspace/test_skills.py +++ b/tests/workspace/test_skills.py @@ -10,7 +10,7 @@ import autoskillit.workspace.skills as _skills_mod from autoskillit.core.io import load_yaml -from autoskillit.core.types import SkillSource +from autoskillit.core.types import SkillExecutionRole, SkillSource from autoskillit.workspace.skills import ( DefaultSkillResolver, bundled_skills_dir, @@ -828,6 +828,9 @@ def test_mixed_capabilities_derive_backend_requirements(self, tmp_path) -> None: ) info = _skill_info_from_frontmatter("test", SkillSource.BUNDLED, skill_md) assert info.backend_requirements == frozenset() + assert info.invalid_reason is not None + assert "run_skill" in info.invalid_reason + assert "session" in info.invalid_reason def test_investigate_skill_has_no_backend_requirement(self) -> None: info = DefaultSkillResolver().resolve("investigate") @@ -859,6 +862,80 @@ def test_backend_requirements_derived_not_read_from_yaml(self, tmp_path) -> None ) +class TestSkillExecutionRoleParsing: + def test_valid_omission_defaults_to_session(self, tmp_path: Path) -> None: + from autoskillit.workspace.skills import _skill_info_from_frontmatter + + skill_md = tmp_path / "SKILL.md" + skill_md.write_text("---\nname: test\n---\n# body") + info = _skill_info_from_frontmatter("test", SkillSource.BUNDLED, skill_md) + assert info.execution_role is SkillExecutionRole.SESSION + assert info.invalid_reason is None + + @pytest.mark.parametrize( + ("role", "expected"), + [ + ("session", SkillExecutionRole.SESSION), + ("orchestrator", SkillExecutionRole.ORCHESTRATOR), + ("fleet", SkillExecutionRole.FLEET), + ], + ) + def test_explicit_execution_role_is_typed( + self, tmp_path: Path, role: str, expected: SkillExecutionRole + ) -> None: + from autoskillit.workspace.skills import _skill_info_from_frontmatter + + skill_md = tmp_path / "SKILL.md" + skill_md.write_text(f"---\nname: test\nexecution_role: {role}\n---\n# body") + info = _skill_info_from_frontmatter("test", SkillSource.BUNDLED, skill_md) + assert info.execution_role is expected + assert info.invalid_reason is None + + @pytest.mark.parametrize( + "content", + [ + "# no frontmatter", + "---\nname: [unterminated\n---\n# body", + "---\nname: test\n# missing closing delimiter", + "---\n- name\n- test\n---\n# body", + ], + ) + def test_invalid_frontmatter_never_receives_session_default( + self, tmp_path: Path, content: str + ) -> None: + from autoskillit.workspace.skills import _skill_info_from_frontmatter + + skill_md = tmp_path / "SKILL.md" + skill_md.write_text(content) + info = _skill_info_from_frontmatter("test", SkillSource.BUNDLED, skill_md) + assert info.execution_role is None + assert info.invalid_reason is not None + + def test_session_contract_cannot_declare_run_skill(self, tmp_path: Path) -> None: + from autoskillit.workspace.skills import _skill_info_from_frontmatter + + skill_md = tmp_path / "SKILL.md" + skill_md.write_text( + "---\nname: test\nexecution_role: session\nuses_capabilities: [run_skill]\n---\n# body" + ) + info = _skill_info_from_frontmatter("test", SkillSource.BUNDLED, skill_md) + assert info.invalid_reason is not None + assert "run_skill" in info.invalid_reason + assert "session" in info.invalid_reason + + def test_orchestrator_contract_may_declare_run_skill(self, tmp_path: Path) -> None: + from autoskillit.workspace.skills import _skill_info_from_frontmatter + + skill_md = tmp_path / "SKILL.md" + skill_md.write_text( + "---\nname: test\nexecution_role: orchestrator\n" + "uses_capabilities: [run_skill]\n---\n# body" + ) + info = _skill_info_from_frontmatter("test", SkillSource.BUNDLED, skill_md) + assert info.execution_role is SkillExecutionRole.ORCHESTRATOR + assert info.invalid_reason is None + + class TestSkillInfoSchemaExhaustiveness: def test_all_skillinfo_fields_parsed_by_frontmatter_function(self) -> None: """Every non-constructor field on SkillInfo must be parsed.""" @@ -869,7 +946,7 @@ def test_all_skillinfo_fields_parsed_by_frontmatter_function(self) -> None: from autoskillit.workspace.skills import SkillInfo, _skill_info_from_frontmatter dc_fields = {f.name for f in dataclasses.fields(SkillInfo)} - constructor_only = {"name", "source", "path"} + constructor_only = {"name", "source", "path", "source_ref"} derived_fields = {"backend_requirements", "invalid_reason"} parseable_fields = dc_fields - constructor_only - derived_fields From cddad0c0f545b0e9357cc4a7afa235c4865f50f5 Mon Sep 17 00:00:00 2001 From: Trecek Date: Wed, 22 Jul 2026 23:05:06 -0700 Subject: [PATCH 02/62] feat: add exact skill execution contracts --- docs/configuration.md | 9 +- docs/execution/orchestration.md | 8 +- docs/execution/tool-access.md | 10 +- docs/orchestration-levels.md | 19 +- docs/skills/catalog.md | 21 +- docs/skills/visibility.md | 70 ++-- src/autoskillit/config/defaults.yaml | 1 - src/autoskillit/core/__init__.pyi | 5 + src/autoskillit/core/types/AGENTS.md | 1 + src/autoskillit/core/types/__init__.py | 3 + .../core/types/_type_constants_registries.py | 48 ++- src/autoskillit/core/types/_type_enums.py | 26 ++ .../core/types/_type_exceptions.py | 5 + src/autoskillit/core/types/_type_helpers.py | 15 +- .../core/types/_type_protocols_workspace.py | 16 + .../core/types/_type_skill_contract.py | 21 + .../hooks/guards/skill_orchestration_guard.py | 57 +-- src/autoskillit/server/_guards.py | 3 +- .../server/tools/tools_execution.py | 3 +- src/autoskillit/skills/sous-chef/SKILL.md | 1 + .../analyze-pipeline-health/SKILL.md | 2 +- .../skills_extended/dry-walkthrough/SKILL.md | 2 +- .../skills_extended/enrich-issues/SKILL.md | 2 +- .../implement-experiment/SKILL.md | 2 +- .../implement-worktree-no-merge/SKILL.md | 2 +- .../implement-worktree/SKILL.md | 2 +- .../skills_extended/migrate-recipes/SKILL.md | 2 +- .../open-integration-pr/SKILL.md | 2 +- .../skills_extended/plan-experiment/SKILL.md | 2 +- .../planner-elaborate-assignments/SKILL.md | 2 +- .../planner-elaborate-wps/SKILL.md | 2 +- .../skills_extended/process-issues/SKILL.md | 1 + .../resolve-design-review/SKILL.md | 2 +- .../skills_extended/resolve-failures/SKILL.md | 2 +- .../skills_extended/resolve-review/SKILL.md | 2 +- .../skills_extended/review-pr/SKILL.md | 4 +- .../review-research-pr/SKILL.md | 2 +- .../select-directions/SKILL.md | 2 +- .../skills_extended/setup-project/SKILL.md | 4 +- .../skills_extended/write-recipe/SKILL.md | 2 +- src/autoskillit/workspace/__init__.py | 14 + src/autoskillit/workspace/session_skills.py | 22 +- src/autoskillit/workspace/skill_format.py | 116 +++++- src/autoskillit/workspace/skills.py | 368 +++++++++++++++--- 44 files changed, 726 insertions(+), 179 deletions(-) create mode 100644 src/autoskillit/core/types/_type_skill_contract.py diff --git a/docs/configuration.md b/docs/configuration.md index 37d3f078f2..1e9161e45c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -334,9 +334,12 @@ skills: # ... ``` -Any bundled skill can be promoted or demoted by adding it to the desired tier list. A skill -in multiple tiers simultaneously is a validation error. See **[Skill Visibility](skills/visibility.md)** -for the full tier breakdown, session mode table, and override rules. +Session-role skills can be promoted or demoted by adding them to the desired tier list. A +skill in multiple tiers simultaneously is a validation error. Exact-role orchestration +skills are not user-tiered: `process-issues`, for example, is exposed only in L2 +orchestrator catalogs and a configuration that adds it to a session tier is rejected. +See **[Skill Visibility](skills/visibility.md)** for the full tier breakdown, session mode +table, role-derived catalogs, and override rules. ## Subset Categories diff --git a/docs/execution/orchestration.md b/docs/execution/orchestration.md index ca885226a6..4813cf1277 100644 --- a/docs/execution/orchestration.md +++ b/docs/execution/orchestration.md @@ -19,11 +19,13 @@ pipeline primarily connects the L2 orchestrator and L1 workers: - **L1 — worker.** A headless Claude session launched by `run_skill`. Sees the 2 free range tools plus `test_check` (the only `headless`-tagged tool). Cannot call `run_skill`, `run_cmd`, or `run_python`. +- **L3 — fleet.** Dispatches L2 food trucks through `dispatch_food_truck`. + It may use `run_cmd` and `run_python`, but cannot call `run_skill` directly. The boundary is enforced three ways: FastMCP visibility, the -`skill_orchestration_guard.py` PreToolUse hook, and the -`_require_orchestrator_or_higher()` runtime guard inside `tools_execution.py`. All -three must independently agree before any orchestration tool can fire. +`skill_orchestration_guard.py` PreToolUse hook, and runtime guards inside +`tools_execution.py`. `run_skill` uses `_require_orchestrator_exact()`; `run_cmd` and +`run_python` use `_require_orchestrator_or_higher()`. All three layers must agree. ## Recipe as a program diff --git a/docs/execution/tool-access.md b/docs/execution/tool-access.md index ab169e60c0..9cf7db8b6c 100644 --- a/docs/execution/tool-access.md +++ b/docs/execution/tool-access.md @@ -33,10 +33,16 @@ session types can see each tool. | `$ autoskillit cook` (after `/open-kitchen`) | ✓ | ✓ | ✗ | | `$ autoskillit order` | ✓ | ✓ (pre-opened) | ✗ | | `run_skill` (headless) | ✓ | ✗ | ✓ | +| L2 food truck | ✓ | ✓ (pre-opened) | ✗ | +| L3 fleet | ✓ | fleet surface | ✗ | Note: Disabled subsets further restrict visibility within the Kitchen tier — their tools remain hidden even after `open_kitchen`. +Visibility is not authority. At the application and hook layers, `run_skill` is restricted +to exact L2 `ORCHESTRATOR` sessions. L3 `FLEET` sessions create L2 food trucks through +`dispatch_food_truck`; they retain `run_cmd` and `run_python` but cannot call `run_skill`. + ## FastMCP Tag Glossary | Tag | Meaning | @@ -77,8 +83,8 @@ Three independent layers prevent headless sessions from calling orchestration to | Layer | Mechanism | What It Blocks | |-------|-----------|----------------| | 1. FastMCP | Kitchen tools remain hidden (`mcp.enable(headless)` does not reveal kitchen-only tools) | `run_skill`, `run_cmd`, `run_python`, `merge_worktree`, and all other kitchen-only tools | -| 2. Hook | `skill_orchestration_guard.py` PreToolUse hook | `run_skill`, `run_cmd`, `run_python` | -| 3. Code | `_require_orchestrator_or_higher()` guard in `tools_execution.py` | `run_skill`, `run_cmd`, `run_python` | +| 2. Hook | `skill_orchestration_guard.py` PreToolUse hook | L1: `run_skill`, `run_cmd`, `run_python`; L3: `run_skill` | +| 3. Code | Exact and monotonic guards in `tools_execution.py` | exact-L2 `run_skill`; L2-or-higher `run_cmd` and `run_python` | All three layers must independently agree before any orchestration tool can execute. A bypassed hook is caught by the code guard; a bypassed code guard is caught by the diff --git a/docs/orchestration-levels.md b/docs/orchestration-levels.md index cbd7d7ae3c..a7f6e9e7b8 100644 --- a/docs/orchestration-levels.md +++ b/docs/orchestration-levels.md @@ -55,7 +55,7 @@ Key properties: - Interactive variant: `autoskillit order` (CLI label `"order"`; headless equivalent carries SessionType `ORCHESTRATOR`) - Headless variant: food truck (dispatched by L3, SessionType `ORCHESTRATOR`) -- Spawns L1 workers via `run_skill` +- Owns `run_skill` exactly and spawns L1 workers through it - Has full kitchen access (38 kitchen-tagged MCP tools) ``` @@ -77,12 +77,13 @@ Key properties: - Interactive only: `autoskillit fleet` (SessionType `FLEET`) - No headless variant (nothing above L3 to dispatch it) -- Dispatches L2 food trucks via `run_skill` +- Dispatches L2 food trucks via `dispatch_food_truck` +- Cannot call `run_skill`; doing so would skip the L2 boundary - Manages campaign state via the sidecar JSONL file ``` L3 (interactive fleet) -└── L2 food truck (run_skill → headless L2) +└── L2 food truck (dispatch_food_truck) └── L1 worker (run_skill) └── L0 subagent (Agent/Task tool) ``` @@ -104,17 +105,19 @@ L3 (interactive fleet) ## Key Rules -- **Headless L1 workers cannot call `run_skill`.** The boundary is enforced three ways: - FastMCP visibility tags, the `skill_orchestration_guard.py` PreToolUse hook, - and the `_require_orchestrator_or_higher()` runtime guard in - `tools_execution.py`. All three must independently agree. +- **`run_skill` is exact-L2.** Headless L1 and L3 sessions are denied. The boundary is + enforced by visibility, `skill_orchestration_guard.py`, and the + `_require_orchestrator_exact()` runtime guard in `tools_execution.py`. +- **`run_cmd` and `run_python` remain L2-or-higher.** L2 and L3 may call them; headless + L1 may not. Interactive sessions retain the existing headless-guard bypass. - **L0 agents cannot launch anything.** They are terminal nodes — they cannot call `run_skill`, cannot invoke the Agent tool to spawn sub-agents, and cannot open sub-sessions. (L0 agents are themselves spawned via Agent/Task by an L1 — the constraint is on outbound calls only.) - **L3 has no headless variant.** There is no L4 to dispatch an L3. Fleet always runs interactively. -- **Spawning is strictly downward.** An L2 dispatches L1, an L1 spawns L0. +- **Spawning is strictly downward.** L3 dispatches L2 with `dispatch_food_truck`, L2 + dispatches L1 with `run_skill`, and L1 spawns L0. No level can spawn a peer or a higher level. - **food trucks are L2, not L1.** A food truck is a headless L2 session dispatched by an L3 fleet. It retains full orchestrator capabilities diff --git a/docs/skills/catalog.md b/docs/skills/catalog.md index 2c3b8c5fc7..d6fb2528a8 100644 --- a/docs/skills/catalog.md +++ b/docs/skills/catalog.md @@ -4,15 +4,14 @@ The complete list of bundled skills (142 total: 3 in `src/autoskillit/skills/`, 139 in `src/autoskillit/skills_extended/`). Filesystem walk this directory if you need an exhaustive listing; this catalog groups by purpose. -## Tier 1 — free range (3) +## Tier 1 — free range (2 configured) Plugin-scanned at `src/autoskillit/skills/`: - `open-kitchen` — reveals the 41 kitchen MCP tools - `close-kitchen` — re-hides them -- `sous-chef` — internal injection by `open_kitchen`; never appears as a slash command -## Tier 2 — interactive cook + headless +## Tier 2 — interactive cook + headless (105 configured) Located under `src/autoskillit/skills_extended/`. Grouped by purpose: @@ -30,7 +29,7 @@ Located under `src/autoskillit/skills_extended/`. Grouped by purpose: `make-req`, `elaborate-phase`, `write-recipe`, `migrate-recipes`, `setup-project`, `design-guards`, `triage-issues`, `collapse-issues`, `issue-splitter`, `enrich-issues`, `prepare-issue`, -`process-issues`, `make-campaign` +`make-campaign` ### Experiment family `scope`, `select-directions`, `plan-experiment`, `implement-experiment`, `run-experiment`, @@ -39,7 +38,7 @@ Located under `src/autoskillit/skills_extended/`. Grouped by purpose: ### Research and review `review-design`, `stage-data`, `download-data`, `setup-environment`, `bundle-local-report`, `reload-session` -## Tier 3 — pipeline / automation +## Tier 3 — pipeline / automation (31 configured) Also under `src/autoskillit/skills_extended/`. Used by recipes for unattended runs: @@ -52,6 +51,13 @@ runs: `compose-research-pr`, `prepare-research-pr`, `review-research-pr`, `promote-to-main`, `classify-experiment-type`, `apply-review-dimensions` +## Role-derived — L2 orchestrator + +- `sous-chef` — internal L2 operating document; never a slash command +- `process-issues` — exposed only in L2 order and food-truck catalogs + +These exact-role skills are intentionally absent from all three configurable tiers. + ## arch-lens family (13) 13 architectural-diagram skills under `skills_extended/arch-lens-*/`. Each @@ -139,8 +145,9 @@ symptom, and the audit suite is updated so the same class of bug cannot recur. Commit messages prefix with `Rectify:` for traceability; the count of `Rectify:` commits is reported in `docs/developer/contributing.md`. -## Total: 140 +## Total: 142 -3 (Tier 1) + 137 (`skills_extended/`) = 140 bundled skills. The total is +3 sources under `skills/` + 139 sources under `skills_extended/` = 142 bundled skills. +Configured tier counts exclude exact-role/internal sources. The filesystem total is verified by `tests/docs/test_doc_counts.py` against a filesystem walk so any addition or removal is caught immediately. diff --git a/docs/skills/visibility.md b/docs/skills/visibility.md index e062227169..feacae2696 100644 --- a/docs/skills/visibility.md +++ b/docs/skills/visibility.md @@ -2,10 +2,12 @@ ## Overview -AutoSkillit's 142 bundled skills are organized into three tiers that control when and where -they appear as slash commands. The tier system is orthogonal to subset categories — you can -disable a subset across all tiers simultaneously, or reclassify individual skills between -tiers. See [Subset Categories](subsets.md) for subset configuration. +AutoSkillit has 142 bundled skill sources. Session-role skills are organized into three +configurable tiers that control when and where they appear as slash commands. Exact-role +orchestration skills are exposed through role-derived catalogs instead of a user tier. The +tier system is orthogonal to subset categories — you can disable a subset across all tiers +simultaneously or reclassify session-role skills between tiers. See +[Subset Categories](subsets.md) for subset configuration. ## The Three Tiers @@ -14,15 +16,15 @@ tiers. See [Subset Categories](subsets.md) for subset configuration. - **Location**: `src/autoskillit/skills/` (plugin-scanned by Claude Code) - **Default members**: `open-kitchen`, `close-kitchen` - **Visible in**: ALL session modes, including plain `$ claude` with the plugin loaded -- `sous-chef` lives in this directory but is internal — injected by `open_kitchen` at - runtime and excluded from user-facing slash commands +- `sous-chef` lives in this directory but is an exact-role L2 document, not a Tier 1 + command - **Filesystem mechanism**: Claude Code auto-discovers skills via `--plugin-dir`; anything in `skills/` is registered as `/autoskillit:` ### Tier 2 — Cook (Interactive Skills) - **Location**: `src/autoskillit/skills_extended/` (NOT plugin-scanned) -- **Default members** (106 total): +- **Default members** (105 total): `investigate`, `make-plan`, `implement-worktree`, `rectify`, `dry-walkthrough`, `make-groups`, `review-approach`, `mermaid`, `make-arch-diag`, `make-experiment-diag`, `plan-visualization`, `select-vis-lenses`, `synthesize-vis-plan`, `phoropter-null-synthesis`, `phoropter-priority-synthesis`, @@ -33,7 +35,7 @@ tiers. See [Subset Categories](subsets.md) for subset configuration. `audit-docs`, `audit-feature-gates`, `audit-review-decisions`, `make-req`, `elaborate-phase`, `write-recipe`, `migrate-recipes`, `setup-project`, `design-guards`, `triage-issues`, `collapse-issues`, - `issue-splitter`, `enrich-issues`, `prepare-issue`, `process-issues`, `make-campaign`, + `issue-splitter`, `enrich-issues`, `prepare-issue`, `make-campaign`, `scope`, `plan-experiment`, `implement-experiment`, `run-experiment`, `generate-report`, `validate-test-audit`, `validate-review-decisions`, `stage-data`, `setup-environment`, `bundle-local-report`, `reload-session` @@ -59,20 +61,31 @@ tiers. See [Subset Categories](subsets.md) for subset configuration. are available in the same session modes. The tier distinction lets users reclassify skills between "interactive" and "automation" via config without moving files. +### Role-derived — L2 Orchestrator + +- `sous-chef` is the internal L2 operating document injected into order and food-truck + orchestrators; it is never a user-facing slash command. +- `process-issues` is an L2 command available in role-derived order and food-truck + catalogs. +- Neither skill appears in `skills.tier1`, `skills.tier2`, or `skills.tier3`. +- L1 session catalogs and L3 fleet catalogs exclude both skills. A custom configuration + that assigns an exact-role skill to a session tier is invalid. + ## Session Mode Skill Visibility ``` -Session Mode Tier 1 Tier 2 Tier 3 -───────────────────── ─────── ─────── ─────── -$ claude (plugin) ✓ ✗ ✗ -$ autoskillit cook ✓ ✓ ✓ -$ autoskillit order ✓ ✓ ✓ -run_skill (headless) ✓ ✓ ✓ +Session Mode Tier 1 Tier 2 Tier 3 L2 role-derived +──────────────────────── ─────── ─────── ─────── ─────────────── +$ claude (plugin) ✓ ✗ ✗ ✗ +$ autoskillit cook (L1) ✓ ✓ ✓ ✗ +$ autoskillit order (L2) ✓ ✓ ✓ ✓ +food truck (L2) ✓ ✓ ✓ ✓ +run_skill worker (L1) ✓ ✓ ✓ ✗ +$ autoskillit fleet (L3) ✓ ✓ ✓ ✗ ``` -Note: All modes see Tier 1. Cook, order, and headless sessions see Tiers 2 and 3. -Subset filtering applies after tier visibility — a disabled subset removes its members -from all tiers. +Subset filtering applies after tier and role visibility — a disabled subset removes its +members from the resulting catalog. ## How Skills Are Discovered Per Session Mode @@ -85,18 +98,17 @@ and `/autoskillit:close-kitchen`. Skills in `skills_extended/` are never seen. ### Cook session (`$ autoskillit cook`) 1. AutoSkillit creates an ephemeral session directory at `/dev/shm/autoskillit-sessions//` -2. Skills from both `skills/` and `skills_extended/` are copied into this ephemeral dir - (subset-filtered and override-aware) +2. Session-role skills from both configured directories are copied into this ephemeral + dir (subset-filtered and override-aware); L2-only skills are excluded 3. Claude Code is launched with `--plugin-dir ` and `--add-dir ` so project-local skills in `.claude/skills/` are also discoverable -4. All 60 bundled slash-command skills appear as `/autoskillit:*` slash commands within the session -5. The ephemeral directory is cleaned up when the session ends +4. The ephemeral directory is cleaned up when the session ends ### Order session (`$ autoskillit order`) -Order is similar to cook: AutoSkillit launches Claude Code with access to all tiers. -The key difference is the orchestrator (`sous-chef` skill) is injected and the kitchen -is pre-opened so all 60 MCP tools are available from the start. +Order is similar to cook, but its L2 role-derived catalog additionally includes +`process-issues`. The internal `sous-chef` document is injected and the kitchen is +pre-opened. ### Headless session (launched by `run_skill`) @@ -104,13 +116,14 @@ is pre-opened so all 60 MCP tools are available from the start. ``` claude --add-dir --add-dir ``` -Both `skills_extended/` skills and project-local skills in `.claude/skills/` are -discoverable. Tier 1 skills from `skills/` are available via the installed plugin. -The AUTOSKILLIT_HEADLESS environment variable activates session-boundary enforcement. +The worker receives the resolved L1 catalog plus applicable project-local skills. +Exact ORCHESTRATOR-role entries such as `process-issues` are excluded. The AUTOSKILLIT_HEADLESS +environment variable activates session-boundary enforcement. ## Config-Driven Tier Reclassification -Any bundled skill can be promoted or demoted via `.autoskillit/config.yaml`: +Any session-role bundled skill can be promoted or demoted via +`.autoskillit/config.yaml`: ```yaml # .autoskillit/config.yaml @@ -127,6 +140,7 @@ skills: **Rules:** - A skill must appear in exactly one tier (listed in multiple tiers = validation error) +- Exact-role skills cannot be assigned to a session tier - Unknown skill names are logged as a warning, not a crash - Resolution order: package defaults → user config (`~/.autoskillit/config.yaml`) → project config (`.autoskillit/config.yaml`), last wins (dynaconf) diff --git a/src/autoskillit/config/defaults.yaml b/src/autoskillit/config/defaults.yaml index 75786447b7..f1b65899ff 100644 --- a/src/autoskillit/config/defaults.yaml +++ b/src/autoskillit/config/defaults.yaml @@ -227,7 +227,6 @@ skills: - issue-splitter - enrich-issues - prepare-issue - - process-issues - scope - select-directions - plan-experiment diff --git a/src/autoskillit/core/__init__.pyi b/src/autoskillit/core/__init__.pyi index a6dd3f6754..ee3f48b387 100644 --- a/src/autoskillit/core/__init__.pyi +++ b/src/autoskillit/core/__init__.pyi @@ -400,13 +400,16 @@ from .types import SessionTelemetry as SessionTelemetry from .types import SessionType as SessionType from .types import Severity as Severity from .types import SkillCapabilityDef as SkillCapabilityDef +from .types import SkillContractError as SkillContractError from .types import SkillContractResolver as SkillContractResolver +from .types import SkillExecutionRole as SkillExecutionRole from .types import SkillFamilyDef as SkillFamilyDef from .types import SkillLister as SkillLister from .types import SkillResolver as SkillResolver from .types import SkillResult as SkillResult from .types import SkillSessionConfig as SkillSessionConfig from .types import SkillSource as SkillSource +from .types import SkillSourceRef as SkillSourceRef from .types import SpilledOutput as SpilledOutput from .types import SpillSpec as SpillSpec from .types import StreamParser as StreamParser @@ -450,8 +453,10 @@ from .types import resolve_skill_name as resolve_skill_name from .types import resolve_target_skill as resolve_target_skill from .types import resume_spec_from_cli as resume_spec_from_cli from .types import session_type as session_type +from .types import session_type_for_skill_execution_role as session_type_for_skill_execution_role from .types import strip_context_window_suffix as strip_context_window_suffix from .types import truncate_text as truncate_text from .types import unsatisfied_backend_capabilities as unsatisfied_backend_capabilities from .types import validate_label_transition as validate_label_transition from .types import validate_recipe_artifact_sections as validate_recipe_artifact_sections +from .types import validate_skill_capability_roles as validate_skill_capability_roles diff --git a/src/autoskillit/core/types/AGENTS.md b/src/autoskillit/core/types/AGENTS.md index eda2c66678..70f98676ae 100644 --- a/src/autoskillit/core/types/AGENTS.md +++ b/src/autoskillit/core/types/AGENTS.md @@ -15,6 +15,7 @@ Type re-export hub and all typed building blocks for the autoskillit package (IL | `_type_constants_registries.py` | Tool registries, pack registries, tool-to-tag mappings, visibility tags | | `_type_constants_features.py` | Feature gates (FeatureDef, FEATURE_REGISTRY), label lifecycle state machine | | `_type_session_env.py` | Typed env spec dataclasses for session launch boundaries (`FleetSessionEnv`) | +| `_type_skill_contract.py` | Backend-neutral skill source identity (`SkillSourceRef`) | | `_type_subprocess.py` | `SubprocessResult` dataclass and `SubprocessRunner` protocol | | `_type_token.py` | `CanonicalTokenUsage` frozen dataclass with factory methods and merge | | `_type_results_execution.py` | Execution-scoped result dataclasses: `SessionTelemetry`, `RecipeIdentity`, `CIRunScope` | diff --git a/src/autoskillit/core/types/__init__.py b/src/autoskillit/core/types/__init__.py index f0800eb82e..94708e2ed4 100644 --- a/src/autoskillit/core/types/__init__.py +++ b/src/autoskillit/core/types/__init__.py @@ -66,6 +66,8 @@ from ._type_resume import __all__ as _resume_all from ._type_session_env import * # noqa: F401, F403 from ._type_session_env import __all__ as _session_env_all +from ._type_skill_contract import * # noqa: F401, F403 +from ._type_skill_contract import __all__ as _skill_contract_all from ._type_subprocess import * # noqa: F401, F403 from ._type_subprocess import __all__ as _subprocess_all from ._type_token import * # noqa: F401, F403 @@ -104,6 +106,7 @@ + _recipe_sections_all + _resume_all + _session_env_all + + _skill_contract_all + _subprocess_all + _token_all + _tradition_manifest_all diff --git a/src/autoskillit/core/types/_type_constants_registries.py b/src/autoskillit/core/types/_type_constants_registries.py index 3b5cfd0e85..7ace5aa467 100644 --- a/src/autoskillit/core/types/_type_constants_registries.py +++ b/src/autoskillit/core/types/_type_constants_registries.py @@ -19,7 +19,8 @@ # not introduce a circular or cross-layer dependency. from ._type_backend import BackendCapabilities from ._type_constants_env import AGENT_BACKEND_CLAUDE_CODE -from ._type_enums import FleetErrorCode +from ._type_enums import FleetErrorCode, SkillExecutionRole +from ._type_exceptions import SkillContractError __all__ = [ "PIPELINE_FORBIDDEN_TOOLS", @@ -64,6 +65,7 @@ "SKILL_CAPABILITY_REGISTRY", "describe_capability_mismatches", "unsatisfied_backend_capabilities", + "validate_skill_capability_roles", ] # Native Claude Code tools that pipeline orchestrators must NEVER use directly. @@ -759,6 +761,7 @@ class SkillCapabilityDef: description: str codex_status: Literal["works-as-is", "degraded", "fix-required", "not-applicable"] + allowed_execution_roles: frozenset[SkillExecutionRole] required_sandbox_overrides: frozenset[str] = frozenset() worker_routable: bool = False # Name of a Boolean field on `BackendCapabilities` that must be True @@ -800,41 +803,51 @@ class HardCapabilityMismatch(NamedTuple): # because the capability is documentary about the feature being incomplete, not a hard # blocker. agent_subagent / agent_model / cross_skill_ref are routed via worker_routable # rather than fix-required. Do not conflate the two registries' semantics. +_ALL_SKILL_EXECUTION_ROLES = frozenset(SkillExecutionRole) + SKILL_CAPABILITY_REGISTRY: dict[str, SkillCapabilityDef] = { "agent_subagent": SkillCapabilityDef( description="Agent(subagent_type=...) tool — delegates to specialized subagent", codex_status="not-applicable", + allowed_execution_roles=_ALL_SKILL_EXECUTION_ROLES, worker_routable=True, ), "agent_model": SkillCapabilityDef( description="Agent(model=...) tool — spawns model-specific subagent", codex_status="not-applicable", + allowed_execution_roles=_ALL_SKILL_EXECUTION_ROLES, worker_routable=True, ), "open_kitchen": SkillCapabilityDef( description="open_kitchen / close_kitchen lifecycle tools", codex_status="not-applicable", + allowed_execution_roles=_ALL_SKILL_EXECUTION_ROLES, ), "run_skill": SkillCapabilityDef( description="run_skill MCP tool call (headless session dispatch)", codex_status="works-as-is", + allowed_execution_roles=frozenset({SkillExecutionRole.ORCHESTRATOR}), ), "test_check": SkillCapabilityDef( description="test_check MCP tool (headless test runner)", codex_status="works-as-is", + allowed_execution_roles=_ALL_SKILL_EXECUTION_ROLES, ), "claude_dir": SkillCapabilityDef( description="Reads/writes .claude/ directory structure", codex_status="works-as-is", + allowed_execution_roles=_ALL_SKILL_EXECUTION_ROLES, ), "cross_skill_ref": SkillCapabilityDef( description="Cross-skill /autoskillit: invocation via Skill tool", codex_status="not-applicable", + allowed_execution_roles=_ALL_SKILL_EXECUTION_ROLES, worker_routable=True, ), "commit_files": SkillCapabilityDef( description="commit_files MCP tool — server-side git stage/commit", codex_status="works-as-is", + allowed_execution_roles=_ALL_SKILL_EXECUTION_ROLES, ), "git_metadata_write": SkillCapabilityDef( description=( @@ -842,6 +855,7 @@ class HardCapabilityMismatch(NamedTuple): "git worktree add, git checkout -b)" ), codex_status="not-applicable", + allowed_execution_roles=_ALL_SKILL_EXECUTION_ROLES, worker_routable=True, required_backend_property="git_metadata_writable", required_recipe_ingredient="backend_supports_git_write", @@ -853,10 +867,34 @@ class HardCapabilityMismatch(NamedTuple): "access. On Codex workers, enables network_access=true in the workspace-write sandbox." ), codex_status="fix-required", + allowed_execution_roles=_ALL_SKILL_EXECUTION_ROLES, required_sandbox_overrides=frozenset({"sandbox_workspace_write.network_access=true"}), ), } + +def validate_skill_capability_roles( + uses_capabilities: frozenset[str], + execution_role: SkillExecutionRole, +) -> None: + """Reject unknown capabilities and declarations not owned by ``execution_role``.""" + unknown = uses_capabilities - SKILL_CAPABILITY_REGISTRY.keys() + if unknown: + raise SkillContractError( + f"unknown skill capabilities for {execution_role.value}: {sorted(unknown)}" + ) + + incompatible = sorted( + capability + for capability in uses_capabilities + if execution_role not in SKILL_CAPABILITY_REGISTRY[capability].allowed_execution_roles + ) + if incompatible: + raise SkillContractError( + f"execution role {execution_role.value!r} cannot declare capabilities {incompatible}" + ) + + _VALID_CODEX_STATUSES = {"works-as-is", "degraded", "fix-required", "not-applicable"} for _cap_name, _cap_def in SKILL_CAPABILITY_REGISTRY.items(): if _cap_def.codex_status not in _VALID_CODEX_STATUSES: @@ -865,6 +903,14 @@ class HardCapabilityMismatch(NamedTuple): f"{_cap_def.codex_status!r} is not valid. " f"Must be one of {sorted(_VALID_CODEX_STATUSES)}." ) + if ( + not _cap_def.allowed_execution_roles + or not _cap_def.allowed_execution_roles <= _ALL_SKILL_EXECUTION_ROLES + ): + raise RuntimeError( + f"SKILL_CAPABILITY_REGISTRY[{_cap_name!r}].allowed_execution_roles " + "must be a non-empty subset of SkillExecutionRole." + ) # Boot-time validation: every `required_backend_property` must name a real # field on `BackendCapabilities`. Catches typos in the registry at import diff --git a/src/autoskillit/core/types/_type_enums.py b/src/autoskillit/core/types/_type_enums.py index 365c788796..3f088db8e8 100644 --- a/src/autoskillit/core/types/_type_enums.py +++ b/src/autoskillit/core/types/_type_enums.py @@ -6,12 +6,14 @@ from __future__ import annotations from enum import StrEnum, unique +from typing import assert_never __all__ = [ "RetryReason", "MergeFailedStep", "MergeState", "RestartScope", + "SkillExecutionRole", "SkillSource", "RecipeSource", "ClaudeFlags", @@ -28,6 +30,7 @@ "ChannelBStatus", "PRState", "SessionType", + "session_type_for_skill_execution_role", "FleetErrorCode", "FeatureLifecycle", "IssueLabelState", @@ -120,9 +123,19 @@ class RestartScope(StrEnum): PARTIAL_RESTART = "partial_restart" +class SkillExecutionRole(StrEnum): + """Exact orchestration role authorized to execute a skill contract.""" + + SESSION = "session" + ORCHESTRATOR = "orchestrator" + FLEET = "fleet" + + class SkillSource(StrEnum): BUNDLED = "bundled" BUNDLED_EXTENDED = "bundled_extended" + PROJECT_LOCAL = "project_local" + THIRD_PARTY = "third_party" class RecipeSource(StrEnum): @@ -416,6 +429,19 @@ class SessionType(StrEnum): SKILL = "skill" +def session_type_for_skill_execution_role(role: SkillExecutionRole) -> SessionType: + """Map a machine-contract role to its runtime session discriminator.""" + match role: + case SkillExecutionRole.SESSION: + return SessionType.SKILL + case SkillExecutionRole.ORCHESTRATOR: + return SessionType.ORCHESTRATOR + case SkillExecutionRole.FLEET: + return SessionType.FLEET + case _ as unreachable: + assert_never(unreachable) + + @unique class FleetErrorCode(StrEnum): """Registered error codes for fleet dispatch failures. diff --git a/src/autoskillit/core/types/_type_exceptions.py b/src/autoskillit/core/types/_type_exceptions.py index fbb74b0859..7312b7d96d 100644 --- a/src/autoskillit/core/types/_type_exceptions.py +++ b/src/autoskillit/core/types/_type_exceptions.py @@ -4,6 +4,7 @@ __all__ = [ "CapabilityNotSupportedError", + "SkillContractError", "RecipeLoadError", "ProcessStaleError", "RecipeNotFoundError", @@ -29,3 +30,7 @@ def __init__(self, capability: str, backend_name: str) -> None: self.capability = capability self.backend_name = backend_name super().__init__(f"{backend_name!r} does not support capability {capability!r}") + + +class SkillContractError(ValueError): + """A skill machine contract is malformed or exceeds its execution role.""" diff --git a/src/autoskillit/core/types/_type_helpers.py b/src/autoskillit/core/types/_type_helpers.py index c619ebe808..79b1f2e4c4 100644 --- a/src/autoskillit/core/types/_type_helpers.py +++ b/src/autoskillit/core/types/_type_helpers.py @@ -11,7 +11,7 @@ import re import shlex import warnings -from typing import Any +from typing import Any, assert_never from ._type_constants import AUTOSKILLIT_SKILL_PREFIX, SKILL_COMMAND_PREFIX from ._type_constants_env import HEADLESS_ENV_VAR, SESSION_TYPE_ENV_VAR @@ -134,11 +134,14 @@ def resolve_target_skill( if info is None: return skill_command, name - # Determine correct prefix based on physical location - if info.source == SkillSource.BUNDLED: - correct_prefix = AUTOSKILLIT_SKILL_PREFIX + name - else: - correct_prefix = SKILL_COMMAND_PREFIX + name + # Determine the invocation namespace from the selected effective origin. + match info.source: + case SkillSource.BUNDLED: + correct_prefix = AUTOSKILLIT_SKILL_PREFIX + name + case SkillSource.BUNDLED_EXTENDED | SkillSource.PROJECT_LOCAL | SkillSource.THIRD_PARTY: + correct_prefix = SKILL_COMMAND_PREFIX + name + case _ as unreachable: + assert_never(unreachable) # Reconstruct: replace the skill reference, preserve trailing arguments stripped = skill_command.strip() diff --git a/src/autoskillit/core/types/_type_protocols_workspace.py b/src/autoskillit/core/types/_type_protocols_workspace.py index fd4dc76ff7..db8510e7c2 100644 --- a/src/autoskillit/core/types/_type_protocols_workspace.py +++ b/src/autoskillit/core/types/_type_protocols_workspace.py @@ -8,6 +8,7 @@ if TYPE_CHECKING: from ._type_protocols_backend import CodingAgentBackend +from ._type_enums import SkillExecutionRole from ._type_results import CleanupResult, CloneResult, ValidatedAddDir __all__ = [ @@ -91,6 +92,21 @@ class SkillResolver(Protocol): def resolve(self, name: str) -> Any: ... + def resolve_effective(self, name: str, project_root: Path | None) -> Any: ... + + def list_effective( + self, + project_root: Path | None, + execution_role: SkillExecutionRole, + ) -> Any: ... + + def resolve_invocation( + self, + name: str, + project_root: Path | None, + execution_role: SkillExecutionRole, + ) -> Any: ... + @runtime_checkable class SkillLister(Protocol): diff --git a/src/autoskillit/core/types/_type_skill_contract.py b/src/autoskillit/core/types/_type_skill_contract.py new file mode 100644 index 0000000000..9161898b5a --- /dev/null +++ b/src/autoskillit/core/types/_type_skill_contract.py @@ -0,0 +1,21 @@ +"""Backend-neutral skill source identity contracts.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from ._type_enums import SkillSource + +__all__ = ["SkillSourceRef"] + + +@dataclass(frozen=True, slots=True) +class SkillSourceRef: + """Exact logical source selected for a skill machine contract.""" + + origin: SkillSource + logical_name: str + skill_path: Path + search_dir: str | None = None + precedence: int | None = None diff --git a/src/autoskillit/hooks/guards/skill_orchestration_guard.py b/src/autoskillit/hooks/guards/skill_orchestration_guard.py index 419476997c..0405d777b2 100644 --- a/src/autoskillit/hooks/guards/skill_orchestration_guard.py +++ b/src/autoskillit/hooks/guards/skill_orchestration_guard.py @@ -1,12 +1,13 @@ #!/usr/bin/env python3 -"""PreToolUse hook — blocks orchestration tools from L1 skill sessions. +"""PreToolUse hook — enforces exact orchestration roles for execution tools. Skill sessions (AUTOSKILLIT_SESSION_TYPE=skill or unset in headless mode) must never call run_skill, run_cmd, or run_python. This is defense-in-depth over the in-handler gate check in each tool. -L2+ invariant: orchestrator (L2) and fleet (L3) sessions may call orchestration tools. -Skill sessions use native Claude Code tools only. +L2 orchestrators may call all three tools. L3 fleet sessions retain run_cmd and +run_python, but must dispatch L2 work through dispatch_food_truck rather than +calling run_skill directly. Skill sessions use native Claude Code tools only. """ import json @@ -28,20 +29,32 @@ def main() -> None: if os.environ.get("AUTOSKILLIT_HEADLESS") != "1": sys.exit(0) - # Headless: resolve session type, fail-closed to skill session - raw_session_type = os.environ.get("AUTOSKILLIT_SESSION_TYPE", "") - session_type = raw_session_type.lower() - if session_type in ("orchestrator", "fleet"): - sys.exit(0) # permitted tiers — not a skill session - # skill, unset → deny below; unrecognized non-empty values also denied - _unrecognized_tier = bool(session_type) and session_type != "skill" - tool_name: str = data.get("tool_name", "") # MCP tool names are prefixed: mcp____ # Check only the last __ segment — avoids false positives where a server # name coincidentally contains an orchestration tool name. tool = tool_name.split("__")[-1] - if tool in _ORCHESTRATION_TOOLS: + if tool not in _ORCHESTRATION_TOOLS: + sys.exit(0) + + # Headless: resolve session type, fail-closed for orchestration tools. + raw_session_type = os.environ.get("AUTOSKILLIT_SESSION_TYPE", "") + session_type = raw_session_type.lower() + if session_type == "orchestrator": + sys.exit(0) + if session_type == "fleet" and tool in {"run_cmd", "run_python"}: + sys.exit(0) + + # skill, unset → deny below; unrecognized non-empty values also denied + _unrecognized_tier = bool(session_type) and session_type != "skill" + + if session_type == "fleet": + denial_reason = ( + "run_skill cannot be called from fleet sessions. " + "Only orchestrator sessions may call run_skill. " + "Fleet sessions create orchestrators with dispatch_food_truck." + ) + else: denial_reason = ( f"{tool} cannot be called from skill sessions. " "Only orchestrator or fleet sessions may call orchestration tools. " @@ -52,18 +65,16 @@ def main() -> None: f" (AUTOSKILLIT_SESSION_TYPE={raw_session_type!r} is not a recognized tier;" " expected: orchestrator, fleet, or skill)" ) - payload = json.dumps( - { - "hookSpecificOutput": { - "hookEventName": "PreToolUse", - "permissionDecision": "deny", - "permissionDecisionReason": denial_reason, - } + payload = json.dumps( + { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": denial_reason, } - ) - sys.stdout.write(payload + "\n") - sys.exit(0) - + } + ) + sys.stdout.write(payload + "\n") sys.exit(0) diff --git a/src/autoskillit/server/_guards.py b/src/autoskillit/server/_guards.py index 08c451af90..366e12de07 100644 --- a/src/autoskillit/server/_guards.py +++ b/src/autoskillit/server/_guards.py @@ -102,8 +102,7 @@ def _require_orchestrator_exact(tool_name: str = "") -> str | None: if st is SessionType.FLEET: msg = ( f"{tool_name} cannot be called from {st.value} sessions. " - f"{st.value.capitalize()} sessions have an auto-opened gate." - " open_kitchen is unnecessary." + "Only orchestrator sessions may call this tool." if tool_name else None ) diff --git a/src/autoskillit/server/tools/tools_execution.py b/src/autoskillit/server/tools/tools_execution.py index 7d8a15c809..4a7a5edc60 100644 --- a/src/autoskillit/server/tools/tools_execution.py +++ b/src/autoskillit/server/tools/tools_execution.py @@ -58,6 +58,7 @@ _check_recipe_read_prohibition, _check_write_target_boundary, _require_enabled, + _require_orchestrator_exact, _require_orchestrator_or_higher, _validate_skill_command, ) @@ -759,7 +760,7 @@ async def run_skill( Never raises. """ - if (tier_gate := _require_orchestrator_or_higher("run_skill")) is not None: + if (tier_gate := _require_orchestrator_exact("run_skill")) is not None: return tier_gate if (gate := _require_enabled()) is not None: return gate diff --git a/src/autoskillit/skills/sous-chef/SKILL.md b/src/autoskillit/skills/sous-chef/SKILL.md index 3bd0e9fe51..b1330a4ece 100644 --- a/src/autoskillit/skills/sous-chef/SKILL.md +++ b/src/autoskillit/skills/sous-chef/SKILL.md @@ -1,6 +1,7 @@ --- name: sous-chef uses_capabilities: [cross_skill_ref, github_api_write, open_kitchen, run_skill, test_check] +execution_role: orchestrator description: Internal bootstrap document injected by open_kitchen into every orchestrator session. ---