diff --git a/.autoskillit/skills/audit-feature-gates/SKILL.md b/.autoskillit/skills/audit-feature-gates/SKILL.md index 896f7ca882..fddd1854bc 100644 --- a/.autoskillit/skills/audit-feature-gates/SKILL.md +++ b/.autoskillit/skills/audit-feature-gates/SKILL.md @@ -1,6 +1,7 @@ --- name: audit-feature-gates categories: [audit] +uses_capabilities: [agent_model] description: > Audit feature flag isolation — traces import chains, runtime gates, tool/skill tag coverage, UI surfaces, and test markers to detect leakage and miswiring. diff --git a/.autoskillit/skills/eval-agent/SKILL.md b/.autoskillit/skills/eval-agent/SKILL.md index 7885a7f003..9229d49fdd 100644 --- a/.autoskillit/skills/eval-agent/SKILL.md +++ b/.autoskillit/skills/eval-agent/SKILL.md @@ -1,6 +1,7 @@ --- name: eval-agent categories: [eval] +uses_capabilities: [agent_subagent] description: > Invoke a named agent definition against a provided prompt and capture its output. Execution primitive for the agent-eval recipe — isolates a single agent invocation. diff --git a/.claude/skills/audit-bugs/SKILL.md b/.claude/skills/audit-bugs/SKILL.md index 1388f0e73a..088913373e 100644 --- a/.claude/skills/audit-bugs/SKILL.md +++ b/.claude/skills/audit-bugs/SKILL.md @@ -1,5 +1,6 @@ --- name: audit-bugs +uses_capabilities: [claude_dir] description: Analyze historical bug patterns by mining Claude Code project logs for /investigate skill invocations since a specified date. Identifies recurring root causes, architectural gaps, and proactive detection strategies. Use when user says "audit bugs", "bug patterns", "analyze investigations", or "bug audit". hooks: PreToolUse: diff --git a/.claude/skills/chart-course/SKILL.md b/.claude/skills/chart-course/SKILL.md index e1e91c71a4..52d78d4f90 100644 --- a/.claude/skills/chart-course/SKILL.md +++ b/.claude/skills/chart-course/SKILL.md @@ -1,5 +1,6 @@ --- name: chart-course +uses_capabilities: [agent_model, cross_skill_ref] description: Interactive strategic compass builder. Guides the user through mapping all possible project directions with progressive codebase analysis, web research, and architectural diagrams at every step. Produces a machine-readable compass document for downstream alignment tracking. hooks: PreToolUse: diff --git a/.claude/skills/check-bearing/SKILL.md b/.claude/skills/check-bearing/SKILL.md index 0d173dd5e2..e7114354fa 100644 --- a/.claude/skills/check-bearing/SKILL.md +++ b/.claude/skills/check-bearing/SKILL.md @@ -1,5 +1,6 @@ --- name: check-bearing +uses_capabilities: [agent_model] description: Assess a branch or PR's alignment with the strategic compass. Evaluates whether changes advance, drift from, or close off strategic directions. Produces an alignment dashboard with per-direction impact analysis and a verdict. hooks: PreToolUse: diff --git a/.claude/skills/promote-to-main/SKILL.md b/.claude/skills/promote-to-main/SKILL.md index eff173ac70..8af7ab7cf2 100644 --- a/.claude/skills/promote-to-main/SKILL.md +++ b/.claude/skills/promote-to-main/SKILL.md @@ -1,6 +1,7 @@ --- name: promote-to-main categories: [github] +uses_capabilities: [agent_model, cross_skill_ref, github_api_write] description: > Promote integration to main with comprehensive changelog and PR creation. Use when user says "promote to main", "open promotion PR", "integration to main", or @@ -96,10 +97,7 @@ working directory. ```bash mkdir -p .autoskillit/temp/promote-to-main python3 - <<'EOF' > .autoskillit/temp/promote-to-main/token_summary.md 2>/dev/null || true -import json, pathlib, sys -from autoskillit.pipeline.tokens import DefaultTokenLog -from autoskillit.pipeline.telemetry_fmt import TelemetryFormatter -from autoskillit.execution.session_log import resolve_log_dir +import json, os, pathlib, sys cfg_path = pathlib.Path(".autoskillit") / "temp" / ".hook_config.json" kitchen_id = "" @@ -108,14 +106,41 @@ if cfg_path.exists(): if isinstance(_cfg, dict): kitchen_id = _cfg.get("kitchen_id") or _cfg.get("pipeline_id", "") -log_root = resolve_log_dir("") -tl = DefaultTokenLog() -n = tl.load_from_log_dir(log_root, kitchen_id_filter=kitchen_id) -if n == 0: +log_root = os.environ.get("AUTOSKILLIT_LOG_DIR", "") +if not log_root: sys.exit(0) -steps = tl.get_report() -total = tl.compute_total() -print(TelemetryFormatter.format_token_table(steps, total)) +tl_path = pathlib.Path(log_root) +if not tl_path.exists(): + sys.exit(0) + +total_input = total_output = 0 +session_count = 0 +for session_dir in sorted(tl_path.iterdir()): + if not session_dir.is_dir(): + continue + token_file = session_dir / "tokens.json" + if not token_file.exists(): + continue + try: + data = json.loads(token_file.read_text()) + if kitchen_id and data.get("kitchen_id") != kitchen_id: + continue + session_count += 1 + total_input += data.get("input_tokens", 0) + total_output += data.get("output_tokens", 0) + except (json.JSONDecodeError, OSError, TypeError): + continue + +if session_count == 0: + sys.exit(0) + +header = "| Metric | Value |\n|---|---|\n" +rows = ( + f"| Sessions | {session_count} |\n" + f"| Input Tokens | {total_input:,} |\n" + f"| Output Tokens | {total_output:,} |\n" +) +print(header + rows) EOF ``` diff --git a/.claude/skills/review-promotion/SKILL.md b/.claude/skills/review-promotion/SKILL.md index 2c3c6f0bf2..4ed8bdfc6d 100644 --- a/.claude/skills/review-promotion/SKILL.md +++ b/.claude/skills/review-promotion/SKILL.md @@ -1,6 +1,7 @@ --- name: review-promotion categories: [github] +uses_capabilities: [agent_model] description: > Reviewer-facing deep analysis of an integration-to-main promotion. Performs domain risk scoring, breaking change audit, regression risk assessment, test coverage delta, diff --git a/.claude/skills/validate-audit/SKILL.md b/.claude/skills/validate-audit/SKILL.md index 4642d39f51..fbe3aebd9c 100644 --- a/.claude/skills/validate-audit/SKILL.md +++ b/.claude/skills/validate-audit/SKILL.md @@ -1,6 +1,7 @@ --- name: validate-audit categories: [audit] +uses_capabilities: [agent_model, github_api_write] description: Validate audit findings from audit-arch, audit-tests, or audit-cohesion against actual code, git history, and design intent using 9–10 parallel subagents. Removes contested findings, documents exceptions, adjusts severities. Use when user says "validate audit", "validate findings", "validate report", or "check audit results". hooks: PreToolUse: diff --git a/docs/README.md b/docs/README.md index dc5130b195..9e1b6b9046 100644 --- a/docs/README.md +++ b/docs/README.md @@ -35,4 +35,5 @@ multi-level orchestrator. The bundled recipes implement issue → plan → workt - [research/audit-trail-format.md](research/audit-trail-format.md) — audit/ artifact structure and lifecycle - [research/codex-delivery-conformance.md](research/codex-delivery-conformance.md) — Codex recipe envelope/pull conformance and protected-host blocker - [audit/surface-freeze-checklist.md](audit/surface-freeze-checklist.md) — commands.py public import surface freeze checklist +- [verification/review-pr-immunity.md](verification/review-pr-immunity.md) — deterministic review-pr projection matrix and live provider-attempt record - [phoropter/](phoropter/README.md) — phoropter lens framework: execution contracts, recipe blocks, synthesis strategies, and authoring guide 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/design/acp-session-contract.md b/docs/design/acp-session-contract.md index 53a31a438d..d99fd8348e 100644 --- a/docs/design/acp-session-contract.md +++ b/docs/design/acp-session-contract.md @@ -175,7 +175,7 @@ The contract nudge exclusively targets `session/resume`; it never invokes ## Section 3: Capabilities Translation `BackendCapabilities` (`src/autoskillit/core/types/_type_backend.py`, -frozen dataclass, 41 fields total) declares feature flags the orchestrator +frozen dataclass, 40 fields total) declares feature flags the orchestrator consumes when selecting an ACP rung or backend-specific code path. Each field falls into one of three categories: @@ -186,7 +186,7 @@ falls into one of three categories: outside the exemption set. Membership is validated against `_FORWARD_DECLARED` in `tests/arch/test_capability_consumption.py`. -The counts below are **17 ACP-Mappable + 6 Forward-Declared + 18 autoskillit-Local = 41 total**. +The counts below are **17 ACP-Mappable + 6 Forward-Declared + 17 autoskillit-Local = 40 total**. ### 3.1 Category 1: ACP-Mappable (17 fields) @@ -210,11 +210,10 @@ The counts below are **17 ACP-Mappable + 6 Forward-Declared + 18 autoskillit-Loc | `record_capable` | ACP scenario recording | | `inspector_capable` | ACP health monitoring callback (Health Inspector per issue #3533) | -### 3.2 Category 2: autoskillit-Local Extension (18 fields) +### 3.2 Category 2: autoskillit-Local Extension (17 fields) | Field | autoskillit-specific contract | |---|---| -| `project_local_skills_capable` | autoskillit `.claude/skills/` discovery (Claude: `True`; Codex: `False`) | | `supports_tool_list_changed` | autoskillit kitchen reveal timing (tool list notification) | | `required_skill_fields` | autoskillit `SKILL.md` validation (`frozenset({"name", "description"})`) | | `applicable_guards` | autoskillit guard script enforcement (Claude: `{"skill_load_guard"}`; Codex: `{"write_guard"}`) | @@ -362,7 +361,7 @@ warning (the others are static no-ops with no observable behavior). | §2 RetryReason enum | `RetryReason` | `src/autoskillit/core/types/_type_enums.py` lines 44–64 | | §2 Retry routing | `_compute_retry`, `_build_skill_result` overrides | `src/autoskillit/execution/session/_retry_fsm.py`, `src/autoskillit/execution/headless/_headless_result.py` | | §2 Contract nudge | `_attempt_contract_nudge`, `_merge_token_usage` | `src/autoskillit/execution/headless/_headless_recovery.py` | -| §3 Capabilities | `BackendCapabilities` (41 fields) | `src/autoskillit/core/types/_type_backend.py` | +| §3 Capabilities | `BackendCapabilities` (40 fields) | `src/autoskillit/core/types/_type_backend.py` | | §3 Forward-declared | `_FORWARD_DECLARED` | `tests/arch/test_capability_consumption.py` | | §4 Codex flags | `CodexFlags` | `src/autoskillit/execution/backends/codex.py` lines 98–107 | -| §4 Codex discard sites | `codex.py` F841 / warning sites | `src/autoskillit/execution/backends/codex.py` | \ No newline at end of file +| §4 Codex discard sites | `codex.py` F841 / warning sites | `src/autoskillit/execution/backends/codex.py` | diff --git a/docs/design/paper-backend-n3-exercise.md b/docs/design/paper-backend-n3-exercise.md index b8781712c7..f0ad63a6cf 100644 --- a/docs/design/paper-backend-n3-exercise.md +++ b/docs/design/paper-backend-n3-exercise.md @@ -121,7 +121,7 @@ provides: (e.g., a GAP on `build_resume_cmd` implies a new `RetryReason`-to-rung pathway for opencode's session model). - **Section 3 (Capabilities Translation)** — the 17 ACP-Mappable / 6 - Forward-Declared / 18 autoskillit-Local = 41 total taxonomy that this + Forward-Declared / 17 autoskillit-Local = 40 total taxonomy that this exercise's Section 3 adopts and re-applies per-field. ### 1.3 Classification legend @@ -205,13 +205,13 @@ and PCR-003 in Section 6. The taxonomy below mirrors `acp-session-contract.md` Section 3: - **ACP-Mappable** — 17 fields with a direct or close ACP analogue. -- **autoskillit-Local Extension** — 18 fields with no ACP analogue but +- **autoskillit-Local Extension** — 17 fields with no ACP analogue but required for autoskillit's extended contract. - **Forward-Declared** — 6 fields with no current production consumer outside the exemption set in `_FORWARD_DECLARED` (`tests/arch/test_capability_consumption.py:26–63`). -All 41 fields appear in exactly one row below. The "opencode value" column +All 40 fields appear in exactly one row below. The "opencode value" column records what the field would hold for an `OpencodeBackend.capabilities` instance; the classification column is TRIVIAL / SHIM-REQUIRED / GAP with the same legend as Section 2. @@ -238,51 +238,51 @@ same legend as Section 2. | 16 | `record_capable` | `False` | GAP | Same as row 15. Maps to PCR-009. | | 17 | `inspector_capable` | `False` | TRIVIAL | Same as both existing backends; raises `CapabilityNotSupportedError`. | -### 3.2 autoskillit-Local Extension fields (18) +### 3.2 autoskillit-Local Extension fields (17) | # | Field | opencode value | Classification | Rationale | |---|---|---|---|---| -| 18 | `project_local_skills_capable` | `False` | GAP | No project-local skill discovery mechanism. Maps to PCR-004. | -| 19 | `supports_tool_list_changed` | `True` | TRIVIAL | Default; kitchen reveal timing is unaffected. | -| 20 | `required_skill_fields` | `frozenset()` | TRIVIAL | No frontmatter requirement when no skill injection exists. | -| 21 | `applicable_guards` | `frozenset()` | TRIVIAL | No hook system documented; guards list is empty. | -| 22 | `write_guard_tool_names` | `frozenset()` (pending vocabulary) | SHIM-REQUIRED | Same dependency as Section 2 row 9; vocabulary must be enumerated before the frozenset can be populated. | -| 23 | `env_denylist_prefixes` | `()` | TRIVIAL | No env scrubbing required unless opencode leaks sensitive env vars; defaults to empty. | -| 24 | `version_check_command` | `"opencode --version"` | TRIVIAL | Same pattern as Claude Code / Codex. | -| 25 | `skills_subdir` | `"skills"` | TRIVIAL | See Section 4 caveat — the value is meaningless while `skill_injection_capable=False`. | -| 26 | `hook_config_format` | `""` | TRIVIAL | No hook config; empty string. | -| 27 | `write_detection_strategy` | `""` (pending) | SHIM-REQUIRED | opencode's write evidence format is unknown; strategy cannot be set until the adapter surfaces write events. Maps to PCR-010. | -| 28 | `default_skill_sandbox_mode` | `"workspace-write"` | GAP | Mismatch: opencode hard-codes `--sandbox deny`; the field value is aspirational but cannot be honoured at runtime. Maps to PCR-001. | -| 29 | `anthropic_provider_capable` | `False` | TRIVIAL | Multi-provider via AI SDK; not Anthropic-exclusive. | -| 30 | `plugin_install_capable` | `False` | TRIVIAL | No plugin system. | -| 31 | `supports_context_window_suffix` | `False` | TRIVIAL | AI SDK model identifiers do not carry `[1m]`-style suffixes. | -| 32 | `has_unguarded_filesystem_access` | `True` (pending adapter) | SHIM-REQUIRED | Default `True` matches Codex behaviour; gated prompt supplements apply until the write-detection adapter surfaces the per-tool evidence needed to gate more precisely. | -| 33 | `git_metadata_writable` | `True` | TRIVIAL | opencode's `--sandbox deny` is write-blocked at the user level, not at the kernel level; `.git/worktrees/` remains writable. | -| 34 | `skill_sigil` | `"$"` (defaulted) | SHIM-REQUIRED | No documented invocation prefix; defaulting to Codex's `"$"` until research surfaces an opencode-native convention. Maps to PCR-011. | -| 35 | `session_dir_persistent` | `True` | SHIM-REQUIRED | SQLite-backed sessions are persistent by construction; filesystem bridge must produce a persistent dir (analogous to Codex's `session_dir_persistent=True`). | +| 18 | `supports_tool_list_changed` | `True` | TRIVIAL | Default; kitchen reveal timing is unaffected. | +| 19 | `required_skill_fields` | `frozenset()` | TRIVIAL | No frontmatter requirement when no skill injection exists. | +| 20 | `applicable_guards` | `frozenset()` | TRIVIAL | No hook system documented; guards list is empty. | +| 21 | `write_guard_tool_names` | `frozenset()` (pending vocabulary) | SHIM-REQUIRED | Same dependency as Section 2 row 9; vocabulary must be enumerated before the frozenset can be populated. | +| 22 | `env_denylist_prefixes` | `()` | TRIVIAL | No env scrubbing required unless opencode leaks sensitive env vars; defaults to empty. | +| 23 | `version_check_command` | `"opencode --version"` | TRIVIAL | Same pattern as Claude Code / Codex. | +| 24 | `skills_subdir` | `"skills"` | TRIVIAL | See Section 4 caveat — the value is meaningless while `skill_injection_capable=False`. | +| 25 | `hook_config_format` | `""` | TRIVIAL | No hook config; empty string. | +| 26 | `write_detection_strategy` | `""` (pending) | SHIM-REQUIRED | opencode's write evidence format is unknown; strategy cannot be set until the adapter surfaces write events. Maps to PCR-010. | +| 27 | `default_skill_sandbox_mode` | `"workspace-write"` | GAP | Mismatch: opencode hard-codes `--sandbox deny`; the field value is aspirational but cannot be honoured at runtime. Maps to PCR-001. | +| 28 | `anthropic_provider_capable` | `False` | TRIVIAL | Multi-provider via AI SDK; not Anthropic-exclusive. | +| 29 | `plugin_install_capable` | `False` | TRIVIAL | No plugin system. | +| 30 | `supports_context_window_suffix` | `False` | TRIVIAL | AI SDK model identifiers do not carry `[1m]`-style suffixes. | +| 31 | `has_unguarded_filesystem_access` | `True` (pending adapter) | SHIM-REQUIRED | Default `True` matches Codex behaviour; gated prompt supplements apply until the write-detection adapter surfaces the per-tool evidence needed to gate more precisely. | +| 32 | `git_metadata_writable` | `True` | TRIVIAL | opencode's `--sandbox deny` is write-blocked at the user level, not at the kernel level; `.git/worktrees/` remains writable. | +| 33 | `skill_sigil` | `"$"` (defaulted) | SHIM-REQUIRED | No documented invocation prefix; defaulting to Codex's `"$"` until research surfaces an opencode-native convention. Maps to PCR-011. | +| 34 | `session_dir_persistent` | `True` | SHIM-REQUIRED | SQLite-backed sessions are persistent by construction; filesystem bridge must produce a persistent dir (analogous to Codex's `session_dir_persistent=True`). | ### 3.3 Forward-Declared fields (6) | # | Field | Forward-declared issue | opencode value | Classification | Rationale | |---|---|---|---|---|---| -| 36 | `supports_thinking_blocks` | #3497 (Claude + Codex pre-existing) | `False` | TRIVIAL | No thinking-block format documented. | -| 37 | `required_session_files` | #3134 (Codex pre-existing) | `frozenset()` | TRIVIAL | No session-dir contract; SQLite-only. | -| 38 | `session_dir_symlinks` | #3134 (Codex pre-existing) | `frozenset()` | TRIVIAL | No symlink targets. | -| 39 | `patch_format` | #3776 (Codex pre-existing) | `""` | SHIM-REQUIRED | Write-detection adapter must surface patch evidence before a format string can be set. Maps to PCR-010. | -| 40 | `min_version` | #3122 (Codex pre-existing) | `""` | TRIVIAL | Version policy deferred; daily cadence makes pinning fragile. | -| 41 | `mcp_env_forward_vars` | #3458 (Codex pre-existing) | `frozenset()` | TRIVIAL | No MCP wiring; empty set. | +| 35 | `supports_thinking_blocks` | #3497 (Claude + Codex pre-existing) | `False` | TRIVIAL | No thinking-block format documented. | +| 36 | `required_session_files` | #3134 (Codex pre-existing) | `frozenset()` | TRIVIAL | No session-dir contract; SQLite-only. | +| 37 | `session_dir_symlinks` | #3134 (Codex pre-existing) | `frozenset()` | TRIVIAL | No symlink targets. | +| 38 | `patch_format` | #3776 (Codex pre-existing) | `""` | SHIM-REQUIRED | Write-detection adapter must surface patch evidence before a format string can be set. Maps to PCR-010. | +| 39 | `min_version` | #3122 (Codex pre-existing) | `""` | TRIVIAL | Version policy deferred; daily cadence makes pinning fragile. | +| 40 | `mcp_env_forward_vars` | #3458 (Codex pre-existing) | `frozenset()` | TRIVIAL | No MCP wiring; empty set. | ### 3.4 Capabilities rollup | Category | TRIVIAL | SHIM-REQUIRED | GAP | |---|---|---|---| | ACP-Mappable (17) | 6 | 3 | 8 | -| autoskillit-Local Extension (18) | 11 | 5 | 2 | +| autoskillit-Local Extension (17) | 11 | 5 | 1 | | Forward-Declared (6) | 5 | 1 | 0 | -| **Total (41)** | **22** | **9** | **10** | +| **Total (40)** | **22** | **9** | **9** | -The 10 GAP rows consolidate into 9 PCRs (PCR-001 through PCR-009, plus the -sandbox-policy mismatch in row 28 maps to PCR-001); the 9 SHIM-REQUIRED rows +The 9 GAP rows consolidate into 8 PCRs (PCR-001 through PCR-005 and PCR-007 +through PCR-009); the sandbox-policy mismatch in row 27 maps to PCR-001. The +9 SHIM-REQUIRED rows consolidate into PCR-006 / PCR-010 / PCR-011 plus the opencode-vocabulary adapter family. @@ -380,17 +380,17 @@ single PCR with multiple affected surfaces. The PCR ID is sequential. | PCR | Name | Source section | Gap description | Protocol impact | Upstream dependency | Precedent | |---|---|---|---|---|---|---| -| PCR-001 | sandbox-write-enablement | §2 rows 4, 12, 13; §3 row 8, 28 | `opencode run` hard-codes `--sandbox deny`; every `build_*_cmd` method that requires headless write capability (`build_cmd`, `build_skill_session_cmd`, `build_food_truck_cmd`) is structurally blocked. `food_truck_capable=False` and `default_skill_sandbox_mode="workspace-write"` are aspirational only. | **Protocol change**: a new capability flag (`sandbox_policy_override_capable: bool`) that distinguishes backends whose sandbox can be relaxed via a flag from those whose sandbox is hard-coded. `build_*_cmd` returns must distinguish "capability blocked" from "config rejected". | sst/opencode#13851 | Codex applies `--dangerously-bypass-approvals-and-sandbox` (`codex.py` lines 98–107); Claude Code applies `--dangerously-skip-permissions`. Both have explicit bypass flags; opencode does not. | +| PCR-001 | sandbox-write-enablement | §2 rows 4, 12, 13; §3 row 8, 27 | `opencode run` hard-codes `--sandbox deny`; every `build_*_cmd` method that requires headless write capability (`build_cmd`, `build_skill_session_cmd`, `build_food_truck_cmd`) is structurally blocked. `food_truck_capable=False` and `default_skill_sandbox_mode="workspace-write"` are aspirational only. | **Protocol change**: a new capability flag (`sandbox_policy_override_capable: bool`) that distinguishes backends whose sandbox can be relaxed via a flag from those whose sandbox is hard-coded. `build_*_cmd` returns must distinguish "capability blocked" from "config rejected". | sst/opencode#13851 | Codex applies `--dangerously-bypass-approvals-and-sandbox` (`codex.py` lines 98–107); Claude Code applies `--dangerously-skip-permissions`. Both have explicit bypass flags; opencode does not. | | PCR-002 | session-resume-mechanism | §2 row 11; §3 row 3 | No documented `--resume ` flag; SQLite session IDs are not first-class CLI arguments. `build_resume_cmd` cannot produce a `CmdSpec` that targets an existing session. `session_resume_capable=False`. | **Protocol change**: a new `ResumeSpec` variant (`SqliteBackedResume(session_db_path: Path, session_id: str)`) under `_type_resume.py`, or a generalised `backend-specific-resume` callable on the Protocol surface. | opencode resume feature | Claude Code uses `--resume ` flag; Codex uses `resume ` positional subcommand (`codex.py` lines 978–979). | | PCR-003 | orchestrator-session-support | §2 row 13; §3 row 8 | `build_food_truck_cmd` requires orchestrator-level L2 session support (driven by fleet dispatch); opencode has no equivalent and the sandbox blocker compounds. `food_truck_capable=False`. | **Protocol change**: a new `food_truck_capable` distinction between "no orchestrator support at all" (`False`) and "blocked by sandbox" (new variant or sub-flag). Same proposal as PCR-001 — likely merged into a single new capability. | sst/opencode#13851 | Codex supports food-truck via `build_food_truck_cmd` (lines 821–823) but discards `plugin_source` / `output_format` / `exit_after_stop_delay_ms`. | -| PCR-004 | skill-registration-discovery | §2 rows 9, 12; §3 row 4, 18; §4 row 2 | No documented `--add-dir` / `--plugin-dir` / skill-discovery mechanism. `skill_injection_capable=False`, `project_local_skills_capable=False`, `project_local_skill_search_dirs=()`. | **Protocol change**: a new `skill_registration: Callable[[SkillManifest], None]` field on the Protocol, decoupling registration from CLI flags. | opencode skill-injection feature | Claude Code uses `--add-dir` / `--plugin-dir`; Codex has no equivalent and uses an empty frozenset. | +| PCR-004 | skill-registration-discovery | §2 rows 9, 12; §3 row 4; §4 row 2 | No documented `--add-dir` / `--plugin-dir` / skill-discovery mechanism. `skill_injection_capable=False` and `project_local_skill_search_dirs=()`. | **Protocol change**: a new `skill_registration: Callable[[SkillManifest], None]` field on the Protocol, decoupling registration from CLI flags. | opencode skill-injection feature | Claude Code uses `--add-dir` / `--plugin-dir`; Codex has no equivalent and uses an empty frozenset. | | PCR-005 | exit-code-semantics | §3 row 6 | Exit codes are undocumented; daily release cadence with no schema versioning. `exit_code_is_terminal=False` is conservative but may yield false positives when opencode exits with a non-zero status on benign conditions. | **Protocol change**: a new `exit_code_is_terminal: Literal["true","false","unknown"]` (string union) to distinguish "verified terminal" from "undocumented". | opencode exit-code documentation | Codex has verified semantics (`exit_code_is_terminal=True`); Claude Code has verified semantics (`False`). | | PCR-006 | mcp-config-wiring | §3 row 7 | opencode may have an MCP-style config; the absence of documentation makes `mcp_config_capable` ambiguous. SHIM-REQUIRED rather than GAP because the Protocol surface is sufficient; the implementation simply awaits research. | **No Protocol change**. Implementation work only. | opencode MCP documentation | Codex: `mcp_config_capable=True`; Claude Code: `False`. | | PCR-007 | triage-probe-mechanism | §3 row 11 | No lightweight probe mechanism documented. `triage_capable=False` blocks `_llm_triage.py` from dispatching to opencode. | **Protocol change**: a new `triage_cmd_builder: Callable[[str], CmdSpec]` field that backends can implement to expose lightweight probes without requiring a full skill session. | opencode probe feature | Claude Code uses `claude -p` for triage (`_llm_triage.py`); Codex has no triage path. | | PCR-008 | context-exhaustion-signal | §3 row 12 | No documented exhaustion signal in stdout or SQLite. `supports_context_exhaustion_detection=False` blocks Codex-style `_headless_result.py` jsonl-context-exhausted handling for opencode. | **Protocol change**: a new `context_exhaustion_signal: Literal["stdout","sqlite","none"]` to distinguish the detection surface. | opencode exhaustion signal | Codex surfaces via `error_code == CODEX_CONTEXT_EXHAUSTION_MARKER` (`_headless_evidence.py` lines 105–108). | | PCR-009 | recording-replay-support | §3 rows 15, 16 | No recording/replay support documented. `replay_capable=False`, `record_capable=False` blocks api_simulator-based REPLAY_SCENARIO / RECORD_SCENARIO runners. | **No Protocol change**. Implementation work only — api_simulator can wrap opencode once the output adapter (PCR-010) lands. | opencode output-format documentation | Claude Code: `replay_capable=True`, `record_capable=True`. Codex: wired via T5-P3-A4-WP1 (`replay_capable=True`, local `CodexScenarioPlayer`). | -| PCR-010 | output-format-adapter | §2 rows 5, 24, 26; §3 rows 9, 10, 27, 39; §5 B3a | opencode emits bulk JSON or terminal-formatted output, not NDJSON. The `StreamParser` / `ResultParser` pair require an adapter; `completion_record_types`, `session_record_types`, `write_detection_strategy`, `patch_format` all depend on the adapter's surface. | **Protocol change**: a new `output_format: Literal["ndjson","bulk_json","terminal"]` field on the Protocol so consumers can dispatch on the actual format rather than assuming NDJSON. | opencode output-format documentation | Both existing backends emit NDJSON; this is the first non-NDJSON candidate. | -| PCR-011 | skill-sigil-convention | §3 row 34 | No documented invocation prefix. Defaulting to Codex's `"$"` until research surfaces an opencode-native convention. | **No Protocol change**. Implementation choice only. | opencode invocation convention | Claude Code uses `"/"`; Codex uses `"$"`. | +| PCR-010 | output-format-adapter | §2 rows 5, 24, 26; §3 rows 9, 10, 26, 38; §5 B3a | opencode emits bulk JSON or terminal-formatted output, not NDJSON. The `StreamParser` / `ResultParser` pair require an adapter; `completion_record_types`, `session_record_types`, `write_detection_strategy`, `patch_format` all depend on the adapter's surface. | **Protocol change**: a new `output_format: Literal["ndjson","bulk_json","terminal"]` field on the Protocol so consumers can dispatch on the actual format rather than assuming NDJSON. | opencode output-format documentation | Both existing backends emit NDJSON; this is the first non-NDJSON candidate. | +| PCR-011 | skill-sigil-convention | §3 row 33 | No documented invocation prefix. Defaulting to Codex's `"$"` until research surfaces an opencode-native convention. | **No Protocol change**. Implementation choice only. | opencode invocation convention | Claude Code uses `"/"`; Codex uses `"$"`. | | PCR-012 | conformance-probe-authoring | §5 B3a | The fixture-based conformance probe pattern (`tests/execution/backends/fixtures/codex_ndjson/`) assumes NDJSON; opencode requires a new authoring pattern. | **No Protocol change**. Test infrastructure extension only. | PCR-010 | Codex NDJSON fixtures. | | PCR-013 | hook-config-discovery | §5 B3b | Hook config file path and format are undocumented; opencode may use shell-level sandbox or another mechanism that bypasses the autoskillit hook surface. | **Protocol change**: a new `hook_config_paths: tuple[Path, ...]` field on `BackendCapabilities` that backends populate to declare the files the probe should write and observe. | opencode hook documentation | Codex: `hook_config_format="toml_nested"` (forward-declared); Claude Code: `hook_config_format=""`. | @@ -414,10 +414,10 @@ semantics. | Section | Source of truth | File | |---|---|---| | §2 method enumeration | `CodingAgentBackend` Protocol + 4 sub-protocols | `src/autoskillit/core/types/_type_protocols_backend.py` | -| §3 capability taxonomy | `BackendCapabilities` (41 fields) + `_FORWARD_DECLARED` | `src/autoskillit/core/types/_type_backend.py`, `tests/arch/test_capability_consumption.py` | +| §3 capability taxonomy | `BackendCapabilities` (40 fields) + `_FORWARD_DECLARED` | `src/autoskillit/core/types/_type_backend.py`, `tests/arch/test_capability_consumption.py` | | §3 capability categories | ACP-Mappable / autoskillit-Local / Forward-Declared counts | `docs/design/acp-session-contract.md` Section 3 | | §4 conventions | `BackendConventions` (2 fields) | `src/autoskillit/core/types/_type_backend.py:38–49` | | §5 B3a pattern | Codex NDJSON fixtures | `tests/execution/backends/fixtures/codex_ndjson/` | | §5 B3b pattern | Codex hook-efficacy probe | `tests/execution/backends/test_hook_deny_efficacy_probe.py` | | §6 PCR-001 / 003 blocker | `--sandbox deny` hard-coding | sst/opencode#13851 | -| §6 PCR-002 / 005 / 008 documentation | Resume, exit codes, exhaustion signals | sst/opencode repository | \ No newline at end of file +| §6 PCR-002 / 005 / 008 documentation | Resume, exit codes, exhaustion signals | sst/opencode repository | 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..d3e78688cd 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 (106 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 (32 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..7ef9c5a724 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,8 +16,8 @@ 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:` @@ -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` @@ -44,7 +46,7 @@ tiers. See [Subset Categories](subsets.md) for subset configuration. ### Tier 3 — Pipeline-Only (Automation Skills) - **Location**: `src/autoskillit/skills_extended/` (same directory as Tier 2) -- **Default members** (31 total): +- **Default members** (32 total): `prepare-pr`, `compose-pr`, `open-integration-pr`, `merge-pr`, `analyze-prs`, `review-pr`, `resolve-review`, `implement-worktree-no-merge`, `resolve-failures`, `retry-worktree`, `resolve-merge-conflicts`, `audit-impl`, `smoke-task`, @@ -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,32 +98,38 @@ 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`) -`run_skill` launches a headless Claude Code process with: -``` -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. +Before launch, `run_skill` resolves the requested L1 skill and its dependency closure +from the fixed-precedence effective catalog. That resolution includes applicable +project-local overrides; role, capability, configured-tier, and closure contracts are +validated before session initialization or filesystem writes. + +The selected canonical documents are then projected into a session-bound ephemeral +skill tree. Machine-only authority (`uses_capabilities`, `execution_role`, +`activate_deps`, and retired `backend_requirements`) stays in AutoSkillit's private +contract and is omitted from every model-facing `SKILL.md`. Claude Code receives that +sanitized ephemeral tree rather than a raw `skills_extended/` directory. Exact +ORCHESTRATOR-role entries such as `process-issues` remain excluded from the L1 catalog, +and project-local bytes are exposed only when that source won effective resolution. +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 +146,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/docs/verification/review-pr-immunity.md b/docs/verification/review-pr-immunity.md new file mode 100644 index 0000000000..25d77c48fa --- /dev/null +++ b/docs/verification/review-pr-immunity.md @@ -0,0 +1,81 @@ +# Review-PR Capability-Contract Immunity Verification + +Date: 2026-07-23 + +This record separates deterministic projection behavior from stochastic agent behavior. +The retry was explicitly constrained to direct repository work: no `open_kitchen`, +`run_skill`, or orchestration. The live evaluation therefore used four direct, +non-xdist Claude Code invocations, one per isolated `review-pr` variant. + +## Deterministic four-way matrix + +The deterministic matrix is implemented by +`test_review_pr_four_way_metadata_transport_projection_matrix`. + +| Variant | Machine `run_skill` declaration | Transport sentence | Projected machine keys | Projected transport sentence | +|---|---:|---:|---:|---:| +| Metadata plus transport | present | present | absent | present | +| Metadata removed | absent | present | absent | present | +| Transport removed | present | absent | absent | absent | +| Both removed | absent | absent | absent | absent | + +The test requires byte identity between the first two projected documents and between +the last two projected documents. It also requires the transport-present and +transport-absent projections to differ. This verifies that machine metadata cannot leak +into the model-facing document and that removal of the independent transport sentence +remains observable. + +## Live non-xdist attempts + +Each variant was installed as an isolated project-local `review-pr` skill under the +feature worktree's `.autoskillit/temp/retry-worktree/live-review-pr-eval-second/` +directory and invoked directly with: + +- Backend: Claude Code 2.1.197 +- Requested model: `sonnet` +- Prompt: `/review-pr impl-rectify_run_skill_capability_contract_immunity_2026-07-22_201946-20260722-223229 develop mode=local` +- Mode: non-interactive JSON output, `dontAsk`, no session persistence +- Tools: disabled, preserving the retry's prohibition on orchestration and preventing + GitHub or filesystem mutations +- Maximum requested budget: USD 0.25 per attempt + +| Attempt | Variant | Session ID | Duration | Provider result | Inference/cost | +|---:|---|---|---:|---|---:| +| 1 | Metadata plus transport | `6ac1ed88-a6cc-4f0f-9c80-6193379b95e9` | 611 ms | HTTP 429; weekly limit response | 0 tokens / USD 0 | +| 2 | Metadata removed | `509c3d29-7502-4191-918c-8663c44ea352` | 509 ms | HTTP 429; weekly limit response | 0 tokens / USD 0 | +| 3 | Transport removed | `f952b6b3-79ff-4ec8-b62e-2a08292fc2cf` | 439 ms | HTTP 429; weekly limit response | 0 tokens / USD 0 | +| 4 | Both removed | `32146007-a7d6-4613-bb2b-d099e90314e5` | 352 ms | HTTP 429; weekly limit response | 0 tokens / USD 0 | +| 5 | Metadata plus transport, post-reset retry | `7a0ce09c-ed3c-4acf-aa54-aa5f39aa263f` | 497 ms | HTTP 429; weekly limit response | 0 tokens / USD 0 | + +All four provider requests were attempted independently. The provider rejected every +request before inference, so there was no agent completion and no behavioral result to +compare. A fifth request retried the first variant after the provider's reported reset +window and received the same environmental failure. These observations satisfy the +attempt record but provide no stochastic evidence for or against a metadata or +transport-prose effect. + +## Context and limits + +- Source: bundled `review-pr` canonical skill contract. +- Projection: `project_agent_skill_document` with a bound immutable catalog. +- Provider/backend context: Claude Code 2.1.197 with requested model `sonnet`. +- Live agent output: none; all four requests failed with HTTP 429 before inference. +- Causal scope: the deterministic result establishes projection behavior only. It does + not claim a causal effect on a stochastic reviewer or CI outcome. + +## Committed verification evidence + +The durable remediation baseline is the repository tree at +`9562bf47fa2987c7108fbb40ffb07817d185c985`. Unlike the original worktree-only +record, that commit contains the implementation and its tests. The deterministic +matrix is preserved in +`tests/workspace/test_session_skills_provider.py::test_review_pr_four_way_metadata_transport_projection_matrix`. + +The verification is reproducible from the committed tree with `develop` as the base ref: + +| Command | Durable evidence | +|---|---| +| `git show --stat 9562bf47fa2987c7108fbb40ffb07817d185c985` | Identifies the committed source tree that contains the remediation. | +| `AUTOSKILLIT_TEST_FILTER=conservative AUTOSKILLIT_TEST_BASE_REF=develop task test-filtered` | Executes the repository-owned deterministic matrix and its affected test closure. | +| `AUTOSKILLIT_TEST_FILTER=conservative AUTOSKILLIT_TEST_BASE_REF=develop task test-check` | Runs the automation-facing PASS/FAIL gate against the committed implementation. | +| `pre-commit run --all-files` | Runs the repository-owned formatting, typing, lock, documentation, architecture, conflict, and secret-scanning guards. | diff --git a/src/autoskillit/_llm_triage.py b/src/autoskillit/_llm_triage.py index ab530158c7..4491cfbafd 100644 --- a/src/autoskillit/_llm_triage.py +++ b/src/autoskillit/_llm_triage.py @@ -7,7 +7,9 @@ from __future__ import annotations +import hashlib import json +from dataclasses import replace from pathlib import Path from typing import Any @@ -15,6 +17,7 @@ ClaudeFlags, CodingAgentBackend, OutputFormat, + SkillExecutionRole, SubprocessResult, TerminationReason, build_agent_env, @@ -22,7 +25,14 @@ ) from autoskillit.execution import parse_session_result, run_managed_async from autoskillit.recipe import StaleItem, load_bundled_manifest -from autoskillit.workspace import bundled_skills_dir +from autoskillit.workspace import ( + EffectiveSkillCatalog, + SkillCatalogEntry, + SkillProjectionContext, + default_skill_resolver, + parse_frontmatter_content, + project_agent_skill_document, +) logger = get_logger(__name__) @@ -31,7 +41,10 @@ async def triage_staleness( - stale_items: list[StaleItem], *, backend: CodingAgentBackend + stale_items: list[StaleItem], + *, + project_root: Path | None = None, + backend: CodingAgentBackend, ) -> list[dict[str, Any]]: """Use Haiku to determine if stale contracts changed meaningfully. @@ -63,22 +76,80 @@ async def triage_staleness( if not hash_items: return results - # Pre-load all SKILL.md content synchronously before any async task starts. - # Path.read_text() is blocking I/O and must not run inside async tasks. + # Pre-project all skill documents synchronously before any async task starts. + effective_project_root = (project_root or Path.cwd()).resolve() skill_md_cache: dict[str, str] = {} + resolver = default_skill_resolver() for item in hash_items: if item.skill not in skill_md_cache: - skill_md_path = bundled_skills_dir() / item.skill / "SKILL.md" - if skill_md_path.is_file(): - skill_md_cache[item.skill] = skill_md_path.read_text() + skill_info = resolver.resolve_effective(item.skill, effective_project_root) + if ( + skill_info is not None + and skill_info.invalid_reason is not None + and skill_info.invalid_reason.startswith("invalid frontmatter:") + ): + synthetic_content = ( + "---\n" + f"name: {skill_info.name}\n" + "description: Skill document used for contract staleness comparison.\n" + "---\n" + f"{skill_info.canonical_content}" + ) + skill_info = replace( + skill_info, + canonical_content=synthetic_content, + canonical_digest=hashlib.sha256(synthetic_content.encode()).hexdigest(), + frontmatter=parse_frontmatter_content(synthetic_content), + execution_role=SkillExecutionRole.SESSION, + invalid_reason=None, + ) + if ( + skill_info is None + or skill_info.invalid_reason is not None + or skill_info.execution_role is None + ): + continue + try: + entry = SkillCatalogEntry.from_skill_info(skill_info) + catalog = EffectiveSkillCatalog( + skills=(entry,), + execution_role=entry.execution_role, + ) + skill_md_cache[item.skill] = project_agent_skill_document( + entry, + SkillProjectionContext( + cwd=effective_project_root, + catalog=catalog, + backend=backend, + conventions=backend.conventions, + gating=False, + ), + ).content + except ValueError: + logger.warning( + "triage_skill_projection_failed", + skill=item.skill, + exc_info=True, + ) - results.extend(await _triage_batch(hash_items, skill_md_cache, backend=backend)) + results.extend( + await _triage_batch( + hash_items, + skill_md_cache, + cwd=effective_project_root, + backend=backend, + ) + ) return results async def _triage_batch( - batch: list[StaleItem], skill_md_cache: dict[str, str], *, backend: CodingAgentBackend + batch: list[StaleItem], + skill_md_cache: dict[str, str], + *, + cwd: Path, + backend: CodingAgentBackend, ) -> list[dict[str, Any]]: """Triage one batch of hash_mismatch items with a single Haiku subprocess call. @@ -137,7 +208,7 @@ async def _triage_batch( _triage_env["NO_COLOR"] = "1" result: SubprocessResult = await run_managed_async( cmd=triage_cmd, - cwd=Path.cwd(), + cwd=cwd, timeout=30.0, env=_triage_env, pty_mode=False, diff --git a/src/autoskillit/cli/_init_helpers.py b/src/autoskillit/cli/_init_helpers.py index 6dac2c6070..0c06acc786 100644 --- a/src/autoskillit/cli/_init_helpers.py +++ b/src/autoskillit/cli/_init_helpers.py @@ -5,6 +5,7 @@ import json import subprocess import sys +from collections.abc import Iterable from datetime import UTC, datetime from pathlib import Path from typing import TYPE_CHECKING, NamedTuple @@ -21,10 +22,32 @@ if TYPE_CHECKING: from autoskillit.core import BackendCapabilities, CodingAgentBackend + from autoskillit.workspace import EffectiveSkillCatalog, SkillInfo logger = get_logger(__name__) +def validate_public_plugin_projection( + source_root: Path, + public_root: Path, + private_manifest: Path, + skills: EffectiveSkillCatalog | Iterable[SkillInfo], +) -> None: + """Fail install when public documents and the private machine manifest drift.""" + from autoskillit.workspace import validate_sanitized_plugin_artifact + + errors = validate_sanitized_plugin_artifact( + source_root, + public_root, + private_manifest, + skills, + ) + if errors: + raise RuntimeError( + "refusing to publish invalid marketplace artifact: " + "; ".join(errors) + ) + + class _ScanResult(NamedTuple): passed: bool bypass_accepted: bool = False @@ -389,7 +412,7 @@ def _register_mcp_server(claude_json_path: Path) -> None: "command": "autoskillit", "args": [], } - atomic_write(claude_json_path, json.dumps(data, indent=2)) + atomic_write(claude_json_path, json.JSONEncoder(indent=2).encode(data)) def evict_direct_mcp_entry(claude_json_path: Path) -> bool: @@ -408,7 +431,7 @@ def evict_direct_mcp_entry(claude_json_path: Path) -> bool: return False del data["mcpServers"]["autoskillit"] try: - atomic_write(claude_json_path, json.dumps(data, indent=2)) + atomic_write(claude_json_path, json.JSONEncoder(indent=2).encode(data)) except OSError: return False return True diff --git a/src/autoskillit/cli/_marketplace.py b/src/autoskillit/cli/_marketplace.py index be6e7998bc..78a565a185 100644 --- a/src/autoskillit/cli/_marketplace.py +++ b/src/autoskillit/cli/_marketplace.py @@ -12,15 +12,28 @@ import regex as re import autoskillit.cli._hooks as _hooks_mod -from autoskillit.cli._init_helpers import _user_claude_json_path, evict_direct_mcp_entry +from autoskillit.cli._init_helpers import ( + _user_claude_json_path, + evict_direct_mcp_entry, + validate_public_plugin_projection, +) from autoskillit.core import ( DIRECT_INSTALL_CACHE_SUBDIR, + SkillExecutionRole, + SkillSource, atomic_write, get_logger, is_git_worktree, pkg_root, ) from autoskillit.hooks import generate_hooks_json +from autoskillit.workspace import ( + DefaultSkillResolver, + EffectiveSkillCatalog, + SkillCatalogEntry, + SkillProjectionContext, + materialize_sanitized_plugin_root, +) logger = get_logger(__name__) @@ -61,16 +74,14 @@ def _ensure_marketplace() -> Path: pkg_dir = pkg_root() - # Guard: refuse to create the symlink when the package is installed - # from a git worktree. The symlink target must outlive the Python - # process that creates it — transient worktree paths will break it. + # Guard: a transient worktree is not a valid canonical source for a + # persistent marketplace artifact. if is_git_worktree(pkg_dir): raise SystemExit( "ERROR: 'autoskillit install' cannot be run when the package\n" "is installed from a git worktree.\n\n" f" Detected worktree path: {pkg_dir}\n\n" - "The marketplace symlink would point to this transient path and\n" - "break when the worktree is deleted.\n\n" + "The marketplace projection would be sourced from this transient path.\n\n" "Fix: run 'autoskillit install' from the main project checkout:\n" " cd /path/to/main/repo && autoskillit install" ) @@ -93,14 +104,38 @@ def _ensure_marketplace() -> Path: } ], } - atomic_write(plugin_dir / "marketplace.json", json.dumps(manifest, indent=2) + "\n") + atomic_write( + plugin_dir / "marketplace.json", + json.JSONEncoder(indent=2).encode(manifest) + "\n", + ) - # Symlink to the live package directory - link_path = marketplace_dir / "plugins" / "autoskillit" - link_path.parent.mkdir(parents=True, exist_ok=True) - if link_path.is_symlink() or link_path.exists(): - link_path.unlink() - link_path.symlink_to(pkg_dir) + public_plugin_root = marketplace_dir / "plugins" / "autoskillit" + source_infos = tuple( + skill for skill in DefaultSkillResolver().list_all() if skill.source is SkillSource.BUNDLED + ) + catalog = EffectiveSkillCatalog( + skills=tuple(SkillCatalogEntry.from_skill_info(skill) for skill in source_infos), + execution_role=SkillExecutionRole.SESSION, + ) + private_manifest = materialize_sanitized_plugin_root( + pkg_dir, + public_plugin_root, + catalog, + SkillProjectionContext( + cwd=Path.cwd().resolve(), + catalog=catalog, + ), + ) + atomic_write( + public_plugin_root / "hooks" / "hooks.json", + json.JSONEncoder(indent=2).encode(generate_hooks_json()) + "\n", + ) + validate_public_plugin_projection( + pkg_dir, + public_plugin_root, + private_manifest, + source_infos, + ) return marketplace_dir @@ -187,10 +222,6 @@ def install(*, scope: str = "user") -> bool: with _InstallLock(): _clear_plugin_cache() - # Regenerate hooks.json from the canonical registry with absolute paths - hooks_json_path = pkg_root() / "hooks" / "hooks.json" - atomic_write(hooks_json_path, json.dumps(generate_hooks_json(), indent=2) + "\n") - # Register the marketplace (idempotent) result = subprocess.run( ["claude", "plugin", "marketplace", "add", str(marketplace_dir)], diff --git a/src/autoskillit/cli/_prompts.py b/src/autoskillit/cli/_prompts.py index 7a4c2f0c2f..bd2f3e04fb 100644 --- a/src/autoskillit/cli/_prompts.py +++ b/src/autoskillit/cli/_prompts.py @@ -12,7 +12,17 @@ from __future__ import annotations -from autoskillit.core import pkg_root +from pathlib import Path +from typing import Any + +from autoskillit.core import SkillExecutionRole +from autoskillit.workspace import ( + DefaultSkillResolver, + EffectiveSkillCatalog, + SkillProjectionContext, + parse_frontmatter_content, + project_agent_skill_document, +) # ── Shared helpers (used by sibling _prompts_*.py modules) ────────────── @@ -26,18 +36,39 @@ ) -def _read_full_sous_chef() -> str: - """Read the full sous-chef SKILL.md for injection into L1/L2 orchestration sessions.""" - path = pkg_root() / "skills" / "sous-chef" / "SKILL.md" - try: - content = path.read_text() - except OSError: +def _read_full_sous_chef( + skill_catalog: EffectiveSkillCatalog | None = None, + *, + project_dir: Path | None = None, + backend: Any | None = None, +) -> str: + """Project the effective sous-chef contract for an orchestrator prompt.""" + effective_root = (project_dir or Path.cwd()).resolve() + catalog = skill_catalog or DefaultSkillResolver().list_effective( + effective_root, + SkillExecutionRole.ORCHESTRATOR, + ) + if catalog.execution_role is not SkillExecutionRole.ORCHESTRATOR: + raise ValueError("sous-chef projection requires an orchestrator skill catalog") + sous_chef = next((skill for skill in catalog.skills if skill.name == "sous-chef"), None) + if ( + sous_chef is None + or sous_chef.invalid_reason is not None + or sous_chef.execution_role is not SkillExecutionRole.ORCHESTRATOR + ): return "" - if content.startswith("---"): - end = content.find("\n---\n", 3) - if end != -1: - content = content[end + 5 :].lstrip("\n") - return content + projected = project_agent_skill_document( + sous_chef, + SkillProjectionContext( + cwd=effective_root, + catalog=catalog, + backend=backend, + conventions=getattr(backend, "conventions", None), + gating=False, + ), + ).content + parsed = parse_frontmatter_content(projected) + return parsed.body.lstrip("\n") if parsed.is_valid else projected def _ingredient_table_display_instruction(source: str) -> str: diff --git a/src/autoskillit/cli/_prompts_campaign.py b/src/autoskillit/cli/_prompts_campaign.py index 0d28165f6b..cbd0cc750e 100644 --- a/src/autoskillit/cli/_prompts_campaign.py +++ b/src/autoskillit/cli/_prompts_campaign.py @@ -8,7 +8,6 @@ from autoskillit.cli._prompts import ( _backend_supplement, _ingredient_table_display_instruction, - _read_full_sous_chef, ) from autoskillit.core import ROUTING_AUTHORITY_CLAUSE, RetryReason @@ -134,8 +133,6 @@ def _build_fleet_campaign_prompt( progress markers. """ dispatch_count = len(campaign_recipe.dispatches) - admiral_content = _read_full_sous_chef() - admiral_section = f"\n## ADMIRAL DISCIPLINE\n\n{admiral_content}\n" if admiral_content else "" has_gate_dispatches = any(d.gate for d in campaign_recipe.dispatches) @@ -257,7 +254,7 @@ def _build_fleet_campaign_prompt( return f"""\ You are a fleet campaign dispatcher. Execute campaign '{campaign_recipe.name}' autonomously. Campaign ID: {campaign_id}. Dispatches: {dispatch_count}. -{admiral_section} + ## CAMPAIGN OVERVIEW - Name: {campaign_recipe.name} diff --git a/src/autoskillit/cli/_prompts_kitchen.py b/src/autoskillit/cli/_prompts_kitchen.py index c646aec8c6..3d02c58e1e 100755 --- a/src/autoskillit/cli/_prompts_kitchen.py +++ b/src/autoskillit/cli/_prompts_kitchen.py @@ -3,6 +3,7 @@ from __future__ import annotations from pathlib import Path +from typing import TYPE_CHECKING from autoskillit.cli._prompts import ( _MCP_RETRY_INSTRUCTION, @@ -12,6 +13,9 @@ from autoskillit.core import PIPELINE_FORBIDDEN_TOOLS, ROUTING_AUTHORITY_CLAUSE from autoskillit.execution import codex_recipe_delivery_calling_contract +if TYPE_CHECKING: + from autoskillit.workspace import EffectiveSkillCatalog + __all__ = [ "_build_open_kitchen_prompt", "_build_fleet_dispatch_prompt", @@ -19,10 +23,18 @@ def _build_open_kitchen_prompt( - mcp_prefix: str, has_unguarded_filesystem_access: bool = False + mcp_prefix: str, + has_unguarded_filesystem_access: bool = False, + skill_catalog: EffectiveSkillCatalog | None = None, + project_dir: Path | None = None, + backend: object | None = None, ) -> str: """Build the --append-system-prompt content for an open-kitchen cook session (no recipe).""" - raw = _read_full_sous_chef() + raw = _read_full_sous_chef( + skill_catalog, + project_dir=project_dir, + backend=backend, + ) sous_chef_content = "\n\n" + raw if raw else "" _forbidden_list = ", ".join(PIPELINE_FORBIDDEN_TOOLS) @@ -126,12 +138,6 @@ def _build_fleet_dispatch_prompt( has_unguarded_filesystem_access: bool = False, ) -> str: """Build the --append-system-prompt content for an ad-hoc fleet dispatcher session.""" - from autoskillit.fleet import _build_admiral_dispatch_block # noqa: PLC0415 - - admiral_block = _build_admiral_dispatch_block() - admiral_section = ( - f"\n## ADMIRAL DISCIPLINE (DISPATCH SUBSET)\n\n{admiral_block}\n" if admiral_block else "" - ) _food_truck_section = "" if recipe_table: _food_truck_section = ( @@ -157,7 +163,7 @@ def _build_fleet_dispatch_prompt( - {mcp_prefix}fetch_github_issue — retrieve issue context when dispatching issue work - {mcp_prefix}get_issue_title — get the title of a GitHub issue - {mcp_prefix}reset_dispatch — reset a failed dispatch and clean stale artifacts -{_food_truck_section}{admiral_section} +{_food_truck_section} ## ROUTING AUTHORITY {ROUTING_AUTHORITY_CLAUSE} diff --git a/src/autoskillit/cli/_prompts_orchestrator.py b/src/autoskillit/cli/_prompts_orchestrator.py index 5cd3116d76..09818d03b7 100644 --- a/src/autoskillit/cli/_prompts_orchestrator.py +++ b/src/autoskillit/cli/_prompts_orchestrator.py @@ -23,6 +23,7 @@ if TYPE_CHECKING: from autoskillit.recipe.loader import RecipeInfo + from autoskillit.workspace import EffectiveSkillCatalog __all__ = [ "_build_orchestrator_prompt", @@ -82,6 +83,9 @@ def _build_orchestrator_prompt( mcp_prefix: str, ingredients_table: str | None = None, has_unguarded_filesystem_access: bool = False, + skill_catalog: EffectiveSkillCatalog | None = None, + project_dir: Path | None = None, + backend: object | None = None, ) -> str: """Build the --append-system-prompt content for a cook session. @@ -89,7 +93,11 @@ def _build_orchestrator_prompt( predicates, orchestrator discipline) and a greeting pool. Recipe content is discovered by the session via ``load_recipe``. """ - raw = _read_full_sous_chef() + raw = _read_full_sous_chef( + skill_catalog, + project_dir=project_dir, + backend=backend, + ) sous_chef_content = "\n\n" + raw if raw else "" _ing_section = "" diff --git a/src/autoskillit/cli/doctor/AGENTS.md b/src/autoskillit/cli/doctor/AGENTS.md index 5d8f192735..5bf8067ccf 100644 --- a/src/autoskillit/cli/doctor/AGENTS.md +++ b/src/autoskillit/cli/doctor/AGENTS.md @@ -1,6 +1,6 @@ # doctor/ -Diagnostic health checks for the autoskillit installation (47 checks). +Diagnostic health checks for the autoskillit installation (48 checks). ## Files @@ -16,6 +16,7 @@ Diagnostic health checks for the autoskillit installation (47 checks). | `_doctor_install.py` | Install path, entry points, version drift, update dismissal checks | | `_doctor_mcp.py` | MCP server registration, dual registration, plugin cache checks | | `_doctor_runtime.py` | Quota cache schema version, claude process state, codex version, and codex graduation checks | +| `_doctor_skills.py` | Bundled skill capability declaration authenticity checks | ## Architecture Notes diff --git a/src/autoskillit/cli/doctor/__init__.py b/src/autoskillit/cli/doctor/__init__.py index cd26fbba73..b326e2dd48 100644 --- a/src/autoskillit/cli/doctor/__init__.py +++ b/src/autoskillit/cli/doctor/__init__.py @@ -73,6 +73,7 @@ _check_quota_cache_schema, _check_script_binary, ) +from ._doctor_skills import _check_skill_capability_authenticity from ._doctor_types import _NON_PROBLEM, DoctorResult logger = get_logger(__name__) @@ -224,15 +225,14 @@ def run_doctor(*, output_json: bool = False) -> None: # Check 36: Codex model-alias staleness results.append(_check_codex_model_alias_staleness()) - # Check 37: Standing backend pin feasibility results.extend(_check_standing_backend_pins_feasibility()) # Check 38: Local recipe validity results.extend(_check_local_recipe_validity()) - # Check 39: Codex limits version pin freshness results.append(_check_codex_limits_verified(backend=_backend)) - + # Check 40: Bundled skill capability declarations match authentic source evidence + results.extend(_check_skill_capability_authenticity()) # Output if output_json: print( diff --git a/src/autoskillit/cli/doctor/_doctor_config.py b/src/autoskillit/cli/doctor/_doctor_config.py index 5095525f56..db8be71254 100644 --- a/src/autoskillit/cli/doctor/_doctor_config.py +++ b/src/autoskillit/cli/doctor/_doctor_config.py @@ -270,7 +270,7 @@ def _check_standing_backend_pins_feasibility( skill_name = target_step.skill_name if not skill_name: continue - skill_info = resolver.resolve(skill_name) + skill_info = resolver.resolve_effective(skill_name, root) if skill_info is None: results.append( DoctorResult( diff --git a/src/autoskillit/cli/doctor/_doctor_skills.py b/src/autoskillit/cli/doctor/_doctor_skills.py new file mode 100644 index 0000000000..4541872f49 --- /dev/null +++ b/src/autoskillit/cli/doctor/_doctor_skills.py @@ -0,0 +1,58 @@ +"""Bundled skill capability-contract diagnostics.""" + +from __future__ import annotations + +from autoskillit.core import ( + Severity, + SkillContractError, + validate_skill_capability_roles, +) +from autoskillit.workspace import ( + DefaultSkillResolver, + validate_skill_capability_authenticity, +) + +from ._doctor_types import DoctorResult + + +def _check_skill_capability_authenticity( + resolver: DefaultSkillResolver | None = None, +) -> list[DoctorResult]: + """Report invalid bundled capability authenticity and exact-role contracts.""" + skill_resolver = resolver or DefaultSkillResolver() + results: list[DoctorResult] = [] + for skill in skill_resolver.list_all(): + diagnostics = [skill.invalid_reason] if skill.invalid_reason is not None else [] + if skill.execution_role is None: + diagnostics.append("execution role is missing or invalid") + else: + try: + validate_skill_capability_roles( + skill.uses_capabilities, + skill.execution_role, + ) + except SkillContractError as exc: + diagnostics.append(str(exc)) + diagnostics.extend(validate_skill_capability_authenticity(skill)) + emitted: list[str] = [] + for diagnostic in diagnostics: + if diagnostic is None or any(diagnostic in existing for existing in emitted): + continue + emitted.append(diagnostic) + results.extend( + DoctorResult( + severity=Severity.ERROR, + check="skill_capability_authenticity", + message=f"{skill.path}: {diagnostic}", + ) + for diagnostic in emitted + ) + if results: + return results + return [ + DoctorResult( + severity=Severity.OK, + check="skill_capability_authenticity", + message="Bundled skill capability declarations match source evidence.", + ) + ] diff --git a/src/autoskillit/cli/fleet/__init__.py b/src/autoskillit/cli/fleet/__init__.py index 05f5baa7d2..576a68133d 100644 --- a/src/autoskillit/cli/fleet/__init__.py +++ b/src/autoskillit/cli/fleet/__init__.py @@ -306,9 +306,7 @@ def fleet_status( if watch: sys.exit(_watch_loop(state_path)) - _render_status_display(state) - if cleanup: from autoskillit.core import ( CODEX_SESSIONS_SUBDIR, @@ -316,6 +314,7 @@ def fleet_status( run_git, sweep_stale_markers, ) + from autoskillit.execution import delete_skill_session_contracts from autoskillit.workspace import ( DefaultSessionSkillManager, SkillsDirectoryProvider, @@ -339,6 +338,8 @@ def fleet_status( for d in state.dispatches: if d.dispatched_session_id: skill_mgr.cleanup_session(d.dispatched_session_id) + contract_session_ids = (d.dispatched_session_id for d in state.dispatches) + delete_skill_session_contracts(contract_session_ids) skill_mgr.cleanup_session(campaign_id) except Exception: logger.warning( diff --git a/src/autoskillit/cli/fleet/_fleet_run.py b/src/autoskillit/cli/fleet/_fleet_run.py index cebf8fb9a4..b5fe08b395 100644 --- a/src/autoskillit/cli/fleet/_fleet_run.py +++ b/src/autoskillit/cli/fleet/_fleet_run.py @@ -56,9 +56,10 @@ async def _execute_fleet_run( ) -> DispatchResult: import functools - from autoskillit.core import detect_autoskillit_mcp_prefix + from autoskillit.core import SkillExecutionRole, detect_autoskillit_mcp_prefix from autoskillit.fleet import _build_food_truck_prompt, execute_dispatch from autoskillit.server import make_context + from autoskillit.workspace import SkillProjectionContext, project_agent_skill_document ctx = make_context(cfg, project_dir=Path.cwd()) @@ -80,6 +81,7 @@ async def _execute_fleet_run( _raw_steps, skill_resolver=ctx.skill_resolver, config_backend=ctx.config.agent_backend, + project_root=ctx.project_dir, ) _effective_backend_map, _backend_origin_map = _compute_effective_backend_map( _raw_steps, @@ -88,6 +90,7 @@ async def _execute_fleet_run( recipe, skill_resolver=ctx.skill_resolver, config_backend=ctx.config.agent_backend, + project_root=ctx.project_dir, ) effective_backend = dispatch_backend or ctx.backend @@ -96,10 +99,37 @@ async def _execute_fleet_run( if effective_backend else False ) + sous_chef = None + if ctx.skill_resolver is not None: + orchestrator_catalog = ctx.skill_resolver.list_effective( + ctx.project_dir, + SkillExecutionRole.ORCHESTRATOR, + ) + sous_chef = next( + (skill for skill in orchestrator_catalog.skills if skill.name == "sous-chef"), + None, + ) + projected_sous_chef = ( + project_agent_skill_document( + sous_chef, + SkillProjectionContext( + cwd=ctx.project_dir.resolve(), + catalog=orchestrator_catalog, + backend=effective_backend, + conventions=( + effective_backend.conventions if effective_backend is not None else None + ), + gating=False, + ), + ).content + if sous_chef is not None + else "" + ) prompt_builder = functools.partial( _build_food_truck_prompt, mcp_prefix=detect_autoskillit_mcp_prefix(), has_unguarded_filesystem_access=has_ufa, + projected_sous_chef=projected_sous_chef, ) if disable_quota_guard: diff --git a/src/autoskillit/cli/session/_session_cook.py b/src/autoskillit/cli/session/_session_cook.py index 63d35b729c..f264937b96 100644 --- a/src/autoskillit/cli/session/_session_cook.py +++ b/src/autoskillit/cli/session/_session_cook.py @@ -77,11 +77,18 @@ def cook( from autoskillit.execution import get_backend from autoskillit.workspace import ( DefaultSessionSkillManager, + DefaultSkillResolver, SkillsDirectoryProvider, + project_plugin_source, resolve_ephemeral_root, + validate_skill_tier_roles, ) config = load_config() + project_dir = Path.cwd() + skill_resolver = DefaultSkillResolver() + skill_visibility = config.skill_visibility_spec() + validate_skill_tier_roles(skill_visibility, skill_resolver, project_dir) if backend is None: backend = get_backend(config.agent_backend.backend) @@ -159,11 +166,13 @@ def cook( NamedResume, NoResume, SessionType, + SkillExecutionRole, _get_autoskillit_install_path, configure_logging, get_logger, pkg_root, resume_spec_from_cli, + temp_dir_display_str, write_registry_entry, ) @@ -173,7 +182,6 @@ def cook( resume_spec = resume_spec_from_cli(resume=resume, session_id=session_id) - project_dir = Path.cwd() initial_prompt: str | None = None _first_run = is_first_run(project_dir) if _first_run: @@ -182,15 +190,28 @@ def cook( session_id_local = uuid.uuid4().hex[:16] write_registry_entry(project_dir, session_id_local, SESSION_TYPE_COOK, None) ephemeral_root = resolve_ephemeral_root() - session_mgr = DefaultSessionSkillManager(SkillsDirectoryProvider(), ephemeral_root) + skills_provider = SkillsDirectoryProvider( + temp_dir_relpath=temp_dir_display_str(config.workspace.temp_dir), + default_base_branch=config.branching.default_base_branch, + ) + session_mgr = DefaultSessionSkillManager(skills_provider, ephemeral_root) session_mgr.cleanup_stale() - skills_dir = session_mgr.init_session( - session_id_local, + session_catalog = skill_resolver.list_effective( + project_dir, + SkillExecutionRole.SESSION, + visibility=skill_visibility, cook_session=True, - config=config, - project_dir=project_dir, + ) + projection_context = skills_provider.catalog_projection_context( + session_catalog, + project_dir, backend=backend, ) + skills_dir = session_mgr.init_session( + session_id_local, + session_catalog, + projection_context, + ) plugin_source: MarketplaceInstall | DirectInstall if InstalledPluginsFile().contains(_AUTOSKILLIT_PLUGIN_KEY): @@ -204,6 +225,13 @@ def cook( plugin_source = DirectInstall(plugin_dir=pkg_root()) else: plugin_source = DirectInstall(plugin_dir=pkg_root()) + plugin_source = project_plugin_source( + plugin_source, + cwd=project_dir, + backend=backend, + default_base_branch=config.branching.default_base_branch, + skill_catalog=session_catalog, + ) if isinstance(resume_spec, BareResume): from autoskillit.cli.session._session_picker import pick_session diff --git a/src/autoskillit/cli/session/_session_launch.py b/src/autoskillit/cli/session/_session_launch.py index 2df6f80630..adf4f7bf3d 100644 --- a/src/autoskillit/cli/session/_session_launch.py +++ b/src/autoskillit/cli/session/_session_launch.py @@ -13,6 +13,7 @@ if TYPE_CHECKING: from autoskillit.core import CodingAgentBackend, ResumeSpec + from autoskillit.workspace import EffectiveSkillCatalog @dataclass(frozen=True, slots=True) @@ -30,6 +31,8 @@ def _run_interactive_session( project_dir: Path | None = None, required_env: frozenset[str] | None = None, backend: CodingAgentBackend | None = None, + skill_catalog: EffectiveSkillCatalog | None = None, + default_base_branch: str = "main", ) -> str | _InfraExitSignal | None: """Launch an interactive Claude Code session. @@ -46,6 +49,9 @@ def _run_interactive_session( config = load_config() backend = get_backend(config.agent_backend.backend) + configured_base_branch = config.branching.default_base_branch + if isinstance(configured_base_branch, str): + default_base_branch = configured_base_branch from autoskillit.core import FEATURE_REGISTRY, is_feature_enabled @@ -86,19 +92,25 @@ def _run_interactive_session( from autoskillit.cli.ui._terminal import terminal_guard from autoskillit.core import ( MARKETPLACE_PREFIX, - DirectInstall, InfraExitCategory, + PluginSource, detect_autoskillit_mcp_prefix, - pkg_root, ) _project_dir = project_dir if project_dir is not None else Path.cwd() + plugin_source: PluginSource | None if backend.capabilities.skill_injection_capable: - plugin_source = ( - None - if detect_autoskillit_mcp_prefix() == MARKETPLACE_PREFIX - else DirectInstall(plugin_dir=pkg_root()) - ) + if detect_autoskillit_mcp_prefix() == MARKETPLACE_PREFIX: + plugin_source = None + else: + from autoskillit.workspace import project_default_plugin_source + + plugin_source = project_default_plugin_source( + cwd=_project_dir, + backend=backend, + default_base_branch=default_base_branch, + skill_catalog=skill_catalog, + ) tools_arg: tuple[str, ...] = ("AskUserQuestion",) else: plugin_source = None @@ -165,6 +177,7 @@ def _launch_cook_session( project_dir: Path | None = None, required_env: frozenset[str], backend: CodingAgentBackend | None = None, + skill_catalog: EffectiveSkillCatalog | None = None, ) -> None: """Launch an interactive Claude Code cook session with reload and infra-resume support.""" _max_reloads = 10 @@ -182,6 +195,7 @@ def _launch_cook_session( project_dir=project_dir, required_env=required_env, backend=backend, + skill_catalog=skill_catalog, ) if session_signal is None: break diff --git a/src/autoskillit/cli/session/_session_order.py b/src/autoskillit/cli/session/_session_order.py index ce9355a8d3..70a7676aa9 100644 --- a/src/autoskillit/cli/session/_session_order.py +++ b/src/autoskillit/cli/session/_session_order.py @@ -16,11 +16,13 @@ from autoskillit.core import ( ORDER_INTERACTIVE_REQUIRED_ENV, RecipeSource, + SkillExecutionRole, atomic_write, get_logger, pkg_root, resume_spec_from_cli, ) +from autoskillit.workspace import DefaultSkillResolver, validate_skill_tier_roles if TYPE_CHECKING: from autoskillit.recipe import Recipe, RecipeInfo @@ -134,6 +136,18 @@ def order( print("ERROR: 'order' cannot run inside a Claude Code session.") print("Run this command in a regular terminal.") sys.exit(1) + from autoskillit.config import load_config + + project_dir = Path.cwd() + config = load_config(project_dir) + skill_resolver = DefaultSkillResolver() + skill_visibility = config.skill_visibility_spec() + validate_skill_tier_roles(skill_visibility, skill_resolver, project_dir) + skill_catalog = skill_resolver.list_effective( + project_dir, + SkillExecutionRole.ORCHESTRATOR, + visibility=skill_visibility, + ) _resume = resume or (session_id is not None) resume_spec = resume_spec_from_cli(resume=_resume, session_id=session_id) @@ -161,6 +175,7 @@ def order( resume_spec=resume_spec, extra_env=_write_order_entry(Path.cwd(), None), required_env=ORDER_INTERACTIVE_REQUIRED_ENV, + skill_catalog=skill_catalog, ) return @@ -197,17 +212,22 @@ def order( from autoskillit.execution import get_backend as _get_backend_early _cfg_early = _load_config_early(Path.cwd()) - _caps_early = _get_backend_early(_cfg_early.agent_backend.backend).capabilities + _backend_early = _get_backend_early(_cfg_early.agent_backend.backend) + _caps_early = _backend_early.capabilities _launch_cook_session( _build_open_kitchen_prompt( mcp_prefix=mcp_prefix, has_unguarded_filesystem_access=_caps_early.has_unguarded_filesystem_access, + skill_catalog=skill_catalog, + project_dir=project_dir, + backend=_backend_early, ), initial_message=random.choice(_OPEN_KITCHEN_GREETINGS), resume_spec=resume_spec, project_dir=Path.cwd(), extra_env=_write_order_entry(Path.cwd(), None), required_env=ORDER_INTERACTIVE_REQUIRED_ENV, + skill_catalog=skill_catalog, ) return elif resolved is None: @@ -325,17 +345,22 @@ def order( _extra_env |= _write_order_entry(Path.cwd(), recipe) from autoskillit.execution import get_backend - _backend_caps = get_backend(_cfg.agent_backend.backend).capabilities + _backend = get_backend(_cfg.agent_backend.backend) + _backend_caps = _backend.capabilities _launch_cook_session( _build_orchestrator_prompt( recipe, mcp_prefix=mcp_prefix, ingredients_table=_itable, has_unguarded_filesystem_access=_backend_caps.has_unguarded_filesystem_access, + skill_catalog=skill_catalog, + project_dir=project_dir, + backend=_backend, ), initial_message=greeting, extra_env=_extra_env, resume_spec=resume_spec, project_dir=Path.cwd(), required_env=ORDER_INTERACTIVE_REQUIRED_ENV, + skill_catalog=skill_catalog, ) 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/config/settings.py b/src/autoskillit/config/settings.py index fc216d73db..296aac7877 100644 --- a/src/autoskillit/config/settings.py +++ b/src/autoskillit/config/settings.py @@ -72,6 +72,7 @@ from autoskillit.core import ( FEATURE_REGISTRY, FeatureLifecycle, + SkillVisibilitySpec, atomic_write, dump_yaml_str, get_logger, @@ -390,6 +391,22 @@ class AutomationConfig: features: dict[str, bool] = field(default_factory=dict) experimental_enabled: bool = False + def skill_visibility_spec(self) -> SkillVisibilitySpec: + """Project config-owned fields into the core workspace policy contract.""" + return SkillVisibilitySpec( + disabled_categories=frozenset(self.subsets.disabled), + custom_tags={ + tag: frozenset(skill_names) + for tag, skill_names in self.subsets.custom_tags.items() + }, + features=self.features, + experimental_enabled=self.experimental_enabled, + enabled_packs=frozenset(self.packs.enabled), + tier1_skills=frozenset(self.skills.tier1), + tier2_skills=frozenset(self.skills.tier2), + tier3_skills=frozenset(self.skills.tier3), + ) + @staticmethod def _build_features_dict(raw: dict[str, Any]) -> tuple[dict[str, bool], bool]: """Validate and coerce the features section from a raw config dict. diff --git a/src/autoskillit/core/__init__.pyi b/src/autoskillit/core/__init__.pyi index a6dd3f6754..7186cc0fc3 100644 --- a/src/autoskillit/core/__init__.pyi +++ b/src/autoskillit/core/__init__.pyi @@ -194,6 +194,9 @@ from .types import KNOWN_CI_EVENTS as KNOWN_CI_EVENTS from .types import LABEL_LIFECYCLE_REGISTRY as LABEL_LIFECYCLE_REGISTRY from .types import LABEL_TRANSITIONS as LABEL_TRANSITIONS from .types import LAUNCH_ID_ENV_VAR as LAUNCH_ID_ENV_VAR +from .types import ( + MACHINE_ONLY_SKILL_FRONTMATTER_KEYS as MACHINE_ONLY_SKILL_FRONTMATTER_KEYS, +) from .types import MCP_CLIENT_BACKEND_ENV_VAR as MCP_CLIENT_BACKEND_ENV_VAR from .types import NON_VARIADIC_CLAUDE_FLAGS as NON_VARIADIC_CLAUDE_FLAGS from .types import ORCHESTRATOR_SESSION_REQUIRED_ENV as ORCHESTRATOR_SESSION_REQUIRED_ENV @@ -258,6 +261,8 @@ from .types import SKILL_CAPABILITY_REGISTRY as SKILL_CAPABILITY_REGISTRY from .types import SKILL_COMMAND_DISPLAY_MAX as SKILL_COMMAND_DISPLAY_MAX from .types import SKILL_COMMAND_PREFIX as SKILL_COMMAND_PREFIX from .types import SKILL_FILE_ADVISORY_MAP as SKILL_FILE_ADVISORY_MAP +from .types import SKILL_PROJECTION_VERSION as SKILL_PROJECTION_VERSION +from .types import SKILL_SESSION_CONTRACT_SCHEMA_VERSION as SKILL_SESSION_CONTRACT_SCHEMA_VERSION from .types import SKILL_SESSION_REQUIRED_ENV as SKILL_SESSION_REQUIRED_ENV from .types import SKILL_TOOLS as SKILL_TOOLS from .types import SOUS_CHEF_MANDATORY_SECTIONS as SOUS_CHEF_MANDATORY_SECTIONS @@ -315,6 +320,8 @@ from .types import DialingConfig as DialingConfig from .types import DirectInstall as DirectInstall from .types import DispatchGateType as DispatchGateType from .types import DispatchIdentity as DispatchIdentity +from .types import EffectiveSkillCatalogAuthority as EffectiveSkillCatalogAuthority +from .types import EffectiveSkillInvocationAuthority as EffectiveSkillInvocationAuthority from .types import EnvPolicy as EnvPolicy from .types import FailureRecord as FailureRecord from .types import FeatureDef as FeatureDef @@ -328,6 +335,7 @@ from .types import GitHubApiLog as GitHubApiLog from .types import GitHubFetcher as GitHubFetcher from .types import HardCapabilityMismatch as HardCapabilityMismatch from .types import HeadlessExecutor as HeadlessExecutor +from .types import HeadlessSkillDispatchContract as HeadlessSkillDispatchContract from .types import InfraExitCategory as InfraExitCategory from .types import InfraOutcome as InfraOutcome from .types import InputContractResolver as InputContractResolver @@ -385,6 +393,7 @@ from .types import RecipeSectionContentFormatDef as RecipeSectionContentFormatDe from .types import RecipeSectionDef as RecipeSectionDef from .types import RecipeSectionValidationFinding as RecipeSectionValidationFinding from .types import RecipeSource as RecipeSource +from .types import ResolvedSkillAuthority as ResolvedSkillAuthority from .types import ResponseBackstopExemptionDef as ResponseBackstopExemptionDef from .types import RestartScope as RestartScope from .types import ResultParser as ResultParser @@ -399,16 +408,27 @@ from .types import SessionSkillManager as SessionSkillManager from .types import SessionTelemetry as SessionTelemetry from .types import SessionType as SessionType from .types import Severity as Severity +from .types import SkillAuthority as SkillAuthority 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 SkillFrontmatterAuthority as SkillFrontmatterAuthority from .types import SkillLister as SkillLister +from .types import SkillProjectionContextAuthority as SkillProjectionContextAuthority from .types import SkillResolver as SkillResolver from .types import SkillResult as SkillResult from .types import SkillSessionConfig as SkillSessionConfig +from .types import SkillSessionContract as SkillSessionContract +from .types import SkillSessionContractStore as SkillSessionContractStore from .types import SkillSource as SkillSource +from .types import SkillSourceIdentity as SkillSourceIdentity +from .types import SkillSourceRef as SkillSourceRef +from .types import SkillVisibilitySpec as SkillVisibilitySpec from .types import SpilledOutput as SpilledOutput from .types import SpillSpec as SpillSpec +from .types import StoredSkillSessionContract as StoredSkillSessionContract from .types import StreamParser as StreamParser from .types import SubprocessResult as SubprocessResult from .types import SubprocessRunner as SubprocessRunner @@ -433,6 +453,7 @@ from .types import assert_prompt_sentinel as assert_prompt_sentinel from .types import canonical_recipe_section_json as canonical_recipe_section_json from .types import closure_authority_spec_from_args as closure_authority_spec_from_args from .types import compute_remaining as compute_remaining +from .types import derive_backend_requirements as derive_backend_requirements from .types import describe_capability_mismatches as describe_capability_mismatches from .types import extract_path_arg as extract_path_arg from .types import extract_positional_args as extract_positional_args @@ -445,13 +466,16 @@ from .types import parse_plan_paths as parse_plan_paths from .types import recipe_section_digest as recipe_section_digest from .types import recipe_section_element_digest as recipe_section_element_digest from .types import recipe_section_plan_digest as recipe_section_plan_digest +from .types import render_target_skill_command as render_target_skill_command from .types import resolve_payload_field as resolve_payload_field 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..73e748b619 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 identities and immutable persisted session contracts | | `_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_backend.py b/src/autoskillit/core/types/_type_backend.py index 3e8ce9c8b9..976bce3176 100644 --- a/src/autoskillit/core/types/_type_backend.py +++ b/src/autoskillit/core/types/_type_backend.py @@ -52,6 +52,8 @@ class BackendConventions: skills_subdir: Path = Path("skills") #: Project-relative directories to scan for project-local skills. project_local_skill_search_dirs: tuple[str, ...] = () + #: Native model-facing skill invocation sigil. + skill_sigil: str = "/" @dataclass(frozen=True, slots=True) @@ -91,8 +93,6 @@ class BackendCapabilities: triage_capable: bool = field(default=False) # Forward-declared: planned for context exhaustion handling supports_context_exhaustion_detection: bool = field(default=False) - # True when backend supports project-local --add-dir skill discovery - project_local_skills_capable: bool = field(default=False) # False triggers pre-reveal kitchen at startup instead of notification-driven reveal supports_tool_list_changed: bool = field(default=True) # SKILL.md front-matter fields required by this backend @@ -282,7 +282,6 @@ def model_class(model: str) -> str: session_record_types=frozenset({"assistant"}), triage_capable=True, supports_context_exhaustion_detection=True, - project_local_skills_capable=True, supports_tool_list_changed=False, required_skill_fields=frozenset({"name", "description"}), required_session_files=frozenset(), 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..1093ad5aa1 100644 --- a/src/autoskillit/core/types/_type_helpers.py +++ b/src/autoskillit/core/types/_type_helpers.py @@ -11,13 +11,16 @@ import re import shlex import warnings -from typing import Any +from pathlib import Path +from typing import Any, assert_never -from ._type_constants import AUTOSKILLIT_SKILL_PREFIX, SKILL_COMMAND_PREFIX +from ._type_backend import BackendConventions +from ._type_constants import SKILL_COMMAND_PREFIX from ._type_constants_env import HEADLESS_ENV_VAR, SESSION_TYPE_ENV_VAR from ._type_constants_registries import FLEET_ERROR_CODES from ._type_enums import SessionType, SkillSource from ._type_protocols_workspace import SkillResolver +from ._type_skill_contract import SkillSourceRef __all__ = [ "is_path_like_token", @@ -25,6 +28,7 @@ "extract_positional_args", "extract_skill_name", "fleet_error", + "render_target_skill_command", "resolve_skill_name", "resolve_target_skill", "session_type", @@ -117,6 +121,7 @@ def resolve_skill_name(skill_command: str) -> str | None: def resolve_target_skill( skill_command: str, resolver: SkillResolver, + project_root: Path | None, ) -> tuple[str, str | None]: """Resolve a skill_command to the correct invocation namespace. @@ -130,15 +135,41 @@ def resolve_target_skill( if name is None: return skill_command, None - info = resolver.resolve(name) + info = resolver.resolve_effective(name, project_root) 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 + return render_target_skill_command( + skill_command, + info.source_ref or info.source, + ), name + + +def render_target_skill_command( + skill_command: str, + source_ref: SkillSourceRef | SkillSource, + conventions: BackendConventions | None = None, +) -> str: + """Render a logical target from its effective source and backend conventions.""" + name = extract_skill_name(skill_command) + if name is None: + return skill_command + + source = source_ref.origin if isinstance(source_ref, SkillSourceRef) else source_ref + configured_sigil = conventions.skill_sigil if conventions is not None else SKILL_COMMAND_PREFIX + sigil = ( + configured_sigil + if isinstance(configured_sigil, str) and configured_sigil + else SKILL_COMMAND_PREFIX + ) + match source: + case SkillSource.BUNDLED: + namespace = "autoskillit:" if sigil == SKILL_COMMAND_PREFIX else "" + case SkillSource.BUNDLED_EXTENDED | SkillSource.PROJECT_LOCAL | SkillSource.THIRD_PARTY: + namespace = "" + case _ as unreachable: + assert_never(unreachable) + correct_prefix = f"{sigil}{namespace}{name}" # Reconstruct: replace the skill reference, preserve trailing arguments stripped = skill_command.strip() @@ -146,7 +177,7 @@ def resolve_target_skill( if m is None: raise RuntimeError(f"regex failed after extract_skill_name succeeded: {stripped!r}") remainder = stripped[m.end() :] - return correct_prefix + remainder, name + return correct_prefix + remainder def truncate_text(text: str, max_len: int = 5000) -> str: diff --git a/src/autoskillit/core/types/_type_protocols_execution.py b/src/autoskillit/core/types/_type_protocols_execution.py index f2f0701e03..89738646ae 100644 --- a/src/autoskillit/core/types/_type_protocols_execution.py +++ b/src/autoskillit/core/types/_type_protocols_execution.py @@ -7,6 +7,8 @@ from typing import Any, Protocol, runtime_checkable from ._type_checkpoint import SessionCheckpoint # noqa: F401, TC001 +from ._type_enums import SkillExecutionRole +from ._type_plugin_source import PluginSource from ._type_results import ( ClosureAuthoritySpec, InputSpec, @@ -15,17 +17,80 @@ ValidatedAddDir, WriteBehaviorSpec, ) +from ._type_skill_contract import ( + SkillSessionContract, + SkillSourceIdentity, + StoredSkillSessionContract, +) __all__ = [ "CompletionRequiredResolver", "InputContractResolver", "TestRunner", "HeadlessExecutor", + "HeadlessSkillDispatchContract", "OutputPatternResolver", + "SkillSessionContractStore", "WriteExpectedResolver", ] +@runtime_checkable +class HeadlessSkillDispatchContract(Protocol): + """Immutable capability/source/projection authority for headless skill work.""" + + @property + def resolved_command(self) -> str: ... + + @property + def projection_context(self) -> object: ... + + @property + def invocation(self) -> object | None: ... + + @property + def catalog(self) -> object | None: ... + + @property + def root_name(self) -> str | None: ... + + @property + def member_names(self) -> tuple[str, ...]: ... + + @property + def execution_role(self) -> SkillExecutionRole: ... + + @property + def capability_union(self) -> frozenset[str]: ... + + @property + def source_identities(self) -> Mapping[str, SkillSourceIdentity]: ... + + @property + def canonical_digests(self) -> Mapping[str, str]: ... + + @property + def projected_digests(self) -> Mapping[str, str]: ... + + @property + def projected_artifacts(self) -> Mapping[str, str]: ... + + @property + def projection_version(self) -> int: ... + + @property + def project_root(self) -> str | None: ... + + @property + def cwd(self) -> str: ... + + @property + def backend(self) -> str | None: ... + + @property + def artifact_paths(self) -> tuple[str, ...]: ... + + @runtime_checkable class TestRunner(Protocol): """Protocol for running a test suite and reporting pass/fail. @@ -38,6 +103,28 @@ def check_infrastructure(self, cwd: Path) -> str | None: ... async def run(self, cwd: Path) -> TestResult: ... +@runtime_checkable +class SkillSessionContractStore(Protocol): + """Persistence boundary for skill-session contract ownership.""" + + def create_provisional( + self, + *, + contract: SkillSessionContract, + snapshot: Mapping[str, str], + ) -> str: ... + + def observe_candidate(self, correlation_key: str, session_id: str) -> None: ... + + def finalize(self, correlation_key: str, session_id: str) -> None: ... + + def load(self, session_id: str) -> StoredSkillSessionContract: ... + + def delete(self, session_id: str) -> None: ... + + def discard(self, correlation_key: str) -> None: ... + + @runtime_checkable class HeadlessExecutor(Protocol): """Protocol for running headless Claude Code sessions.""" @@ -84,7 +171,9 @@ async def run( network_access: bool = False, closure_spec: ClosureAuthoritySpec | None = None, closure_report_root: Path | None = None, + on_session_id_resolved: Callable[[str], None] | None = None, skill_contract: Any | None = None, + capability_contract: HeadlessSkillDispatchContract | None = None, ) -> SkillResult: ... async def dispatch_food_truck( @@ -93,6 +182,7 @@ async def dispatch_food_truck( cwd: str, *, completion_marker: str, + plugin_source: PluginSource | None = None, prior_completion_markers: Sequence[str] | None = None, resume_session_id: str | None = None, resume_checkpoint: SessionCheckpoint | None = None, @@ -122,6 +212,7 @@ async def dispatch_food_truck( resume_message: str | None = None, backend_override: str | None = None, on_session_id_resolved: Callable[[str], None] | None = None, + capability_contract: HeadlessSkillDispatchContract | None = None, ) -> SkillResult: ... diff --git a/src/autoskillit/core/types/_type_protocols_workspace.py b/src/autoskillit/core/types/_type_protocols_workspace.py index fd4dc76ff7..503dd5bd9f 100644 --- a/src/autoskillit/core/types/_type_protocols_workspace.py +++ b/src/autoskillit/core/types/_type_protocols_workspace.py @@ -2,23 +2,175 @@ from __future__ import annotations +from collections.abc import Mapping, Sequence from pathlib import Path -from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable - -if TYPE_CHECKING: - from ._type_protocols_backend import CodingAgentBackend +from typing import Any, Protocol, runtime_checkable +from ._type_backend import BackendConventions +from ._type_enums import SkillExecutionRole, SkillSource +from ._type_protocols_backend import CodingAgentBackend from ._type_results import CleanupResult, CloneResult, ValidatedAddDir +from ._type_skill_contract import SkillSourceIdentity, SkillSourceRef, SkillVisibilitySpec __all__ = [ "WorkspaceManager", "CloneManager", + "EffectiveSkillCatalogAuthority", + "EffectiveSkillInvocationAuthority", + "ResolvedSkillAuthority", "SessionSkillManager", + "SkillAuthority", + "SkillFrontmatterAuthority", "SkillLister", + "SkillProjectionContextAuthority", "SkillResolver", ] +@runtime_checkable +class SkillFrontmatterAuthority(Protocol): + """Parsed frontmatter fields required by projection without an IL-1 import.""" + + @property + def data(self) -> Mapping[str, Any] | None: ... + + @property + def is_valid(self) -> bool: ... + + @property + def error(self) -> str | None: ... + + @property + def body(self) -> str: ... + + +@runtime_checkable +class SkillAuthority(Protocol): + """Structural machine authority shared by resolved and catalog skill records.""" + + @property + def name(self) -> str: ... + + @property + def source(self) -> SkillSource: ... + + @property + def source_identity(self) -> SkillSourceIdentity: ... + + @property + def categories(self) -> frozenset[str]: ... + + @property + def uses_capabilities(self) -> frozenset[str]: ... + + @property + def execution_role(self) -> SkillExecutionRole | None: ... + + @property + def activate_deps(self) -> tuple[str, ...]: ... + + @property + def canonical_content(self) -> str: ... + + @property + def canonical_digest(self) -> str: ... + + @property + def frontmatter(self) -> SkillFrontmatterAuthority | None: ... + + @property + def invalid_reason(self) -> str | None: ... + + @property + def backend_requirements(self) -> frozenset[str]: ... + + +@runtime_checkable +class ResolvedSkillAuthority(SkillAuthority, Protocol): + """Structural authority for one source-resolved skill.""" + + @property + def path(self) -> Path: ... + + @property + def source_ref(self) -> SkillSourceRef | None: ... + + +@runtime_checkable +class EffectiveSkillCatalogAuthority(Protocol): + """Role-filtered immutable skill catalog crossing the IL-0 boundary.""" + + @property + def skills(self) -> tuple[SkillAuthority, ...]: ... + + @property + def execution_role(self) -> SkillExecutionRole: ... + + @property + def namespace_sources(self) -> Mapping[str, SkillSource]: ... + + +@runtime_checkable +class EffectiveSkillInvocationAuthority(Protocol): + """Resolved root and complete executable closure crossing the IL-0 boundary.""" + + @property + def root(self) -> ResolvedSkillAuthority: ... + + @property + def closure(self) -> tuple[ResolvedSkillAuthority, ...]: ... + + @property + def capability_union(self) -> frozenset[str]: ... + + @property + def project_root(self) -> Path | None: ... + + @property + def execution_role(self) -> SkillExecutionRole: ... + + @property + def backend_requirements(self) -> frozenset[str]: ... + + +@runtime_checkable +class SkillProjectionContextAuthority(Protocol): + """Projection inputs bound to an invocation or catalog.""" + + @property + def cwd(self) -> Path: ... + + @property + def project_root(self) -> Path | None: ... + + @property + def catalog(self) -> EffectiveSkillCatalogAuthority | None: ... + + @property + def invocation(self) -> EffectiveSkillInvocationAuthority | None: ... + + @property + def backend(self) -> CodingAgentBackend | None: ... + + @property + def conventions(self) -> BackendConventions | None: ... + + @property + def substitutions(self) -> Mapping[str, str] | None: ... + + @property + def gating(self) -> bool | None: ... + + @property + def namespace(self) -> str | None: ... + + @property + def projection_version(self) -> int: ... + + @property + def skills(self) -> tuple[SkillAuthority, ...]: ... + + @runtime_checkable class WorkspaceManager(Protocol): """Protocol for directory teardown operations.""" @@ -61,22 +213,19 @@ def push_to_remote( class SessionSkillManager(Protocol): """Protocol for managing per-session ephemeral skill directories.""" - def init_session( + def materialize_invocation( self, session_id: str, - *, - cook_session: bool = False, - config: Any | None = None, - project_dir: Path | None = None, - recipe_packs: frozenset[str] | None = None, - recipe_features: frozenset[str] | None = None, - allow_only: frozenset[str] | None = None, - backend: CodingAgentBackend | None = None, + invocation: EffectiveSkillInvocationAuthority, + projection_context: SkillProjectionContextAuthority, ) -> ValidatedAddDir: ... - def compute_skill_closure(self, skill_name: str) -> frozenset[str]: ... - - def activate_skill_deps(self, session_id: str, skill_name: str) -> bool: ... + def init_session( + self, + session_id: str, + catalog: EffectiveSkillCatalogAuthority, + projection_context: SkillProjectionContextAuthority, + ) -> ValidatedAddDir: ... def cleanup_session(self, session_id: str) -> bool: ... @@ -89,7 +238,36 @@ def cleanup_stale(self, max_age_seconds: int = 86400) -> int: ... class SkillResolver(Protocol): """Protocol for resolving skill names to their source tier.""" - def resolve(self, name: str) -> Any: ... + def resolve(self, name: str) -> ResolvedSkillAuthority | None: ... + + def resolve_effective( + self, + name: str, + project_root: Path | None, + ) -> ResolvedSkillAuthority | None: ... + + def list_effective( + self, + project_root: Path | None, + execution_role: SkillExecutionRole, + *, + visibility: SkillVisibilitySpec | None = None, + cook_session: bool = False, + recipe_packs: frozenset[str] | None = None, + recipe_features: frozenset[str] | None = None, + allow_only: frozenset[str] | None = None, + ) -> EffectiveSkillCatalogAuthority: ... + + def resolve_invocation( + self, + name: str, + project_root: Path | None, + execution_role: SkillExecutionRole, + *, + visibility: SkillVisibilitySpec | None = None, + recipe_packs: frozenset[str] | None = None, + recipe_features: frozenset[str] | None = None, + ) -> EffectiveSkillInvocationAuthority: ... @runtime_checkable @@ -103,4 +281,4 @@ class SkillLister(Protocol): protocol structurally. """ - def list_all(self) -> list[Any]: ... + def list_all(self) -> Sequence[ResolvedSkillAuthority]: ... 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..bcda5eadb4 --- /dev/null +++ b/src/autoskillit/core/types/_type_skill_contract.py @@ -0,0 +1,211 @@ +"""Backend-neutral skill source identity contracts.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass, field +from pathlib import Path +from types import MappingProxyType + +from ._type_constants_registries import ( + SKILL_CAPABILITY_REGISTRY, + validate_skill_capability_roles, +) +from ._type_enums import SkillExecutionRole, SkillSource +from ._type_exceptions import SkillContractError +from ._type_results import WriteBehaviorSpec + +__all__ = [ + "MACHINE_ONLY_SKILL_FRONTMATTER_KEYS", + "SKILL_PROJECTION_VERSION", + "SKILL_SESSION_CONTRACT_SCHEMA_VERSION", + "SkillSessionContract", + "SkillSourceIdentity", + "SkillSourceRef", + "SkillVisibilitySpec", + "StoredSkillSessionContract", + "derive_backend_requirements", +] + + +MACHINE_ONLY_SKILL_FRONTMATTER_KEYS = frozenset( + { + "activate_deps", + "backend_requirements", + "execution_role", + "uses_capabilities", + } +) +SKILL_PROJECTION_VERSION = 1 +SKILL_SESSION_CONTRACT_SCHEMA_VERSION = 2 + + +def derive_backend_requirements(uses_capabilities: frozenset[str]) -> frozenset[str]: + """Return the backend-name constraints implied by a capability set.""" + known = uses_capabilities & SKILL_CAPABILITY_REGISTRY.keys() + return frozenset().union( + *(SKILL_CAPABILITY_REGISTRY[capability].required_backends for capability in known) + ) + + +@dataclass(frozen=True, slots=True) +class SkillSourceIdentity: + """Path-free logical identity safe to carry beyond source resolution.""" + + origin: SkillSource + logical_name: str + search_dir: str | None = None + precedence: int | None = None + + +@dataclass(frozen=True, slots=True) +class SkillSourceRef: + """Private source reference selected for a skill machine contract.""" + + origin: SkillSource + logical_name: str + skill_path: Path + search_dir: str | None = None + precedence: int | None = None + + def validate_identity( + self, + origin: SkillSource, + logical_name: str, + skill_path: Path, + ) -> None: + """Reject a direct skill identity that conflicts with this source reference.""" + if (origin, logical_name, skill_path) != ( + self.origin, + self.logical_name, + self.skill_path, + ): + raise SkillContractError("SkillInfo source_ref does not match direct fields") + + @property + def identity(self) -> SkillSourceIdentity: + """Return the path-free identity used by catalogs and projections.""" + return SkillSourceIdentity( + origin=self.origin, + logical_name=self.logical_name, + search_dir=self.search_dir, + precedence=self.precedence, + ) + + +@dataclass(frozen=True, slots=True) +class SkillVisibilitySpec: + """Typed visibility and tier policy passed across composition boundaries.""" + + disabled_categories: frozenset[str] = frozenset() + custom_tags: Mapping[str, frozenset[str]] = field(default_factory=dict) + features: Mapping[str, bool] = field(default_factory=dict) + experimental_enabled: bool = False + enabled_packs: frozenset[str] = frozenset() + tier1_skills: frozenset[str] = frozenset() + tier2_skills: frozenset[str] = frozenset() + tier3_skills: frozenset[str] = frozenset() + + def __post_init__(self) -> None: + object.__setattr__( + self, + "disabled_categories", + frozenset(self.disabled_categories), + ) + object.__setattr__( + self, + "custom_tags", + MappingProxyType( + {tag: frozenset(skill_names) for tag, skill_names in self.custom_tags.items()} + ), + ) + object.__setattr__(self, "features", MappingProxyType(dict(self.features))) + object.__setattr__(self, "enabled_packs", frozenset(self.enabled_packs)) + object.__setattr__(self, "tier1_skills", frozenset(self.tier1_skills)) + object.__setattr__(self, "tier2_skills", frozenset(self.tier2_skills)) + object.__setattr__(self, "tier3_skills", frozenset(self.tier3_skills)) + + +@dataclass(frozen=True, slots=True) +class SkillSessionContract: + """Immutable execution contract bound to a projected skill snapshot.""" + + root_name: str + execution_role: SkillExecutionRole + source_refs: Mapping[str, SkillSourceRef] + closure: tuple[str, ...] + capability_union: frozenset[str] + canonical_digests: Mapping[str, str] + projected_digests: Mapping[str, str] + projection_version: int + project_root: str + cwd: str + backend: str + resolved_command: str + member_roles: Mapping[str, SkillExecutionRole] + member_capabilities: Mapping[str, frozenset[str]] + member_activate_deps: Mapping[str, tuple[str, ...]] + canonical_contents: Mapping[str, str] + expected_output_patterns: tuple[str, ...] = () + write_behavior: WriteBehaviorSpec = WriteBehaviorSpec() + read_only: bool = False + completion_required: bool = False + skill_contract_json: str = "" + projection_substitutions: tuple[tuple[str, str], ...] = () + projection_gating: bool | None = None + projection_namespace: str | None = None + schema_version: int = SKILL_SESSION_CONTRACT_SCHEMA_VERSION + + def __post_init__(self) -> None: + object.__setattr__(self, "source_refs", MappingProxyType(dict(self.source_refs))) + object.__setattr__( + self, + "canonical_digests", + MappingProxyType(dict(self.canonical_digests)), + ) + object.__setattr__( + self, + "projected_digests", + MappingProxyType(dict(self.projected_digests)), + ) + object.__setattr__(self, "member_roles", MappingProxyType(dict(self.member_roles))) + object.__setattr__( + self, + "member_capabilities", + MappingProxyType( + { + name: frozenset(capabilities) + for name, capabilities in self.member_capabilities.items() + } + ), + ) + object.__setattr__( + self, + "member_activate_deps", + MappingProxyType( + { + name: tuple(dependencies) + for name, dependencies in self.member_activate_deps.items() + } + ), + ) + object.__setattr__( + self, + "canonical_contents", + MappingProxyType(dict(self.canonical_contents)), + ) + + @property + def backend_requirements(self) -> frozenset[str]: + """Derive backend constraints from the persisted capability union.""" + validate_skill_capability_roles(self.capability_union, self.execution_role) + return derive_backend_requirements(self.capability_union) + + +@dataclass(frozen=True, slots=True) +class StoredSkillSessionContract: + """Validated contract plus the retained projected snapshot directory.""" + + contract: SkillSessionContract + snapshot_dir: Path + raw_session_id: str diff --git a/src/autoskillit/execution/__init__.py b/src/autoskillit/execution/__init__.py index a7d3f585c7..21c35ba23c 100644 --- a/src/autoskillit/execution/__init__.py +++ b/src/autoskillit/execution/__init__.py @@ -136,10 +136,13 @@ from autoskillit.execution.session import ( ClaudeSessionResult, ContentState, + DefaultSkillSessionContractStore, SessionState, + SkillSessionContract, _collapse_hr_split_delimiters, # noqa: F401 — re-exported for fleet.result_parser classify_infra_exit, clear_session_state, + delete_skill_session_contracts, extract_token_usage, parse_session_result, persist_session_state, @@ -194,10 +197,13 @@ # session "ClaudeSessionResult", "ContentState", + "DefaultSkillSessionContractStore", "SessionState", + "SkillSessionContract", "SkillResult", "classify_infra_exit", "clear_session_state", + "delete_skill_session_contracts", "extract_token_usage", "parse_session_result", "persist_session_state", diff --git a/src/autoskillit/execution/_recording_skills.py b/src/autoskillit/execution/_recording_skills.py index 483e92bd2f..a8fb581401 100644 --- a/src/autoskillit/execution/_recording_skills.py +++ b/src/autoskillit/execution/_recording_skills.py @@ -4,17 +4,80 @@ import hashlib import shutil +from collections.abc import Mapping from datetime import UTC, datetime from pathlib import Path from typing import Any import regex as re -from autoskillit.core import ValidatedAddDir, write_versioned_json +from autoskillit.core import ( + MACHINE_ONLY_SKILL_FRONTMATTER_KEYS, + ValidatedAddDir, + load_yaml, + write_versioned_json, +) SKILLS_SNAPSHOT_DIR = "skill-snapshots" _EPHEMERAL_SESSION_PATTERN = "autoskillit-sessions" _GATED_PATTERN = re.compile(r"disable-model-invocation\s*:\s*true", re.IGNORECASE) +_FRONTMATTER_PATTERN = re.compile(r"\A---\r?\n(.*?)\r?\n---(?:\r?\n|\Z)", re.DOTALL) + + +def _assert_agent_safe_skill_tree(skills_dir: Path) -> None: + """Reject snapshots that could restore machine-only authority to an agent.""" + for entry in skills_dir.rglob("*"): + if entry.is_symlink(): + raise ValueError(f"agent-safe skill snapshots must not contain symlinks: {entry}") + for skill_dir in skills_dir.iterdir(): + if not skill_dir.is_dir(): + raise ValueError( + f"agent-safe skill snapshots must contain only skill directories: {skill_dir}" + ) + children = {child.name for child in skill_dir.iterdir()} + if children != {"SKILL.md"} or not (skill_dir / "SKILL.md").is_file(): + raise ValueError(f"agent-safe skill directory must contain only SKILL.md: {skill_dir}") + for skill_md in sorted(skills_dir.rglob("SKILL.md")): + try: + content = skill_md.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as exc: + raise ValueError(f"agent-safe SKILL.md is unreadable: {skill_md}") from exc + match = _FRONTMATTER_PATTERN.match(content) + if match is None: + if content.startswith("---"): + raise ValueError(f"agent-safe SKILL.md has malformed frontmatter: {skill_md}") + continue + try: + frontmatter = load_yaml(match.group(1)) + except Exception as exc: + raise ValueError(f"agent-safe SKILL.md has invalid YAML: {skill_md}") from exc + if frontmatter is None: + frontmatter = {} + if not isinstance(frontmatter, Mapping): + raise ValueError(f"agent-safe SKILL.md frontmatter must be a mapping: {skill_md}") + leaked = sorted(MACHINE_ONLY_SKILL_FRONTMATTER_KEYS & frontmatter.keys()) + if leaked: + raise ValueError( + f"agent-safe SKILL.md contains machine-only fields {leaked!r}: {skill_md}" + ) + + +def validate_skill_snapshot_members( + snapshot_path: Path, + expected_names: frozenset[str], +) -> None: + """Validate a replay snapshot against its fresh resolved invocation.""" + skills_dir = snapshot_path / ".claude" / "skills" + if not skills_dir.is_dir(): + raise ValueError("skill snapshot has no projected skills directory") + _assert_agent_safe_skill_tree(skills_dir) + actual_names = frozenset(entry.name for entry in skills_dir.iterdir()) + if actual_names != expected_names: + raise ValueError( + "skill snapshot inventory does not match the effective invocation: " + f"missing={sorted(expected_names - actual_names)!r}, " + f"unexpected={sorted(actual_names - expected_names)!r}" + ) def _extract_ephemeral_add_dir(cmd: list[str]) -> Path | None: @@ -40,6 +103,7 @@ def build_skills_manifest(skills_dir: Path) -> dict[str, Any]: "skill_count": 0, "skills": {}, } + _assert_agent_safe_skill_tree(skills_dir) skills: dict[str, Any] = {} for skill_dir in sorted(skills_dir.iterdir()): if not skill_dir.is_dir(): @@ -80,6 +144,7 @@ def snapshot_skill_dir(scenario_dir: Path, step_name: str, add_dir_path: Path) - skill_subdirs = [d for d in skills_src.iterdir() if d.is_dir()] if not skill_subdirs: return None + _assert_agent_safe_skill_tree(skills_src) snapshot_dir = scenario_dir / SKILLS_SNAPSHOT_DIR / step_name snapshot_dir.mkdir(parents=True, exist_ok=True) @@ -89,11 +154,12 @@ def snapshot_skill_dir(scenario_dir: Path, step_name: str, add_dir_path: Path) - shutil.rmtree(dest_skills) try: shutil.copytree(skills_src, dest_skills) + _assert_agent_safe_skill_tree(dest_skills) except Exception: shutil.rmtree(snapshot_dir, ignore_errors=True) raise - manifest = build_skills_manifest(skills_src) + manifest = build_skills_manifest(dest_skills) write_versioned_json(snapshot_dir / "manifest.json", manifest, 1) return snapshot_dir @@ -111,6 +177,7 @@ def restore_skill_snapshot( skills_src = snapshot_path / ".claude" / "skills" if not skills_src.exists(): return None + _assert_agent_safe_skill_tree(skills_src) session_dir = ephemeral_root / session_id dest_skills = session_dir / ".claude" / "skills" @@ -119,6 +186,7 @@ def restore_skill_snapshot( try: shutil.copytree(skills_src, dest_skills) + _assert_agent_safe_skill_tree(dest_skills) except Exception: shutil.rmtree(dest_skills, ignore_errors=True) raise diff --git a/src/autoskillit/execution/backends/claude.py b/src/autoskillit/execution/backends/claude.py index f7cc3381d2..8b20fee652 100644 --- a/src/autoskillit/execution/backends/claude.py +++ b/src/autoskillit/execution/backends/claude.py @@ -275,6 +275,14 @@ def parse_stdout(self, stdout: str, *, exit_code: int = 0) -> AgentSessionResult @dataclass(frozen=True, slots=True) class ClaudeCodeBackend(BackendCmdBuilderBase): + def _plugin_dir_arg(self, plugin_dir: Path) -> str: + if plugin_dir.resolve() == pkg_root().resolve(): + raise ValueError( + "DirectInstall must be projected before Claude command construction; " + "refusing to expose the canonical package root" + ) + return str(plugin_dir) + def _binary(self) -> str: return "claude" @@ -311,6 +319,7 @@ def conventions(self) -> BackendConventions: ".autoskillit/skills", ".agents/skills", ), + skill_sigil=self.capabilities.skill_sigil, ) def setup_session_dir(self, session_dir: Path) -> None: @@ -398,8 +407,8 @@ def build_interactive_cmd( Optional model override. plugin_source When provided, determines the ``--plugin-dir`` flag. DirectInstall uses - the plugin_dir path; MarketplaceInstall omits the flag (parent session - already has it loaded). + a generated model-safe projection of plugin_dir; MarketplaceInstall + omits the flag (parent session already has it loaded). add_dirs Each entry is appended as ``--add-dir ``. resume_spec @@ -441,7 +450,7 @@ def build_interactive_cmd( builder.kv_flag(ClaudeFlags.MODEL, self.translate_model(model)) match plugin_source: case DirectInstall(plugin_dir=p): - builder.kv_flag(ClaudeFlags.PLUGIN_DIR, str(p)) + builder.kv_flag(ClaudeFlags.PLUGIN_DIR, self._plugin_dir_arg(p)) case MarketplaceInstall(): pass case None: @@ -488,7 +497,7 @@ def build_resume_cmd( _apply_output_format(cmd, output_format) match plugin_source: case DirectInstall(plugin_dir=p): - cmd += [ClaudeFlags.PLUGIN_DIR, str(p)] + cmd += [ClaudeFlags.PLUGIN_DIR, self._plugin_dir_arg(p)] case MarketplaceInstall(): pass case None: @@ -615,7 +624,7 @@ def build_skill_session_cmd( cmd: list[str] = [*spec.cmd] match plugin_source: case DirectInstall(plugin_dir=p): - cmd += [ClaudeFlags.PLUGIN_DIR, str(p)] + cmd += [ClaudeFlags.PLUGIN_DIR, self._plugin_dir_arg(p)] case MarketplaceInstall(): pass case None: @@ -705,7 +714,7 @@ def build_food_truck_cmd( cmd: list[str] = [*spec.cmd] match plugin_source: case DirectInstall(plugin_dir=p): - cmd += [ClaudeFlags.PLUGIN_DIR, str(p)] + cmd += [ClaudeFlags.PLUGIN_DIR, self._plugin_dir_arg(p)] case MarketplaceInstall(cache_path=cp): cmd += [ClaudeFlags.PLUGIN_DIR, str(cp)] _apply_output_format(cmd, output_format) diff --git a/src/autoskillit/execution/backends/codex.py b/src/autoskillit/execution/backends/codex.py index ac0af7d491..079074099e 100644 --- a/src/autoskillit/execution/backends/codex.py +++ b/src/autoskillit/execution/backends/codex.py @@ -41,6 +41,8 @@ ClaudeDirectoryConventions, CmdSpec, CodexEventType, + DirectInstall, + MarketplaceInstall, NamedResume, NoResume, OutputFormat, @@ -81,6 +83,17 @@ from autoskillit.execution.backends._codex_hooks import sync_hooks_to_codex_config from autoskillit.execution.backends._codex_parse import CodexResultParser, CodexStreamParser + +def _codex_home_from_plugin_source(plugin_source: PluginSource | None) -> str | None: + if plugin_source is None: + return None + if isinstance(plugin_source, MarketplaceInstall): + raise ValueError("Codex requires a sanitized DirectInstall plugin projection") + if isinstance(plugin_source, DirectInstall): + return str(plugin_source.plugin_dir) + raise TypeError(f"Unsupported plugin source: {type(plugin_source).__name__}") + + __all__ = [ "CODEX_EXEC_FLAGS", "CODEX_TOP_LEVEL_ONLY_FLAGS", @@ -514,51 +527,6 @@ def _register_agent_tomls(session_dir: Path) -> int: return len(registrations) // 4 -def _materialize_profile_skills(session_dir: Path) -> int: - """Symlink ~/.codex/skills/ dirs into session_dir/skills/. - - Scans Path.home() / ".codex" / "skills" for subdirectories containing - SKILL.md. Each is symlinked into session_dir/skills/. Falls back - to shutil.copytree if symlink creation fails. Subdirectories without - SKILL.md are skipped. Returns the number of skills materialized. - """ - profile_skills_root = Path.home() / ".codex" / "skills" - if not profile_skills_root.is_dir(): - return 0 - count = 0 - skills_base = session_dir / "skills" - skills_base.mkdir(parents=True, exist_ok=True) - try: - entries = list(profile_skills_root.iterdir()) - except OSError: - return 0 - for entry in entries: - if not entry.is_dir() or not (entry / "SKILL.md").is_file(): - continue - target = skills_base / entry.name - if target.exists() or target.is_symlink(): - continue - try: - target.symlink_to(entry.resolve()) - except OSError: - logger.debug( - "codex_profile_skill_symlink_failed_using_copytree", - skill=entry.name, - exc_info=True, - ) - try: - shutil.copytree(entry, target) - except OSError: - logger.warning( - "codex_profile_skill_copy_failed", - skill=entry.name, - exc_info=True, - ) - continue - count += 1 - return count - - @dataclass(frozen=True, slots=True) class CodexBackend(BackendCmdBuilderBase): def _binary(self) -> str: @@ -600,7 +568,6 @@ def capabilities(self) -> BackendCapabilities: session_record_types=frozenset({"item.completed"}), triage_capable=False, supports_context_exhaustion_detection=False, - project_local_skills_capable=False, supports_tool_list_changed=False, required_skill_fields=frozenset({"name", "description"}), required_session_files=frozenset({"config.toml"}), @@ -650,6 +617,7 @@ def conventions(self) -> BackendConventions: return BackendConventions( skills_subdir=ClaudeDirectoryConventions.PLUGIN_DIR_SKILLS_SUBDIR, project_local_skill_search_dirs=(".codex/skills", ".agents/skills"), + skill_sigil=self.capabilities.skill_sigil, ) def build_cmd(self, skill_command: str, cwd: str) -> CmdSpec: @@ -762,8 +730,7 @@ def build_skill_session_cmd( resume_message = cfg["resume_message"] sandbox_mode = cfg["sandbox_mode"] network_access = cfg.get("network_access", False) - if plugin_source is not None: - logger.warning("codex_plugin_source_discarded", plugin_source=str(plugin_source)) + projected_codex_home = _codex_home_from_plugin_source(plugin_source) if output_format != OutputFormat.JSON: logger.warning("codex_output_format_coerced") _has_prefix = ( @@ -826,6 +793,8 @@ def build_skill_session_cmd( extras["AUTOSKILLIT_COMPLETION_MARKER"] = completion_marker if add_dirs: extras["CODEX_HOME"] = add_dirs[0].path + elif projected_codex_home is not None: + extras["CODEX_HOME"] = projected_codex_home if exit_after_stop_delay_ms: extras.setdefault( "AUTOSKILLIT_IDLE_OUTPUT_TIMEOUT", str(exit_after_stop_delay_ms / 1000) @@ -888,8 +857,7 @@ def build_food_truck_cmd( sentinel_contract: str = "", resume_message: str | None = None, ) -> CmdSpec: - if plugin_source is not None: - logger.warning("codex_plugin_source_discarded", plugin_source=str(plugin_source)) + projected_codex_home = _codex_home_from_plugin_source(plugin_source) if output_format != OutputFormat.STREAM_JSON: logger.warning("codex_output_format_coerced") @@ -936,6 +904,8 @@ def build_food_truck_cmd( for k, v in env_extras.items(): if k not in _PROVIDER_EXTRAS_BASE_DENYLIST: extras[k] = v + if projected_codex_home is not None: + extras["CODEX_HOME"] = projected_codex_home if exit_after_stop_delay_ms: extras.setdefault( "AUTOSKILLIT_IDLE_OUTPUT_TIMEOUT", str(exit_after_stop_delay_ms / 1000) @@ -1039,6 +1009,10 @@ def build_interactive_cmd( merged_extras.update(env_extras) if add_dirs: merged_extras.setdefault("CODEX_HOME", str(add_dirs[0])) + else: + projected_codex_home = _codex_home_from_plugin_source(plugin_source) + if projected_codex_home is not None: + merged_extras.setdefault("CODEX_HOME", projected_codex_home) effective_required = CODEX_INTERACTIVE_REQUIRED_ENV | (required_env or frozenset()) env = CodexEnvPolicy().build_env( base_env, extras=merged_extras, required=effective_required @@ -1073,6 +1047,9 @@ def build_resume_cmd( ) if env_extras: resume_extras.update(env_extras) + projected_codex_home = _codex_home_from_plugin_source(plugin_source) + if projected_codex_home is not None: + resume_extras["CODEX_HOME"] = projected_codex_home env = self.env_policy().build_env( filtered_base, extras=resume_extras, @@ -1157,11 +1134,6 @@ def setup_session_dir(self, session_dir: Path) -> None: except Exception: logger.warning("codex_agent_toml_generation_failed", exc_info=True) - try: - _materialize_profile_skills(session_dir) - except Exception: - logger.warning("codex_profile_skills_materialization_failed", exc_info=True) - def validate_skill_content(self, content: str) -> list[str]: return [] diff --git a/src/autoskillit/execution/headless/AGENTS.md b/src/autoskillit/execution/headless/AGENTS.md index ba925a8e3a..748b40127a 100644 --- a/src/autoskillit/execution/headless/AGENTS.md +++ b/src/autoskillit/execution/headless/AGENTS.md @@ -14,7 +14,7 @@ Headless Claude session orchestration — command prep, subprocess invocation, r | `_headless_recovery.py` | Session recovery: `_recover_from_separate_marker`, `_synthesize_from_write_artifacts`, `_infer_enum_token_from_write_contract` (contract-aware enum-token inference) | | `_headless_result.py` | `SkillResult` construction: `_build_skill_result` (evidence/telemetry moved to `_headless_evidence.py`) | | `_headless_evidence.py` | Evidence computation (`_compute_write_evidence`, `_adapt_agent_result`), audit recording (`_capture_failure`, `_apply_budget_guard`), telemetry builders (`_build_session_telemetry`, `_build_error_path_telemetry`) | -| `_headless_outcome.py` | Contract-field parser and outcome invariant evaluator for post-session adjudication | +| `_headless_outcome.py` | Dispatch authority validator, contract-field parser, and outcome invariant evaluator | | `_headless_scan.py` | `_scan_jsonl_write_paths()` — scans stdout JSONL for Write/Edit/Bash tool calls; uses `core/bash_write_targets` for precise write-target extraction | ## Architecture Notes diff --git a/src/autoskillit/execution/headless/__init__.py b/src/autoskillit/execution/headless/__init__.py index 1766052149..71adac7663 100644 --- a/src/autoskillit/execution/headless/__init__.py +++ b/src/autoskillit/execution/headless/__init__.py @@ -1,11 +1,4 @@ -"""Headless Claude Code session orchestration. - -IL-1 module (execution/). Owns the full lifecycle of a headless claude CLI session: -command preparation, subprocess invocation via the injected runner, and -SkillResult construction. - -Public API: run_headless_core(skill_command, cwd, ctx, *, ...) -> SkillResult -""" +"""IL-1 headless session lifecycle and food-truck dispatch.""" from __future__ import annotations @@ -20,6 +13,8 @@ SKILL_COMMAND_DISPLAY_MAX, ClosureAuthoritySpec, CodingAgentBackend, + HeadlessSkillDispatchContract, + PluginSource, SessionCheckpoint, # noqa: F401, TC001 SkillResult, SkillSessionConfig, @@ -56,6 +51,7 @@ assert_interactive_ordering, resolve_model_identity, ) +from autoskillit.execution.headless._headless_outcome import validated_dispatch_cwd from autoskillit.execution.headless._headless_path_tokens import ( # noqa: F401 _BRANCH_NAME_PATTERN, _INTENTIONALLY_EXCLUDED_PATH_TOKENS, @@ -152,20 +148,25 @@ async def run_headless_core( network_access: bool = False, closure_spec: ClosureAuthoritySpec | None = None, closure_report_root: Path | None = None, + on_session_id_resolved: Callable[[str], None] | None = None, skill_contract: SkillContract | None = None, + capability_contract: HeadlessSkillDispatchContract | None = None, ) -> SkillResult: """Shared headless runner used by run_skill. Does NOT check open_kitchen gate — callers in server.py are responsible. Accepts explicit ToolContext so this module has no server.py dependency. """ + cwd = validated_dispatch_cwd( + capability_contract, + resolved_command=skill_command, + cwd=cwd, + ) cfg = ctx.config.run_skill effective_marker = completion_marker or cfg.completion_marker original_skill_command = skill_command - if not step_name and isinstance(ctx.runner, RecordingSubprocessRunner): step_name = _derive_step_name_from_skill_command(skill_command) - with structlog.contextvars.bound_contextvars( skill_command=original_skill_command[:SKILL_COMMAND_DISPLAY_MAX], step_name=step_name or None, @@ -190,7 +191,6 @@ async def run_headless_core( step_backend=step_backend.name, ctx_backend=ctx.backend.name, ) - _cmd_backend = step_backend if step_backend is not None else ctx.backend config = SkillSessionConfig( completion_marker=effective_marker, @@ -219,7 +219,6 @@ async def run_headless_core( effective_timeout = timeout if timeout is not None else cfg.timeout effective_stale = stale_threshold if stale_threshold is not None else cfg.stale_threshold - logger.debug( "run_headless_core_entry", cwd=cwd, @@ -229,7 +228,6 @@ async def run_headless_core( plugin_source=repr(ctx.plugin_source), add_dirs=list(add_dirs) if add_dirs else None, ) - effective_provider = provider_name or profile_name return await _execute_claude_headless( spec, @@ -268,6 +266,7 @@ async def run_headless_core( backend_override_source=backend_override_source, closure_spec=closure_spec, closure_report_root=closure_report_root, + on_session_id_resolved=on_session_id_resolved, skill_contract=skill_contract, ) @@ -320,7 +319,9 @@ async def run( network_access: bool = False, closure_spec: ClosureAuthoritySpec | None = None, closure_report_root: Path | None = None, + on_session_id_resolved: Callable[[str], None] | None = None, skill_contract: SkillContract | None = None, + capability_contract: HeadlessSkillDispatchContract | None = None, ) -> SkillResult: cfg = self._ctx.config.run_skill effective_timeout = timeout if timeout is not None else cfg.timeout @@ -366,7 +367,9 @@ async def run( network_access=network_access, closure_spec=closure_spec, closure_report_root=closure_report_root, + on_session_id_resolved=on_session_id_resolved, skill_contract=skill_contract, + capability_contract=capability_contract, ) async def dispatch_food_truck( @@ -375,6 +378,7 @@ async def dispatch_food_truck( cwd: str, *, completion_marker: str, + plugin_source: PluginSource | None = None, prior_completion_markers: Sequence[str] | None = None, resume_session_id: str | None = None, resume_checkpoint: SessionCheckpoint | None = None, @@ -404,7 +408,13 @@ async def dispatch_food_truck( resume_message: str | None = None, backend_override: str | None = None, on_session_id_resolved: Callable[[str], None] | None = None, + capability_contract: HeadlessSkillDispatchContract | None = None, ) -> SkillResult: + cwd = validated_dispatch_cwd( + capability_contract, + resolved_command=orchestrator_prompt, + cwd=cwd, + ) dispatch_backend: CodingAgentBackend | None if backend_override is not None: from autoskillit.execution.backends import get_backend @@ -412,7 +422,6 @@ async def dispatch_food_truck( dispatch_backend = get_backend(backend_override) else: dispatch_backend = self._ctx.backend - if dispatch_backend is not None and not dispatch_backend.capabilities.food_truck_capable: raise RuntimeError( f"backend does not support food truck dispatch " @@ -426,13 +435,11 @@ async def dispatch_food_truck( f"Pre-launch check failed for dispatch backend " f"{dispatch_backend.name!r}: {'; '.join(pre_launch_errors)}" ) - cfg = self._ctx.config model_identity = resolve_model_identity( model, cfg, step_name=step_name, profile_name=profile_name ) fleet_cfg = cfg.fleet - merged_extras: dict[str, str] = dict(env_extras) if env_extras else {} if requires_packs: if FOOD_TRUCK_TOOL_TAGS_ENV_VAR in merged_extras: @@ -441,7 +448,6 @@ async def dispatch_food_truck( f"{FOOD_TRUCK_TOOL_TAGS_ENV_VAR} — use requires_packs exclusively" ) merged_extras[FOOD_TRUCK_TOOL_TAGS_ENV_VAR] = ",".join(sorted(requires_packs)) - fleet_idle = fleet_cfg.idle_output_timeout if idle_output_timeout is not None: merged_extras["AUTOSKILLIT_IDLE_OUTPUT_TIMEOUT"] = str(idle_output_timeout) @@ -451,7 +457,6 @@ async def dispatch_food_truck( idle_cfg_val = cfg.run_skill.idle_output_timeout if idle_cfg_val > 0: merged_extras.setdefault("AUTOSKILLIT_IDLE_OUTPUT_TIMEOUT", str(idle_cfg_val)) - if dispatch_backend is None: raise RuntimeError( "dispatch_backend must be resolved before dispatch_food_truck execution" @@ -459,7 +464,7 @@ async def dispatch_food_truck( backend = dispatch_backend cmd_spec = backend.build_food_truck_cmd( orchestrator_prompt=orchestrator_prompt, - plugin_source=self._ctx.plugin_source, + plugin_source=cast(PluginSource, plugin_source), cwd=cwd, completion_marker=completion_marker, resume_session_id=resume_session_id, @@ -477,14 +482,12 @@ async def dispatch_food_truck( resume_message=resume_message, ) spec = cmd_spec - effective_timeout = timeout if timeout is not None else fleet_cfg.default_timeout_sec effective_stale = ( stale_threshold if stale_threshold is not None else cfg.run_skill.stale_threshold ) effective_deadline_ext = fleet_cfg.enable_deadline_extension effective_max_ext = float(fleet_cfg.max_extension_seconds) - effective_idle_out: float | None = ( idle_output_timeout if idle_output_timeout is not None @@ -492,13 +495,11 @@ async def dispatch_food_truck( if fleet_idle > 0 else None ) - effective_marker_dir: Path | None = marker_dir or ( _resolve_session_log_dir(cwd, cast(CodingAgentBackend, dispatch_backend)) if cwd else None ) - return await _execute_claude_headless( spec, cwd, diff --git a/src/autoskillit/execution/headless/_headless_execute.py b/src/autoskillit/execution/headless/_headless_execute.py index b67ef7372f..885e7a4351 100644 --- a/src/autoskillit/execution/headless/_headless_execute.py +++ b/src/autoskillit/execution/headless/_headless_execute.py @@ -123,8 +123,7 @@ async def _execute_claude_headless( Accepts an already-built CmdSpec and handles runner invocation, exception handling, _build_skill_result, and session log flushing. - Used by both run_headless_core (leaf path) and - DefaultHeadlessExecutor.dispatch_food_truck (food truck path). + Used by both leaf and food-truck execution paths. """ campaign_id = campaign_id or os.environ.get(CAMPAIGN_ID_ENV_VAR, "") dispatch_id = dispatch_id or os.environ.get(DISPATCH_ID_ENV_VAR, "") @@ -472,6 +471,9 @@ async def _execute_claude_headless( if nudge_success is not None: skill_result = nudge_success + if on_session_id_resolved is not None and skill_result.session_id: + on_session_id_resolved(skill_result.session_id) + _clone_reverted = False if _clone_snapshot is not None: _exclude_prefix = _derived_prefix or GUARD_EXCLUDE_PREFIX diff --git a/src/autoskillit/execution/headless/_headless_outcome.py b/src/autoskillit/execution/headless/_headless_outcome.py index 019598d2af..855d7d8100 100644 --- a/src/autoskillit/execution/headless/_headless_outcome.py +++ b/src/autoskillit/execution/headless/_headless_outcome.py @@ -1,4 +1,4 @@ -"""Contract-field parser and outcome invariant evaluator. +"""Dispatch-contract validation, field parsing, and outcome invariant evaluation. Extracts declared output fields from session result text as ``KEY = value`` lines, typed per the contract's output declarations. Evaluates outcome @@ -8,11 +8,12 @@ from __future__ import annotations import operator +from pathlib import Path from typing import TYPE_CHECKING, Any import regex as re -from autoskillit.core import get_logger +from autoskillit.core import HeadlessSkillDispatchContract, get_logger if TYPE_CHECKING: from autoskillit.recipe._contracts_types import ( @@ -37,6 +38,34 @@ _EXPR_RE = re.compile(r"^(\w+)\s*(>=|<=|!=|==|>|<)\s*(\d+)$") +def validated_dispatch_cwd( + capability_contract: HeadlessSkillDispatchContract | None, + *, + resolved_command: str, + cwd: str, +) -> str: + """Normalize cwd and reject primitives that disagree with immutable authority.""" + normalized_cwd = str(Path(cwd).resolve()) if cwd else "" + if capability_contract is None: + return normalized_cwd + if capability_contract.resolved_command != resolved_command: + raise ValueError("headless command does not match capability dispatch contract") + if capability_contract.cwd != normalized_cwd: + raise ValueError("headless cwd does not match capability dispatch contract") + members = set(capability_contract.member_names) + if not members: + raise ValueError("capability dispatch contract has no projected members") + for field_name, values in ( + ("source identities", capability_contract.source_identities), + ("canonical digests", capability_contract.canonical_digests), + ("projected digests", capability_contract.projected_digests), + ("projected artifacts", capability_contract.projected_artifacts), + ): + if set(values) != members: + raise ValueError(f"capability dispatch {field_name} do not match members") + return capability_contract.cwd + + def parse_outcome_fields( result_text: str, contract: SkillContract, diff --git a/src/autoskillit/execution/process/__init__.py b/src/autoskillit/execution/process/__init__.py index 832fa0cb07..82845da727 100644 --- a/src/autoskillit/execution/process/__init__.py +++ b/src/autoskillit/execution/process/__init__.py @@ -435,6 +435,7 @@ async def run_managed_async( max_suppression_seconds, marker_dir, session_id, + on_session_id_resolved, ) if idle_output_timeout is not None and idle_output_timeout > 0: tg.start_soon( diff --git a/src/autoskillit/execution/process/_process_race.py b/src/autoskillit/execution/process/_process_race.py index 2192292788..77a664db33 100644 --- a/src/autoskillit/execution/process/_process_race.py +++ b/src/autoskillit/execution/process/_process_race.py @@ -443,6 +443,7 @@ async def _watch_session_log( max_suppression_seconds: float | None = None, marker_dir: Path | None = None, session_id: str | None = None, + on_session_id_resolved: Callable[[str], None] | None = None, ) -> None: """Monitor the session JSONL log and deposit the Channel B signal. @@ -495,6 +496,8 @@ async def _watch_session_log( acc.channel_b_status = monitor_result.status acc.channel_b_session_id = monitor_result.session_id acc.channel_b_orphaned_tool_result = monitor_result.orphaned_tool_result + if on_session_id_resolved is not None and monitor_result.session_id: + on_session_id_resolved(monitor_result.session_id) channel_b_ready.set() trigger.set() diff --git a/src/autoskillit/execution/recording.py b/src/autoskillit/execution/recording.py index b723d833b4..4419775ee5 100644 --- a/src/autoskillit/execution/recording.py +++ b/src/autoskillit/execution/recording.py @@ -30,6 +30,7 @@ _extract_ephemeral_add_dir, scan_skill_snapshots, snapshot_skill_dir, + validate_skill_snapshot_members, ) from autoskillit.execution._recording_skills import ( restore_skill_snapshot as _restore_skill_snapshot, @@ -180,6 +181,7 @@ async def __call__( step_name=step_name, completion_record_types=completion_record_types, session_record_types=session_record_types, + on_session_id_resolved=on_session_id_resolved, child_deferral_ceiling=child_deferral_ceiling, capture_dir=capture_dir, ) @@ -207,6 +209,7 @@ async def __call__( stream_parser=stream_parser, completion_record_types=completion_record_types, session_record_types=session_record_types, + on_session_id_resolved=on_session_id_resolved, child_deferral_ceiling=child_deferral_ceiling, capture_dir=capture_dir, ) @@ -244,6 +247,7 @@ async def __call__( stream_parser=stream_parser, completion_record_types=completion_record_types, session_record_types=session_record_types, + on_session_id_resolved=on_session_id_resolved, child_deferral_ceiling=child_deferral_ceiling, capture_dir=capture_dir, ) @@ -321,6 +325,7 @@ async def _record_non_pty_session( step_name: str, completion_record_types: frozenset[str] = frozenset({"result"}), session_record_types: frozenset[str] = frozenset({"assistant"}), + on_session_id_resolved: Callable[[str], None] | None = None, child_deferral_ceiling: float = 0.0, capture_dir: Path | None = None, ) -> SubprocessResult: @@ -347,6 +352,7 @@ async def _record_non_pty_session( stream_parser=stream_parser, completion_record_types=completion_record_types, session_record_types=session_record_types, + on_session_id_resolved=on_session_id_resolved, child_deferral_ceiling=child_deferral_ceiling, capture_dir=capture_dir, ) @@ -419,6 +425,17 @@ def restore_skill_snapshot( return None return _restore_skill_snapshot(snap_path, ephemeral_root, session_id) + def validate_skill_snapshot( + self, + step_name: str, + expected_names: frozenset[str], + ) -> None: + """Reject replay snapshot drift before any restore-side filesystem work.""" + snap_path = self.skill_snapshots.get(step_name) + if snap_path is None: + return + validate_skill_snapshot_members(snap_path, expected_names) + async def __call__( self, cmd: list[str], diff --git a/src/autoskillit/execution/session/AGENTS.md b/src/autoskillit/execution/session/AGENTS.md index df77840a79..42a4184ac1 100644 --- a/src/autoskillit/execution/session/AGENTS.md +++ b/src/autoskillit/execution/session/AGENTS.md @@ -9,6 +9,7 @@ Session result processing — parse, validate content, compute retry, adjudicate | `__init__.py` | Thin facade re-exporting all sub-module symbols | | `_exit_classification.py` | Infrastructure exit classification: `classify_infra_exit()`, `InfraExitCategory` detection from session + stderr | | `_session_state.py` | Session state persistence for resume: `SessionState`, `persist_session_state()`, `read_session_state()`, `clear_session_state()` | +| `_skill_session_contract_store.py` | Always-on effective skill contract and projected snapshot persistence for resume | | `_session_model.py` | `ClaudeSessionResult`, `ContentState`, `parse_session_result()`, `extract_token_usage()` | | `_session_content.py` | Content validation: `_check_session_content()`, `_check_expected_patterns()`, `_normalize_model_output()`, `_collapse_hr_split_delimiters()` | | `_retry_fsm.py` | Retry FSM: `_compute_retry()`, maps `(TerminationReason, CliSubtype)` to `RetryReason` | diff --git a/src/autoskillit/execution/session/__init__.py b/src/autoskillit/execution/session/__init__.py index 6fd2323de2..0a8c0206c3 100644 --- a/src/autoskillit/execution/session/__init__.py +++ b/src/autoskillit/execution/session/__init__.py @@ -1,21 +1,11 @@ -"""Domain model for Claude Code headless session results. +"""Typed headless-session result facade. -IL-1 module: imports only from IL-0 (types, _logging). No server side-effects. -Centralizes all session-parsing concerns so callers can work with typed -objects instead of raw JSON strings. - -Facade: re-exports from _session_model, _session_content, _retry_fsm, and -_session_outcome sub-modules. +IL-1 module with no server side effects. """ from __future__ import annotations -from autoskillit.core import ( - CliSubtype, - SkillResult, - get_logger, - truncate_text, -) +from autoskillit.core import CliSubtype, SkillResult, get_logger, truncate_text from autoskillit.execution.session._exit_classification import ( classify_infra_exit, # noqa: F401 — re-export for callers has_rate_limit_signal, # noqa: F401 — re-export for callers @@ -49,16 +39,26 @@ persist_session_state, # noqa: F401 — re-export for callers read_session_state, # noqa: F401 — re-export for callers ) +from autoskillit.execution.session._skill_session_contract_store import ( + DefaultSkillSessionContractStore, + SkillSessionContract, + StoredSkillSessionContract, + delete_skill_session_contracts, +) logger = get_logger(__name__) _truncate = truncate_text # Re-export SkillResult so existing callers can import from this module. __all__ = [ "CliSubtype", + "DefaultSkillSessionContractStore", "SessionState", "SessionStateLock", + "SkillSessionContract", "SkillResult", + "StoredSkillSessionContract", "classify_infra_exit", + "delete_skill_session_contracts", "has_rate_limit_signal", "clear_session_state", "persist_session_state", diff --git a/src/autoskillit/execution/session/_skill_session_contract_store.py b/src/autoskillit/execution/session/_skill_session_contract_store.py new file mode 100644 index 0000000000..9a9305a0fa --- /dev/null +++ b/src/autoskillit/execution/session/_skill_session_contract_store.py @@ -0,0 +1,570 @@ +"""Persistent effective-skill contracts for resumable backend sessions.""" + +from __future__ import annotations + +import hashlib +import json +import os +import secrets +import shutil +import tempfile +from collections.abc import Iterable, Mapping +from pathlib import Path, PurePosixPath +from threading import RLock +from typing import Any + +import regex as re + +from autoskillit.core import ( + SKILL_PROJECTION_VERSION, + SKILL_SESSION_CONTRACT_SCHEMA_VERSION, + SkillContractError, + SkillExecutionRole, + SkillSessionContract, + SkillSource, + SkillSourceRef, + StoredSkillSessionContract, + WriteBehaviorSpec, + atomic_write, + default_log_dir, + read_versioned_json, + validate_skill_capability_roles, + write_versioned_json, +) + +__all__ = [ + "DefaultSkillSessionContractStore", + "SkillSessionContract", + "StoredSkillSessionContract", + "delete_skill_session_contracts", +] + +_STORE_MANIFEST_SCHEMA_VERSION = 1 +_MANIFEST_FILENAME = "manifest.json" +_SNAPSHOT_DIRNAME = "snapshot" +_CORRELATION_KEY_RE = re.compile(r"^[0-9a-f]{32}$") +_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") + + +class DefaultSkillSessionContractStore: + """Filesystem-backed provisional-to-final session contract store.""" + + def __init__(self, root: Path | None = None) -> None: + configured_root = root or (default_log_dir() / "skill-session-contracts") + self._root = configured_root.expanduser().resolve() + self._provisional_root = self._root / "provisional" + self._sessions_root = self._root / "sessions" + self._provisional_root.mkdir(parents=True, exist_ok=True) + self._sessions_root.mkdir(parents=True, exist_ok=True) + self._lock = RLock() + + def create_provisional( + self, + contract: SkillSessionContract, + snapshot: Mapping[str, str], + ) -> str: + """Atomically persist an unbound contract under a random correlation key.""" + _validate_contract(contract) + snapshot_paths = _validate_snapshot_mapping(contract, snapshot) + correlation_key = secrets.token_hex(16) + destination = self._provisional_path(correlation_key) + temp_path = Path( + tempfile.mkdtemp(prefix=".create-", dir=str(self._provisional_root)) + ).resolve() + self._ensure_contained(temp_path) + try: + snapshot_root = temp_path / _SNAPSHOT_DIRNAME + for relative_path, content in snapshot.items(): + target = snapshot_root / PurePosixPath(relative_path) + self._ensure_contained(target) + target.parent.mkdir(parents=True, exist_ok=True) + atomic_write(target, content) + manifest = _build_manifest( + contract=contract, + raw_session_id=None, + candidate_session_ids=(), + snapshot_paths=snapshot_paths, + ) + write_versioned_json( + temp_path / _MANIFEST_FILENAME, + manifest, + schema_version=_STORE_MANIFEST_SCHEMA_VERSION, + ) + with self._lock: + os.replace(temp_path, destination) + except Exception: + shutil.rmtree(temp_path, ignore_errors=True) + raise + return correlation_key + + def observe_candidate(self, correlation_key: str, session_id: str) -> None: + """Record a repeated advisory candidate without binding store ownership.""" + _validate_raw_session_id(session_id) + provisional = self._provisional_path(correlation_key) + with self._lock: + manifest = self._read_manifest(provisional) + candidates = list(manifest.get("candidate_session_ids", [])) + if session_id not in candidates: + candidates.append(session_id) + manifest["candidate_session_ids"] = candidates + self._write_manifest(provisional, manifest) + + def finalize(self, correlation_key: str, session_id: str) -> None: + """Bind a provisional entry only to the final backend session ID.""" + _validate_raw_session_id(session_id) + provisional = self._provisional_path(correlation_key) + destination = self._session_path(session_id) + with self._lock: + manifest = self._read_manifest(provisional) + self._validate_entry(provisional, manifest, expected_raw_session_id=None) + manifest["raw_session_id"] = session_id + self._write_manifest(provisional, manifest) + if destination.exists(): + existing = self._read_manifest(destination) + self._validate_entry( + destination, + existing, + expected_raw_session_id=session_id, + ) + raise FileExistsError(f"Session contract already finalized: {session_id!r}") + os.replace(provisional, destination) + + def load(self, session_id: str) -> StoredSkillSessionContract: + """Load and fully validate the contract bound to ``session_id``.""" + _validate_raw_session_id(session_id) + entry = self._session_path(session_id) + with self._lock: + manifest = self._read_manifest(entry) + contract = self._validate_entry( + entry, + manifest, + expected_raw_session_id=session_id, + ) + return StoredSkillSessionContract( + contract=contract, + snapshot_dir=entry / _SNAPSHOT_DIRNAME, + raw_session_id=session_id, + ) + + def delete(self, session_id: str) -> None: + """Explicitly delete finalized retained state for one raw session ID.""" + with self._lock: + _delete_finalized_contract(self._sessions_root, session_id) + + def discard(self, correlation_key: str) -> None: + """Explicitly discard a provisional entry that never finalized.""" + entry = self._provisional_path(correlation_key) + with self._lock: + shutil.rmtree(entry, ignore_errors=True) + + def _provisional_path(self, correlation_key: str) -> Path: + if not _CORRELATION_KEY_RE.fullmatch(correlation_key): + raise ValueError(f"Invalid correlation key: {correlation_key!r}") + path = self._provisional_root / correlation_key + self._ensure_contained(path) + return path + + def _session_path(self, session_id: str) -> Path: + path = _finalized_contract_path(self._sessions_root, session_id) + self._ensure_contained(path) + return path + + def _ensure_contained(self, path: Path) -> None: + if not path.resolve().is_relative_to(self._root): + raise ValueError(f"Skill session contract path escapes store root: {path}") + + def _read_manifest(self, entry: Path) -> dict[str, Any]: + self._ensure_contained(entry) + manifest_path = entry / _MANIFEST_FILENAME + if not manifest_path.exists(): + raise FileNotFoundError(f"Skill session contract not found: {entry.name}") from None + loaded = read_versioned_json(manifest_path, _STORE_MANIFEST_SCHEMA_VERSION) + if loaded is None: + raise ValueError("Invalid or unsupported skill session contract manifest") + return loaded + + def _write_manifest(self, entry: Path, manifest: Mapping[str, Any]) -> None: + self._ensure_contained(entry) + write_versioned_json( + entry / _MANIFEST_FILENAME, + dict(manifest), + schema_version=_STORE_MANIFEST_SCHEMA_VERSION, + ) + + def _validate_entry( + self, + entry: Path, + manifest: Mapping[str, Any], + *, + expected_raw_session_id: str | None, + ) -> SkillSessionContract: + if manifest.get("schema_version") != _STORE_MANIFEST_SCHEMA_VERSION: + raise ValueError("Unsupported skill session store schema") + raw_session_id = manifest.get("raw_session_id") + if raw_session_id != expected_raw_session_id: + raise ValueError("Skill session contract raw session ID mismatch") + + contract_data = manifest.get("contract") + if not isinstance(contract_data, dict): + raise ValueError("Skill session contract manifest is missing contract") + expected_contract_digest = _digest_json(contract_data) + if manifest.get("contract_digest") != expected_contract_digest: + raise ValueError("Skill session contract manifest digest mismatch") + contract = _contract_from_dict(contract_data) + _validate_contract(contract) + + snapshot_paths_raw = manifest.get("snapshot_paths") + if not isinstance(snapshot_paths_raw, dict): + raise ValueError("Skill session contract manifest is missing snapshot paths") + snapshot_paths = { + str(name): str(relative_path) for name, relative_path in snapshot_paths_raw.items() + } + if set(snapshot_paths) != set(contract.closure): + raise ValueError("Skill session contract snapshot closure mismatch") + + snapshot_root = entry / _SNAPSHOT_DIRNAME + self._ensure_contained(snapshot_root) + declared_files: set[Path] = set() + for name, relative_path in snapshot_paths.items(): + safe_relative = _validate_relative_path(relative_path) + projected_path = snapshot_root / safe_relative + self._ensure_contained(projected_path) + declared_files.add(safe_relative) + try: + content = projected_path.read_bytes() + except OSError as exc: + raise ValueError( + f"Skill session projected snapshot is unreadable for {name!r}" + ) from exc + digest = hashlib.sha256(content).hexdigest() + if digest != contract.projected_digests[name]: + raise ValueError(f"Skill session projected digest mismatch for {name!r}") + + actual_files = ( + { + path.relative_to(snapshot_root) + for path in snapshot_root.rglob("*") + if path.is_file() + } + if snapshot_root.is_dir() + else set() + ) + if actual_files != declared_files: + raise ValueError("Skill session projected snapshot file set mismatch") + return contract + + +def delete_skill_session_contracts( + session_ids: Iterable[str], + *, + root: Path | None = None, +) -> None: + """Explicitly remove retained contracts for completed external sessions.""" + configured_root = root or (default_log_dir() / "skill-session-contracts") + sessions_root = configured_root.expanduser().resolve() / "sessions" + for session_id in session_ids: + if session_id: + _delete_finalized_contract(sessions_root, session_id) + + +def _validate_raw_session_id(session_id: str) -> None: + if not isinstance(session_id, str) or not session_id or "\x00" in session_id: + raise ValueError(f"Invalid session ID: {session_id!r}") + + +def _finalized_contract_path(sessions_root: Path, session_id: str) -> Path: + _validate_raw_session_id(session_id) + key = hashlib.sha256(session_id.encode()).hexdigest() + path = sessions_root / key + if not path.resolve().is_relative_to(sessions_root.resolve()): + raise ValueError(f"Skill session contract path escapes sessions root: {path}") + return path + + +def _delete_finalized_contract(sessions_root: Path, session_id: str) -> None: + shutil.rmtree( + _finalized_contract_path(sessions_root, session_id), + ignore_errors=True, + ) + + +def _validate_digest_map( + field_name: str, + digests: Mapping[str, str], + closure: set[str], +) -> None: + if set(digests) != closure: + raise SkillContractError(f"{field_name} keys must exactly match closure") + for name, digest in digests.items(): + if not isinstance(digest, str) or not _SHA256_RE.fullmatch(digest): + raise SkillContractError(f"Invalid {field_name} digest for {name!r}") + + +def _validate_contract(contract: SkillSessionContract) -> None: + if contract.schema_version != SKILL_SESSION_CONTRACT_SCHEMA_VERSION: + raise SkillContractError("Unsupported skill session contract schema") + if not contract.root_name or not contract.closure: + raise SkillContractError("Skill session contract requires a root and closure") + closure = set(contract.closure) + if len(closure) != len(contract.closure) or contract.root_name not in closure: + raise SkillContractError("Skill session contract closure is invalid") + if set(contract.source_refs) != closure: + raise SkillContractError("source_refs keys must exactly match closure") + if set(contract.member_roles) != closure: + raise SkillContractError("member_roles keys must exactly match closure") + if set(contract.member_capabilities) != closure: + raise SkillContractError("member_capabilities keys must exactly match closure") + if set(contract.member_activate_deps) != closure: + raise SkillContractError("member_activate_deps keys must exactly match closure") + if set(contract.canonical_contents) != closure: + raise SkillContractError("canonical_contents keys must exactly match closure") + for name in contract.closure: + source_ref = contract.source_refs[name] + if not isinstance(source_ref, SkillSourceRef): + raise SkillContractError(f"source reference for {name!r} must be typed") + if source_ref.logical_name != name: + raise SkillContractError(f"source reference logical name mismatch for {name!r}") + role = contract.member_roles[name] + capabilities = contract.member_capabilities[name] + validate_skill_capability_roles(capabilities, role) + if role is not contract.execution_role: + raise SkillContractError(f"member role mismatch for {name!r}") + canonical_digest = hashlib.sha256(contract.canonical_contents[name].encode()).hexdigest() + if canonical_digest != contract.canonical_digests[name]: + raise SkillContractError(f"canonical content digest mismatch for {name!r}") + member_capability_union = frozenset().union( + *(contract.member_capabilities[name] for name in contract.closure) + ) + if member_capability_union != contract.capability_union: + raise SkillContractError("member capability union does not match contract") + _validate_digest_map("canonical_digests", contract.canonical_digests, closure) + _validate_digest_map("projected_digests", contract.projected_digests, closure) + if contract.projection_version != SKILL_PROJECTION_VERSION: + raise SkillContractError( + f"unsupported projection_version {contract.projection_version}; " + f"expected {SKILL_PROJECTION_VERSION}" + ) + if not contract.project_root or not contract.cwd: + raise SkillContractError("project_root and cwd are required") + if not contract.backend or not contract.resolved_command: + raise SkillContractError("backend and resolved_command are required") + validate_skill_capability_roles(contract.capability_union, contract.execution_role) + + +def _validate_relative_path(value: str) -> Path: + if not isinstance(value, str) or not value: + raise ValueError("Projected snapshot path must be a non-empty relative path") + relative = PurePosixPath(value) + if relative.is_absolute() or any(part in ("", ".", "..") for part in relative.parts): + raise ValueError(f"Unsafe projected snapshot path: {value!r}") + return Path(*relative.parts) + + +def _validate_snapshot_mapping( + contract: SkillSessionContract, + snapshot: Mapping[str, str], +) -> dict[str, str]: + if not isinstance(snapshot, Mapping): + raise ValueError("Projected snapshot must be a mapping") + by_skill: dict[str, str] = {} + seen_paths: set[Path] = set() + for raw_relative_path, content in snapshot.items(): + relative_path = _validate_relative_path(raw_relative_path) + if relative_path in seen_paths: + raise ValueError(f"Duplicate projected snapshot path: {raw_relative_path!r}") + if relative_path.name != "SKILL.md" or len(relative_path.parts) < 2: + raise ValueError( + f"Projected snapshot path must end in /SKILL.md: {raw_relative_path!r}" + ) + if not isinstance(content, str): + raise ValueError(f"Projected snapshot content must be text: {raw_relative_path!r}") + skill_name = relative_path.parent.name + if skill_name in by_skill: + raise ValueError(f"Multiple projected documents for skill {skill_name!r}") + digest = hashlib.sha256(content.encode()).hexdigest() + if contract.projected_digests.get(skill_name) != digest: + raise ValueError(f"Projected snapshot digest mismatch for {skill_name!r}") + by_skill[skill_name] = relative_path.as_posix() + seen_paths.add(relative_path) + if set(by_skill) != set(contract.closure): + raise ValueError("Projected snapshot documents must exactly match closure") + return by_skill + + +def _source_ref_to_dict(source_ref: SkillSourceRef) -> dict[str, Any]: + return { + "origin": source_ref.origin.value, + "logical_name": source_ref.logical_name, + "skill_path": str(source_ref.skill_path), + "search_dir": source_ref.search_dir, + "precedence": source_ref.precedence, + } + + +def _source_ref_from_dict(data: Mapping[str, Any]) -> SkillSourceRef: + try: + return SkillSourceRef( + origin=SkillSource(str(data["origin"])), + logical_name=str(data["logical_name"]), + skill_path=Path(str(data["skill_path"])), + search_dir=(str(data["search_dir"]) if data.get("search_dir") is not None else None), + precedence=(int(data["precedence"]) if data.get("precedence") is not None else None), + ) + except (KeyError, TypeError, ValueError) as exc: + raise ValueError("Invalid skill source reference") from exc + + +def _contract_to_dict(contract: SkillSessionContract) -> dict[str, Any]: + return { + "schema_version": contract.schema_version, + "root_name": contract.root_name, + "execution_role": contract.execution_role.value, + "source_refs": { + name: _source_ref_to_dict(source_ref) + for name, source_ref in sorted(contract.source_refs.items()) + }, + "closure": list(contract.closure), + "capability_union": sorted(contract.capability_union), + "canonical_digests": dict(sorted(contract.canonical_digests.items())), + "projected_digests": dict(sorted(contract.projected_digests.items())), + "projection_version": contract.projection_version, + "project_root": contract.project_root, + "cwd": contract.cwd, + "backend": contract.backend, + "resolved_command": contract.resolved_command, + "member_roles": {name: role.value for name, role in sorted(contract.member_roles.items())}, + "member_capabilities": { + name: sorted(capabilities) + for name, capabilities in sorted(contract.member_capabilities.items()) + }, + "member_activate_deps": { + name: list(dependencies) + for name, dependencies in sorted(contract.member_activate_deps.items()) + }, + "canonical_contents": dict(sorted(contract.canonical_contents.items())), + "expected_output_patterns": list(contract.expected_output_patterns), + "write_behavior": { + "mode": contract.write_behavior.mode, + "expected_when": list(contract.write_behavior.expected_when), + }, + "read_only": contract.read_only, + "completion_required": contract.completion_required, + "skill_contract_json": contract.skill_contract_json, + "projection_substitutions": [list(item) for item in contract.projection_substitutions], + "projection_gating": contract.projection_gating, + "projection_namespace": contract.projection_namespace, + } + + +def _contract_from_dict(data: Mapping[str, Any]) -> SkillSessionContract: + try: + source_refs_raw = data["source_refs"] + if not isinstance(source_refs_raw, dict): + raise ValueError("source_refs must be an object") + read_only = data.get("read_only", False) + if not isinstance(read_only, bool): + raise ValueError("read_only must be a boolean") + completion_required = data.get("completion_required", False) + if not isinstance(completion_required, bool): + raise ValueError("completion_required must be a boolean") + projection_gating = data.get("projection_gating") + if projection_gating is not None and not isinstance(projection_gating, bool): + raise ValueError("projection_gating must be a boolean or null") + projection_substitutions = data.get("projection_substitutions", []) + if not isinstance(projection_substitutions, list) or any( + not isinstance(item, list) or len(item) != 2 for item in projection_substitutions + ): + raise ValueError("projection_substitutions entries must be two-element lists") + return SkillSessionContract( + root_name=str(data["root_name"]), + execution_role=SkillExecutionRole(str(data["execution_role"])), + source_refs={ + str(name): _source_ref_from_dict(source_ref) + for name, source_ref in source_refs_raw.items() + if isinstance(source_ref, dict) + }, + closure=tuple(str(name) for name in data["closure"]), + capability_union=frozenset(str(cap) for cap in data["capability_union"]), + canonical_digests={ + str(name): str(digest) for name, digest in data["canonical_digests"].items() + }, + projected_digests={ + str(name): str(digest) for name, digest in data["projected_digests"].items() + }, + projection_version=int(data["projection_version"]), + project_root=str(data["project_root"]), + cwd=str(data["cwd"]), + backend=str(data["backend"]), + resolved_command=str(data["resolved_command"]), + member_roles={ + str(name): SkillExecutionRole(str(role)) + for name, role in data["member_roles"].items() + }, + member_capabilities={ + str(name): frozenset(str(capability) for capability in capabilities) + for name, capabilities in data["member_capabilities"].items() + }, + member_activate_deps={ + str(name): tuple(str(dependency) for dependency in dependencies) + for name, dependencies in data["member_activate_deps"].items() + }, + canonical_contents={ + str(name): str(content) for name, content in data["canonical_contents"].items() + }, + expected_output_patterns=tuple( + str(pattern) for pattern in data.get("expected_output_patterns", []) + ), + write_behavior=WriteBehaviorSpec( + mode=( + str(data.get("write_behavior", {}).get("mode")) + if data.get("write_behavior", {}).get("mode") is not None + else None + ), + expected_when=tuple( + str(pattern) + for pattern in data.get("write_behavior", {}).get("expected_when", []) + ), + ), + read_only=read_only, + completion_required=completion_required, + skill_contract_json=str(data.get("skill_contract_json", "")), + projection_substitutions=tuple( + (str(item[0]), str(item[1])) for item in projection_substitutions + ), + projection_gating=projection_gating, + projection_namespace=( + str(data["projection_namespace"]) + if data.get("projection_namespace") is not None + else None + ), + schema_version=int(data["schema_version"]), + ) + except (KeyError, TypeError, ValueError, AttributeError) as exc: + raise ValueError("Invalid serialized skill session contract") from exc + + +def _digest_json(value: Mapping[str, Any]) -> str: + encoded = json.dumps( + dict(value), + sort_keys=True, + separators=(",", ":"), + ).encode() + return hashlib.sha256(encoded).hexdigest() + + +def _build_manifest( + *, + contract: SkillSessionContract, + raw_session_id: str | None, + candidate_session_ids: tuple[str, ...], + snapshot_paths: Mapping[str, str], +) -> dict[str, Any]: + contract_data = _contract_to_dict(contract) + return { + "schema_version": _STORE_MANIFEST_SCHEMA_VERSION, + "raw_session_id": raw_session_id, + "candidate_session_ids": list(candidate_session_ids), + "contract": contract_data, + "contract_digest": _digest_json(contract_data), + "snapshot_paths": dict(sorted(snapshot_paths.items())), + } diff --git a/src/autoskillit/fleet/_api.py b/src/autoskillit/fleet/_api.py index 2748aef84a..42e7e5ddec 100644 --- a/src/autoskillit/fleet/_api.py +++ b/src/autoskillit/fleet/_api.py @@ -34,6 +34,7 @@ DispatchRejected, DispatchResult, ) +from autoskillit.workspace import default_skill_resolver, prepare_effective_skill_dispatch if TYPE_CHECKING: from autoskillit.core import CodingAgentBackend @@ -744,7 +745,6 @@ def _reject_with_state(error_code: FleetErrorCode, message: str) -> DispatchResu from autoskillit.fleet.sidecar import sidecar_path as compute_sidecar_path # noqa: PLC0415 dispatch_sidecar_path = str(compute_sidecar_path(dispatch_id, tool_ctx.project_dir)) - resolved_timeout = resolve_dispatch_timeout( timeout_sec, tool_ctx.config.fleet.default_timeout_sec ) @@ -758,23 +758,33 @@ def _reject_with_state(error_code: FleetErrorCode, message: str) -> DispatchResu capture=capture, caller_instructions=caller_instructions, ) - if tool_ctx.executor is None: return _reject_with_state( FleetErrorCode.FLEET_MANIFEST_MISSING, "Executor not configured.", ) - + food_truck_plugin_source = food_truck_capability_contract = None + if _effective_backend is not None: + food_truck_plugin_source, food_truck_capability_contract = ( + prepare_effective_skill_dispatch( + resolved_command=prompt, + project_root=tool_ctx.project_dir, + cwd=tool_ctx.project_dir, + backend=_effective_backend, + resolver=tool_ctx.skill_resolver or default_skill_resolver(), + visibility=tool_ctx.config.skill_visibility_spec(), + default_base_branch=tool_ctx.config.branching.default_base_branch, + recipe_packs=tool_ctx.active_recipe_packs, + recipe_features=tool_ctx.active_recipe_features, + ) + ) started_at = time.time() _dispatched_pid: list[int] = [] _dispatched_ticks: list[int] = [] _dispatched_create_time: list[float] = [] _dispatched_boot_id: list[str] = [] _dispatched_session_id: list[str] = [] - # Closure-scoped spawn error state (layer L2). See _write_pid docstring - # for why raising from on_spawn would not propagate. _spawn_error: list[str] = [] - # Collect prior dispatch_ids from attempt_history for defense-in-depth parsing prior_ids: list[str] = [] try: @@ -792,11 +802,9 @@ def _reject_with_state(error_code: FleetErrorCode, message: str) -> DispatchResu state_path=str(state_path), exc_info=True, ) - prior_completion_markers = ( [f"%%L3_DONE::{pid[:8]}%%" for pid in prior_ids] if prior_ids else None ) - _issue_urls_raw = extract_issue_urls(effective_ingredients) def _on_spawn(pid: int, ticks: int) -> None: @@ -864,6 +872,8 @@ def _on_session_id(session_id: str) -> None: orchestrator_prompt=prompt, cwd=str(tool_ctx.project_dir), completion_marker=completion_marker, + plugin_source=food_truck_plugin_source, + capability_contract=food_truck_capability_contract, prior_completion_markers=prior_completion_markers, resume_session_id=resume_session_id, resume_checkpoint=resume_checkpoint, diff --git a/src/autoskillit/fleet/_prompts.py b/src/autoskillit/fleet/_prompts.py index 746031db45..22cf821ba8 100644 --- a/src/autoskillit/fleet/_prompts.py +++ b/src/autoskillit/fleet/_prompts.py @@ -1,8 +1,8 @@ """Food truck prompt builder for L2 food truck sessions. -Moved from autoskillit.cli._prompts — this module -depends only on autoskillit.core and stdlib, making it importable from both -the server and CLI layers without introducing cross-L3 coupling. +Moved from autoskillit.cli._prompts — this module depends only on +autoskillit.core and stdlib, making it importable from both server and CLI +layers without introducing cross-L3 coupling. """ from __future__ import annotations @@ -20,7 +20,6 @@ ROUTING_AUTHORITY_CLAUSE, CaptureEntrySpec, get_logger, - pkg_root, resolve_payload_field, ) @@ -42,23 +41,21 @@ def _backend_supplement(has_unguarded_filesystem_access: bool) -> str: return "" -def _build_admiral_dispatch_block() -> str: - """Extract the dispatch-relevant subset of sous-chef SKILL.md. +def _build_admiral_dispatch_block( + projected_sous_chef: str = "", +) -> str: + """Extract the dispatch-relevant subset of the projected sous-chef document. Uses regex to split on ``## `` section headers and retains only sections whose title starts with one of the ADMIRAL_DISPATCH_SECTIONS prefixes. - Returns empty string if SKILL.md is absent (graceful degradation). + Projection is performed by the L3 composition caller before this IL-2 + prompt builder receives the document. """ - path = pkg_root() / "skills" / "sous-chef" / "SKILL.md" - if not path.exists(): - return "" - try: - content = path.read_text() - except OSError: + if not projected_sous_chef: return "" # Split into sections on ## boundaries (keeping the delimiter via lookahead) - sections = re.split(r"(?=^## )", content, flags=re.MULTILINE) + sections = re.split(r"(?=^## )", projected_sous_chef, flags=re.MULTILINE) retained: list[str] = [] for section in sections: @@ -81,6 +78,7 @@ def _build_food_truck_prompt( capture: dict[str, CaptureEntrySpec] | None = None, caller_instructions: str | None = None, has_unguarded_filesystem_access: bool = False, + projected_sous_chef: str = "", ) -> str: """Build the system prompt for an L2 food truck headless session. @@ -97,7 +95,7 @@ def _build_food_truck_prompt( ingredients_json = json.dumps(ingredients) ingredients_pretty_json = json.dumps(ingredients, indent=2) - admiral_block = _build_admiral_dispatch_block() + admiral_block = _build_admiral_dispatch_block(projected_sous_chef) capture_field_pairs: list[tuple[str, str, str]] = [] if capture: 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/pipeline/context.py b/src/autoskillit/pipeline/context.py index 04eac17cf3..da6c7b0611 100644 --- a/src/autoskillit/pipeline/context.py +++ b/src/autoskillit/pipeline/context.py @@ -40,6 +40,7 @@ SessionSkillManager, SkillContractResolver, SkillResolver, + SkillSessionContractStore, SubprocessRunner, TestRunner, TimingLog, @@ -115,6 +116,8 @@ class ToolContext: log retention purge. session_skill_manager: SessionSkillManager — manages per-session ephemeral skill dirs skill_resolver: SkillResolver — resolves skill names to source tier + skill_session_contract_store: SkillSessionContractStore — binds projected skill + contracts and snapshots to resumable backend session IDs. kitchen_id: UUID string assigned when open_kitchen fires; scopes token telemetry to the current kitchen session lifetime. active_recipe_packs: frozenset[str] | None — pack names declared by the loaded recipe @@ -169,6 +172,7 @@ class ToolContext: backend: CodingAgentBackend | None = field(default=None) session_skill_manager: SessionSkillManager | None = field(default=None) skill_resolver: SkillResolver | None = field(default=None) + skill_session_contract_store: SkillSessionContractStore = field(default=_MISSING) recipe_name: str = field(default="") recipe_content_hash: str = field(default="") recipe_composite_hash: str = field(default="") @@ -198,6 +202,11 @@ def __post_init__(self) -> None: "project_dir must be supplied explicitly — do not rely on defaults. " "Use make_context() or pass project_dir= directly." ) + if self.skill_session_contract_store is _MISSING: + raise TypeError( + "skill_session_contract_store must be supplied explicitly. " + "Use make_context() or pass an isolated store directly." + ) if self.background is None: self.background = DefaultBackgroundSupervisor(audit=self.audit) diff --git a/src/autoskillit/recipe/__init__.py b/src/autoskillit/recipe/__init__.py index faf171c200..633e559902 100644 --- a/src/autoskillit/recipe/__init__.py +++ b/src/autoskillit/recipe/__init__.py @@ -28,7 +28,9 @@ ) from autoskillit.recipe.contracts import ( # noqa: E402 OutcomeInvariantEntry, + ResultFieldSpec, SkillContract, + SkillInput, SkillOutput, StaleItem, SuccessQualifierEntry, @@ -330,7 +332,9 @@ "resolve_skill_name", "validate_recipe_cards", "OutcomeInvariantEntry", + "ResultFieldSpec", "SkillContract", + "SkillInput", "SkillOutput", "SuccessQualifierEntry", "DefaultRecipeRepository", diff --git a/src/autoskillit/recipe/_api.py b/src/autoskillit/recipe/_api.py index 413252fe67..733b7586dd 100644 --- a/src/autoskillit/recipe/_api.py +++ b/src/autoskillit/recipe/_api.py @@ -395,6 +395,7 @@ def load_and_validate( # for this reason — see test_filter_pruning_scope. _pre_prune_val_ctx = make_validation_context( active_recipe, + project_dir=_pdir, backend_name=backend_name, skill_resolver=_skill_resolver, effective_backend_map=effective_backend_map, @@ -540,7 +541,11 @@ def load_and_validate( resolved_temp = temp_dir if temp_dir is not None else resolve_temp_dir(_pdir, None) staleness_cache_path = resolved_temp / "recipe_staleness_cache.json" stale = check_contract_staleness( - contract, recipe_path=match.path, cache_path=staleness_cache_path + contract, + recipe_path=match.path, + cache_path=staleness_cache_path, + resolver=_skill_resolver, + project_root=_pdir, ) suggestions.extend(stale_to_suggestions(stale)) t0 = _t("staleness_check", t0, name) diff --git a/src/autoskillit/recipe/_contracts_staleness.py b/src/autoskillit/recipe/_contracts_staleness.py index 7b9046e116..7a533353b2 100644 --- a/src/autoskillit/recipe/_contracts_staleness.py +++ b/src/autoskillit/recipe/_contracts_staleness.py @@ -27,6 +27,7 @@ def check_contract_staleness( cache_path: Path | None = None, skills_dir: Path | None = None, resolver: SkillResolver | None = None, + project_root: Path | None = None, stored_card: Any = None, ) -> list[StaleItem]: """Check a pipeline contract for staleness against the current manifest. @@ -140,7 +141,7 @@ def check_contract_staleness( raise RuntimeError( "check_staleness called without effective_skills_dir or resolver" ) - info = _resolver.resolve(skill_name) + info = _resolver.resolve_effective(skill_name, project_root) current_hash = ( compute_skill_hash(skill_name, skills_dir=info.path.parent.parent) if info is not None diff --git a/src/autoskillit/recipe/_skill_helpers.py b/src/autoskillit/recipe/_skill_helpers.py index 91298585e8..290307b64a 100644 --- a/src/autoskillit/recipe/_skill_helpers.py +++ b/src/autoskillit/recipe/_skill_helpers.py @@ -40,11 +40,16 @@ def get_allowed_values_for_skill(skill_name: str) -> dict[str, list[str]]: SKILL_SEARCH_DIRS: list[Path] | None = None -def _resolve_skill_md(skill_name: str, *, resolver: SkillResolver | None = None) -> Path | None: +def _resolve_skill_md( + skill_name: str, + *, + project_root: Path | None, + resolver: SkillResolver | None = None, +) -> Path | None: """Resolve a skill name to its SKILL.md path. When SKILL_SEARCH_DIRS is set (e.g., in tests), searches those directories. - Otherwise uses SkillResolver to find the bundled skill. + Otherwise uses SkillResolver to select the effective project-aware source. """ if SKILL_SEARCH_DIRS is not None: for search_dir in SKILL_SEARCH_DIRS: @@ -56,10 +61,11 @@ def _resolve_skill_md(skill_name: str, *, resolver: SkillResolver | None = None) from autoskillit.workspace import DefaultSkillResolver # noqa: PLC0415 resolver = DefaultSkillResolver() - skill_info = resolver.resolve(skill_name) + skill_info = resolver.resolve_effective(skill_name, project_root) if skill_info is None: return None - return skill_info.path + skill_path = getattr(skill_info, "path", None) + return skill_path if isinstance(skill_path, Path) else None def _has_dynamic_skill_name(skill_cmd: str) -> bool: diff --git a/src/autoskillit/recipe/rules/rules_backend_compat.py b/src/autoskillit/recipe/rules/rules_backend_compat.py index f2ad111fb5..dd873296f9 100644 --- a/src/autoskillit/recipe/rules/rules_backend_compat.py +++ b/src/autoskillit/recipe/rules/rules_backend_compat.py @@ -7,6 +7,8 @@ SKILL_CAPABILITY_REGISTRY, SKILL_TOOLS, Severity, + SkillContractError, + SkillExecutionRole, describe_capability_mismatches, unsatisfied_backend_capabilities, ) @@ -41,17 +43,20 @@ def _check_backend_incompatible_skill(ctx: ValidationContext) -> list[RuleFindin skill_name = resolve_skill_name(skill_cmd) if skill_name is None: continue - skill_info = ctx.skill_resolver.resolve(skill_name) - if skill_info is None: - continue - if getattr(skill_info, "invalid_reason", None): + try: + invocation = ctx.skill_resolver.resolve_invocation( + skill_name, + ctx.project_dir, + SkillExecutionRole.SESSION, + ) + except SkillContractError as exc: findings.append( make_finding( rule_name="backend-incompatible-skill", step_name=step_name, message=( - f"step '{step_name}': skill '{skill_name}' has invalid capabilities: " - f"{skill_info.invalid_reason}" + f"step '{step_name}': skill '{skill_name}' has no valid effective " + f"invocation: {exc}" ), ) ) @@ -66,7 +71,7 @@ def _check_backend_incompatible_skill(ctx: ValidationContext) -> list[RuleFindin ) if step_backend is None: continue - uses_caps: frozenset[str] = getattr(skill_info, "uses_capabilities", frozenset()) + uses_caps = invocation.capability_union _step_origin = (ctx.backend_origin_map or {}).get(step_name) _step_remedy = ( ( @@ -77,7 +82,7 @@ def _check_backend_incompatible_skill(ctx: ValidationContext) -> list[RuleFindin if _step_origin else None ) - if skill_info.backend_requirements and step_backend not in skill_info.backend_requirements: + if invocation.backend_requirements and step_backend not in invocation.backend_requirements: cap_def = SKILL_CAPABILITY_REGISTRY.get(_GIT_METADATA_WRITE_CAP) _required = CLAUDE_CODE_CAPABILITIES.git_metadata_writable git_detail = ( @@ -91,7 +96,7 @@ def _check_backend_incompatible_skill(ctx: ValidationContext) -> list[RuleFindin step_name=step_name, message=( f"step '{step_name}': skill '{skill_name}' requires backend " - f"{sorted(skill_info.backend_requirements)} but recipe targets " + f"{sorted(invocation.backend_requirements)} but recipe targets " f"backend '{step_backend}'.{git_detail}" ), origin=_step_origin, diff --git a/src/autoskillit/recipe/rules/rules_pseudocode_sync.py b/src/autoskillit/recipe/rules/rules_pseudocode_sync.py index f421aa190b..6af7102f95 100644 --- a/src/autoskillit/recipe/rules/rules_pseudocode_sync.py +++ b/src/autoskillit/recipe/rules/rules_pseudocode_sync.py @@ -129,7 +129,11 @@ def _check_pseudocode_callable_divergence(ctx: ValidationContext) -> list[RuleFi skill_name = resolve_skill_name(skill_cmd) if skill_name is None: continue - skill_md_path = _resolve_skill_md(skill_name, resolver=ctx.skill_resolver) + skill_md_path = _resolve_skill_md( + skill_name, + project_root=ctx.project_dir, + resolver=ctx.skill_resolver, + ) if skill_md_path is None: continue try: diff --git a/src/autoskillit/recipe/rules/rules_skill_content.py b/src/autoskillit/recipe/rules/rules_skill_content.py index a58473c83a..f65504bed2 100644 --- a/src/autoskillit/recipe/rules/rules_skill_content.py +++ b/src/autoskillit/recipe/rules/rules_skill_content.py @@ -74,7 +74,9 @@ def _check_undefined_bash_placeholder(ctx: ValidationContext) -> list[RuleFindin if skill_name is None: continue - skill_md = _resolve_skill_md(skill_name, resolver=ctx.skill_resolver) + skill_md = _resolve_skill_md( + skill_name, project_root=ctx.project_dir, resolver=ctx.skill_resolver + ) if skill_md is None: continue # unknown-skill-command rule handles missing skills @@ -147,7 +149,9 @@ def _check_hardcoded_origin_remote(ctx: ValidationContext) -> list[RuleFinding]: skill_name = resolve_skill_name(skill_cmd) if skill_name is None: continue - skill_md = _resolve_skill_md(skill_name, resolver=ctx.skill_resolver) + skill_md = _resolve_skill_md( + skill_name, project_root=ctx.project_dir, resolver=ctx.skill_resolver + ) if skill_md is None: continue # unknown-skill-command rule handles missing skills try: @@ -199,7 +203,9 @@ def _check_blind_git_add_in_skill(ctx: ValidationContext) -> list[RuleFinding]: skill_name = resolve_skill_name(skill_cmd) if skill_name is None: continue - skill_md = _resolve_skill_md(skill_name, resolver=ctx.skill_resolver) + skill_md = _resolve_skill_md( + skill_name, project_root=ctx.project_dir, resolver=ctx.skill_resolver + ) if skill_md is None: continue try: @@ -251,7 +257,9 @@ def _check_no_interpreter_mediated_writes(ctx: ValidationContext) -> list[RuleFi continue if any(sname == skill_name for sname, _ in INTERPRETER_WRITE_ALLOWLIST): continue - skill_md = _resolve_skill_md(skill_name, resolver=ctx.skill_resolver) + skill_md = _resolve_skill_md( + skill_name, project_root=ctx.project_dir, resolver=ctx.skill_resolver + ) if skill_md is None: continue try: @@ -316,7 +324,9 @@ def _check_no_autoskillit_import(ctx: ValidationContext) -> list[RuleFinding]: skill_name = resolve_skill_name(skill_cmd) if skill_name is None: continue - skill_md = _resolve_skill_md(skill_name, resolver=ctx.skill_resolver) + skill_md = _resolve_skill_md( + skill_name, project_root=ctx.project_dir, resolver=ctx.skill_resolver + ) if skill_md is None: continue try: @@ -384,7 +394,9 @@ def _check_no_posix_char_class(ctx: ValidationContext) -> list[RuleFinding]: skill_name = resolve_skill_name(skill_cmd) if skill_name is None: continue - skill_md = _resolve_skill_md(skill_name, resolver=ctx.skill_resolver) + skill_md = _resolve_skill_md( + skill_name, project_root=ctx.project_dir, resolver=ctx.skill_resolver + ) if skill_md is None: continue try: @@ -439,7 +451,9 @@ def _check_no_grep_bre_alternation(ctx: ValidationContext) -> list[RuleFinding]: skill_name = resolve_skill_name(skill_cmd) if skill_name is None: continue - skill_md = _resolve_skill_md(skill_name, resolver=ctx.skill_resolver) + skill_md = _resolve_skill_md( + skill_name, project_root=ctx.project_dir, resolver=ctx.skill_resolver + ) if skill_md is None: continue # unknown-skill-command rule handles missing skills try: @@ -512,7 +526,9 @@ def _check_output_section_no_markdown_directive(ctx: ValidationContext) -> list[ if not skill_data or not skill_data.get("expected_output_patterns"): continue # Only check skills that have contracts with patterns - skill_md = _resolve_skill_md(skill_name, resolver=ctx.skill_resolver) + skill_md = _resolve_skill_md( + skill_name, project_root=ctx.project_dir, resolver=ctx.skill_resolver + ) if skill_md is None: continue # unknown-skill-command rule handles missing skills @@ -564,7 +580,9 @@ def _check_no_gh_issue_comment(ctx: ValidationContext) -> list[RuleFinding]: skill_name = resolve_skill_name(skill_cmd) if skill_name is None: continue - skill_md = _resolve_skill_md(skill_name, resolver=ctx.skill_resolver) + skill_md = _resolve_skill_md( + skill_name, project_root=ctx.project_dir, resolver=ctx.skill_resolver + ) if skill_md is None: continue try: @@ -652,7 +670,9 @@ def _check_transition_boundary_anti_confirmation(ctx: ValidationContext) -> list skill_name = resolve_skill_name(skill_cmd) if skill_name is None: continue - skill_md = _resolve_skill_md(skill_name, resolver=ctx.skill_resolver) + skill_md = _resolve_skill_md( + skill_name, project_root=ctx.project_dir, resolver=ctx.skill_resolver + ) if skill_md is None: continue try: @@ -723,7 +743,9 @@ def _check_executable_field_content_validity( continue if skill_name not in _EXECUTABLE_FIELD_SKILLS: continue - skill_md = _resolve_skill_md(skill_name, resolver=ctx.skill_resolver) + skill_md = _resolve_skill_md( + skill_name, project_root=ctx.project_dir, resolver=ctx.skill_resolver + ) if skill_md is None: continue try: @@ -783,7 +805,9 @@ def _check_reviews_post_requires_input_flag(ctx: ValidationContext) -> list[Rule skill_name = resolve_skill_name(skill_cmd) if skill_name is None: continue - skill_md = _resolve_skill_md(skill_name, resolver=ctx.skill_resolver) + skill_md = _resolve_skill_md( + skill_name, project_root=ctx.project_dir, resolver=ctx.skill_resolver + ) if skill_md is None: continue try: @@ -849,7 +873,9 @@ def _check_source_attribution_directive(ctx: ValidationContext) -> list[RuleFind if not skill_data or not skill_data.get("source_pin_fields"): continue - skill_md = _resolve_skill_md(skill_name, resolver=ctx.skill_resolver) + skill_md = _resolve_skill_md( + skill_name, project_root=ctx.project_dir, resolver=ctx.skill_resolver + ) if skill_md is None: continue @@ -898,7 +924,9 @@ def _check_graphql_query_requires_shell_invocation(ctx: ValidationContext) -> li if skill_name is None: continue - skill_md = _resolve_skill_md(skill_name, resolver=ctx.skill_resolver) + skill_md = _resolve_skill_md( + skill_name, project_root=ctx.project_dir, resolver=ctx.skill_resolver + ) if skill_md is None: continue @@ -1002,7 +1030,9 @@ def _check_inline_content_in_subagent_prompt(ctx: ValidationContext) -> list[Rul skill_name = resolve_skill_name(skill_cmd) if skill_name is None: continue - skill_md = _resolve_skill_md(skill_name, resolver=ctx.skill_resolver) + skill_md = _resolve_skill_md( + skill_name, project_root=ctx.project_dir, resolver=ctx.skill_resolver + ) if skill_md is None: continue # unknown-skill-command rule handles missing skills try: diff --git a/src/autoskillit/recipe/rules/rules_skill_write_path_alignment.py b/src/autoskillit/recipe/rules/rules_skill_write_path_alignment.py index 346c94bf1f..1dc78e3bc8 100644 --- a/src/autoskillit/recipe/rules/rules_skill_write_path_alignment.py +++ b/src/autoskillit/recipe/rules/rules_skill_write_path_alignment.py @@ -93,7 +93,11 @@ def _check_skill_write_path_alignment(ctx: ValidationContext) -> list[RuleFindin if skill_name is None: continue - skill_md_path = _resolve_skill_md(skill_name, resolver=ctx.skill_resolver) + skill_md_path = _resolve_skill_md( + skill_name, + project_root=ctx.project_dir, + resolver=ctx.skill_resolver, + ) if skill_md_path is None: continue diff --git a/src/autoskillit/recipe/rules/rules_stamp_ownership.py b/src/autoskillit/recipe/rules/rules_stamp_ownership.py index 46fb5110eb..cea72596ab 100644 --- a/src/autoskillit/recipe/rules/rules_stamp_ownership.py +++ b/src/autoskillit/recipe/rules/rules_stamp_ownership.py @@ -48,7 +48,9 @@ def _check_exclusive_stamp_ownership(ctx: ValidationContext) -> list[RuleFinding skill_name = resolve_skill_name(skill_cmd) if skill_name is None: continue - skill_md = _resolve_skill_md(skill_name, resolver=ctx.skill_resolver) + skill_md = _resolve_skill_md( + skill_name, project_root=ctx.project_dir, resolver=ctx.skill_resolver + ) if skill_md is None: continue try: diff --git a/src/autoskillit/recipe/rules/rules_verdict_degradation.py b/src/autoskillit/recipe/rules/rules_verdict_degradation.py index 0a4313c12a..c7c60ea3e0 100644 --- a/src/autoskillit/recipe/rules/rules_verdict_degradation.py +++ b/src/autoskillit/recipe/rules/rules_verdict_degradation.py @@ -10,17 +10,12 @@ import regex as re -from autoskillit.core import Severity, get_logger +from autoskillit.core import Severity, SkillContractError, SkillExecutionRole from autoskillit.recipe._analysis import ValidationContext -from autoskillit.recipe._skill_helpers import ( - _resolve_skill_md, - get_allowed_values_for_skill, -) +from autoskillit.recipe._skill_helpers import get_allowed_values_for_skill from autoskillit.recipe.contracts import resolve_skill_name from autoskillit.recipe.registry import RuleFinding, make_finding, semantic_rule -logger = get_logger(__name__) - # Patterns that identify a graceful-degradation trigger line in SKILL.md. _DEGRADATION_TRIGGER_RE = re.compile(r"unavailable|graceful.{0,20}degrada", re.IGNORECASE) @@ -88,6 +83,8 @@ def _find_nominal_verdicts(content: str) -> set[str]: ) def _check_verdict_ungated_degradation(ctx: ValidationContext) -> list[RuleFinding]: """Scan recipe steps for skills whose SKILL.md shares a verdict across both paths.""" + if ctx.skill_resolver is None: + return [] findings: list[RuleFinding] = [] for step_name, step in ctx.recipe.steps.items(): @@ -105,17 +102,16 @@ def _check_verdict_ungated_degradation(ctx: ValidationContext) -> list[RuleFindi if not verdict_allowed: continue - # Locate and read the SKILL.md. - skill_md_path = _resolve_skill_md(name) - if skill_md_path is None: - continue try: - skill_md_content = skill_md_path.read_text(encoding="utf-8") - except (OSError, UnicodeDecodeError): - logger.warning( - "could not read %s; skipping verdict-ungated-degradation check", - skill_md_path, + invocation = ctx.skill_resolver.resolve_invocation( + name, + ctx.project_dir, + SkillExecutionRole.SESSION, ) + except SkillContractError: + continue + skill_md_content = getattr(invocation.root, "canonical_content", "") + if not isinstance(skill_md_content, str) or not skill_md_content: continue # Check that a degradation trigger phrase is present. diff --git a/src/autoskillit/server/_factory.py b/src/autoskillit/server/_factory.py index 0100527cd3..3c4060761d 100644 --- a/src/autoskillit/server/_factory.py +++ b/src/autoskillit/server/_factory.py @@ -22,6 +22,7 @@ FleetLock, MarketplaceInstall, PluginSource, + SkillExecutionRole, SubprocessRunner, WriteBehaviorSpec, _get_autoskillit_install_path, @@ -43,6 +44,7 @@ DefaultGitHubFetcher, DefaultHeadlessExecutor, DefaultMergeQueueWatcher, + DefaultSkillSessionContractStore, DefaultSubprocessRunner, DefaultTestRunner, RecordingSubprocessRunner, @@ -73,7 +75,9 @@ DefaultSessionSkillManager, DefaultWorkspaceManager, SkillsDirectoryProvider, + project_plugin_source, resolve_ephemeral_root, + validate_skill_tier_roles, ) logger = get_logger(__name__) @@ -305,7 +309,6 @@ def make_context( resolved_plugin_source = DirectInstall(plugin_dir=_default_plugin_dir()) else: resolved_plugin_source = DirectInstall(plugin_dir=_default_plugin_dir()) - plugin_source = resolved_plugin_source gate = DefaultGateState(enabled=False) project_dir = project_dir if project_dir is not None else _resolve_project_dir() @@ -316,6 +319,20 @@ def make_context( temp_dir_relpath=temp_dir_relpath, default_base_branch=config.branching.default_base_branch, ) + skill_visibility = config.skill_visibility_spec() + validate_skill_tier_roles(skill_visibility, provider.resolver, project_dir) + session_catalog = provider.resolver.list_effective( + project_dir, + SkillExecutionRole.SESSION, + visibility=skill_visibility, + ) + plugin_source = project_plugin_source( + resolved_plugin_source, + cwd=project_dir, + backend=backend, + default_base_branch=config.branching.default_base_branch, + skill_catalog=session_catalog, + ) ephemeral_root = resolve_ephemeral_root() codex_root = temp_dir / "codex-sessions" session_mgr = DefaultSessionSkillManager(provider, ephemeral_root, codex_root=codex_root) @@ -345,6 +362,7 @@ def make_context( github_api_log=github_api_log, session_skill_manager=session_mgr, skill_resolver=provider.resolver, + skill_session_contract_store=DefaultSkillSessionContractStore(), ephemeral_root=ephemeral_root, quota_refresh_task=None, session_serve_overrides=None, 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/_misc.py b/src/autoskillit/server/_misc.py index 120e7530d7..b32b80b66c 100644 --- a/src/autoskillit/server/_misc.py +++ b/src/autoskillit/server/_misc.py @@ -63,7 +63,16 @@ validate_plugin_cache_hooks, ) from autoskillit.hooks import _HOOK_CONFIG_PATH_COMPONENTS +from autoskillit.workspace import ( + SkillProjectionContext as SkillProjectionContext, +) +from autoskillit.workspace import ( + build_effective_skill_dispatch_contract as build_effective_skill_dispatch_contract, +) from autoskillit.workspace import clone_registry as clone_registry +from autoskillit.workspace import ( + project_agent_skill_document as project_agent_skill_document, +) from autoskillit.workspace import ( resolve_closure_write_dirs as resolve_closure_write_dirs, ) diff --git a/src/autoskillit/server/tools/_auto_overrides.py b/src/autoskillit/server/tools/_auto_overrides.py index a7e0093711..9d9c81bd7b 100644 --- a/src/autoskillit/server/tools/_auto_overrides.py +++ b/src/autoskillit/server/tools/_auto_overrides.py @@ -9,6 +9,7 @@ from __future__ import annotations import shutil +from pathlib import Path from typing import TYPE_CHECKING, Any, cast from autoskillit.config import BACKEND_CAPABILITY_INGREDIENTS, ProvidersConfig @@ -19,8 +20,9 @@ SKILL_CAPABILITY_REGISTRY, SKILL_TOOLS, CapabilityResolutionDetail, - extract_skill_name, + SkillExecutionRole, get_logger, + resolve_skill_name, ) if TYPE_CHECKING: @@ -32,6 +34,23 @@ logger = get_logger(__name__) +def _resolve_capability_union( + skill_resolver: SkillResolver, + skill_name: str, + project_root: Path | None, +) -> frozenset[str]: + """Resolve closure-wide capabilities from the effective project source.""" + if "{" in skill_name or "}" in skill_name: + # Dynamic recipe-family targets are expanded after ingredient binding. + return frozenset() + invocation = skill_resolver.resolve_invocation( + skill_name, + project_root, + SkillExecutionRole.SESSION, + ) + return frozenset(getattr(invocation, "capability_union", frozenset())) + + def _backend_capability_overrides(backend: CodingAgentBackend | None) -> dict[str, str]: """Return ingredient overrides derived from backend capabilities. @@ -52,6 +71,7 @@ def _provider_aware_capability_overrides( *, skill_resolver: SkillResolver | None = None, config_backend: AgentBackendConfig | None = None, + project_root: Path | None = None, ) -> tuple[dict[str, str], CapabilityResolutionDetail]: """Return capability overrides with per-step provider awareness. @@ -102,7 +122,7 @@ def _provider_aware_capability_overrides( continue _wa = getattr(step, "with_args", None) skill_cmd = _wa.get("skill_command", "") if isinstance(_wa, dict) else "" - skill_name = extract_skill_name(skill_cmd) if skill_cmd else None + skill_name = resolve_skill_name(skill_cmd) if skill_cmd else None if not skill_name: continue @@ -118,27 +138,31 @@ def _provider_aware_capability_overrides( ) _explicit_be = _explicit_resolution.backend if _explicit_resolution else None if _explicit_be == AGENT_BACKEND_CLAUDE_CODE: - cap_resolved = skill_resolver.resolve(skill_name) - if cap_resolved: - for cap in getattr(cap_resolved, "uses_capabilities", frozenset()): - cap_def = SKILL_CAPABILITY_REGISTRY.get(cap) - if cap_def and cap_def.worker_routable: - has_capability_requirement = True - has_explicit_pin_capability = True - if cap in CAPABILITY_INGREDIENT_MAP: - triggered_ingredients.add(CAPABILITY_INGREDIENT_MAP[cap]) + for cap in _resolve_capability_union( + skill_resolver, + skill_name, + project_root, + ): + cap_def = SKILL_CAPABILITY_REGISTRY.get(cap) + if cap_def and cap_def.worker_routable: + has_capability_requirement = True + has_explicit_pin_capability = True + if cap in CAPABILITY_INGREDIENT_MAP: + triggered_ingredients.add(CAPABILITY_INGREDIENT_MAP[cap]) continue if _explicit_be is not None: continue - cap_resolved = skill_resolver.resolve(skill_name) - if cap_resolved: - for cap in getattr(cap_resolved, "uses_capabilities", frozenset()): - cap_def = SKILL_CAPABILITY_REGISTRY.get(cap) - if cap_def and cap_def.worker_routable: - has_capability_requirement = True - if cap in CAPABILITY_INGREDIENT_MAP: - triggered_ingredients.add(CAPABILITY_INGREDIENT_MAP[cap]) + for cap in _resolve_capability_union( + skill_resolver, + skill_name, + project_root, + ): + cap_def = SKILL_CAPABILITY_REGISTRY.get(cap) + if cap_def and cap_def.worker_routable: + has_capability_requirement = True + if cap in CAPABILITY_INGREDIENT_MAP: + triggered_ingredients.add(CAPABILITY_INGREDIENT_MAP[cap]) if has_capability_requirement: # Explicit claude-code pins bypass the binary probe — the operator @@ -236,6 +260,7 @@ def _compute_effective_backend_map( *, skill_resolver: SkillResolver | None = None, config_backend: AgentBackendConfig | None = None, + project_root: Path | None = None, ) -> tuple[dict[str, str] | None, dict[str, str]]: """Build a per-step effective backend map mirroring ``tools_execution`` dispatch logic. @@ -300,13 +325,17 @@ def _compute_effective_backend_map( if skill_resolver is not None: _wa2 = getattr(step, "with_args", None) skill_cmd = _wa2.get("skill_command", "") if isinstance(_wa2, dict) else "" - skill_name = extract_skill_name(skill_cmd) if skill_cmd else None + skill_name = resolve_skill_name(skill_cmd) if skill_cmd else None if skill_name: - resolved = skill_resolver.resolve(skill_name) - if resolved and any( + capabilities = _resolve_capability_union( + skill_resolver, + skill_name, + project_root, + ) + if any( SKILL_CAPABILITY_REGISTRY.get(cap) is not None and SKILL_CAPABILITY_REGISTRY[cap].worker_routable - for cap in getattr(resolved, "uses_capabilities", frozenset()) + for cap in capabilities ): result[step_name] = AGENT_BACKEND_CLAUDE_CODE continue diff --git a/src/autoskillit/server/tools/_backend_compat.py b/src/autoskillit/server/tools/_backend_compat.py index 37bc8b1a62..2ca95d324d 100644 --- a/src/autoskillit/server/tools/_backend_compat.py +++ b/src/autoskillit/server/tools/_backend_compat.py @@ -3,27 +3,68 @@ from __future__ import annotations import os +from dataclasses import dataclass +from pathlib import Path from typing import TYPE_CHECKING +from uuid import uuid4 from autoskillit.core import ( DISPATCH_ID_ENV_VAR, CodingAgentBackend, + SkillContractError, + SkillExecutionRole, SkillResult, + ValidatedAddDir, extract_skill_name, - resolve_target_skill, + render_target_skill_command, ) from autoskillit.server.tools._preflight import ( _get_fix_required_hook_matchers, check_hard_capability_feasibility, ) +from autoskillit.workspace import ( + EffectiveSkillDispatchContract, + SkillProjectionContext, + build_effective_skill_dispatch_contract, +) if TYPE_CHECKING: from autoskillit.pipeline import ToolContext -def _is_backend_incompatible(skill_info: object, effective_backend: str) -> bool: - """Return True if skill's backend_requirements exclude effective_backend.""" - reqs = getattr(skill_info, "backend_requirements", None) +@dataclass(frozen=True, slots=True) +class DirectSkillDispatch: + """Projected materialization retained for one direct headless dispatch.""" + + add_dirs: tuple[ValidatedAddDir, ...] + session_id: str + capability_contract: EffectiveSkillDispatchContract + + def __post_init__(self) -> None: + if self.capability_contract.invocation is None: + raise SkillContractError("direct skill dispatch must bind an invocation") + + @property + def resolved_command(self) -> str: + return self.capability_contract.resolved_command + + @property + def invocation(self) -> object: + assert self.capability_contract.invocation is not None + return self.capability_contract.invocation + + @property + def projection_context(self) -> SkillProjectionContext: + return self.capability_contract.projection_context + + def cleanup(self, tool_ctx: ToolContext) -> None: + if tool_ctx.session_skill_manager is not None: + tool_ctx.session_skill_manager.cleanup_session(self.session_id) + + +def _is_backend_incompatible(skill_invocation: object, effective_backend: str) -> bool: + """Return True if the closure's derived requirements exclude the backend.""" + reqs = getattr(skill_invocation, "backend_requirements", None) return bool(reqs and effective_backend not in reqs) @@ -36,7 +77,13 @@ def _check_backend_compat( effective_backend_obj: CodingAgentBackend | None, skill_resolver: object | None, ) -> str | None: - """Fail closed when a skill is incompatible with its effective backend.""" + """Fail closed when an effective skill invocation is backend-incompatible. + + ``skill_info`` retains its compatibility-facing parameter name because + ``tools_execution`` still calls this helper by keyword. The value is an + ``EffectiveSkillInvocation``; policy reads its closure-wide capability union + and derived backend requirements. + """ if target_name is None: return None if skill_resolver is None: @@ -69,7 +116,7 @@ def _check_backend_compat( skill_command=resolved_command, order_id=effective_order_id, ).to_json() - skill_capabilities: frozenset[str] = getattr(skill_info, "uses_capabilities", frozenset()) + skill_capabilities: frozenset[str] = getattr(skill_info, "capability_union", frozenset()) if skill_capabilities: hard_capability_error = check_hard_capability_feasibility( skill_capabilities, effective_backend_obj @@ -107,21 +154,130 @@ def _resolve_and_check_backend_compat( """Resolve a direct skill invocation and run the fail-closed compatibility gate.""" resolved_command = skill_command target_name = extract_skill_name(skill_command) - skill_info: object | None = None - if tool_ctx.skill_resolver is not None: - resolved_command, target_name = resolve_target_skill( - skill_command, - tool_ctx.skill_resolver, - ) - if target_name is not None: - skill_info = tool_ctx.skill_resolver.resolve(target_name) + skill_invocation: object | None = None + if tool_ctx.skill_resolver is not None and target_name is not None: + try: + skill_invocation = tool_ctx.skill_resolver.resolve_invocation( + target_name, + tool_ctx.project_dir, + SkillExecutionRole.SESSION, + visibility=tool_ctx.config.skill_visibility_spec(), + recipe_packs=tool_ctx.active_recipe_packs, + recipe_features=tool_ctx.active_recipe_features, + ) + except SkillContractError as exc: + return SkillResult.crashed( + exception=exc, + skill_command=resolved_command, + order_id=os.environ.get(DISPATCH_ID_ENV_VAR, ""), + ).to_json() return _check_backend_compat( skill_command=skill_command, resolved_command=resolved_command, effective_order_id=os.environ.get(DISPATCH_ID_ENV_VAR, ""), target_name=target_name, - skill_info=skill_info, + skill_info=skill_invocation, effective_backend_obj=tool_ctx.backend, skill_resolver=tool_ctx.skill_resolver, ) + + +def _prepare_direct_skill_dispatch( + skill_command: str, + cwd: str | Path, + tool_ctx: ToolContext, +) -> tuple[DirectSkillDispatch | None, str | None]: + """Resolve policy once, then materialize its agent-safe projection.""" + target_name = extract_skill_name(skill_command) + order_id = os.environ.get(DISPATCH_ID_ENV_VAR, "") + if target_name is None: + return None, SkillResult.crashed( + exception=SkillContractError("Direct dispatch requires a skill command"), + skill_command=skill_command, + order_id=order_id, + ).to_json() + if tool_ctx.skill_resolver is None: + return None, SkillResult.crashed( + exception=SkillContractError( + f"Cannot resolve direct skill {target_name!r}: skill resolver is unavailable" + ), + skill_command=skill_command, + order_id=order_id, + ).to_json() + if tool_ctx.session_skill_manager is None: + return None, SkillResult.crashed( + exception=SkillContractError( + f"Cannot materialize direct skill {target_name!r}: " + "session skill manager is unavailable" + ), + skill_command=skill_command, + order_id=order_id, + ).to_json() + try: + invocation = tool_ctx.skill_resolver.resolve_invocation( + target_name, + tool_ctx.project_dir, + SkillExecutionRole.SESSION, + visibility=tool_ctx.config.skill_visibility_spec(), + recipe_packs=tool_ctx.active_recipe_packs, + recipe_features=tool_ctx.active_recipe_features, + ) + except SkillContractError as exc: + return None, SkillResult.crashed( + exception=exc, + skill_command=skill_command, + order_id=order_id, + ).to_json() + + compatibility_error = _check_backend_compat( + skill_command=skill_command, + resolved_command=skill_command, + effective_order_id=order_id, + target_name=target_name, + skill_info=invocation, + effective_backend_obj=tool_ctx.backend, + skill_resolver=tool_ctx.skill_resolver, + ) + if compatibility_error is not None: + return None, compatibility_error + + normalized_cwd = Path(cwd).resolve() + backend = tool_ctx.backend + projection_context = SkillProjectionContext( + cwd=normalized_cwd, + invocation=invocation, + backend=backend, + conventions=backend.conventions if backend is not None else None, + substitutions={"{{AUTOSKILLIT_TEMP}}": str(normalized_cwd / ".autoskillit" / "temp")}, + gating=False, + ) + session_id = f"direct-{uuid4().hex[:12]}" + try: + add_dir = tool_ctx.session_skill_manager.materialize_invocation( + session_id, + invocation, + projection_context, + ) + except (OSError, RuntimeError, ValueError, SkillContractError) as exc: + tool_ctx.session_skill_manager.cleanup_session(session_id) + return None, SkillResult.crashed( + exception=exc, + skill_command=skill_command, + order_id=order_id, + ).to_json() + resolved_command = render_target_skill_command( + skill_command, + invocation.root.source_ref or invocation.root.source, + backend.conventions if backend is not None else None, + ) + capability_contract = build_effective_skill_dispatch_contract( + resolved_command, + projection_context, + artifact_paths=(add_dir.path,), + ) + return DirectSkillDispatch( + add_dirs=(add_dir,), + session_id=session_id, + capability_contract=capability_contract, + ), None diff --git a/src/autoskillit/server/tools/_execution_helpers.py b/src/autoskillit/server/tools/_execution_helpers.py index 2e7f917c7b..57d77ca4b4 100644 --- a/src/autoskillit/server/tools/_execution_helpers.py +++ b/src/autoskillit/server/tools/_execution_helpers.py @@ -3,6 +3,8 @@ from __future__ import annotations import asyncio +import dataclasses +import hashlib import json import os import time @@ -14,18 +16,48 @@ from autoskillit.core import ( RUN_PYTHON_SENTINEL_KEYS, + SKILL_CAPABILITY_REGISTRY, + WORKTREE_SKILLS, BackendCapabilities, CapturedStream, + CodingAgentBackend, + EffectiveSkillInvocationAuthority, + SkillContractError, + SkillExecutionRole, + SkillResolver, + SkillSessionContractStore, + SkillSourceRef, SpillSpec, SubprocessResult, + ValidatedAddDir, + WriteBehaviorSpec, + extract_skill_name, get_logger, + is_git_worktree, resolve_general_output_token_limit, resolve_temp_dir, spill_output, ) -from autoskillit.execution import CaptureReadError, summarize_capture -from autoskillit.server._misc import _hook_config_overlay_path +from autoskillit.execution import CaptureReadError, SkillSessionContract, summarize_capture +from autoskillit.pipeline import canonical_step_name +from autoskillit.recipe import ( + OutcomeInvariantEntry, + ResultFieldSpec, + SkillContract, + SkillInput, + SkillOutput, + SuccessQualifierEntry, +) +from autoskillit.server._misc import SkillProjectionContext, _hook_config_overlay_path from autoskillit.server._response_budget import shape_json_response +from autoskillit.server.tools._types import deny_envelope +from autoskillit.workspace import ( + EffectiveSkillDispatchContract, + EffectiveSkillInvocation, + SkillInfo, + build_effective_skill_dispatch_contract, + default_skill_resolver, +) logger = get_logger(__name__) @@ -36,6 +68,443 @@ _PATH_LIKE_ARGS: frozenset[str] = frozenset({"output_dir", "workspace", "diagnostics_log_dir"}) +@dataclasses.dataclass(slots=True) +class _RunSkillContractLifecycle: + """Own provisional and finalized contract state across run_skill exits.""" + + store: SkillSessionContractStore | None = None + correlation_key: str | None = None + bound_session_id: str | None = None + retain_bound: bool = True + execution_started: bool = False + + def observe_candidate(self, candidate_session_id: str) -> None: + if self.store is not None and self.correlation_key is not None: + self.store.observe_candidate(self.correlation_key, candidate_session_id) + + def finalize(self, session_id: str) -> None: + if self.store is None or self.correlation_key is None: + return + if session_id: + self.store.finalize(self.correlation_key, session_id) + self.bound_session_id = session_id + else: + self.store.discard(self.correlation_key) + self.correlation_key = None + + def apply_retention(self, needs_retry: bool) -> None: + self.retain_bound = needs_retry + if self.store is not None and self.bound_session_id is not None and not needs_retry: + self.store.delete(self.bound_session_id) + self.bound_session_id = None + + def cleanup(self) -> None: + if self.store is not None and self.correlation_key is not None: + try: + self.store.discard(self.correlation_key) + except Exception: + logger.warning("skill_session_contract_discard_failed", exc_info=True) + if ( + self.store is not None + and self.bound_session_id is not None + and self.execution_started + and not self.retain_bound + ): + try: + self.store.delete(self.bound_session_id) + except Exception: + logger.warning( + "skill_session_contract_delete_failed", + session_id=self.bound_session_id, + exc_info=True, + ) + + +def check_review_approach_plan_path(step_name: str, skill_command: str) -> str | None: + """Reject review-approach issue URLs where a plan path is required.""" + if canonical_step_name(step_name) != "review_approach": + return None + parts = skill_command.split() + if len(parts) < 2: + return None + first_arg = parts[1] + if not first_arg.startswith(("https://", "http://")): + return None + return json.dumps( + deny_envelope( + ( + "review_approach requires a plan file path argument (a path " + "under the project's temp directory produced by " + "rectify/make_plan), not an issue URL." + ), + stage="preflight:plan_path", + retriable=False, + ) + ) + + +def derive_run_cmd_write_prefixes() -> tuple[str, ...]: + """Read allowed write prefixes from the canonical environment variables.""" + multi = os.environ.get("AUTOSKILLIT_ALLOWED_WRITE_PREFIXES", "") + if multi: + return tuple(p for p in multi.split(":") if p) + single = os.environ.get("AUTOSKILLIT_ALLOWED_WRITE_PREFIX", "") + return (single,) if single else () + + +def compute_write_prefixes( + write_watch_dirs: list[Path], + cwd: str, + skill_command: str, +) -> tuple[str, tuple[str, ...]]: + worktree_write_prefixes: list[str] = [] + extracted = extract_skill_name(skill_command) + if write_watch_dirs and extracted and extracted in WORKTREE_SKILLS: + resolved_cwd = Path(cwd).resolve() + if is_git_worktree(resolved_cwd): + worktree_write_prefixes.extend( + (str(resolved_cwd) + "/", str(resolved_cwd.parent) + "/") + ) + else: + nested_wt = resolved_cwd / "worktrees" + sibling_wt = resolved_cwd.parent / "worktrees" + if nested_wt.is_dir(): + worktree_write_prefixes.append(str(nested_wt) + "/") + if sibling_wt.is_dir() or not nested_wt.is_dir(): + worktree_write_prefixes.append(str(sibling_wt) + "/") + base_prefixes = [str(d.resolve()) + "/" for d in write_watch_dirs] + return ( + base_prefixes[0] if base_prefixes else "", + tuple(base_prefixes + worktree_write_prefixes), + ) + + +def scope_covers_cwd(allowed_write_prefixes: tuple[str, ...], cwd: str) -> bool: + """Return whether any allowed prefix lexically covers cwd.""" + if not allowed_write_prefixes or not cwd: + return False + resolved_cwd = str(Path(cwd).resolve()).rstrip("/") + "/" + return any(resolved_cwd.startswith(prefix) for prefix in allowed_write_prefixes) + + +def invocation_member_names( + invocation: EffectiveSkillInvocationAuthority, +) -> frozenset[str]: + """Return the exact member inventory bound to an effective invocation.""" + return frozenset(member.name for member in invocation.closure) + + +def build_fresh_projection_context( + cwd: str, + invocation: EffectiveSkillInvocationAuthority, +) -> SkillProjectionContext: + """Bind a fresh invocation to normalized backend-neutral projection authority.""" + normalized_cwd = Path(cwd).resolve() + return SkillProjectionContext( + cwd=normalized_cwd, + invocation=invocation, + substitutions={"{{AUTOSKILLIT_TEMP}}": str(normalized_cwd / ".autoskillit" / "temp")}, + gating=False, + ) + + +def bind_projection_backend( + context: SkillProjectionContext, + backend: CodingAgentBackend | None, +) -> SkillProjectionContext: + """Complete fresh projection authority after capability-driven backend selection.""" + return dataclasses.replace( + context, + backend=backend, + conventions=backend.conventions if backend is not None else None, + ) + + +def build_validated_skill_dispatch_contract( + resolved_command: str, + projection_context: SkillProjectionContext, + add_dirs: list[ValidatedAddDir], + stored_contract: SkillSessionContract | None, +) -> EffectiveSkillDispatchContract: + """Build immutable executor authority and verify resumed projected bytes.""" + contract = build_effective_skill_dispatch_contract( + resolved_command, + projection_context, + artifact_paths=(add_dir.path for add_dir in add_dirs), + ) + if stored_contract is not None and dict(contract.projected_digests) != dict( + stored_contract.projected_digests + ): + raise SkillContractError("resumed projected artifacts do not match the persisted contract") + return contract + + +def aggregate_sandbox_overrides(skill_caps: frozenset[str]) -> frozenset[str]: + """Aggregate required sandbox overrides from declared capabilities.""" + return frozenset().union( + *( + SKILL_CAPABILITY_REGISTRY[cap].required_sandbox_overrides + for cap in skill_caps + if cap in SKILL_CAPABILITY_REGISTRY + ) + ) + + +def has_routing_capability(skill_caps: frozenset[str]) -> bool: + """Return whether any declared capability is worker-routable.""" + return any( + SKILL_CAPABILITY_REGISTRY.get(cap) is not None + and SKILL_CAPABILITY_REGISTRY[cap].worker_routable + for cap in skill_caps + ) + + +def get_routing_caps(skill_caps: frozenset[str]) -> list[str]: + """Return the sorted worker-routable capabilities.""" + return sorted( + cap + for cap in skill_caps + if SKILL_CAPABILITY_REGISTRY.get(cap) and SKILL_CAPABILITY_REGISTRY[cap].worker_routable + ) + + +def build_skill_session_contract( + *, + session_root: ValidatedAddDir, + invocation: object, + projection_context: SkillProjectionContext, + resolved_command: str, + expected_output_patterns: tuple[str, ...], + write_behavior: WriteBehaviorSpec, + read_only: bool, + completion_required: bool, + skill_contract_json: str, +) -> tuple[SkillSessionContract, dict[str, str]]: + """Capture the exact projected invocation bytes before executor launch.""" + closure = tuple(getattr(invocation, "closure")) + root = getattr(invocation, "root") + backend = projection_context.backend + conventions = projection_context.conventions + if backend is None or conventions is None: + raise SkillContractError("Projected invocation requires an effective backend") + snapshot: dict[str, str] = {} + projected_digests: dict[str, str] = {} + canonical_digests: dict[str, str] = {} + source_refs: dict[str, SkillSourceRef] = {} + member_roles: dict[str, SkillExecutionRole] = {} + member_capabilities: dict[str, frozenset[str]] = {} + member_activate_deps: dict[str, tuple[str, ...]] = {} + canonical_contents: dict[str, str] = {} + session_path = Path(session_root.path) + for member in closure: + relative_path = Path(conventions.skills_subdir) / member.name / "SKILL.md" + content = (session_path / relative_path).read_text(encoding="utf-8") + snapshot[relative_path.as_posix()] = content + projected_digests[member.name] = hashlib.sha256(content.encode()).hexdigest() + canonical_digests[member.name] = member.canonical_digest + if member.source_ref is None or member.execution_role is None: + raise SkillContractError( + f"Effective invocation member {member.name!r} lacks typed identity" + ) + source_refs[member.name] = member.source_ref + member_roles[member.name] = member.execution_role + member_capabilities[member.name] = member.uses_capabilities + member_activate_deps[member.name] = member.activate_deps + canonical_contents[member.name] = member.canonical_content + project_root = getattr(invocation, "project_root") + if project_root is None: + raise SkillContractError("Effective invocation requires a project root") + contract = SkillSessionContract( + root_name=root.name, + execution_role=getattr(invocation, "execution_role"), + source_refs=source_refs, + closure=tuple(member.name for member in closure), + capability_union=getattr(invocation, "capability_union"), + canonical_digests=canonical_digests, + projected_digests=projected_digests, + projection_version=projection_context.projection_version, + project_root=str(project_root.resolve()), + cwd=str(projection_context.cwd.resolve()), + backend=backend.name, + resolved_command=resolved_command, + member_roles=member_roles, + member_capabilities=member_capabilities, + member_activate_deps=member_activate_deps, + canonical_contents=canonical_contents, + expected_output_patterns=expected_output_patterns, + write_behavior=write_behavior, + read_only=read_only, + completion_required=completion_required, + skill_contract_json=skill_contract_json, + projection_substitutions=tuple(sorted((projection_context.substitutions or {}).items())), + projection_gating=projection_context.gating, + projection_namespace=projection_context.namespace, + ) + return contract, snapshot + + +def serialize_skill_contract(skill_contract: object | None) -> str: + """Serialize the resolved recipe contract into immutable resume state.""" + if skill_contract is None: + return "" + if isinstance(skill_contract, type) or not dataclasses.is_dataclass(skill_contract): + raise SkillContractError("Resolved skill contract must be a dataclass") + return json.JSONEncoder(sort_keys=True, separators=(",", ":")).encode( + dataclasses.asdict(typing.cast(typing.Any, skill_contract)) + ) + + +def deserialize_skill_contract(payload: str) -> SkillContract | None: + """Reconstruct a recipe skill contract without consulting current metadata.""" + if not payload: + return None + try: + data = json.loads(payload) + if not isinstance(data, dict): + raise ValueError("persisted skill execution contract must be an object") + read_only = data.get("read_only", False) + if not isinstance(read_only, bool): + raise ValueError("read_only must be a boolean") + completion_required = data.get("completion_required", False) + if not isinstance(completion_required, bool): + raise ValueError("completion_required must be a boolean") + return SkillContract( + inputs=[SkillInput(**item) for item in data["inputs"]], + outputs=[SkillOutput(**item) for item in data["outputs"]], + expected_output_patterns=list(data.get("expected_output_patterns", [])), + pattern_examples=list(data.get("pattern_examples", [])), + write_behavior=data.get("write_behavior"), + write_expected_when=list(data.get("write_expected_when", [])), + read_only=read_only, + completion_required=completion_required, + result_fields=[ResultFieldSpec(**item) for item in data.get("result_fields", [])], + outcome_invariants=[ + OutcomeInvariantEntry(**item) for item in data.get("outcome_invariants", []) + ], + success_qualifiers=[ + SuccessQualifierEntry(**item) for item in data.get("success_qualifiers", []) + ], + ) + except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc: + raise SkillContractError("Persisted skill execution contract is invalid") from exc + + +def resolve_skill_dispatch_metadata( + tool_ctx: ToolContext, + skill_command: str, + stored_contract: SkillSessionContract | None, +) -> tuple[list[str], WriteBehaviorSpec | None, SkillContract | None]: + """Resolve fresh metadata or restore the exact persisted execution metadata.""" + if stored_contract is not None: + return ( + list(stored_contract.expected_output_patterns), + stored_contract.write_behavior, + deserialize_skill_contract(stored_contract.skill_contract_json), + ) + return ( + list(tool_ctx.output_pattern_resolver(skill_command)) + if tool_ctx.output_pattern_resolver + else [], + tool_ctx.write_expected_resolver(skill_command) + if tool_ctx.write_expected_resolver + else None, + tool_ctx.skill_contract_resolver(skill_command) + if tool_ctx.skill_contract_resolver + else None, + ) + + +def resolve_step_name_from_recipe( + skill_command: str, + active_recipe_steps: dict[str, object], +) -> tuple[str, bool]: + """Match a command prefix to exactly one active recipe step.""" + command_prefix = skill_command.split()[0] if skill_command.strip() else "" + if not command_prefix: + return ("", False) + matches = [ + step_name + for step_name, step in active_recipe_steps.items() + if isinstance((with_args := getattr(step, "with_args", None)), dict) + and (step_command := with_args.get("skill_command", "")) + and step_command.split()[0] == command_prefix + ] + if len(matches) == 1: + return (matches[0], False) + return ("", len(matches) > 1) + + +def rehydrate_skill_invocation( + contract: SkillSessionContract, + backend: CodingAgentBackend, +) -> tuple[EffectiveSkillInvocation, SkillProjectionContext]: + """Rebuild the immutable effective graph exclusively from persisted state.""" + closure = tuple( + SkillInfo( + name=name, + source=contract.source_refs[name].origin, + path=contract.source_refs[name].skill_path, + source_ref=contract.source_refs[name], + uses_capabilities=contract.member_capabilities[name], + execution_role=contract.member_roles[name], + activate_deps=contract.member_activate_deps[name], + canonical_content=contract.canonical_contents[name], + canonical_digest=contract.canonical_digests[name], + ) + for name in contract.closure + ) + by_name = {member.name: member for member in closure} + invocation = EffectiveSkillInvocation( + root=by_name[contract.root_name], + closure=closure, + capability_union=contract.capability_union, + project_root=Path(contract.project_root), + execution_role=contract.execution_role, + ) + projection_context = SkillProjectionContext( + cwd=Path(contract.cwd), + invocation=invocation, + backend=backend, + conventions=backend.conventions, + substitutions=dict(contract.projection_substitutions), + gating=contract.projection_gating, + namespace=contract.projection_namespace, + projection_version=contract.projection_version, + ) + return invocation, projection_context + + +def make_project_skill_resolver() -> SkillResolver: + """Construct the standard project-aware resolver for a fresh dispatch.""" + return default_skill_resolver() + + +def validate_resumed_skill_contract( + contract: SkillSessionContract, + *, + cwd: str, + project_root: Path, + backend: CodingAgentBackend | None, +) -> None: + """Validate resume invariants that depend on the current dispatch context.""" + if contract.execution_role is not SkillExecutionRole.SESSION: + raise SkillContractError( + f"Resume contract role must be session, got {contract.execution_role.value!r}" + ) + if contract.cwd != str(Path(cwd).resolve()) or contract.project_root != str( + project_root.resolve() + ): + raise SkillContractError( + "Resume contract cwd/project_root does not match the requested execution cwd" + ) + if backend is None or contract.backend != backend.name: + actual = backend.name if backend is not None else "unconfigured" + raise SkillContractError( + f"Resume contract backend {contract.backend!r} does not match {actual!r}" + ) + contract.backend_requirements + + def persist_run_skill_state(skill_result: SkillResult, project_dir: Path) -> None: from autoskillit.server._misc import persist_run_skill_state as persist # circular-break diff --git a/src/autoskillit/server/tools/_preflight.py b/src/autoskillit/server/tools/_preflight.py index cbbf935b15..0166aaa12f 100644 --- a/src/autoskillit/server/tools/_preflight.py +++ b/src/autoskillit/server/tools/_preflight.py @@ -8,6 +8,8 @@ from autoskillit.core import ( CodingAgentBackend, + SkillContractError, + SkillExecutionRole, describe_capability_mismatches, unsatisfied_backend_capabilities, ) @@ -66,6 +68,7 @@ def _check_dispatch_feasibility( *, config_backend: AgentBackendConfig | None = None, skill_resolver: SkillResolver | None, + project_root: Path | None = None, ) -> str | None: """Fail-closed dispatch-feasibility preflight. @@ -140,26 +143,48 @@ def _check_dispatch_feasibility( getattr(_step_obj, "skill_name", None) if _step_obj is not None else None ) if _skill_name: - _skill_info = skill_resolver.resolve(_skill_name) - if _skill_info is None: + try: + _skill_invocation = skill_resolver.resolve_invocation( + _skill_name, + project_root, + SkillExecutionRole.SESSION, + ) + except SkillContractError as exc: return json.dumps( { "success": False, "kitchen": "preflight_failed", "user_visible_message": ( f"Cannot verify capability feasibility for explicitly-pinned " - f"step '{step_name}': skill '{_skill_name}' could not be " - "resolved." + f"step '{step_name}': skill '{_skill_name}' has no valid " + f"effective invocation. {exc}" ), - "error": "skill_not_found_for_pinned_step", + "error": "invalid_skill_invocation_for_pinned_step", "stage": "dispatch_feasibility_preflight", "step": step_name, "skill": _skill_name, } ) - _skill_caps: frozenset[str] = getattr( - _skill_info, "uses_capabilities", frozenset() - ) + _requirements: frozenset[str] = _skill_invocation.backend_requirements + if _requirements and _pinned_backend.name not in _requirements: + return json.dumps( + { + "success": False, + "kitchen": "preflight_failed", + "user_visible_message": ( + f"Cannot dispatch step '{step_name}': explicitly pinned " + f"to backend '{_explicit}', but effective skill " + f"invocation '{_skill_name}' requires " + f"{sorted(_requirements)}." + ), + "error": "backend_requirements_unsatisfied", + "stage": "dispatch_feasibility_preflight", + "backend": _explicit, + "step": step_name, + "skill": _skill_name, + } + ) + _skill_caps = _skill_invocation.capability_union if _skill_caps: hard_cap_err = check_hard_capability_feasibility( _skill_caps, _pinned_backend diff --git a/src/autoskillit/server/tools/_serve_helpers.py b/src/autoskillit/server/tools/_serve_helpers.py index d7dab9cbeb..5dd755782b 100644 --- a/src/autoskillit/server/tools/_serve_helpers.py +++ b/src/autoskillit/server/tools/_serve_helpers.py @@ -16,7 +16,9 @@ from autoskillit.core import ( RESPONSE_BACKSTOP_EXEMPTION_REGISTRY, RESPONSE_BACKSTOP_EXEMPTION_REGISTRY_DIGEST, + SkillExecutionRole, ) +from autoskillit.server._misc import SkillProjectionContext, project_agent_skill_document if TYPE_CHECKING: from autoskillit.core import BackendCapabilities, CodingAgentBackend @@ -51,6 +53,38 @@ def build_backend_capabilities_map( return out +def project_orchestrator_guidance(tool_ctx: Any) -> str: + """Project the sous-chef document for an anonymous kitchen open.""" + if tool_ctx.skill_resolver is None: + return "" + catalog = tool_ctx.skill_resolver.list_effective( + tool_ctx.project_dir, + SkillExecutionRole.ORCHESTRATOR, + visibility=tool_ctx.config.skill_visibility_spec(), + recipe_packs=tool_ctx.active_recipe_packs, + recipe_features=tool_ctx.active_recipe_features, + ) + sous_chef = next((skill for skill in catalog.skills if skill.name == "sous-chef"), None) + if ( + sous_chef is None + or sous_chef.invalid_reason is not None + or sous_chef.execution_role is not SkillExecutionRole.ORCHESTRATOR + ): + return "" + backend = tool_ctx.backend + document = project_agent_skill_document( + sous_chef, + SkillProjectionContext( + cwd=tool_ctx.project_dir, + catalog=catalog, + backend=backend, + conventions=backend.conventions if backend is not None else None, + gating=False, + ), + ) + return "\n\n" + document.content + + def response_backstop_tool_meta( tool_name: str, *, always_load: bool = False ) -> dict[str, bool | int | str]: diff --git a/src/autoskillit/server/tools/tools_execution.py b/src/autoskillit/server/tools/tools_execution.py index 7d8a15c809..f9e323bff9 100644 --- a/src/autoskillit/server/tools/tools_execution.py +++ b/src/autoskillit/server/tools/tools_execution.py @@ -23,12 +23,13 @@ AGENT_BACKEND_CLAUDE_CODE, CODEX_SESSIONS_SUBDIR, DISPATCH_ID_ENV_VAR, - SKILL_CAPABILITY_REGISTRY, SKILL_COMMAND_DISPLAY_MAX, WORKTREE_SKILLS, - ClaudeDirectoryConventions, ClosureAuthoritySpec, CodingAgentBackend, + EffectiveSkillInvocationAuthority, + SkillContractError, + SkillExecutionRole, SkillResult, TerminationReason, ValidatedAddDir, @@ -39,9 +40,8 @@ find_caller_session_id, get_logger, is_feature_enabled, - is_git_worktree, parse_plan_paths, - resolve_target_skill, + render_target_skill_command, ) from autoskillit.core import current_order_id as _current_order_id from autoskillit.core import current_step_name as _current_step_name @@ -58,11 +58,13 @@ _check_recipe_read_prohibition, _check_write_target_boundary, _require_enabled, + _require_orchestrator_exact, _require_orchestrator_or_higher, _validate_skill_command, ) from autoskillit.server._misc import ( SCENARIO_STEP_NAME_ENV, + SkillProjectionContext, _hook_config_overlay_path, resolve_closure_write_dirs, ) @@ -78,18 +80,63 @@ from autoskillit.server.tools._cancellation_shield import _cancellation_shield from autoskillit.server.tools._execution_helpers import ( _import_and_call, + _RunSkillContractLifecycle, _spill_spec, _summarize_streams, + bind_projection_backend, + build_fresh_projection_context, + build_validated_skill_dispatch_contract, clear_run_skill_state, + invocation_member_names, maybe_promote_work_dir, persist_run_skill_state, propagate_session_deadline, resolve_relative_path_args, + resolve_skill_dispatch_metadata, run_cmd_artifact_root, shape_execution_response, spill_run_cmd_result, validate_path_arg_anchoring, ) +from autoskillit.server.tools._execution_helpers import ( + aggregate_sandbox_overrides as _aggregate_sandbox_overrides, +) +from autoskillit.server.tools._execution_helpers import ( + build_skill_session_contract as _build_skill_session_contract, +) +from autoskillit.server.tools._execution_helpers import ( + check_review_approach_plan_path as _check_review_approach_plan_path, +) +from autoskillit.server.tools._execution_helpers import ( + compute_write_prefixes as _compute_write_prefixes, +) +from autoskillit.server.tools._execution_helpers import ( + derive_run_cmd_write_prefixes as _derive_run_cmd_write_prefixes, +) +from autoskillit.server.tools._execution_helpers import ( + get_routing_caps as _get_routing_caps, +) +from autoskillit.server.tools._execution_helpers import ( + has_routing_capability as _has_routing_capability, +) +from autoskillit.server.tools._execution_helpers import ( + make_project_skill_resolver as _make_project_skill_resolver, +) +from autoskillit.server.tools._execution_helpers import ( + rehydrate_skill_invocation as _rehydrate_skill_invocation, +) +from autoskillit.server.tools._execution_helpers import ( + resolve_step_name_from_recipe as _resolve_step_name_from_recipe, +) +from autoskillit.server.tools._execution_helpers import ( + scope_covers_cwd as _scope_covers_cwd, +) +from autoskillit.server.tools._execution_helpers import ( + serialize_skill_contract as _serialize_skill_contract, +) +from autoskillit.server.tools._execution_helpers import ( + validate_resumed_skill_contract as _validate_resumed_skill_contract, +) from autoskillit.server.tools._preflight import ( _get_fix_required_hook_matchers, # noqa: F401 (re-exported for tests/server/test_admission_dispatch_agreement.py) ) @@ -213,33 +260,6 @@ def _check_pipeline_deps(step_name: str, order_id: str) -> str | None: ) -def _resolve_step_name_from_recipe( - skill_command: str, - active_recipe_steps: dict[str, object], -) -> tuple[str, bool]: - """Resolve step_name from active_recipe_steps by matching skill_command prefix. - - Returns (step_name, is_ambiguous): - - (name, False) when exactly one recipe step matches - - ("", True) when multiple steps match (ambiguous) - - ("", False) when no steps match - """ - cmd_prefix = skill_command.split()[0] if skill_command.strip() else "" - if not cmd_prefix: - return ("", False) - matches: list[str] = [] - for step_key, step_obj in active_recipe_steps.items(): - with_args = getattr(step_obj, "with_args", None) - if not isinstance(with_args, dict): - continue - step_sc = with_args.get("skill_command", "") - if step_sc and step_sc.split()[0] == cmd_prefix: - matches.append(step_key) - if len(matches) == 1: - return (matches[0], False) - return ("", len(matches) > 1) - - def _has_active_locks(order_id: str) -> bool: """Return True if any ingredient locks are actively denying steps.""" from autoskillit.server import _get_ctx # circular-break @@ -282,51 +302,6 @@ def _has_active_deps() -> bool: return bool(tracker.get("dependencies")) -def _check_review_approach_plan_path(step_name: str, skill_command: str) -> str | None: - """Return a deny JSON if review_approach is invoked without a plan-path argument. - - review_approach requires a plan file path produced by rectify/make_plan. - Heuristic: the first argument after the skill name must not be a bare - issue URL, which would otherwise fall back to ambiguous "conversation - context" inference instead of failing loudly. - """ - if _canonical_step_name(step_name) != "review_approach": - return None - parts = skill_command.split() - if len(parts) < 2: - return None - first_arg = parts[1] - if first_arg.startswith("https://") or first_arg.startswith("http://"): - return json.dumps( - deny_envelope( - ( - "review_approach requires a plan file path argument (a path " - "under the project's temp directory produced by " - "rectify/make_plan), not an issue URL." - ), - stage="preflight:plan_path", - retriable=False, - ) - ) - return None - - -def _derive_run_cmd_write_prefixes() -> tuple[str, ...]: - """Read allowed write prefixes from environment. - - Mirrors the env-var resolution logic in hooks/guards/write_guard.py: - AUTOSKILLIT_ALLOWED_WRITE_PREFIXES (colon-separated) takes precedence over - AUTOSKILLIT_ALLOWED_WRITE_PREFIX (single value). - """ - multi = os.environ.get("AUTOSKILLIT_ALLOWED_WRITE_PREFIXES", "") - if multi: - return tuple(p for p in multi.split(":") if p) - single = os.environ.get("AUTOSKILLIT_ALLOWED_WRITE_PREFIX", "") - if single: - return (single,) - return () - - def _mark_step_complete_server_side( tool_ctx: ToolContext, step_name: str, @@ -605,75 +580,6 @@ async def run_python( return json.dumps({"success": False, "error": f"{type(exc).__name__}: {exc}"}) -def _compute_write_prefixes( - write_watch_dirs: list[Path], - cwd: str, - skill_command: str, -) -> tuple[str, tuple[str, ...]]: - - worktree_write_prefixes: list[str] = [] - extracted = extract_skill_name(skill_command) - if write_watch_dirs and extracted and extracted in WORKTREE_SKILLS: - resolved_cwd = Path(cwd).resolve() - if is_git_worktree(resolved_cwd): - # cwd IS the worktree — include cwd itself and its parent (the worktrees/ dir) - worktree_write_prefixes.append(str(resolved_cwd) + "/") - worktree_write_prefixes.append(str(resolved_cwd.parent) + "/") - else: - nested_wt = resolved_cwd / "worktrees" - sibling_wt = resolved_cwd.parent / "worktrees" - if nested_wt.is_dir(): - worktree_write_prefixes.append(str(nested_wt) + "/") - if sibling_wt.is_dir(): - worktree_write_prefixes.append(str(sibling_wt) + "/") - if not nested_wt.is_dir() and not sibling_wt.is_dir(): - worktree_write_prefixes.append(str(sibling_wt) + "/") - - base_prefixes = [str(d.resolve()) + "/" for d in write_watch_dirs] - all_prefixes = base_prefixes + worktree_write_prefixes - return base_prefixes[0] if base_prefixes else "", tuple(all_prefixes) - - -def _scope_covers_cwd(allowed_write_prefixes: tuple[str, ...], cwd: str) -> bool: - """Return True if any allowed_write_prefix covers cwd (lexical prefix match).""" - if not allowed_write_prefixes or not cwd: - return False - resolved_cwd_str = str(Path(cwd).resolve()).rstrip("/") + "/" - for pfx in allowed_write_prefixes: - if resolved_cwd_str.startswith(pfx): - return True - return False - - -def _aggregate_sandbox_overrides(skill_caps: frozenset[str]) -> frozenset[str]: - """Aggregate required_sandbox_overrides from all declared capabilities.""" - return frozenset().union( - *( - SKILL_CAPABILITY_REGISTRY[cap].required_sandbox_overrides - for cap in skill_caps - if cap in SKILL_CAPABILITY_REGISTRY - ) - ) - - -def _has_routing_capability(skill_caps: frozenset[str]) -> bool: - """Return True if any declared capability is worker_routable (triggers backend reroute).""" - return any( - SKILL_CAPABILITY_REGISTRY.get(cap) is not None - and SKILL_CAPABILITY_REGISTRY[cap].worker_routable - for cap in skill_caps - ) - - -def _get_routing_caps(skill_caps: frozenset[str]) -> list[str]: - """Return sorted list of worker_routable capabilities that trigger backend reroute.""" - return sorted( - cap - for cap in skill_caps - if SKILL_CAPABILITY_REGISTRY.get(cap) and SKILL_CAPABILITY_REGISTRY[cap].worker_routable - ) - - @mcp.tool(tags={"autoskillit", "kitchen", "kitchen-core"}, annotations={"readOnlyHint": True}) @_cancellation_shield() @track_response_size("run_skill") @@ -759,12 +665,10 @@ 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 - if not resume_session_id and (cmd_error := _validate_skill_command(skill_command)) is not None: - return cmd_error if cwd and not _is_absolute_path(cwd): return json.dumps( deny_envelope( @@ -785,43 +689,51 @@ async def run_skill( retriable=False, ) ) - if ( - step_name - and not resume_session_id - and (_lock_denial := _check_ingredient_locks(step_name, order_id)) is not None - ): - return _lock_denial - if ( - step_name - and not resume_session_id - and (_dep_denial := _check_pipeline_deps(step_name, order_id)) is not None - ): - return _dep_denial - if ( - step_name - and not resume_session_id - and (_plan_path_denial := _check_review_approach_plan_path(step_name, skill_command)) - is not None - ): - return _plan_path_denial try: + contract_lifecycle = _RunSkillContractLifecycle() _sn_token = _oid_token = None from autoskillit.server import _get_ctx # circular-break _cleanup_session_id: str | None = None tool_ctx = _get_ctx() - - if not step_name and not resume_session_id and tool_ctx.active_recipe_steps: - _resolved, _ambiguous = _resolve_step_name_from_recipe( - skill_command, tool_ctx.active_recipe_steps - ) - if _resolved: - step_name = _resolved - logger.warning( - "step_name_resolved_from_recipe", - step=step_name, - command=skill_command[:80], + _contract_store = tool_ctx.skill_session_contract_store + contract_lifecycle.store = _contract_store + _stored_contract_entry = None + _resume_backend_obj: CodingAgentBackend | None = None + _effective_skill_resolver = None + invocation: EffectiveSkillInvocationAuthority | None = None + projection_context: SkillProjectionContext | None = None + target_name: str | None = None + if resume_session_id: + try: + _stored_contract_entry = _contract_store.load(resume_session_id) + _resume_backend_obj = _get_backend(_stored_contract_entry.contract.backend) + _validate_resumed_skill_contract( + _stored_contract_entry.contract, + cwd=cwd, + project_root=tool_ctx.project_dir, + backend=_resume_backend_obj, + ) + if _resume_backend_obj is None: + raise SkillContractError("Resume contract backend is unavailable") + invocation, projection_context = _rehydrate_skill_invocation( + _stored_contract_entry.contract, + _resume_backend_obj, ) + except (OSError, ValueError, SkillContractError) as exc: + return SkillResult.crashed( + exception=SkillContractError( + f"Cannot resume session {resume_session_id!r}: {exc}" + ), + skill_command=skill_command, + order_id=order_id, + ).to_json() + contract_lifecycle.bound_session_id = resume_session_id + target_name = _stored_contract_entry.contract.root_name + else: + if (cmd_error := _validate_skill_command(skill_command)) is not None: + return cmd_error + if step_name: if (_lock_denial := _check_ingredient_locks(step_name, order_id)) is not None: return _lock_denial if (_dep_denial := _check_pipeline_deps(step_name, order_id)) is not None: @@ -830,45 +742,95 @@ async def run_skill( _plan_path_denial := _check_review_approach_plan_path(step_name, skill_command) ) is not None: return _plan_path_denial - elif _ambiguous: - if _has_active_deps(): + _effective_skill_resolver = tool_ctx.skill_resolver + if _effective_skill_resolver is None: + _effective_skill_resolver = _make_project_skill_resolver() + target_name = extract_skill_name(skill_command) + if target_name is None: + return SkillResult.crashed( + exception=SkillContractError( + f"Cannot resolve a logical skill target from {skill_command!r}" + ), + skill_command=skill_command, + order_id=order_id, + ).to_json() + try: + invocation = _effective_skill_resolver.resolve_invocation( + target_name, + tool_ctx.project_dir, + SkillExecutionRole.SESSION, + visibility=tool_ctx.config.skill_visibility_spec(), + recipe_packs=tool_ctx.active_recipe_packs, + recipe_features=tool_ctx.active_recipe_features, + ) + projection_context = build_fresh_projection_context(cwd, invocation) + except SkillContractError as exc: + return SkillResult.crashed( + exception=exc, + skill_command=skill_command, + order_id=order_id, + ).to_json() + if not step_name and tool_ctx.active_recipe_steps: + _resolved, _ambiguous = _resolve_step_name_from_recipe( + skill_command, tool_ctx.active_recipe_steps + ) + if _resolved: + step_name = _resolved + logger.warning( + "step_name_resolved_from_recipe", + step=step_name, + command=skill_command[:80], + ) + if (_lock_denial := _check_ingredient_locks(step_name, order_id)) is not None: + return _lock_denial + if (_dep_denial := _check_pipeline_deps(step_name, order_id)) is not None: + return _dep_denial + if ( + _plan_path_denial := _check_review_approach_plan_path( + step_name, skill_command + ) + ) is not None: + return _plan_path_denial + elif _ambiguous: + if _has_active_deps(): + return json.dumps( + deny_envelope( + ( + f"{DEPENDENCY_DENY_PREFIX}: step_name is empty and matched " + "multiple recipe steps by skill_command prefix (ambiguous). " + "Cannot verify dependency status. Pass step_name explicitly." + ), + stage="preflight:ambiguous_step", + retriable=False, + ) + ) + elif _has_active_locks(order_id): return json.dumps( deny_envelope( ( - f"{DEPENDENCY_DENY_PREFIX}: step_name is empty and matched " - "multiple recipe steps by skill_command prefix (ambiguous). " - "Cannot verify dependency status. Pass step_name explicitly." + f"{INGREDIENT_LOCK_DENY_PREFIX}: step_name is empty and could " + "not be resolved from the recipe. Cannot verify lock " + "status. Pass step_name explicitly or call " + "lock_ingredients(unlock=[...]) to release all locks." ), - stage="preflight:ambiguous_step", + stage="preflight:ingredient_locks", retriable=False, ) ) - elif _has_active_locks(order_id): - return json.dumps( - deny_envelope( - ( - f"{INGREDIENT_LOCK_DENY_PREFIX}: step_name is empty and could " - "not be resolved from the recipe. Cannot verify lock " - "status. Pass step_name explicitly or call " - "lock_ingredients(unlock=[...]) to release all locks." - ), - stage="preflight:ingredient_locks", - retriable=False, - ) - ) - elif _has_active_deps(): - return json.dumps( - deny_envelope( - ( - f"{DEPENDENCY_DENY_PREFIX}: step_name is empty and could " - "not be resolved from the recipe. Cannot verify dependency " - "status. Pass step_name explicitly." - ), - stage="preflight:unresolved_step", - retriable=False, + elif _has_active_deps(): + return json.dumps( + deny_envelope( + ( + f"{DEPENDENCY_DENY_PREFIX}: step_name is empty and could " + "not be resolved from the recipe. Cannot verify dependency " + "status. Pass step_name explicitly." + ), + stage="preflight:unresolved_step", + retriable=False, + ) ) - ) - + if invocation is None or projection_context is None: + raise SkillContractError("Skill dispatch branches did not produce a bound contract") with structlog.contextvars.bound_contextvars(tool="run_skill", cwd=cwd): logger.info("run_skill", command=skill_command[:80], cwd=cwd) await _notify( @@ -958,29 +920,18 @@ async def run_skill( if _step_mo: effective_model = _step_mo - # Resolve correct namespace and prepare for tier2 activation. - # Must precede backend_override — _skill_info feeds skill-requirement check. - resolved_command = skill_command - target_name: str | None = None - if tool_ctx.skill_resolver is not None: - resolved_command, target_name = resolve_target_skill( - skill_command, tool_ctx.skill_resolver - ) - _skill_info = ( - tool_ctx.skill_resolver.resolve(target_name) - if tool_ctx.skill_resolver and target_name - else None + # The fresh branch resolved the complete effective invocation before any + # notification or provider/executor work. Backend-specific rendering waits + # until capability-driven backend selection is complete. + _stored_contract = ( + _stored_contract_entry.contract if _stored_contract_entry is not None else None ) - if target_name and _skill_info is None: - return SkillResult.crashed( - exception=RuntimeError( - f"Skill '{target_name}' not found in any discovery source " - f"(bundled skills/, skills_extended/, or project-local). " - f"Cannot launch session for undiscoverable skill name." - ), - skill_command=resolved_command, - order_id=effective_order_id, - ).to_json() + resolved_command = ( + _stored_contract.resolved_command + if _stored_contract is not None + else skill_command + ) + _effective_skill_contract = invocation if invocation is not None else _stored_contract _provider_override = ( provider_extras @@ -1003,8 +954,10 @@ async def run_skill( ) _skill_caps: frozenset[str] = ( - getattr(_skill_info, "uses_capabilities", frozenset()) - if _skill_info + invocation.capability_union + if invocation is not None + else _stored_contract.capability_union + if _stored_contract is not None else frozenset() ) _sandbox_overrides = _aggregate_sandbox_overrides(_skill_caps) @@ -1020,7 +973,11 @@ async def run_skill( # When an explicit backend override pins a step to a non-claude # backend, capability-driven routing must NOT crash on the missing # claude binary — the operator has explicitly chosen the backend. - if _skill_requires_claude and _explicit_backend_override is None: + if ( + _stored_contract is None + and _skill_requires_claude + and _explicit_backend_override is None + ): if shutil.which("claude") is None: return SkillResult.crashed( exception=RuntimeError( @@ -1035,8 +992,11 @@ async def run_skill( # If an explicit override points to a non-claude backend whose # binary is absent, fail closed with a clear message. - if _explicit_backend_override is not None and _explicit_backend_override != ( - tool_ctx.backend.name if tool_ctx.backend else None + if ( + _stored_contract is None + and _explicit_backend_override is not None + and _explicit_backend_override + != (tool_ctx.backend.name if tool_ctx.backend else None) ): try: _explicit_backend_obj_check = _get_backend(_explicit_backend_override) @@ -1071,7 +1031,9 @@ async def run_skill( order_id=effective_order_id, ).to_json() - if _explicit_backend_override is not None: + if _stored_contract is not None: + backend_override = _stored_contract.backend + elif _explicit_backend_override is not None: backend_override = _explicit_backend_override elif _provider_override or _skill_requires_claude: backend_override = AGENT_BACKEND_CLAUDE_CODE @@ -1079,13 +1041,33 @@ async def run_skill( backend_override = None _effective_backend_obj: CodingAgentBackend | None = ( - _get_backend(backend_override) + _resume_backend_obj + if _stored_contract is not None + else _get_backend(backend_override) if backend_override is not None and tool_ctx.backend is not None else tool_ctx.backend ) + if _stored_contract is None: + projection_context = bind_projection_backend( + projection_context, _effective_backend_obj + ) + if invocation is not None and _stored_contract is None: + if invocation.root.source_ref is None: + raise SkillContractError("Effective skill source identity is missing") + resolved_command = render_target_skill_command( + skill_command, + invocation.root.source_ref, + ( + _effective_backend_obj.conventions + if _effective_backend_obj is not None + else None + ), + ) _backend_override_source: str | None = None - if _explicit_resolution is not None: + if _stored_contract is not None: + _backend_override_source = "stored_contract" + elif _explicit_resolution is not None: _backend_override_source = _explicit_resolution.key_path elif _skill_requires_claude: _backend_override_source = "skill_requirement" @@ -1107,19 +1089,9 @@ async def run_skill( routing_capabilities=_routing_caps, ) - # Look up artifact validation patterns from skill contract - expected_output_patterns: list[str] = [] - if tool_ctx.output_pattern_resolver: - expected_output_patterns = list(tool_ctx.output_pattern_resolver(skill_command)) - - # Look up write-expectation metadata from skill contract - write_spec: WriteBehaviorSpec | None = None - if tool_ctx.write_expected_resolver: - write_spec = tool_ctx.write_expected_resolver(skill_command) - - _skill_contract = None - if tool_ctx.skill_contract_resolver: - _skill_contract = tool_ctx.skill_contract_resolver(skill_command) + expected_output_patterns, write_spec, _skill_contract = ( + resolve_skill_dispatch_metadata(tool_ctx, skill_command, _stored_contract) + ) # Resolve closure spec from explicit MCP tool parameters. # Closure args are first-class parameters (not embedded in skill_command text) @@ -1143,9 +1115,13 @@ async def run_skill( resolved_command=resolved_command, effective_order_id=effective_order_id, target_name=target_name, - skill_info=_skill_info, + skill_info=_effective_skill_contract, effective_backend_obj=_effective_backend_obj, - skill_resolver=tool_ctx.skill_resolver, + skill_resolver=( + _effective_skill_resolver + if _effective_skill_resolver is not None + else _stored_contract_entry + ), ): return compat_error @@ -1218,19 +1194,28 @@ async def run_skill( if _default_temp: write_watch_dirs.append(_default_temp) - is_read_only = bool( - tool_ctx.read_only_resolver and tool_ctx.read_only_resolver(skill_command) - ) - completion_required = bool( - tool_ctx.completion_required_resolver - and tool_ctx.completion_required_resolver(skill_command) - ) + if _stored_contract is not None: + is_read_only = _stored_contract.read_only + completion_required = _stored_contract.completion_required + else: + is_read_only = bool( + tool_ctx.read_only_resolver and tool_ctx.read_only_resolver(skill_command) + ) + completion_required = bool( + tool_ctx.completion_required_resolver + and tool_ctx.completion_required_resolver(skill_command) + ) invocation_marker = f"%%ORDER_UP::{uuid4().hex[:8]}%%" skill_add_dirs: list[ValidatedAddDir] = [] replay_snapshot_used = False _runner = tool_ctx.runner - if ( + if _stored_contract_entry is not None: + skill_add_dirs.append( + ValidatedAddDir(path=str(_stored_contract_entry.snapshot_dir)) + ) + replay_snapshot_used = True + elif ( step_name and _runner is not None and getattr(_runner, "skill_snapshots", None) @@ -1238,6 +1223,15 @@ async def run_skill( and tool_ctx.ephemeral_root is not None ): _ephemeral_root = tool_ctx.ephemeral_root + if invocation is None: + raise SkillContractError( + "Fresh replay requires a validated effective invocation" + ) + if hasattr(_runner, "validate_skill_snapshot"): + _runner.validate_skill_snapshot( # type: ignore[attr-defined] + step_name, + invocation_member_names(invocation), + ) session_id = f"headless-{uuid4().hex[:12]}" _cleanup_session_id = session_id _restored = _runner.restore_skill_snapshot( # type: ignore[attr-defined] @@ -1268,34 +1262,28 @@ async def run_skill( ) if not replay_snapshot_used and tool_ctx.session_skill_manager is not None: - allow_only: frozenset[str] | None = None - closure: frozenset[str] = frozenset() - if target_name: - closure = tool_ctx.session_skill_manager.compute_skill_closure(target_name) - if not closure: - return SkillResult.crashed( - exception=RuntimeError( - f"Skill '{target_name}' resolved to an empty closure. " - f"This indicates the skill exists but has no injectable content." - ), - skill_command=resolved_command, - order_id=effective_order_id, - ).to_json() - allow_only = closure - - session_id = f"headless-{uuid4().hex[:12]}" - _cleanup_session_id = session_id - session_root = tool_ctx.session_skill_manager.init_session( - session_id, - cook_session=False, - config=tool_ctx.config, - project_dir=tool_ctx.project_dir, - recipe_packs=tool_ctx.active_recipe_packs, - recipe_features=tool_ctx.active_recipe_features, - allow_only=allow_only, - backend=_effective_backend_obj, - ) - if not tool_ctx.session_skill_manager.validate_session_exists(session_id): + if _stored_contract_entry is not None: + assert resume_session_id is not None + session_root = ValidatedAddDir(path=str(_stored_contract_entry.snapshot_dir)) + session_id = resume_session_id + elif invocation is not None: + session_id = f"headless-{uuid4().hex[:12]}" + _cleanup_session_id = session_id + if projection_context is None: + raise SkillContractError("Projection context was not prepared") + session_root = tool_ctx.session_skill_manager.materialize_invocation( + session_id, + invocation, + projection_context, + ) + else: + raise SkillContractError( + "Fresh execution requires a resolved skill invocation" + ) + if _stored_contract_entry is None and ( + not session_id + or not tool_ctx.session_skill_manager.validate_session_exists(session_id) + ): logger.warning( "stale_session_path", session_id=session_id, @@ -1312,65 +1300,46 @@ async def run_skill( ).to_json() skill_add_dirs.append(session_root) - if target_name: - tool_ctx.session_skill_manager.activate_skill_deps(session_id, target_name) - _is_known_skill = ( - tool_ctx.skill_resolver is not None - and tool_ctx.skill_resolver.resolve(target_name) is not None + # Both fresh and rehydrated invocations extend scope from their + # validated closure, independent of whether a snapshot was replayed. + if invocation is not None: + write_watch_dirs.extend( + resolve_closure_write_dirs( + invocation.closure, + cwd, + write_watch_dirs, ) - if _is_known_skill: - _skills_subdir = ( - _effective_backend_obj.conventions.skills_subdir - if _effective_backend_obj is not None - else ClaudeDirectoryConventions.ADD_DIR_SKILLS_SUBDIR - ) - _skill_md = ( - Path(session_root.path) / _skills_subdir / target_name / "SKILL.md" - ) - if not _skill_md.exists(): - logger.error( - "target_skill_not_in_session", - target=target_name, - session_id=session_id, - session_root=str(session_root.path), - ) - return SkillResult.crashed( - exception=RuntimeError( - f"Target skill {target_name!r} not available in session " - f"{session_id!r}: SKILL.md not found after init_session + " - f"activate_skill_deps. Check tier/feature/pack gating." - ), - skill_command=resolved_command, - session_id=session_id, - order_id=effective_order_id, - ).to_json() - else: - # Defense-in-depth: should be unreachable after the pre-init - # existence gate (3a), but if reached, reject rather than proceed. - logger.error( - "unknown_skill_reached_init", - target=target_name, - session_id=session_id, - ) - return SkillResult.crashed( - exception=RuntimeError( - f"Skill '{target_name}' unknown to resolver after session init. " - f"This should have been caught by the pre-init existence gate." - ), - skill_command=resolved_command, - session_id=session_id, - order_id=effective_order_id, - ).to_json() - # Replay-path sessions inherit write scope from their original - # snapshot — they don't need re-augmentation because the snapshot - # was built from a live session that already had the full prefix set. - if closure and tool_ctx.skill_resolver is not None: - write_watch_dirs.extend( - resolve_closure_write_dirs( - closure, tool_ctx.skill_resolver, cwd, write_watch_dirs - ) - ) + ) + if invocation is not None and _stored_contract is None: + if not skill_add_dirs: + raise SkillContractError( + "Fresh execution requires a materialized skill snapshot" + ) + if projection_context is None: + raise SkillContractError("Projection context was not prepared") + _session_contract, _session_snapshot = _build_skill_session_contract( + session_root=skill_add_dirs[0], + invocation=invocation, + projection_context=projection_context, + resolved_command=resolved_command, + expected_output_patterns=tuple(expected_output_patterns), + write_behavior=write_spec or WriteBehaviorSpec(), + read_only=is_read_only, + completion_required=completion_required, + skill_contract_json=_serialize_skill_contract(_skill_contract), + ) + contract_lifecycle.correlation_key = _contract_store.create_provisional( + contract=_session_contract, + snapshot=_session_snapshot, + ) + + _capability_contract = build_validated_skill_dispatch_contract( + resolved_command, + projection_context, + skill_add_dirs, + _stored_contract, + ) allowed_write_prefix = "" allowed_write_prefixes: tuple[str, ...] = () if write_watch_dirs: @@ -1388,7 +1357,6 @@ async def run_skill( "read_only_skill_no_target_name", skill_command=skill_command[:SKILL_COMMAND_DISPLAY_MAX], ) - # Preflight: for WORKTREE_SKILLS dispatches, the computed scope must cover cwd # so the session can write to its own tracked tree. Fail-fast BEFORE spawning # a session — otherwise the session locks itself out and burns N turns. @@ -1414,6 +1382,9 @@ async def run_skill( # Propagate AUTOSKILLIT_SESSION_DEADLINE to L1 sessions. provider_extras = propagate_session_deadline(tool_ctx.project_dir, provider_extras) + def _observe_contract_session_id(candidate_session_id: str) -> None: + contract_lifecycle.observe_candidate(candidate_session_id) + _start = time.monotonic() try: try: @@ -1423,9 +1394,10 @@ async def run_skill( _orchestrator_sid, "run-skill", ): + contract_lifecycle.execution_started = True skill_result = await tool_ctx.executor.run( resolved_command, - cwd, + _capability_contract.cwd, model=effective_model, add_dirs=skill_add_dirs, step_name=step_name, @@ -1463,8 +1435,15 @@ async def run_skill( closure_spec=closure_spec, closure_report_root=closure_report_root, skill_contract=_skill_contract, + capability_contract=_capability_contract, + on_session_id_resolved=( + _observe_contract_session_id + if contract_lifecycle.correlation_key is not None + else None + ), ) except TimeoutError as exc: + contract_lifecycle.retain_bound = False logger.error( "run_skill_mcp_tool_timeout", timeout_sec=_cfg.run_skill.mcp_tool_timeout_sec, @@ -1478,6 +1457,19 @@ async def run_skill( skill_command=resolved_command, order_id=effective_order_id, ).to_json() + + contract_lifecycle.finalize(skill_result.session_id) + + if ( + _stored_contract_entry is not None + and skill_result.session_id + and skill_result.session_id != resume_session_id + ): + raise SkillContractError( + "Resumed execution returned a different final session ID" + ) + contract_lifecycle.apply_retention(skill_result.needs_retry) + if skill_result.success: tool_ctx.audit.record_success(skill_command) clear_run_skill_state(tool_ctx.project_dir) @@ -1547,6 +1539,7 @@ async def run_skill( work_dir=cwd, ) except Exception as exc: + contract_lifecycle.retain_bound = False logger.error("run_skill executor raised unexpectedly", exc_info=True) return SkillResult.crashed( exception=exc, @@ -1575,6 +1568,7 @@ async def run_skill( order_id=_oid, # type: ignore[arg-type] ).to_json() finally: + contract_lifecycle.cleanup() if _sn_token is not None: _current_step_name.reset(_sn_token) # type: ignore[possibly-undefined] if _oid_token is not None: diff --git a/src/autoskillit/server/tools/tools_fleet_dispatch.py b/src/autoskillit/server/tools/tools_fleet_dispatch.py index 55041e1e21..522d181738 100755 --- a/src/autoskillit/server/tools/tools_fleet_dispatch.py +++ b/src/autoskillit/server/tools/tools_fleet_dispatch.py @@ -23,6 +23,7 @@ CodingAgentBackend, FleetErrorCode, SessionCheckpoint, + SkillExecutionRole, detect_autoskillit_mcp_prefix, find_caller_session_id, fleet_error, @@ -49,7 +50,12 @@ ) from autoskillit.server import mcp from autoskillit.server._guards import _require_enabled, _require_fleet -from autoskillit.server._misc import resolve_backend_override, resolve_log_dir +from autoskillit.server._misc import ( + SkillProjectionContext, + project_agent_skill_document, + resolve_backend_override, + resolve_log_dir, +) from autoskillit.server._notify import track_response_size from autoskillit.server.tools._auto_overrides import ( _compute_effective_backend_map, @@ -146,6 +152,7 @@ def _write_dispatch_to_campaign_state( def _get_food_truck_prompt_builder( has_unguarded_filesystem_access: bool = False, + projected_sous_chef: str = "", ) -> Callable[..., str]: """Return the food truck prompt builder with mcp_prefix pre-bound.""" @@ -154,7 +161,37 @@ def _get_food_truck_prompt_builder( _build_food_truck_prompt, mcp_prefix=mcp_prefix, has_unguarded_filesystem_access=has_unguarded_filesystem_access, + projected_sous_chef=projected_sous_chef, + ) + + +def _project_food_truck_sous_chef( + tool_ctx: Any, + backend: CodingAgentBackend | None, +) -> str: + """Project L2 orchestration guidance before crossing into the fleet layer.""" + if tool_ctx.skill_resolver is None: + return "" + catalog = tool_ctx.skill_resolver.list_effective( + tool_ctx.project_dir, + SkillExecutionRole.ORCHESTRATOR, + visibility=tool_ctx.config.skill_visibility_spec(), + recipe_packs=tool_ctx.active_recipe_packs, + recipe_features=tool_ctx.active_recipe_features, ) + sous_chef = next((skill for skill in catalog.skills if skill.name == "sous-chef"), None) + if sous_chef is None: + return "" + return project_agent_skill_document( + sous_chef, + SkillProjectionContext( + cwd=tool_ctx.project_dir.resolve(), + catalog=catalog, + backend=backend, + conventions=backend.conventions if backend is not None else None, + gating=False, + ), + ).content @mcp.tool( @@ -364,6 +401,7 @@ async def dispatch_food_truck( _preflight_raw_steps, skill_resolver=tool_ctx.skill_resolver, config_backend=tool_ctx.config.agent_backend, + project_root=tool_ctx.project_dir, ) _merged_ingredients = {**(ingredients or {}), **_capability_overrides} _effective_backend_map, _backend_origin_map = _compute_effective_backend_map( @@ -373,6 +411,7 @@ async def dispatch_food_truck( recipe, skill_resolver=tool_ctx.skill_resolver, config_backend=tool_ctx.config.agent_backend, + project_root=tool_ctx.project_dir, ) _preflight_backend_capabilities_map = build_backend_capabilities_map( _effective_backend_map, _override_backend @@ -447,6 +486,7 @@ async def dispatch_food_truck( recipe_name=recipe, config_backend=tool_ctx.config.agent_backend, skill_resolver=tool_ctx.skill_resolver, + project_root=tool_ctx.project_dir, ) if _preflight_err is not None: return _preflight_err @@ -470,6 +510,10 @@ async def dispatch_food_truck( if _override_backend else False ), + projected_sous_chef=_project_food_truck_sous_chef( + tool_ctx, + _override_backend, + ), ), quota_checker=lambda cfg: check_and_sleep_if_needed( cfg, diff --git a/src/autoskillit/server/tools/tools_fleet_reset.py b/src/autoskillit/server/tools/tools_fleet_reset.py index 9ecd91e68e..f656f53ef4 100644 --- a/src/autoskillit/server/tools/tools_fleet_reset.py +++ b/src/autoskillit/server/tools/tools_fleet_reset.py @@ -195,6 +195,9 @@ async def reset_dispatch( ) report.state_updated = state_updated + if state_updated and dispatch.dispatched_session_id: + tool_ctx.skill_session_contract_store.delete(dispatch.dispatched_session_id) + _cleanup_resume_gate_state(project_dir, dispatch_id) if dispatch.dispatch_id and dispatch.dispatch_id != dispatch_id: _cleanup_resume_gate_state(project_dir, dispatch.dispatch_id) diff --git a/src/autoskillit/server/tools/tools_github.py b/src/autoskillit/server/tools/tools_github.py index 33e6f3a7b5..9435e013b9 100644 --- a/src/autoskillit/server/tools/tools_github.py +++ b/src/autoskillit/server/tools/tools_github.py @@ -18,7 +18,10 @@ from autoskillit.server._guards import _require_enabled from autoskillit.server._misc import _extract_block, resolve_log_dir from autoskillit.server._notify import _notify, track_response_size -from autoskillit.server.tools._backend_compat import _resolve_and_check_backend_compat +from autoskillit.server.tools._backend_compat import ( + DirectSkillDispatch, + _prepare_direct_skill_dispatch, +) from autoskillit.server.tools._cancellation_shield import _cancellation_shield if TYPE_CHECKING: @@ -270,10 +273,6 @@ async def report_bug( f"Report output path: {report_path}" ) - # Backend compatibility gate — must precede executor dispatch. - if _compat_err := _resolve_and_check_backend_compat(skill_command, tool_ctx): - return _compat_err - log_dir = config.linux_tracing.log_dir if config.linux_tracing is not None else "" expected_output_patterns: list[str] = [] @@ -285,6 +284,15 @@ async def report_bug( write_spec = tool_ctx.write_expected_resolver(skill_command) if severity == "blocking": + dispatch, dispatch_error = _prepare_direct_skill_dispatch( + skill_command, + cwd, + tool_ctx, + ) + if dispatch_error is not None or dispatch is None: + return dispatch_error or json.dumps( + {"success": False, "error": "Direct skill dispatch preparation failed"} + ) result = await _run_report_session( skill_command, cwd, @@ -301,6 +309,8 @@ async def report_bug( provider_extras=report_provider_extras, profile_name=report_profile_name, provider_name=report_profile_name, + direct_dispatch=dispatch, + tool_ctx=tool_ctx, ) if not result["success"]: await _notify( @@ -314,17 +324,27 @@ async def report_bug( # Non-blocking: supervised background dispatch, return immediately. status_path = report_path.with_suffix(".status.json") - atomic_write( - status_path, - json.dumps( - {"status": "pending", "dispatched_at": datetime.now(UTC).isoformat()}, - indent=2, - ), - ) if tool_ctx.background is None: # always set by ToolContext.__post_init__ raise RuntimeError("ToolContext.background not initialized") - tool_ctx.background.submit( - _run_report_session( + dispatch, dispatch_error = _prepare_direct_skill_dispatch( + skill_command, + cwd, + tool_ctx, + ) + if dispatch_error is not None or dispatch is None: + return dispatch_error or json.dumps( + {"success": False, "error": "Direct skill dispatch preparation failed"} + ) + report_session = None + try: + atomic_write( + status_path, + json.dumps( + {"status": "pending", "dispatched_at": datetime.now(UTC).isoformat()}, + indent=2, + ), + ) + report_session = _run_report_session( skill_command, cwd, report_path, @@ -341,9 +361,18 @@ async def report_bug( provider_extras=report_provider_extras, profile_name=report_profile_name, provider_name=report_profile_name, - ), - label=step_name or "report_bug", - ) + direct_dispatch=dispatch, + tool_ctx=tool_ctx, + ) + tool_ctx.background.submit( + report_session, + label=step_name or "report_bug", + ) + except Exception: + if report_session is not None: + report_session.close() + dispatch.cleanup(tool_ctx) + raise return json.dumps( { "success": True, @@ -625,6 +654,8 @@ async def _run_report_session( provider_extras: dict[str, str] | None = None, profile_name: str = "", provider_name: str = "", + direct_dispatch: DirectSkillDispatch | None = None, + tool_ctx: Any = None, ) -> dict[str, Any]: """Run the headless session, write the report, and handle GitHub filing. @@ -634,8 +665,8 @@ async def _run_report_session( try: cfg = config.report_bug skill_result = await executor.run( - skill_command, - cwd, + (direct_dispatch.resolved_command if direct_dispatch is not None else skill_command), + (str(direct_dispatch.projection_context.cwd) if direct_dispatch is not None else cwd), model=model, step_name=step_name, timeout=float(cfg.timeout), @@ -644,6 +675,10 @@ async def _run_report_session( provider_extras=provider_extras, profile_name=profile_name, provider_name=provider_name, + add_dirs=direct_dispatch.add_dirs if direct_dispatch is not None else (), + capability_contract=( + direct_dispatch.capability_contract if direct_dispatch is not None else None + ), ) report_text = skill_result.result or skill_result.stderr or "No report generated." @@ -697,3 +732,6 @@ async def _run_report_session( "error": str(exc), "report_path": str(report_path), } + finally: + if direct_dispatch is not None and tool_ctx is not None: + direct_dispatch.cleanup(tool_ctx) diff --git a/src/autoskillit/server/tools/tools_issue_headless.py b/src/autoskillit/server/tools/tools_issue_headless.py index fba539344f..5878a3ae22 100644 --- a/src/autoskillit/server/tools/tools_issue_headless.py +++ b/src/autoskillit/server/tools/tools_issue_headless.py @@ -15,7 +15,7 @@ from autoskillit.server._guards import _require_enabled from autoskillit.server._misc import _extract_block from autoskillit.server._notify import _notify, track_response_size -from autoskillit.server.tools._backend_compat import _resolve_and_check_backend_compat +from autoskillit.server.tools._backend_compat import _prepare_direct_skill_dispatch from autoskillit.server.tools._cancellation_shield import _cancellation_shield if TYPE_CHECKING: @@ -315,16 +315,26 @@ async def prepare_issue( if tool_ctx.write_expected_resolver: write_spec = tool_ctx.write_expected_resolver(skill_command) - # Backend compatibility gate — must precede executor.run(). - if _compat_err := _resolve_and_check_backend_compat(skill_command, tool_ctx): - return _compat_err - - result = await tool_ctx.executor.run( + dispatch, dispatch_error = _prepare_direct_skill_dispatch( skill_command, - str(tool_ctx.project_dir), - expected_output_patterns=expected_output_patterns, - write_behavior=write_spec, + tool_ctx.project_dir, + tool_ctx, ) + if dispatch_error is not None or dispatch is None: + return dispatch_error or json.dumps( + {"success": False, "error": "Direct skill dispatch preparation failed"} + ) + try: + result = await tool_ctx.executor.run( + dispatch.resolved_command, + str(dispatch.projection_context.cwd), + add_dirs=dispatch.add_dirs, + expected_output_patterns=expected_output_patterns, + write_behavior=write_spec, + capability_contract=dispatch.capability_contract, + ) + finally: + dispatch.cleanup(tool_ctx) if not result.success: extra = _extract_partial_issue_data(result.result) if result.result else {} @@ -437,16 +447,26 @@ async def enrich_issues( if tool_ctx.write_expected_resolver: write_spec = tool_ctx.write_expected_resolver(skill_command) - # Backend compatibility gate — must precede executor.run(). - if _compat_err := _resolve_and_check_backend_compat(skill_command, tool_ctx): - return _compat_err - - result = await tool_ctx.executor.run( + dispatch, dispatch_error = _prepare_direct_skill_dispatch( skill_command, - str(tool_ctx.project_dir), - expected_output_patterns=expected_output_patterns, - write_behavior=write_spec, + tool_ctx.project_dir, + tool_ctx, ) + if dispatch_error is not None or dispatch is None: + return dispatch_error or json.dumps( + {"success": False, "error": "Direct skill dispatch preparation failed"} + ) + try: + result = await tool_ctx.executor.run( + dispatch.resolved_command, + str(dispatch.projection_context.cwd), + add_dirs=dispatch.add_dirs, + expected_output_patterns=expected_output_patterns, + write_behavior=write_spec, + capability_contract=dispatch.capability_contract, + ) + finally: + dispatch.cleanup(tool_ctx) if not result.success: extra = _extract_partial_enrich_data(result.result) if result.result else {} diff --git a/src/autoskillit/server/tools/tools_kitchen.py b/src/autoskillit/server/tools/tools_kitchen.py index 6644fa48fb..44b77b69f0 100644 --- a/src/autoskillit/server/tools/tools_kitchen.py +++ b/src/autoskillit/server/tools/tools_kitchen.py @@ -39,7 +39,6 @@ get_state_dir, is_marker_fresh, kitchen_entry_alive, - pkg_root, read_active_kitchens_registry, read_marker, register_active_kitchen, @@ -92,6 +91,7 @@ from autoskillit.server.tools._serve_helpers import ( build_backend_capabilities_map, build_open_kitchen_recipe_payload, + project_orchestrator_guidance, render_served_response, response_backstop_tool_meta, serve_recipe, @@ -691,6 +691,7 @@ def get_recipe(name: str) -> str: _raw_recipe.steps, skill_resolver=ctx.skill_resolver, config_backend=ctx.config.agent_backend, + project_root=ctx.project_dir, ) _effective_backend_map, _backend_origin_map = _compute_effective_backend_map( _raw_recipe.steps, @@ -699,6 +700,7 @@ def get_recipe(name: str) -> str: name, skill_resolver=ctx.skill_resolver, config_backend=ctx.config.agent_backend, + project_root=ctx.project_dir, ) _backend_capabilities_map = build_backend_capabilities_map( _effective_backend_map, ctx.backend @@ -941,6 +943,7 @@ async def open_kitchen( _raw_recipe.steps if _raw_recipe is not None else None, skill_resolver=tool_ctx.skill_resolver, config_backend=tool_ctx.config.agent_backend, + project_root=tool_ctx.project_dir, ) _session_overrides.update(_provider_overrides) _config_layer = build_config_authoritative_layer(_defaults) @@ -953,6 +956,7 @@ async def open_kitchen( name, skill_resolver=tool_ctx.skill_resolver, config_backend=tool_ctx.config.agent_backend, + project_root=tool_ctx.project_dir, ) _backend_capabilities_map = build_backend_capabilities_map( _effective_backend_map, tool_ctx.backend @@ -1049,6 +1053,7 @@ async def open_kitchen( recipe_name=name, config_backend=tool_ctx.config.agent_backend, skill_resolver=tool_ctx.skill_resolver, + project_root=tool_ctx.project_dir, ) if _preflight_err is not None: tool_ctx.gate.disable() @@ -1194,6 +1199,7 @@ async def open_kitchen( recipe_name=name, config_backend=tool_ctx.config.agent_backend, skill_resolver=tool_ctx.skill_resolver, + project_root=tool_ctx.project_dir, ) if _preflight_err is not None: tool_ctx.gate.disable() @@ -1270,14 +1276,13 @@ async def open_kitchen( "and let the downstream skill handle diagnosis." ) - # Inject sous-chef global orchestration rules (graceful degradation if absent) - _sous_chef_path = pkg_root() / "skills" / "sous-chef" / "SKILL.md" + # Anonymous opens receive the projected orchestrator discipline. Named opens + # returned above and preserve their attested recipe-delivery bytes unchanged. try: - if _sous_chef_path.exists(): - text += "\n\n" + _sous_chef_path.read_text() + text += project_orchestrator_guidance(_ctx) except Exception as exc: - logger.warning("open_kitchen_failure", stage="read_sous_chef", exc_info=True) - return _kitchen_failure_envelope(exc, stage="read_sous_chef") + logger.warning("open_kitchen_failure", stage="project_sous_chef", exc_info=True) + return _kitchen_failure_envelope(exc, stage="project_sous_chef") # Check if the project needs an upgrade scripts_dir = _ctx.project_dir / ".autoskillit" / "scripts" diff --git a/src/autoskillit/server/tools/tools_recipe.py b/src/autoskillit/server/tools/tools_recipe.py index 8dfd6b9130..a75e752b83 100644 --- a/src/autoskillit/server/tools/tools_recipe.py +++ b/src/autoskillit/server/tools/tools_recipe.py @@ -355,6 +355,7 @@ async def load_recipe( _raw_recipe_obj.steps if _raw_recipe_obj is not None else None, skill_resolver=tool_ctx.skill_resolver, config_backend=tool_ctx.config.agent_backend, + project_root=tool_ctx.project_dir, ) _session_overrides.update(_provider_overrides) _config_layer = build_config_authoritative_layer(_defaults) @@ -367,6 +368,7 @@ async def load_recipe( name, skill_resolver=tool_ctx.skill_resolver, config_backend=tool_ctx.config.agent_backend, + project_root=tool_ctx.project_dir, ) _backend_capabilities_map = build_backend_capabilities_map( _effective_backend_map, tool_ctx.backend @@ -815,6 +817,7 @@ async def validate_recipe(script_path: str) -> str: _validate_recipe_steps, skill_resolver=tool_ctx.skill_resolver, config_backend=tool_ctx.config.agent_backend, + project_root=tool_ctx.project_dir, ) _validate_effective_backend_map, _validate_backend_origin_map = ( _compute_effective_backend_map( @@ -824,6 +827,7 @@ async def validate_recipe(script_path: str) -> str: _validate_recipe_name, skill_resolver=tool_ctx.skill_resolver, config_backend=tool_ctx.config.agent_backend, + project_root=tool_ctx.project_dir, ) ) _validate_backend_capabilities_map = build_backend_capabilities_map( 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. ---