From 94ca27f91238415e08322cd69f0a4a97ca0c3203 Mon Sep 17 00:00:00 2001 From: Trecek Date: Thu, 23 Jul 2026 17:06:25 -0700 Subject: [PATCH 01/89] feat(core): add provenance-bound audit cycle admission --- src/autoskillit/core/AGENTS.md | 1 + src/autoskillit/core/__init__.pyi | 20 + src/autoskillit/core/audit_cycle_verifier.py | 660 +++++++++++++++++ src/autoskillit/core/closure_hashing.py | 74 +- src/autoskillit/core/closure_verifier.py | 8 +- src/autoskillit/core/io.py | 37 + src/autoskillit/core/path_containment.py | 43 +- src/autoskillit/core/types/AGENTS.md | 1 + src/autoskillit/core/types/__init__.py | 5 +- .../core/types/_type_audit_cycle.py | 701 ++++++++++++++++++ tests/_test_filter.py | 2 + tests/core/AGENTS.md | 3 + tests/core/test_audit_cycle_attacks.py | 205 +++++ tests/core/test_audit_cycle_authority.py | 278 +++++++ tests/core/test_inventory_admission.py | 373 ++++++++++ tests/test_test_filter_core_cascade.py | 7 + 16 files changed, 2407 insertions(+), 11 deletions(-) create mode 100644 src/autoskillit/core/audit_cycle_verifier.py create mode 100644 src/autoskillit/core/types/_type_audit_cycle.py create mode 100644 tests/core/test_audit_cycle_attacks.py create mode 100644 tests/core/test_audit_cycle_authority.py create mode 100644 tests/core/test_inventory_admission.py diff --git a/src/autoskillit/core/AGENTS.md b/src/autoskillit/core/AGENTS.md index dff29aa69..19dd8e846 100644 --- a/src/autoskillit/core/AGENTS.md +++ b/src/autoskillit/core/AGENTS.md @@ -33,6 +33,7 @@ Sub-packages: types/ (see types/AGENTS.md) and runtime/ (see runtime/AGENTS.md). | `path_containment.py` | Path containment guards — symlink/hardlink check, TOCTOU guard (stdlib-only, IL-0) | | `closure_verifier.py` | Independent verifier for closure-mode reports (stdlib-only, IL-0) | | `context_admission.py` | Pure protocol-v1 cumulative context-admission reducer, replay, and coverage resolution | +| `audit_cycle_verifier.py` | Bounded verifier and pure evaluator for provenance-bound audit-cycle inventory admission | ## Architecture Notes diff --git a/src/autoskillit/core/__init__.pyi b/src/autoskillit/core/__init__.pyi index 0eced8ce9..60e8faf13 100644 --- a/src/autoskillit/core/__init__.pyi +++ b/src/autoskillit/core/__init__.pyi @@ -43,6 +43,11 @@ from ._terminal_table import TerminalColumn as TerminalColumn from ._terminal_table import _render_gfm_table as _render_gfm_table from ._terminal_table import _render_terminal_table as _render_terminal_table from ._version_snapshot import collect_version_snapshot as collect_version_snapshot +from .audit_cycle_verifier import ArtifactByteReader as ArtifactByteReader +from .audit_cycle_verifier import AuditCycleVerificationError as AuditCycleVerificationError +from .audit_cycle_verifier import AuditCycleVerifier as AuditCycleVerifier +from .audit_cycle_verifier import InventoryAdmissionEvaluator as InventoryAdmissionEvaluator +from .audit_cycle_verifier import VerifiedAuditCycle as VerifiedAuditCycle from .bash_write_targets import extract_bash_write_targets as extract_bash_write_targets from .branch_guard import is_protected_branch as is_protected_branch from .claude_conventions import ClaudeDirectoryConventions as ClaudeDirectoryConventions @@ -79,6 +84,7 @@ from .io import ReadResult as ReadResult from .io import YAMLError as YAMLError from .io import atomic_write as atomic_write from .io import compose_yaml as compose_yaml +from .io import decode_versioned_json_bytes as decode_versioned_json_bytes from .io import dump_yaml_str as dump_yaml_str from .io import ensure_project_temp as ensure_project_temp from .io import load_yaml as load_yaml @@ -89,6 +95,7 @@ from .io import resolve_temp_dir as resolve_temp_dir from .io import safe_upsert_section as safe_upsert_section from .io import spill_output as spill_output from .io import temp_dir_display_str as temp_dir_display_str +from .io import write_canonical_versioned_json as write_canonical_versioned_json from .io import write_versioned_json as write_versioned_json from .logging import configure_logging as configure_logging from .logging import get_logger as get_logger @@ -151,6 +158,7 @@ from .tool_sequence_analysis import render_adjacency_table as render_adjacency_t from .tool_sequence_analysis import render_dot as render_dot from .tool_sequence_analysis import render_mermaid as render_mermaid from .types import ADMIRAL_DISPATCH_SECTIONS as ADMIRAL_DISPATCH_SECTIONS +from .types import AUDIT_CYCLE_SCHEMA_VERSION as AUDIT_CYCLE_SCHEMA_VERSION from .types import AGENT_BACKEND_CLAUDE_CODE as AGENT_BACKEND_CLAUDE_CODE from .types import AGENT_BACKEND_CODEX as AGENT_BACKEND_CODEX from .types import AGENT_BACKEND_DYNACONF_ENV_VAR as AGENT_BACKEND_DYNACONF_ENV_VAR @@ -337,11 +345,19 @@ from .types import AgentInstanceId as AgentInstanceId from .types import AgentPackDef as AgentPackDef from .types import AgentSessionResult as AgentSessionResult from .types import AggregateRevision as AggregateRevision +from .types import AdmissionReason as AdmissionReason +from .types import AdmissionStatus as AdmissionStatus from .types import ApiRetryOutcome as ApiRetryOutcome +from .types import ArtifactRef as ArtifactRef +from .types import AuditAssessment as AuditAssessment +from .types import AuditAssessmentRow as AuditAssessmentRow +from .types import AuditCycleAuthority as AuditCycleAuthority +from .types import AuditCycleHead as AuditCycleHead from .types import AuditLog as AuditLog from .types import AuthoritySourceId as AuthoritySourceId from .types import AuthorityUnavailableEffect as AuthorityUnavailableEffect from .types import AuthorityUnavailableEvent as AuthorityUnavailableEvent +from .types import AuditVerdict as AuditVerdict from .types import BackendCapabilities as BackendCapabilities from .types import BackendConventions as BackendConventions from .types import BackendEventKind as BackendEventKind @@ -448,6 +464,7 @@ from .types import InspectorEvidence as InspectorEvidence from .types import InspectorVerdict as InspectorVerdict from .types import IntakeRuleDef as IntakeRuleDef from .types import InvariantDef as InvariantDef +from .types import InventoryAdmissionDecision as InventoryAdmissionDecision from .types import IssueLabelState as IssueLabelState from .types import KillReason as KillReason from .types import LabelDef as LabelDef @@ -481,6 +498,8 @@ from .types import PhoropterPrescription as PhoropterPrescription from .types import PluginSource as PluginSource from .types import PrepareBatchEvent as PrepareBatchEvent from .types import ProcessedEventRecord as ProcessedEventRecord +from .types import PlanDispositionReport as PlanDispositionReport +from .types import PlanDispositionRow as PlanDispositionRow from .types import ProcessStaleError as ProcessStaleError from .types import ProducerCoverageDef as ProducerCoverageDef from .types import ProducerInstanceId as ProducerInstanceId @@ -613,6 +632,7 @@ from .types import WriteExpectedResolver as WriteExpectedResolver 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_findings_digest as compute_findings_digest 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 diff --git a/src/autoskillit/core/audit_cycle_verifier.py b/src/autoskillit/core/audit_cycle_verifier.py new file mode 100644 index 000000000..8e46537c9 --- /dev/null +++ b/src/autoskillit/core/audit_cycle_verifier.py @@ -0,0 +1,660 @@ +"""Tamper-evident audit-cycle verification and pure inventory admission.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Protocol, runtime_checkable + +from .closure_hashing import compute_bytes_hash +from .io import decode_versioned_json_bytes +from .path_containment import ContainmentError, read_stable_contained_bytes +from .types._type_audit_cycle import ( + AUDIT_CYCLE_SCHEMA_VERSION, + AdmissionReason, + ArtifactRef, + AuditCycleAuthority, + AuditCycleHead, + AuditVerdict, + InventoryAdmissionDecision, + PlanDispositionReport, + PlanDispositionRow, +) + +__all__ = [ + "ArtifactByteReader", + "AuditCycleVerificationError", + "AuditCycleVerifier", + "InventoryAdmissionEvaluator", + "VerifiedAuditCycle", +] + +_REQUIREMENTS_HEADER = ("Requirement ID", "Disposition", "Implementation Step") +_STEP_HEADING_RE = re.compile( + r"^###\s+(Step\s+[1-9][0-9]*(?:\.[1-9][0-9]*)*)(?::[^\n]*)?$", + re.MULTILINE, +) + + +@runtime_checkable +class ArtifactByteReader(Protocol): + def __call__( + self, + path: str | Path, + allowed_root: str | Path, + *, + max_size_bytes: int, + ) -> tuple[Path, bytes]: ... + + +class AuditCycleVerificationError(ValueError): + """A stable-reason rejection raised by the imperative verifier.""" + + def __init__(self, reason: AdmissionReason, message: str) -> None: + super().__init__(message) + self.reason = reason + + +@dataclass(frozen=True, slots=True) +class VerifiedAuditCycle: + authority: AuditCycleAuthority + report: PlanDispositionReport + inventory_requirement_ids: tuple[str, ...] + current_plan_text: str + + +def _reject(reason: AdmissionReason, detail: str) -> InventoryAdmissionDecision: + return InventoryAdmissionDecision.reject(reason, detail) + + +def _extract_section(markdown: str, heading: str) -> str: + pattern = re.compile(rf"^## {re.escape(heading)}[ \t]*$", re.MULTILINE) + matches = tuple(pattern.finditer(markdown)) + if len(matches) != 1: + raise ValueError(f"expected exactly one ## {heading} section") + start = matches[0].end() + next_heading = re.search(r"^##\s+", markdown[start:], re.MULTILINE) + end = start + next_heading.start() if next_heading is not None else len(markdown) + return markdown[start:end] + + +def _split_table_row(line: str) -> tuple[str, ...]: + stripped = line.strip() + if not stripped.startswith("|") or not stripped.endswith("|"): + raise ValueError("Requirements Map rows must be pipe-delimited") + return tuple(cell.strip() for cell in stripped[1:-1].split("|")) + + +def _parse_requirements_map(markdown: str) -> tuple[PlanDispositionRow, ...]: + section = _extract_section(markdown, "Requirements Map") + lines = tuple(line for line in section.splitlines() if line.strip()) + if len(lines) < 3: + raise ValueError("Requirements Map must contain a header, separator, and rows") + if _split_table_row(lines[0]) != _REQUIREMENTS_HEADER: + raise ValueError( + "Requirements Map header must be " + "| Requirement ID | Disposition | Implementation Step |" + ) + separator = _split_table_row(lines[1]) + if len(separator) != 3 or any(re.fullmatch(r":?-{3,}:?", cell) is None for cell in separator): + raise ValueError("Requirements Map separator is invalid") + rows: list[PlanDispositionRow] = [] + for line in lines[2:]: + cells = _split_table_row(line) + if len(cells) != 3: + raise ValueError("Requirements Map rows must have exactly three columns") + requirement_id, disposition, implementation_step = cells + step = None if implementation_step in {"", "-", "—"} else implementation_step + rows.append( + PlanDispositionRow.create( + requirement_id=requirement_id, + disposition=disposition, + implementation_step=step, + ) + ) + ids = tuple(row.requirement_id for row in rows) + if len(ids) != len(set(ids)): + raise ValueError("Requirements Map contains duplicate requirement IDs") + return tuple(rows) + + +def _implementation_step_blocks(markdown: str) -> dict[str, str]: + section = _extract_section(markdown, "Implementation Steps") + matches = tuple(_STEP_HEADING_RE.finditer(section)) + if not matches: + raise ValueError("Implementation Steps must contain ### Step N directives") + blocks: dict[str, str] = {} + for index, matched in enumerate(matches): + step_name = matched.group(1) + if step_name in blocks: + raise ValueError(f"duplicate implementation step {step_name}") + end = matches[index + 1].start() if index + 1 < len(matches) else len(section) + blocks[step_name] = section[matched.start() : end] + return blocks + + +class InventoryAdmissionEvaluator: + """Pure, total evaluator for one verified authority/report/plan tuple.""" + + def evaluate( + self, + *, + authority: AuditCycleAuthority | None, + trusted_head: AuditCycleHead | None, + report: PlanDispositionReport | None, + expected_generation: str, + expected_plan_set_id: str, + expected_scope_id: str, + expected_part_id: str, + current_plan_ref: ArtifactRef | None = None, + inventory_requirement_ids: tuple[str, ...] = (), + current_plan_text: str = "", + ) -> InventoryAdmissionDecision: + try: + return self._evaluate( + authority=authority, + trusted_head=trusted_head, + report=report, + expected_generation=expected_generation, + expected_plan_set_id=expected_plan_set_id, + expected_scope_id=expected_scope_id, + expected_part_id=expected_part_id, + current_plan_ref=current_plan_ref, + inventory_requirement_ids=inventory_requirement_ids, + current_plan_text=current_plan_text, + ) + except Exception as exc: + return _reject(AdmissionReason.INTERNAL_ERROR, f"inventory admission failed: {exc}") + + def _evaluate( + self, + *, + authority: AuditCycleAuthority | None, + trusted_head: AuditCycleHead | None, + report: PlanDispositionReport | None, + expected_generation: str, + expected_plan_set_id: str, + expected_scope_id: str, + expected_part_id: str, + current_plan_ref: ArtifactRef | None, + inventory_requirement_ids: tuple[str, ...], + current_plan_text: str, + ) -> InventoryAdmissionDecision: + if authority is None: + if report is not None: + return _reject( + AdmissionReason.REPORT_WITHOUT_AUTHORITY, + "a disposition report cannot activate without authority", + ) + return InventoryAdmissionDecision.omit(AdmissionReason.NO_AUTHORITY) + if trusted_head is None: + return _reject(AdmissionReason.HEAD_MISSING, "trusted audit-cycle head is absent") + if authority.authority_digest != trusted_head.current_authority_digest: + return _reject( + AdmissionReason.AUTHORITY_NOT_CURRENT, + "authority is not the trusted current head", + ) + if authority.execution_generation != trusted_head.execution_generation: + return _reject( + AdmissionReason.GENERATION_MISMATCH, + "authority and trusted head generations differ", + ) + if authority.cycle_id != trusted_head.cycle_id: + return _reject(AdmissionReason.CYCLE_MISMATCH, "authority and head cycle IDs differ") + if authority.plan_set_id != trusted_head.plan_set_id: + return _reject( + AdmissionReason.PLAN_SET_MISMATCH, + "authority and head plan-set IDs differ", + ) + if authority.scope_id != trusted_head.scope_id: + return _reject(AdmissionReason.SCOPE_MISMATCH, "authority and head scope IDs differ") + if authority.part_id != trusted_head.part_id: + return _reject(AdmissionReason.PART_MISMATCH, "authority and head part IDs differ") + if authority.audit_round != trusted_head.audit_round: + return _reject(AdmissionReason.ROUND_MISMATCH, "authority and head rounds differ") + if authority.verdict is not trusted_head.verdict: + return _reject( + AdmissionReason.AUTHORITY_NOT_CURRENT, + "authority and trusted head verdicts differ", + ) + if authority.execution_generation != expected_generation: + return _reject( + AdmissionReason.GENERATION_MISMATCH, + "authority is from another execution generation", + ) + if authority.plan_set_id != expected_plan_set_id: + return _reject( + AdmissionReason.PLAN_SET_MISMATCH, + "authority is from another plan set", + ) + if authority.scope_id != expected_scope_id: + return _reject(AdmissionReason.SCOPE_MISMATCH, "authority is from another scope") + if authority.verdict is AuditVerdict.GO: + if expected_part_id == authority.part_id: + return InventoryAdmissionDecision.omit(AdmissionReason.TRUSTED_GO) + if expected_part_id == trusted_head.authorized_successor_part_id: + return InventoryAdmissionDecision.omit(AdmissionReason.TRUSTED_GO_SUCCESSOR) + return _reject( + AdmissionReason.PART_MISMATCH, + "GO authority does not authorize this successor part", + ) + if authority.part_id != expected_part_id: + return _reject( + AdmissionReason.PART_MISMATCH, + "NO GO authority is from another part", + ) + if report is None: + return _reject( + AdmissionReason.AUTHORITY_WITHOUT_REPORT, + "current NO GO authority requires a disposition report", + ) + provenance_checks = ( + ( + report.execution_generation == authority.execution_generation, + AdmissionReason.GENERATION_MISMATCH, + "report generation differs from authority", + ), + ( + report.cycle_id == authority.cycle_id, + AdmissionReason.CYCLE_MISMATCH, + "report cycle differs from authority", + ), + ( + report.plan_set_id == authority.plan_set_id, + AdmissionReason.PLAN_SET_MISMATCH, + "report plan set differs from authority", + ), + ( + report.scope_id == authority.scope_id, + AdmissionReason.SCOPE_MISMATCH, + "report scope differs from authority", + ), + ( + report.part_id == authority.part_id, + AdmissionReason.PART_MISMATCH, + "report part differs from authority", + ), + ( + report.audit_round == authority.audit_round, + AdmissionReason.ROUND_MISMATCH, + "report round differs from authority", + ), + ( + report.parent_authority_digest == authority.authority_digest, + AdmissionReason.PARENT_MISMATCH, + "report is not bound to this authority", + ), + ( + report.inventory_digest == authority.inventory_ref.content_digest, + AdmissionReason.INVENTORY_MISMATCH, + "report inventory differs from authority", + ), + ( + report.findings_digest == authority.findings_digest, + AdmissionReason.FINDINGS_MISMATCH, + "report findings differ from authority", + ), + ) + for matches, reason, detail in provenance_checks: + if not matches: + return _reject(reason, detail) + if current_plan_ref is None: + return _reject(AdmissionReason.PLAN_MISMATCH, "current plan is unverified") + if report.current_plan_ref.content_digest != current_plan_ref.content_digest: + return _reject( + AdmissionReason.PLAN_MISMATCH, + "report is bound to another current plan", + ) + if ( + not inventory_requirement_ids + or len(inventory_requirement_ids) != len(set(inventory_requirement_ids)) + or any(not isinstance(item, str) or not item for item in inventory_requirement_ids) + ): + return _reject( + AdmissionReason.INVENTORY_INVALID, + "inventory requirement IDs must be non-empty and unique", + ) + assessment_ids = tuple(row.requirement_id for row in authority.assessments) + report_ids = tuple(row.requirement_id for row in report.dispositions) + if assessment_ids != inventory_requirement_ids or report_ids != inventory_requirement_ids: + return _reject( + AdmissionReason.REQUIREMENT_ORDER_MISMATCH, + "inventory, assessment, and disposition IDs/order must match exactly", + ) + try: + plan_rows = _parse_requirements_map(current_plan_text) + except ValueError as exc: + return _reject(AdmissionReason.REQUIREMENTS_MAP_INVALID, str(exc)) + if tuple(row.requirement_id for row in plan_rows) != inventory_requirement_ids: + return _reject( + AdmissionReason.REQUIREMENT_ORDER_MISMATCH, + "Requirements Map IDs/order differ from inventory", + ) + if plan_rows != report.dispositions: + return _reject( + AdmissionReason.DISPOSITION_MISMATCH, + "Requirements Map and disposition report rows differ", + ) + try: + step_blocks = _implementation_step_blocks(current_plan_text) + except ValueError as exc: + if any(row.disposition == "carried@step" for row in plan_rows): + return _reject(AdmissionReason.IMPLEMENTATION_STEP_MISSING, str(exc)) + step_blocks = {} + for assessment, disposition in zip(authority.assessments, plan_rows, strict=True): + if assessment.assessment.blocking: + if disposition.disposition != "carried@step": + return _reject( + AdmissionReason.UNMAPPED_REQUIREMENT, + f"{assessment.requirement_id} is blocking but not carried", + ) + step = disposition.implementation_step + block = step_blocks.get(step or "") + if ( + block is None + or re.search( + rf"(? None: + self._allowed_root = allowed_root + self._max_size_bytes = max_size_bytes + self._reader = reader + + def _read_path(self, path: str | Path) -> bytes: + try: + _, data = self._reader( + path, + self._allowed_root, + max_size_bytes=self._max_size_bytes, + ) + except (ContainmentError, OSError) as exc: + raise AuditCycleVerificationError( + AdmissionReason.INVENTORY_INVALID, + f"artifact containment/read failed: {exc}", + ) from exc + return data + + def verify_artifact_ref(self, ref: ArtifactRef) -> bytes: + data = self._read_path(ref.locator) + if len(data) != ref.byte_size: + raise AuditCycleVerificationError( + AdmissionReason.INVENTORY_MISMATCH, + "artifact byte size differs from its reference", + ) + if compute_bytes_hash(data) != ref.content_digest: + raise AuditCycleVerificationError( + AdmissionReason.INVENTORY_MISMATCH, + "artifact content digest differs from its reference", + ) + return data + + def load_authority(self, path: str | Path) -> AuditCycleAuthority: + data = self._read_path(path) + raw = decode_versioned_json_bytes( + data, + expected_version=AUDIT_CYCLE_SCHEMA_VERSION, + require_canonical=True, + ) + if raw is None: + raise AuditCycleVerificationError( + AdmissionReason.AUTHORITY_NOT_CURRENT, + "authority is not strict canonical versioned JSON", + ) + try: + return AuditCycleAuthority.from_dict(raw) + except ValueError as exc: + raise AuditCycleVerificationError( + AdmissionReason.AUTHORITY_NOT_CURRENT, + f"authority validation failed: {exc}", + ) from exc + + def load_report(self, path: str | Path) -> PlanDispositionReport: + data = self._read_path(path) + raw = decode_versioned_json_bytes( + data, + expected_version=AUDIT_CYCLE_SCHEMA_VERSION, + require_canonical=True, + ) + if raw is None: + raise AuditCycleVerificationError( + AdmissionReason.DISPOSITION_MISMATCH, + "disposition report is not strict canonical versioned JSON", + ) + try: + return PlanDispositionReport.from_dict(raw) + except ValueError as exc: + raise AuditCycleVerificationError( + AdmissionReason.DISPOSITION_MISMATCH, + f"disposition report validation failed: {exc}", + ) from exc + + @staticmethod + def verify_successor(candidate: AuditCycleAuthority, trusted_head: AuditCycleHead) -> None: + if candidate.execution_generation != trusted_head.execution_generation: + raise AuditCycleVerificationError( + AdmissionReason.GENERATION_MISMATCH, + "successor authority crosses execution generations", + ) + if candidate.cycle_id != trusted_head.cycle_id: + raise AuditCycleVerificationError( + AdmissionReason.CYCLE_MISMATCH, + "successor authority crosses cycle identity", + ) + if ( + candidate.plan_set_id != trusted_head.plan_set_id + or candidate.scope_id != trusted_head.scope_id + or candidate.part_id != trusted_head.part_id + ): + raise AuditCycleVerificationError( + AdmissionReason.SCOPE_MISMATCH, + "successor authority crosses plan/scope/part identity", + ) + if candidate.audit_round != trusted_head.audit_round + 1: + raise AuditCycleVerificationError( + AdmissionReason.ROUND_MISMATCH, + "successor authority round is not monotonic", + ) + if candidate.parent_authority_digest != trusted_head.current_authority_digest: + raise AuditCycleVerificationError( + AdmissionReason.PARENT_MISMATCH, + "successor parent is not the trusted current head", + ) + + def verify_active_tuple( + self, + *, + authority_path: str | Path, + report_path: str | Path, + trusted_head: AuditCycleHead, + current_plan_path: str | Path, + ) -> VerifiedAuditCycle: + authority = self.load_authority(authority_path) + return self._verify_active_tuple( + authority=authority, + report_path=report_path, + trusted_head=trusted_head, + current_plan_path=current_plan_path, + ) + + def _verify_active_tuple( + self, + *, + authority: AuditCycleAuthority, + report_path: str | Path, + trusted_head: AuditCycleHead, + current_plan_path: str | Path, + ) -> VerifiedAuditCycle: + if authority.authority_digest != trusted_head.current_authority_digest: + raise AuditCycleVerificationError( + AdmissionReason.AUTHORITY_NOT_CURRENT, + "authority is stale or replayed", + ) + report = self.load_report(report_path) + provenance = InventoryAdmissionEvaluator().evaluate( + authority=authority, + trusted_head=trusted_head, + report=report, + expected_generation=trusted_head.execution_generation, + expected_plan_set_id=trusted_head.plan_set_id, + expected_scope_id=trusted_head.scope_id, + expected_part_id=trusted_head.part_id, + current_plan_ref=report.current_plan_ref, + ) + if provenance.reason is not AdmissionReason.INVENTORY_INVALID: + raise AuditCycleVerificationError( + provenance.reason, + provenance.details[0] + if provenance.details + else "audit-cycle provenance verification failed", + ) + if Path(report.current_plan_ref.locator) != Path(current_plan_path): + raise AuditCycleVerificationError( + AdmissionReason.PLAN_MISMATCH, + "report current-plan locator differs from bound plan path", + ) + plan_bytes = self.verify_artifact_ref(report.current_plan_ref) + try: + plan_text = plan_bytes.decode("utf-8", errors="strict") + except UnicodeDecodeError as exc: + raise AuditCycleVerificationError( + AdmissionReason.PLAN_MISMATCH, "current plan is not UTF-8" + ) from exc + for audited_plan_ref in authority.audited_plan_refs: + self.verify_artifact_ref(audited_plan_ref) + if authority.remediation_ref is None: + raise AuditCycleVerificationError( + AdmissionReason.AUTHORITY_NOT_CURRENT, + "active NO GO authority has no remediation artifact", + ) + self.verify_artifact_ref(authority.remediation_ref) + inventory_bytes = self.verify_artifact_ref(authority.inventory_ref) + inventory_raw = decode_versioned_json_bytes( + inventory_bytes, + expected_version=authority.inventory_ref.schema_version, + require_canonical=True, + ) + if inventory_raw is None: + raise AuditCycleVerificationError( + AdmissionReason.INVENTORY_INVALID, + "inventory is not strict canonical versioned JSON", + ) + try: + requirement_ids_raw = inventory_raw["requirement_ids"] + requirements_raw = inventory_raw["requirements"] + requirement_ids = tuple(requirement_ids_raw) + row_ids = tuple(item["id"] for item in requirements_raw) + except (KeyError, TypeError) as exc: + raise AuditCycleVerificationError( + AdmissionReason.INVENTORY_INVALID, + f"inventory schema is invalid: {exc}", + ) from exc + if requirement_ids != row_ids: + raise AuditCycleVerificationError( + AdmissionReason.INVENTORY_INVALID, + "inventory requirement_ids and requirements order differ", + ) + if any(not isinstance(item, str) or not item for item in requirement_ids): + raise AuditCycleVerificationError( + AdmissionReason.INVENTORY_INVALID, + "inventory requirement IDs must be non-empty strings", + ) + return VerifiedAuditCycle( + authority=authority, + report=report, + inventory_requirement_ids=requirement_ids, + current_plan_text=plan_text, + ) + + def evaluate_paths( + self, + *, + authority_path: str | Path | None, + report_path: str | Path | None, + trusted_head: AuditCycleHead | None, + current_plan_path: str | Path, + expected_generation: str, + expected_plan_set_id: str, + expected_scope_id: str, + expected_part_id: str, + ) -> InventoryAdmissionDecision: + evaluator = InventoryAdmissionEvaluator() + if authority_path is None: + return evaluator.evaluate( + authority=None, + trusted_head=trusted_head, + report=None if report_path is None else self.load_report(report_path), + expected_generation=expected_generation, + expected_plan_set_id=expected_plan_set_id, + expected_scope_id=expected_scope_id, + expected_part_id=expected_part_id, + ) + try: + authority = self.load_authority(authority_path) + if authority.verdict is AuditVerdict.GO: + return evaluator.evaluate( + authority=authority, + trusted_head=trusted_head, + report=None, + expected_generation=expected_generation, + expected_plan_set_id=expected_plan_set_id, + expected_scope_id=expected_scope_id, + expected_part_id=expected_part_id, + ) + if report_path is None or trusted_head is None: + return evaluator.evaluate( + authority=authority, + trusted_head=trusted_head, + report=None, + expected_generation=expected_generation, + expected_plan_set_id=expected_plan_set_id, + expected_scope_id=expected_scope_id, + expected_part_id=expected_part_id, + ) + verified = self._verify_active_tuple( + authority=authority, + report_path=report_path, + trusted_head=trusted_head, + current_plan_path=current_plan_path, + ) + return evaluator.evaluate( + authority=verified.authority, + trusted_head=trusted_head, + report=verified.report, + expected_generation=expected_generation, + expected_plan_set_id=expected_plan_set_id, + expected_scope_id=expected_scope_id, + expected_part_id=expected_part_id, + current_plan_ref=verified.report.current_plan_ref, + inventory_requirement_ids=verified.inventory_requirement_ids, + current_plan_text=verified.current_plan_text, + ) + except AuditCycleVerificationError as exc: + return _reject(exc.reason, str(exc)) diff --git a/src/autoskillit/core/closure_hashing.py b/src/autoskillit/core/closure_hashing.py index 0b70c2a5c..84c7694aa 100644 --- a/src/autoskillit/core/closure_hashing.py +++ b/src/autoskillit/core/closure_hashing.py @@ -15,31 +15,95 @@ __all__ = [ "HASH_RE", + "canonical_json_bytes", + "compute_bytes_hash", "compute_canonical_hash", "compute_file_hash", "compute_request_hash", "compute_row_hash", "compute_report_hash", + "parse_canonical_json_bytes", ] HASH_RE = re.compile(r"^sha256:[0-9a-f]{64}$") -def _canonical_bytes(payload: dict[str, Any]) -> bytes: - return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") +def _reject_noncanonical_value(value: Any) -> None: + if isinstance(value, float): + raise ValueError("canonical JSON does not permit floating-point values") + if isinstance(value, dict): + if any(not isinstance(key, str) for key in value): + raise ValueError("canonical JSON object keys must be strings") + for item in value.values(): + _reject_noncanonical_value(item) + elif isinstance(value, (list, tuple)): + for item in value: + _reject_noncanonical_value(item) + elif value is not None and not isinstance(value, (str, int, bool)): + raise ValueError(f"unsupported canonical JSON value: {type(value).__name__}") + + +def canonical_json_bytes(payload: Any) -> bytes: + """Encode the narrow AutoSkillit canonical JSON profile.""" + _reject_noncanonical_value(payload) + return json.dumps( + payload, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + + +def parse_canonical_json_bytes(data: bytes) -> Any: + """Decode canonical JSON, rejecting duplicates, floats, NaN, and noncanonical bytes.""" + + def _object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ValueError(f"duplicate JSON object key: {key!r}") + result[key] = value + return result + + def _reject_number(value: str) -> Any: + raise ValueError(f"canonical JSON does not permit floating-point value {value!r}") + + try: + text = data.decode("utf-8", errors="strict") + parsed = json.loads( + text, + object_pairs_hook=_object, + parse_float=_reject_number, + parse_constant=_reject_number, + ) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ValueError(f"invalid canonical JSON: {exc}") from exc + _reject_noncanonical_value(parsed) + try: + encoded = canonical_json_bytes(parsed) + except UnicodeEncodeError as exc: + raise ValueError(f"invalid canonical JSON Unicode: {exc}") from exc + if encoded != data: + raise ValueError("JSON bytes are not in canonical compact sorted-key form") + return parsed + + +def compute_bytes_hash(data: bytes) -> str: + """Return an algorithm-qualified digest for an already-bounded byte buffer.""" + return f"sha256:{hashlib.sha256(data).hexdigest()}" def compute_canonical_hash( payload: dict[str, Any], *, domain: str = "autoskillit-closure-v1" ) -> str: domain_prefix = domain.encode("utf-8") + b"\n" - digest = hashlib.sha256(domain_prefix + _canonical_bytes(payload)).hexdigest() + digest = hashlib.sha256(domain_prefix + canonical_json_bytes(payload)).hexdigest() return f"sha256:{digest}" def compute_file_hash(path: str | Path) -> str: - digest = hashlib.sha256(Path(path).read_bytes()).hexdigest() - return f"sha256:{digest}" + return compute_bytes_hash(Path(path).read_bytes()) def compute_request_hash( diff --git a/src/autoskillit/core/closure_verifier.py b/src/autoskillit/core/closure_verifier.py index af62131e9..e7f21d925 100644 --- a/src/autoskillit/core/closure_verifier.py +++ b/src/autoskillit/core/closure_verifier.py @@ -16,8 +16,8 @@ compute_request_hash, compute_row_hash, ) -from .io import read_versioned_json -from .path_containment import ContainmentError, resolve_contained_path +from .io import decode_versioned_json_bytes +from .path_containment import ContainmentError, read_stable_contained_bytes from .types._type_closure_report import ClosureReport __all__ = ["VerificationResult", "verify_closure_report"] @@ -44,7 +44,7 @@ def verify_closure_report( errors: list[str] = [] try: - resolved_report_path = resolve_contained_path(report_path, output_root) + _resolved_report_path, report_bytes = read_stable_contained_bytes(report_path, output_root) except ContainmentError as exc: errors.append(f"report containment failed: {exc}") return VerificationResult( @@ -56,7 +56,7 @@ def verify_closure_report( success=False, verdict=None, errors=tuple(errors), report_path=str(report_path) ) - raw = read_versioned_json(resolved_report_path, expected_version=1) + raw = decode_versioned_json_bytes(report_bytes, expected_version=1) if raw is None: errors.append("report unreadable or schema_version mismatch") return VerificationResult( diff --git a/src/autoskillit/core/io.py b/src/autoskillit/core/io.py index 0d73539b3..a2e77cdfb 100644 --- a/src/autoskillit/core/io.py +++ b/src/autoskillit/core/io.py @@ -46,6 +46,7 @@ "load_yaml", "mapping_entry_byte_ranges_from_yaml", "dump_yaml_str", + "decode_versioned_json_bytes", "read_versioned_json", "resolve_skill_temp_dir", "resolve_temp_dir", @@ -53,6 +54,7 @@ "spill_output", "temp_dir_display_str", "write_versioned_json", + "write_canonical_versioned_json", ] @@ -287,6 +289,41 @@ def write_versioned_json(path: Path, payload: dict[str, Any], schema_version: in atomic_write(path, _fast_dumps(enriched, indent=True)) +def write_canonical_versioned_json( + path: Path, payload: dict[str, Any], schema_version: int +) -> None: + """Atomically write versioned canonical JSON for hash-bound artifacts.""" + from .closure_hashing import canonical_json_bytes + + if not isinstance(payload, dict): + raise TypeError("write_canonical_versioned_json requires a dict payload") + enriched = {**payload, "schema_version": schema_version} + atomic_write(path, canonical_json_bytes(enriched).decode("utf-8")) + + +def decode_versioned_json_bytes( + data: bytes, + expected_version: int, + *, + require_canonical: bool = False, +) -> dict[str, Any] | None: + """Decode one caller-owned byte buffer and enforce its schema version.""" + import json as _json + + try: + if require_canonical: + from .closure_hashing import parse_canonical_json_bytes + + raw = parse_canonical_json_bytes(data) + else: + raw = _json.loads(data.decode("utf-8", errors="strict")) + except (UnicodeDecodeError, _json.JSONDecodeError, ValueError): + return None + if not isinstance(raw, dict) or raw.get("schema_version") != expected_version: + return None + return raw + + def read_versioned_json( path: Path, expected_version: int, diff --git a/src/autoskillit/core/path_containment.py b/src/autoskillit/core/path_containment.py index 01d5009dc..ebcd32dde 100644 --- a/src/autoskillit/core/path_containment.py +++ b/src/autoskillit/core/path_containment.py @@ -13,7 +13,12 @@ import stat from pathlib import Path -__all__ = ["ContainmentError", "resolve_contained_path", "check_metadata_stable"] +__all__ = [ + "ContainmentError", + "check_metadata_stable", + "read_stable_contained_bytes", + "resolve_contained_path", +] class ContainmentError(Exception): @@ -37,6 +42,8 @@ def resolve_contained_path( if not resolved.is_relative_to(allowed_root_resolved): raise ContainmentError("Path escapes allowed root") st = resolved.stat() + if not stat.S_ISREG(st.st_mode): + raise ContainmentError("Regular file required") if st.st_nlink > 1: raise ContainmentError("Hardlink not allowed") if st.st_size > max_size_bytes: @@ -53,3 +60,37 @@ def check_metadata_stable(path: Path, pre_stat: os.stat_result, post_stat: os.st raise ContainmentError(f"File {path} modified between reads (TOCTOU)") if pre_stat.st_ino != post_stat.st_ino: raise ContainmentError(f"File {path} modified between reads (TOCTOU)") + if pre_stat.st_dev != post_stat.st_dev: + raise ContainmentError(f"File {path} modified between reads (TOCTOU)") + if pre_stat.st_mode != post_stat.st_mode: + raise ContainmentError(f"File {path} modified between reads (TOCTOU)") + if pre_stat.st_nlink != post_stat.st_nlink: + raise ContainmentError(f"File {path} modified between reads (TOCTOU)") + + +def read_stable_contained_bytes( + path: str | Path, + allowed_root: str | Path, + *, + max_size_bytes: int = 50_000_000, +) -> tuple[Path, bytes]: + """Read one bounded buffer while detecting containment and metadata drift.""" + resolved = resolve_contained_path(path, allowed_root, max_size_bytes=max_size_bytes) + pre_stat = resolved.stat() + flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) + fd = os.open(resolved, flags) + try: + opened_stat = os.fstat(fd) + check_metadata_stable(resolved, pre_stat, opened_stat) + with os.fdopen(fd, "rb", closefd=False) as handle: + data = handle.read(max_size_bytes + 1) + post_fd_stat = os.fstat(fd) + finally: + os.close(fd) + if len(data) > max_size_bytes: + raise ContainmentError("File too large") + if len(data) != pre_stat.st_size: + raise ContainmentError(f"File {resolved} modified between reads (TOCTOU)") + check_metadata_stable(resolved, pre_stat, post_fd_stat) + check_metadata_stable(resolved, pre_stat, resolved.stat()) + return resolved, data diff --git a/src/autoskillit/core/types/AGENTS.md b/src/autoskillit/core/types/AGENTS.md index 1d4b7fc80..377ba8f0d 100644 --- a/src/autoskillit/core/types/AGENTS.md +++ b/src/autoskillit/core/types/AGENTS.md @@ -21,6 +21,7 @@ Type re-export hub and all typed building blocks for the autoskillit package (IL | `_type_results_execution.py` | Execution-scoped result dataclasses: `SessionTelemetry`, `RecipeIdentity`, `CIRunScope` | | `_type_results.py` | Core result dataclasses: `SkillResult`, `ProviderOutcome`, `LoadResult`, `FailureRecord`, `WriteBehaviorSpec`, `ClosureAuthoritySpec`, `closure_authority_spec_from_args` | | `_type_closure_report.py` | Closure report dataclasses: `ClosureRow`, `ClosureReport`, `CLOSURE_REPORT_SCHEMA_VERSION` | +| `_type_audit_cycle.py` | Frozen audit-cycle authority, artifact reference, disposition, head, and admission decision models | | `_type_protocols_logging.py` | Protocols: `AuditLog`, `TokenLog`, `TimingLog`, `McpResponseLog`, `GitHubApiLog`, `SupportsDebug`, `SupportsLogger` | | `_type_protocols_execution.py` | Protocols: `TestRunner`, `HeadlessExecutor`, `OutputPatternResolver`, `WriteExpectedResolver` | | `_type_protocols_github.py` | Protocols: `GitHubFetcher`, `CIWatcher`, `MergeQueueWatcher` | diff --git a/src/autoskillit/core/types/__init__.py b/src/autoskillit/core/types/__init__.py index 61a051ca7..0e6fc09ea 100644 --- a/src/autoskillit/core/types/__init__.py +++ b/src/autoskillit/core/types/__init__.py @@ -6,6 +6,8 @@ from __future__ import annotations +from ._type_audit_cycle import * # noqa: F401, F403 +from ._type_audit_cycle import __all__ as _audit_cycle_all from ._type_backend import * # noqa: F401, F403 from ._type_backend import __all__ as _backend_all from ._type_capture import * # noqa: F401, F403 @@ -80,7 +82,8 @@ from ._type_tradition_manifest import __all__ as _tradition_manifest_all __all__ = ( - _backend_all + _audit_cycle_all + + _backend_all + _capture_all + _checkpoint_all + _closure_report_all diff --git a/src/autoskillit/core/types/_type_audit_cycle.py b/src/autoskillit/core/types/_type_audit_cycle.py new file mode 100644 index 000000000..c8e42ad72 --- /dev/null +++ b/src/autoskillit/core/types/_type_audit_cycle.py @@ -0,0 +1,701 @@ +"""Immutable audit-cycle authority and inventory-admission value objects.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from enum import StrEnum +from pathlib import Path +from typing import Any, Self + +from ..closure_hashing import HASH_RE, canonical_json_bytes, compute_canonical_hash + +__all__ = [ + "AUDIT_CYCLE_SCHEMA_VERSION", + "AdmissionReason", + "AdmissionStatus", + "ArtifactRef", + "AuditAssessment", + "AuditAssessmentRow", + "AuditCycleAuthority", + "AuditCycleHead", + "AuditVerdict", + "InventoryAdmissionDecision", + "PlanDispositionReport", + "PlanDispositionRow", + "compute_findings_digest", +] + +AUDIT_CYCLE_SCHEMA_VERSION = 1 + +_ASSESSMENT_ROW_DOMAIN = "autoskillit:audit-cycle:assessment-row:v1:sha256" +_AUTHORITY_DOMAIN = "autoskillit:audit-cycle:authority:v1:sha256" +_DISPOSITION_ROW_DOMAIN = "autoskillit:audit-cycle:disposition-row:v1:sha256" +_FINDINGS_DOMAIN = "autoskillit:audit-cycle:findings:v1:sha256" +_REPORT_DOMAIN = "autoskillit:audit-cycle:plan-disposition:v1:sha256" +_SATISFIED_RE = re.compile(r"^satisfied-by-round-([1-9][0-9]*)$") + + +class AuditVerdict(StrEnum): + GO = "GO" + NO_GO = "NO GO" + + +class AuditAssessment(StrEnum): + COVERED = "COVERED" + MISSING = "MISSING" + ODD = "ODD" + CONFLICT = "CONFLICT" + NAMED_DEVIATION = "NAMED_DEVIATION" + + @property + def blocking(self) -> bool: + return self in {AuditAssessment.MISSING, AuditAssessment.CONFLICT} + + +class AdmissionStatus(StrEnum): + OMIT = "OMIT" + PASS = "PASS" + REJECT = "REJECT" + + +class AdmissionReason(StrEnum): + NO_AUTHORITY = "no_authority" + TRUSTED_GO = "trusted_go" + TRUSTED_GO_SUCCESSOR = "trusted_go_successor" + ADMITTED = "admitted" + REPORT_WITHOUT_AUTHORITY = "report_without_authority" + HEAD_MISSING = "head_missing" + AUTHORITY_NOT_CURRENT = "authority_not_current" + GENERATION_MISMATCH = "generation_mismatch" + CYCLE_MISMATCH = "cycle_mismatch" + PLAN_SET_MISMATCH = "plan_set_mismatch" + SCOPE_MISMATCH = "scope_mismatch" + PART_MISMATCH = "part_mismatch" + ROUND_MISMATCH = "round_mismatch" + PARENT_MISMATCH = "parent_mismatch" + AUTHORITY_WITHOUT_REPORT = "authority_without_report" + INVENTORY_MISMATCH = "inventory_mismatch" + FINDINGS_MISMATCH = "findings_mismatch" + PLAN_MISMATCH = "plan_mismatch" + INVENTORY_INVALID = "inventory_invalid" + REQUIREMENTS_MAP_INVALID = "requirements_map_invalid" + REQUIREMENT_ORDER_MISMATCH = "requirement_order_mismatch" + DISPOSITION_MISMATCH = "disposition_mismatch" + SATISFIED_ROUND_MISMATCH = "satisfied_round_mismatch" + UNMAPPED_REQUIREMENT = "unmapped_requirement" + IMPLEMENTATION_STEP_MISSING = "implementation_step_missing" + INTERNAL_ERROR = "internal_error" + + +def _require_nonempty(name: str, value: str) -> None: + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{name} must be a non-empty string") + + +def _require_digest(name: str, value: str) -> None: + if not isinstance(value, str) or HASH_RE.fullmatch(value) is None: + raise ValueError(f"{name} must be an algorithm-qualified sha256 digest") + + +def _require_positive_int(name: str, value: int, *, allow_zero: bool = False) -> None: + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError(f"{name} must be an integer") + minimum = 0 if allow_zero else 1 + if value < minimum: + raise ValueError(f"{name} must be >= {minimum}") + + +@dataclass(frozen=True, slots=True) +class ArtifactRef: + """Hash identity plus an independently enforceable absolute locator policy.""" + + locator: str = field(compare=False) + media_type: str = field(compare=False) + schema_version: int = field(compare=False) + byte_size: int = field(compare=False) + content_digest: str + + def __post_init__(self) -> None: + _require_nonempty("ArtifactRef.locator", self.locator) + locator = Path(self.locator) + if not locator.is_absolute() or ".." in locator.parts: + raise ValueError("ArtifactRef.locator must be an absolute non-traversing path") + _require_nonempty("ArtifactRef.media_type", self.media_type) + _require_positive_int("ArtifactRef.schema_version", self.schema_version) + _require_positive_int("ArtifactRef.byte_size", self.byte_size, allow_zero=True) + _require_digest("ArtifactRef.content_digest", self.content_digest) + + @property + def canonical_bytes(self) -> bytes: + return canonical_json_bytes(self.to_dict()) + + @property + def metadata_digest(self) -> str: + return compute_canonical_hash( + self.to_dict(), + domain=f"autoskillit:audit-cycle:artifact-ref:v{self.schema_version}:sha256", + ) + + def to_dict(self) -> dict[str, Any]: + return { + "byte_size": self.byte_size, + "content_digest": self.content_digest, + "locator": self.locator, + "media_type": self.media_type, + "schema_version": self.schema_version, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> Self: + try: + return cls( + locator=data["locator"], + media_type=data["media_type"], + schema_version=data["schema_version"], + byte_size=data["byte_size"], + content_digest=data["content_digest"], + ) + except (KeyError, TypeError, ValueError) as exc: + raise ValueError(f"invalid ArtifactRef: {exc}") from exc + + +@dataclass(frozen=True, slots=True) +class AuditAssessmentRow: + requirement_id: str + requirement_text: str + assessment: AuditAssessment + evidence_summary: str + row_digest: str + + def __post_init__(self) -> None: + _require_nonempty("AuditAssessmentRow.requirement_id", self.requirement_id) + _require_nonempty("AuditAssessmentRow.requirement_text", self.requirement_text) + if not isinstance(self.assessment, AuditAssessment): + raise ValueError("AuditAssessmentRow.assessment must be an AuditAssessment") + _require_nonempty("AuditAssessmentRow.evidence_summary", self.evidence_summary) + _require_digest("AuditAssessmentRow.row_digest", self.row_digest) + if self.row_digest != self.compute_digest(): + raise ValueError("AuditAssessmentRow.row_digest does not match row content") + + @classmethod + def create( + cls, + *, + requirement_id: str, + requirement_text: str, + assessment: AuditAssessment, + evidence_summary: str, + ) -> Self: + payload = { + "assessment": assessment.value, + "evidence_summary": evidence_summary, + "requirement_id": requirement_id, + "requirement_text": requirement_text, + } + return cls( + requirement_id=requirement_id, + requirement_text=requirement_text, + assessment=assessment, + evidence_summary=evidence_summary, + row_digest=compute_canonical_hash(payload, domain=_ASSESSMENT_ROW_DOMAIN), + ) + + def compute_digest(self) -> str: + return compute_canonical_hash( + self.to_dict(include_digest=False), domain=_ASSESSMENT_ROW_DOMAIN + ) + + def to_dict(self, *, include_digest: bool = True) -> dict[str, Any]: + payload: dict[str, Any] = { + "assessment": self.assessment.value, + "evidence_summary": self.evidence_summary, + "requirement_id": self.requirement_id, + "requirement_text": self.requirement_text, + } + if include_digest: + payload["row_digest"] = self.row_digest + return payload + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> Self: + try: + return cls( + requirement_id=data["requirement_id"], + requirement_text=data["requirement_text"], + assessment=AuditAssessment(data["assessment"]), + evidence_summary=data["evidence_summary"], + row_digest=data["row_digest"], + ) + except (KeyError, TypeError, ValueError) as exc: + raise ValueError(f"invalid AuditAssessmentRow: {exc}") from exc + + +def compute_findings_digest(rows: tuple[AuditAssessmentRow, ...]) -> str: + return compute_canonical_hash( + {"blocking_row_digests": [row.row_digest for row in rows if row.assessment.blocking]}, + domain=_FINDINGS_DOMAIN, + ) + + +@dataclass(frozen=True, slots=True) +class AuditCycleAuthority: + schema_version: int + execution_generation: str + cycle_id: str + plan_set_id: str + scope_id: str + part_id: str + audit_round: int + parent_authority_digest: str | None + audited_plan_refs: tuple[ArtifactRef, ...] + inventory_ref: ArtifactRef + findings_digest: str + assessments: tuple[AuditAssessmentRow, ...] + verdict: AuditVerdict + remediation_ref: ArtifactRef | None + generated_at: str + authority_digest: str + + def __post_init__(self) -> None: + if ( + isinstance(self.schema_version, bool) + or not isinstance(self.schema_version, int) + or self.schema_version != AUDIT_CYCLE_SCHEMA_VERSION + ): + raise ValueError( + f"AuditCycleAuthority.schema_version must be {AUDIT_CYCLE_SCHEMA_VERSION}" + ) + for name in ("execution_generation", "cycle_id", "plan_set_id", "scope_id", "part_id"): + _require_nonempty(f"AuditCycleAuthority.{name}", getattr(self, name)) + _require_positive_int("AuditCycleAuthority.audit_round", self.audit_round) + if self.parent_authority_digest is not None: + _require_digest( + "AuditCycleAuthority.parent_authority_digest", + self.parent_authority_digest, + ) + if not self.audited_plan_refs: + raise ValueError("AuditCycleAuthority.audited_plan_refs must be non-empty") + if len({ref.content_digest for ref in self.audited_plan_refs}) != len( + self.audited_plan_refs + ): + raise ValueError("AuditCycleAuthority.audited_plan_refs contain duplicate content") + ids = tuple(row.requirement_id for row in self.assessments) + if len(set(ids)) != len(ids): + raise ValueError("AuditCycleAuthority.assessments contain duplicate requirement IDs") + _require_digest("AuditCycleAuthority.findings_digest", self.findings_digest) + if self.findings_digest != compute_findings_digest(self.assessments): + raise ValueError("AuditCycleAuthority.findings_digest does not match assessments") + if not isinstance(self.verdict, AuditVerdict): + raise ValueError("AuditCycleAuthority.verdict must be an AuditVerdict") + if self.verdict is AuditVerdict.NO_GO and self.remediation_ref is None: + raise ValueError("NO GO authority requires remediation_ref") + if self.verdict is AuditVerdict.GO and self.remediation_ref is not None: + raise ValueError("GO authority cannot contain remediation_ref") + if self.verdict is AuditVerdict.GO and any( + row.assessment.blocking for row in self.assessments + ): + raise ValueError("GO authority cannot contain blocking assessments") + _require_nonempty("AuditCycleAuthority.generated_at", self.generated_at) + _require_digest("AuditCycleAuthority.authority_digest", self.authority_digest) + if self.authority_digest != self.compute_digest(): + raise ValueError("AuditCycleAuthority.authority_digest does not match event content") + + @classmethod + def create( + cls, + *, + execution_generation: str, + cycle_id: str, + plan_set_id: str, + scope_id: str, + part_id: str, + audit_round: int, + parent_authority_digest: str | None, + audited_plan_refs: tuple[ArtifactRef, ...], + inventory_ref: ArtifactRef, + assessments: tuple[AuditAssessmentRow, ...], + verdict: AuditVerdict, + remediation_ref: ArtifactRef | None, + generated_at: str, + ) -> Self: + values: dict[str, Any] = { + "assessments": [row.to_dict() for row in assessments], + "audit_round": audit_round, + "audited_plan_refs": [ref.to_dict() for ref in audited_plan_refs], + "cycle_id": cycle_id, + "execution_generation": execution_generation, + "findings_digest": compute_findings_digest(assessments), + "generated_at": generated_at, + "inventory_ref": inventory_ref.to_dict(), + "parent_authority_digest": parent_authority_digest, + "part_id": part_id, + "plan_set_id": plan_set_id, + "remediation_ref": remediation_ref.to_dict() if remediation_ref else None, + "schema_version": AUDIT_CYCLE_SCHEMA_VERSION, + "scope_id": scope_id, + "verdict": verdict.value, + } + digest = compute_canonical_hash(values, domain=_AUTHORITY_DOMAIN) + return cls( + schema_version=AUDIT_CYCLE_SCHEMA_VERSION, + execution_generation=execution_generation, + cycle_id=cycle_id, + plan_set_id=plan_set_id, + scope_id=scope_id, + part_id=part_id, + audit_round=audit_round, + parent_authority_digest=parent_authority_digest, + audited_plan_refs=audited_plan_refs, + inventory_ref=inventory_ref, + findings_digest=values["findings_digest"], + assessments=assessments, + verdict=verdict, + remediation_ref=remediation_ref, + generated_at=generated_at, + authority_digest=digest, + ) + + @property + def canonical_bytes(self) -> bytes: + return canonical_json_bytes(self.to_dict()) + + def compute_digest(self) -> str: + return compute_canonical_hash(self.to_dict(include_digest=False), domain=_AUTHORITY_DOMAIN) + + def to_dict(self, *, include_digest: bool = True) -> dict[str, Any]: + payload: dict[str, Any] = { + "assessments": [row.to_dict() for row in self.assessments], + "audit_round": self.audit_round, + "audited_plan_refs": [ref.to_dict() for ref in self.audited_plan_refs], + "cycle_id": self.cycle_id, + "execution_generation": self.execution_generation, + "findings_digest": self.findings_digest, + "generated_at": self.generated_at, + "inventory_ref": self.inventory_ref.to_dict(), + "parent_authority_digest": self.parent_authority_digest, + "part_id": self.part_id, + "plan_set_id": self.plan_set_id, + "remediation_ref": ( + self.remediation_ref.to_dict() if self.remediation_ref is not None else None + ), + "schema_version": self.schema_version, + "scope_id": self.scope_id, + "verdict": self.verdict.value, + } + if include_digest: + payload["authority_digest"] = self.authority_digest + return payload + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> Self: + try: + remediation_raw = data["remediation_ref"] + return cls( + schema_version=data["schema_version"], + execution_generation=data["execution_generation"], + cycle_id=data["cycle_id"], + plan_set_id=data["plan_set_id"], + scope_id=data["scope_id"], + part_id=data["part_id"], + audit_round=data["audit_round"], + parent_authority_digest=data["parent_authority_digest"], + audited_plan_refs=tuple( + ArtifactRef.from_dict(item) for item in data["audited_plan_refs"] + ), + inventory_ref=ArtifactRef.from_dict(data["inventory_ref"]), + findings_digest=data["findings_digest"], + assessments=tuple( + AuditAssessmentRow.from_dict(item) for item in data["assessments"] + ), + verdict=AuditVerdict(data["verdict"]), + remediation_ref=( + ArtifactRef.from_dict(remediation_raw) if remediation_raw is not None else None + ), + generated_at=data["generated_at"], + authority_digest=data["authority_digest"], + ) + except (KeyError, TypeError, ValueError) as exc: + raise ValueError(f"invalid AuditCycleAuthority: {exc}") from exc + + +@dataclass(frozen=True, slots=True) +class AuditCycleHead: + execution_generation: str + cycle_id: str + plan_set_id: str + scope_id: str + part_id: str + current_authority_digest: str + audit_round: int + verdict: AuditVerdict + authorized_successor_part_id: str | None = None + + def __post_init__(self) -> None: + for name in ("execution_generation", "cycle_id", "plan_set_id", "scope_id", "part_id"): + _require_nonempty(f"AuditCycleHead.{name}", getattr(self, name)) + _require_digest("AuditCycleHead.current_authority_digest", self.current_authority_digest) + _require_positive_int("AuditCycleHead.audit_round", self.audit_round) + if not isinstance(self.verdict, AuditVerdict): + raise ValueError("AuditCycleHead.verdict must be an AuditVerdict") + if self.authorized_successor_part_id is not None: + _require_nonempty( + "AuditCycleHead.authorized_successor_part_id", + self.authorized_successor_part_id, + ) + if self.verdict is not AuditVerdict.GO: + raise ValueError("only a GO head may authorize a successor part") + + +@dataclass(frozen=True, slots=True) +class PlanDispositionRow: + requirement_id: str + disposition: str + implementation_step: str | None + row_digest: str + + def __post_init__(self) -> None: + _require_nonempty("PlanDispositionRow.requirement_id", self.requirement_id) + if self.disposition == "carried@step": + if self.implementation_step is None: + raise ValueError("carried@step requires implementation_step") + _require_nonempty("PlanDispositionRow.implementation_step", self.implementation_step) + elif _SATISFIED_RE.fullmatch(self.disposition): + if self.implementation_step is not None: + raise ValueError("satisfied-by-round-N cannot name implementation_step") + else: + raise ValueError("disposition must be carried@step or satisfied-by-round-N") + _require_digest("PlanDispositionRow.row_digest", self.row_digest) + if self.row_digest != self.compute_digest(): + raise ValueError("PlanDispositionRow.row_digest does not match row content") + + @property + def satisfied_round(self) -> int | None: + matched = _SATISFIED_RE.fullmatch(self.disposition) + return int(matched.group(1)) if matched is not None else None + + @classmethod + def create( + cls, + *, + requirement_id: str, + disposition: str, + implementation_step: str | None = None, + ) -> Self: + payload = { + "disposition": disposition, + "implementation_step": implementation_step, + "requirement_id": requirement_id, + } + return cls( + requirement_id=requirement_id, + disposition=disposition, + implementation_step=implementation_step, + row_digest=compute_canonical_hash(payload, domain=_DISPOSITION_ROW_DOMAIN), + ) + + def compute_digest(self) -> str: + return compute_canonical_hash( + self.to_dict(include_digest=False), domain=_DISPOSITION_ROW_DOMAIN + ) + + def to_dict(self, *, include_digest: bool = True) -> dict[str, Any]: + payload = { + "disposition": self.disposition, + "implementation_step": self.implementation_step, + "requirement_id": self.requirement_id, + } + if include_digest: + payload["row_digest"] = self.row_digest + return payload + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> Self: + try: + return cls( + requirement_id=data["requirement_id"], + disposition=data["disposition"], + implementation_step=data["implementation_step"], + row_digest=data["row_digest"], + ) + except (KeyError, TypeError, ValueError) as exc: + raise ValueError(f"invalid PlanDispositionRow: {exc}") from exc + + +@dataclass(frozen=True, slots=True) +class PlanDispositionReport: + schema_version: int + execution_generation: str + cycle_id: str + plan_set_id: str + scope_id: str + part_id: str + audit_round: int + parent_authority_digest: str + inventory_digest: str + findings_digest: str + current_plan_ref: ArtifactRef + dispositions: tuple[PlanDispositionRow, ...] + generated_at: str + report_digest: str + + def __post_init__(self) -> None: + if ( + isinstance(self.schema_version, bool) + or not isinstance(self.schema_version, int) + or self.schema_version != AUDIT_CYCLE_SCHEMA_VERSION + ): + raise ValueError( + f"PlanDispositionReport.schema_version must be {AUDIT_CYCLE_SCHEMA_VERSION}" + ) + for name in ("execution_generation", "cycle_id", "plan_set_id", "scope_id", "part_id"): + _require_nonempty(f"PlanDispositionReport.{name}", getattr(self, name)) + _require_positive_int("PlanDispositionReport.audit_round", self.audit_round) + for name in ("parent_authority_digest", "inventory_digest", "findings_digest"): + _require_digest(f"PlanDispositionReport.{name}", getattr(self, name)) + ids = tuple(row.requirement_id for row in self.dispositions) + if len(set(ids)) != len(ids): + raise ValueError("PlanDispositionReport.dispositions contain duplicate IDs") + _require_nonempty("PlanDispositionReport.generated_at", self.generated_at) + _require_digest("PlanDispositionReport.report_digest", self.report_digest) + if self.report_digest != self.compute_digest(): + raise ValueError("PlanDispositionReport.report_digest does not match report content") + + @classmethod + def create( + cls, + *, + execution_generation: str, + cycle_id: str, + plan_set_id: str, + scope_id: str, + part_id: str, + audit_round: int, + parent_authority_digest: str, + inventory_digest: str, + findings_digest: str, + current_plan_ref: ArtifactRef, + dispositions: tuple[PlanDispositionRow, ...], + generated_at: str, + ) -> Self: + values: dict[str, Any] = { + "audit_round": audit_round, + "current_plan_ref": current_plan_ref.to_dict(), + "cycle_id": cycle_id, + "dispositions": [row.to_dict() for row in dispositions], + "execution_generation": execution_generation, + "findings_digest": findings_digest, + "generated_at": generated_at, + "inventory_digest": inventory_digest, + "parent_authority_digest": parent_authority_digest, + "part_id": part_id, + "plan_set_id": plan_set_id, + "schema_version": AUDIT_CYCLE_SCHEMA_VERSION, + "scope_id": scope_id, + } + digest = compute_canonical_hash(values, domain=_REPORT_DOMAIN) + return cls( + schema_version=AUDIT_CYCLE_SCHEMA_VERSION, + execution_generation=execution_generation, + cycle_id=cycle_id, + plan_set_id=plan_set_id, + scope_id=scope_id, + part_id=part_id, + audit_round=audit_round, + parent_authority_digest=parent_authority_digest, + inventory_digest=inventory_digest, + findings_digest=findings_digest, + current_plan_ref=current_plan_ref, + dispositions=dispositions, + generated_at=generated_at, + report_digest=digest, + ) + + @property + def canonical_bytes(self) -> bytes: + return canonical_json_bytes(self.to_dict()) + + def compute_digest(self) -> str: + return compute_canonical_hash(self.to_dict(include_digest=False), domain=_REPORT_DOMAIN) + + def to_dict(self, *, include_digest: bool = True) -> dict[str, Any]: + payload: dict[str, Any] = { + "audit_round": self.audit_round, + "current_plan_ref": self.current_plan_ref.to_dict(), + "cycle_id": self.cycle_id, + "dispositions": [row.to_dict() for row in self.dispositions], + "execution_generation": self.execution_generation, + "findings_digest": self.findings_digest, + "generated_at": self.generated_at, + "inventory_digest": self.inventory_digest, + "parent_authority_digest": self.parent_authority_digest, + "part_id": self.part_id, + "plan_set_id": self.plan_set_id, + "schema_version": self.schema_version, + "scope_id": self.scope_id, + } + if include_digest: + payload["report_digest"] = self.report_digest + return payload + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> Self: + try: + return cls( + schema_version=data["schema_version"], + execution_generation=data["execution_generation"], + cycle_id=data["cycle_id"], + plan_set_id=data["plan_set_id"], + scope_id=data["scope_id"], + part_id=data["part_id"], + audit_round=data["audit_round"], + parent_authority_digest=data["parent_authority_digest"], + inventory_digest=data["inventory_digest"], + findings_digest=data["findings_digest"], + current_plan_ref=ArtifactRef.from_dict(data["current_plan_ref"]), + dispositions=tuple( + PlanDispositionRow.from_dict(item) for item in data["dispositions"] + ), + generated_at=data["generated_at"], + report_digest=data["report_digest"], + ) + except (KeyError, TypeError, ValueError) as exc: + raise ValueError(f"invalid PlanDispositionReport: {exc}") from exc + + +@dataclass(frozen=True, slots=True) +class InventoryAdmissionDecision: + status: AdmissionStatus + reason: AdmissionReason + dispositions: tuple[PlanDispositionRow, ...] = () + details: tuple[str, ...] = () + + def __post_init__(self) -> None: + if not isinstance(self.status, AdmissionStatus): + raise ValueError("InventoryAdmissionDecision.status must be an AdmissionStatus") + if not isinstance(self.reason, AdmissionReason): + raise ValueError("InventoryAdmissionDecision.reason must be an AdmissionReason") + if self.status is AdmissionStatus.PASS and self.reason is not AdmissionReason.ADMITTED: + raise ValueError("PASS admission requires the admitted reason") + if self.status is AdmissionStatus.OMIT and self.dispositions: + raise ValueError("OMIT admission cannot carry disposition rows") + + @classmethod + def omit(cls, reason: AdmissionReason) -> Self: + return cls(status=AdmissionStatus.OMIT, reason=reason) + + @classmethod + def reject(cls, reason: AdmissionReason, detail: str) -> Self: + return cls( + status=AdmissionStatus.REJECT, + reason=reason, + details=(detail,), + ) + + @classmethod + def admitted(cls, dispositions: tuple[PlanDispositionRow, ...]) -> Self: + return cls( + status=AdmissionStatus.PASS, + reason=AdmissionReason.ADMITTED, + dispositions=dispositions, + ) diff --git a/tests/_test_filter.py b/tests/_test_filter.py index faa0b94dc..6b4fd3d2c 100644 --- a/tests/_test_filter.py +++ b/tests/_test_filter.py @@ -279,8 +279,10 @@ class ImportContext(enum.StrEnum): "_execution_marker": frozenset({"core", "execution", "fleet", "server"}), "bash_write_targets": frozenset({"core", "execution", "server"}), "_delivery_bounds": frozenset({"core", "execution", "server"}), + "_type_audit_cycle": frozenset({"core", "recipe", "server"}), "_type_closure_report": frozenset({"core"}), "context_admission": frozenset({"core"}), + "audit_cycle_verifier": frozenset({"core", "recipe", "server"}), "closure_hashing": frozenset({"core"}), "path_containment": frozenset({"core"}), "closure_verifier": frozenset({"core", "execution"}), diff --git a/tests/core/AGENTS.md b/tests/core/AGENTS.md index 0e7c6f33a..e49f40d7d 100644 --- a/tests/core/AGENTS.md +++ b/tests/core/AGENTS.md @@ -71,6 +71,9 @@ Core layer (IL-0) unit tests — paths, IO, types, feature flags. | `test_path_containment.py` | Tests for core.path_containment — symlink/hardlink/traversal guards | | `test_closure_verifier.py` | Tests for core.closure_verifier — independent report verifier | | `test_closure_attacks.py` | Adversarial attack tests for closure-mode verification (forged verdicts, containment escape, ref drift, unauthorized rows, metadata stability) | +| `test_audit_cycle_authority.py` | Canonical serialization, digest coverage, and immutable audit-cycle authority contracts | +| `test_audit_cycle_attacks.py` | Audit-cycle containment, tamper, replay, and lineage attack tests | +| `test_inventory_admission.py` | Pure evaluator truth table and zero-inventory-read GO/OMIT contracts | | `test_closure_authority_spec.py` | Tests for ClosureAuthoritySpec and factory validation | | `test_closure_report.py` | Tests for ClosureRow and ClosureReport schema validation | | `types/test_context_admission_contract.py` | Frozen context-admission enums, records, unions, validation, serialization, and gateway contract | diff --git a/tests/core/test_audit_cycle_attacks.py b/tests/core/test_audit_cycle_attacks.py new file mode 100644 index 000000000..8f815498a --- /dev/null +++ b/tests/core/test_audit_cycle_attacks.py @@ -0,0 +1,205 @@ +"""Adversarial containment, integrity, replay, and lineage tests.""" + +from __future__ import annotations + +import os +from dataclasses import replace +from pathlib import Path + +import pytest + +from autoskillit.core import ( + AdmissionReason, + ArtifactRef, + AuditAssessment, + AuditAssessmentRow, + AuditCycleAuthority, + AuditCycleHead, + AuditCycleVerificationError, + AuditCycleVerifier, + AuditVerdict, +) +from autoskillit.core.closure_hashing import compute_bytes_hash +from autoskillit.core.path_containment import ( + ContainmentError, + read_stable_contained_bytes, + resolve_contained_path, +) + +pytestmark = [pytest.mark.layer("core"), pytest.mark.small] + +_HASH = "sha256:" + "a" * 64 + + +def _ref(path: Path, content: bytes, *, schema_version: int = 1) -> ArtifactRef: + return ArtifactRef( + locator=str(path), + media_type="application/json", + schema_version=schema_version, + byte_size=len(content), + content_digest=compute_bytes_hash(content), + ) + + +def _authority(root: Path, *, parent: str | None = None, round_: int = 1) -> AuditCycleAuthority: + plan = _ref(root / "plan.md", b"plan") + inventory = _ref(root / "inventory.json", b"inventory") + remediation = _ref(root / "remediation.md", b"remediation") + row = AuditAssessmentRow.create( + requirement_id="REQ-001", + requirement_text="requirement", + assessment=AuditAssessment.MISSING, + evidence_summary="missing", + ) + return AuditCycleAuthority.create( + execution_generation="generation-1", + cycle_id="cycle-1", + plan_set_id="plans-1", + scope_id="scope-1", + part_id="part-a", + audit_round=round_, + parent_authority_digest=parent, + audited_plan_refs=(plan,), + inventory_ref=inventory, + assessments=(row,), + verdict=AuditVerdict.NO_GO, + remediation_ref=remediation, + generated_at=f"2026-07-23T00:0{round_}:00Z", + ) + + +@pytest.mark.parametrize("attack", ["relative", "symlink", "hardlink", "world", "directory"]) +def test_containment_rejects_hostile_artifacts(tmp_path: Path, attack: str) -> None: + root = tmp_path / "root" + root.mkdir() + target = root / "target.json" + target.write_bytes(b"{}") + candidate: Path + if attack == "relative": + candidate = Path("target.json") + elif attack == "symlink": + candidate = root / "link.json" + candidate.symlink_to(target) + elif attack == "hardlink": + candidate = root / "hard.json" + os.link(target, candidate) + elif attack == "world": + candidate = target + candidate.chmod(0o666) + else: + candidate = root + with pytest.raises((ContainmentError, ValueError, FileNotFoundError)): + resolve_contained_path(candidate, root) + + +def test_containment_rejects_oversized_buffer(tmp_path: Path) -> None: + artifact = tmp_path / "large.bin" + artifact.write_bytes(b"x" * 5) + with pytest.raises(ContainmentError, match="large"): + read_stable_contained_bytes(artifact, tmp_path, max_size_bytes=4) + + +def test_artifact_reference_rejects_size_digest_and_post_reference_mutation( + tmp_path: Path, +) -> None: + artifact = tmp_path / "artifact.json" + artifact.write_bytes(b'{"schema_version":1}') + ref = _ref(artifact, artifact.read_bytes()) + verifier = AuditCycleVerifier(tmp_path) + assert verifier.verify_artifact_ref(ref) == b'{"schema_version":1}' + with pytest.raises(AuditCycleVerificationError, match="size"): + verifier.verify_artifact_ref(replace(ref, byte_size=ref.byte_size + 1)) + artifact.write_bytes(b'{"schema_version":2}') + with pytest.raises(AuditCycleVerificationError, match="digest"): + verifier.verify_artifact_ref(ref) + + +def test_authority_rejects_forged_digest_and_noncanonical_bytes(tmp_path: Path) -> None: + authority = _authority(tmp_path) + path = tmp_path / "authority.json" + path.write_bytes(authority.canonical_bytes) + verifier = AuditCycleVerifier(tmp_path) + assert verifier.load_authority(path) == authority + forged = authority.to_dict() + forged["cycle_id"] = "forged" + import json + + path.write_text(json.dumps(forged, indent=2), encoding="utf-8") + with pytest.raises(AuditCycleVerificationError, match="canonical"): + verifier.load_authority(path) + + +def test_duplicate_keys_float_and_nan_are_rejected_before_authority_parsing( + tmp_path: Path, +) -> None: + verifier = AuditCycleVerifier(tmp_path) + path = tmp_path / "authority.json" + for payload in ( + b'{"schema_version":1,"schema_version":1}', + b'{"schema_version":1,"value":1.5}', + b'{"schema_version":1,"value":NaN}', + ): + path.write_bytes(payload) + with pytest.raises(AuditCycleVerificationError, match="canonical"): + verifier.load_authority(path) + + +def test_successor_must_descend_from_trusted_current_head(tmp_path: Path) -> None: + first = _authority(tmp_path) + head = AuditCycleHead( + execution_generation=first.execution_generation, + cycle_id=first.cycle_id, + plan_set_id=first.plan_set_id, + scope_id=first.scope_id, + part_id=first.part_id, + current_authority_digest=first.authority_digest, + audit_round=first.audit_round, + verdict=first.verdict, + ) + successor = _authority(tmp_path, parent=first.authority_digest, round_=2) + AuditCycleVerifier.verify_successor(successor, head) + forged_parent = _authority(tmp_path, parent=_HASH, round_=2) + with pytest.raises(AuditCycleVerificationError) as caught: + AuditCycleVerifier.verify_successor(forged_parent, head) + assert caught.value.reason is AdmissionReason.PARENT_MISMATCH + + +def test_stale_authority_replay_is_rejected_before_inventory_read(tmp_path: Path) -> None: + stale = _authority(tmp_path) + current = _authority(tmp_path, parent=stale.authority_digest, round_=2) + stale_path = tmp_path / "stale.json" + stale_path.write_bytes(stale.canonical_bytes) + reads: list[Path] = [] + + def recording_reader( + path: str | Path, + root: str | Path, + *, + max_size_bytes: int, + ) -> tuple[Path, bytes]: + reads.append(Path(path)) + return read_stable_contained_bytes(path, root, max_size_bytes=max_size_bytes) + + verifier = AuditCycleVerifier(tmp_path, reader=recording_reader) + head = AuditCycleHead( + execution_generation=current.execution_generation, + cycle_id=current.cycle_id, + plan_set_id=current.plan_set_id, + scope_id=current.scope_id, + part_id=current.part_id, + current_authority_digest=current.authority_digest, + audit_round=current.audit_round, + verdict=current.verdict, + ) + decision = verifier.evaluate_paths( + authority_path=stale_path, + report_path=None, + trusted_head=head, + current_plan_path=tmp_path / "plan.md", + expected_generation=current.execution_generation, + expected_plan_set_id=current.plan_set_id, + expected_scope_id=current.scope_id, + expected_part_id=current.part_id, + ) + assert decision.reason is AdmissionReason.AUTHORITY_NOT_CURRENT + assert reads == [stale_path] diff --git a/tests/core/test_audit_cycle_authority.py b/tests/core/test_audit_cycle_authority.py new file mode 100644 index 000000000..d6ff63b92 --- /dev/null +++ b/tests/core/test_audit_cycle_authority.py @@ -0,0 +1,278 @@ +"""Typed and canonical contracts for audit-cycle authority artifacts.""" + +from __future__ import annotations + +from dataclasses import FrozenInstanceError, replace +from pathlib import Path + +import pytest + +from autoskillit.core import ( + AUDIT_CYCLE_SCHEMA_VERSION, + ArtifactRef, + AuditAssessment, + AuditAssessmentRow, + AuditCycleAuthority, + AuditCycleHead, + AuditVerdict, + PlanDispositionReport, + PlanDispositionRow, +) +from autoskillit.core.closure_hashing import ( + canonical_json_bytes, + parse_canonical_json_bytes, +) + +pytestmark = [pytest.mark.layer("core"), pytest.mark.small] + +_HASH_A = "sha256:" + "a" * 64 +_HASH_B = "sha256:" + "b" * 64 +_HASH_C = "sha256:" + "c" * 64 + + +def _ref(path: Path, digest: str = _HASH_A) -> ArtifactRef: + return ArtifactRef( + locator=str(path), + media_type="application/json", + schema_version=1, + byte_size=17, + content_digest=digest, + ) + + +def _row( + requirement_id: str = "REQ-001", + assessment: AuditAssessment = AuditAssessment.COVERED, +) -> AuditAssessmentRow: + return AuditAssessmentRow.create( + requirement_id=requirement_id, + requirement_text=f"text for {requirement_id}", + assessment=assessment, + evidence_summary=f"evidence for {requirement_id}", + ) + + +def _authority( + tmp_path: Path, + *, + verdict: AuditVerdict = AuditVerdict.GO, + assessment: AuditAssessment = AuditAssessment.COVERED, +) -> AuditCycleAuthority: + remediation = ( + _ref(tmp_path / "remediation.md", _HASH_C) if verdict is AuditVerdict.NO_GO else None + ) + return AuditCycleAuthority.create( + execution_generation="generation-1", + cycle_id="cycle-1", + plan_set_id="plans-1", + scope_id="scope-1", + part_id="part-a", + audit_round=2, + parent_authority_digest=_HASH_B, + audited_plan_refs=(_ref(tmp_path / "plan.md"),), + inventory_ref=_ref(tmp_path / "inventory.json", _HASH_B), + assessments=(_row(assessment=assessment),), + verdict=verdict, + remediation_ref=remediation, + generated_at="2026-07-23T00:00:00Z", + ) + + +def test_artifact_ref_uses_content_digest_as_identity(tmp_path: Path) -> None: + first = _ref(tmp_path / "first.json") + second = ArtifactRef( + locator=str(tmp_path / "second.json"), + media_type="text/plain", + schema_version=9, + byte_size=999, + content_digest=first.content_digest, + ) + assert first == second + assert hash(first) == hash(second) + assert first.metadata_digest != second.metadata_digest + with pytest.raises(ValueError, match="absolute"): + replace(first, locator="../inventory.json") + + +def test_canonical_json_profile_is_compact_sorted_and_order_sensitive() -> None: + first = canonical_json_bytes({"z": ["b", "a"], "a": {"b": 2, "a": 1}}) + second = canonical_json_bytes({"a": {"a": 1, "b": 2}, "z": ["b", "a"]}) + reordered = canonical_json_bytes({"z": ["a", "b"], "a": {"b": 2, "a": 1}}) + assert first == b'{"a":{"a":1,"b":2},"z":["b","a"]}' + assert first == second + assert first != reordered + assert parse_canonical_json_bytes(first) == { + "a": {"a": 1, "b": 2}, + "z": ["b", "a"], + } + + +@pytest.mark.parametrize( + "payload", + [ + b'{"a":1,"a":2}', + b'{"a":1.5}', + b'{"a":NaN}', + b'{ "a":1}', + b'{"b":2,"a":1}', + b'{"a":"\\ud800"}', + ], +) +def test_canonical_json_profile_rejects_noncanonical_payloads(payload: bytes) -> None: + with pytest.raises(ValueError): + parse_canonical_json_bytes(payload) + + +def test_authority_and_rows_are_frozen_and_digest_bound(tmp_path: Path) -> None: + authority = _authority(tmp_path) + with pytest.raises(FrozenInstanceError): + authority.cycle_id = "forged" # type: ignore[misc] + with pytest.raises(ValueError, match="row content"): + replace(authority.assessments[0], evidence_summary="forged") + with pytest.raises(ValueError, match="event content"): + replace(authority, cycle_id="forged") + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("execution_generation", "generation-2"), + ("cycle_id", "cycle-2"), + ("plan_set_id", "plans-2"), + ("scope_id", "scope-2"), + ("part_id", "part-b"), + ("audit_round", 3), + ("parent_authority_digest", _HASH_C), + ], +) +def test_authority_digest_covers_cycle_lineage(tmp_path: Path, field: str, value: object) -> None: + base = _authority(tmp_path) + kwargs = { + "execution_generation": base.execution_generation, + "cycle_id": base.cycle_id, + "plan_set_id": base.plan_set_id, + "scope_id": base.scope_id, + "part_id": base.part_id, + "audit_round": base.audit_round, + "parent_authority_digest": base.parent_authority_digest, + "audited_plan_refs": base.audited_plan_refs, + "inventory_ref": base.inventory_ref, + "assessments": base.assessments, + "verdict": base.verdict, + "remediation_ref": base.remediation_ref, + "generated_at": base.generated_at, + } + kwargs[field] = value + changed = AuditCycleAuthority.create(**kwargs) # type: ignore[arg-type] + assert changed.authority_digest != base.authority_digest + + +def test_authority_digest_covers_plan_inventory_and_findings(tmp_path: Path) -> None: + base = _authority(tmp_path) + changed_plan = AuditCycleAuthority.create( + execution_generation=base.execution_generation, + cycle_id=base.cycle_id, + plan_set_id=base.plan_set_id, + scope_id=base.scope_id, + part_id=base.part_id, + audit_round=base.audit_round, + parent_authority_digest=base.parent_authority_digest, + audited_plan_refs=(_ref(tmp_path / "other-plan.md", _HASH_C),), + inventory_ref=base.inventory_ref, + assessments=base.assessments, + verdict=base.verdict, + remediation_ref=base.remediation_ref, + generated_at=base.generated_at, + ) + changed_inventory = replace( + base.inventory_ref, + content_digest=_HASH_C, + ) + changed_inventory_authority = AuditCycleAuthority.create( + execution_generation=base.execution_generation, + cycle_id=base.cycle_id, + plan_set_id=base.plan_set_id, + scope_id=base.scope_id, + part_id=base.part_id, + audit_round=base.audit_round, + parent_authority_digest=base.parent_authority_digest, + audited_plan_refs=base.audited_plan_refs, + inventory_ref=changed_inventory, + assessments=base.assessments, + verdict=base.verdict, + remediation_ref=base.remediation_ref, + generated_at=base.generated_at, + ) + changed_findings = _authority( + tmp_path, + verdict=AuditVerdict.NO_GO, + assessment=AuditAssessment.MISSING, + ) + assert changed_plan.authority_digest != base.authority_digest + assert changed_inventory_authority.authority_digest != base.authority_digest + assert changed_findings.findings_digest != base.findings_digest + assert changed_findings.authority_digest != base.authority_digest + + +def test_go_and_no_go_remediation_invariants(tmp_path: Path) -> None: + go = _authority(tmp_path) + no_go = _authority( + tmp_path, + verdict=AuditVerdict.NO_GO, + assessment=AuditAssessment.MISSING, + ) + with pytest.raises(ValueError, match="GO authority cannot"): + replace(go, remediation_ref=_ref(tmp_path / "active.md")) + with pytest.raises(ValueError, match="requires remediation"): + replace(no_go, remediation_ref=None) + with pytest.raises(ValueError, match="blocking"): + replace(no_go, verdict=AuditVerdict.GO, remediation_ref=None) + + +def test_plan_disposition_report_is_bound_to_full_identity(tmp_path: Path) -> None: + authority = _authority( + tmp_path, + verdict=AuditVerdict.NO_GO, + assessment=AuditAssessment.MISSING, + ) + disposition = PlanDispositionRow.create( + requirement_id="REQ-001", + disposition="carried@step", + implementation_step="Step 2.1", + ) + report = PlanDispositionReport.create( + execution_generation=authority.execution_generation, + cycle_id=authority.cycle_id, + plan_set_id=authority.plan_set_id, + scope_id=authority.scope_id, + part_id=authority.part_id, + audit_round=authority.audit_round, + parent_authority_digest=authority.authority_digest, + inventory_digest=authority.inventory_ref.content_digest, + findings_digest=authority.findings_digest, + current_plan_ref=_ref(tmp_path / "current-plan.md", _HASH_C), + dispositions=(disposition,), + generated_at="2026-07-23T00:01:00Z", + ) + assert PlanDispositionReport.from_dict(report.to_dict()) == report + with pytest.raises(ValueError, match="report content"): + replace(report, cycle_id="forged-cycle") + + +def test_head_allows_successor_only_for_go() -> None: + with pytest.raises(ValueError, match="only a GO"): + AuditCycleHead( + execution_generation="generation-1", + cycle_id="cycle-1", + plan_set_id="plans-1", + scope_id="scope-1", + part_id="part-a", + current_authority_digest=_HASH_A, + audit_round=1, + verdict=AuditVerdict.NO_GO, + authorized_successor_part_id="part-b", + ) + + +def test_schema_version_is_public() -> None: + assert AUDIT_CYCLE_SCHEMA_VERSION == 1 diff --git a/tests/core/test_inventory_admission.py b/tests/core/test_inventory_admission.py new file mode 100644 index 000000000..47640927e --- /dev/null +++ b/tests/core/test_inventory_admission.py @@ -0,0 +1,373 @@ +"""Executable inventory-admission truth table.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from autoskillit.core import ( + AdmissionReason, + AdmissionStatus, + ArtifactRef, + AuditAssessment, + AuditAssessmentRow, + AuditCycleAuthority, + AuditCycleHead, + AuditCycleVerifier, + AuditVerdict, + InventoryAdmissionDecision, + InventoryAdmissionEvaluator, + PlanDispositionReport, + PlanDispositionRow, +) +from autoskillit.core.closure_hashing import compute_bytes_hash + +pytestmark = [pytest.mark.layer("core"), pytest.mark.small] + +_HASH_A = "sha256:" + "a" * 64 +_HASH_B = "sha256:" + "b" * 64 + + +def _ref(path: Path, digest: str = _HASH_A, size: int = 10) -> ArtifactRef: + return ArtifactRef( + locator=str(path), + media_type="application/json", + schema_version=1, + byte_size=size, + content_digest=digest, + ) + + +def _assessment(requirement_id: str, assessment: AuditAssessment) -> AuditAssessmentRow: + return AuditAssessmentRow.create( + requirement_id=requirement_id, + requirement_text=f"text for {requirement_id}", + assessment=assessment, + evidence_summary=f"evidence for {requirement_id}", + ) + + +def _authority( + tmp_path: Path, + *, + verdict: AuditVerdict = AuditVerdict.NO_GO, + rows: tuple[AuditAssessmentRow, ...] | None = None, +) -> AuditCycleAuthority: + if rows is None: + rows = ( + _assessment("ITEM-A", AuditAssessment.COVERED), + _assessment("ITEM-B", AuditAssessment.MISSING), + ) + return AuditCycleAuthority.create( + execution_generation="generation-1", + cycle_id="cycle-1", + plan_set_id="plans-1", + scope_id="scope-1", + part_id="part-a", + audit_round=2, + parent_authority_digest=_HASH_A, + audited_plan_refs=(_ref(tmp_path / "audited-plan.md"),), + inventory_ref=_ref(tmp_path / "inventory.json", _HASH_B), + assessments=rows, + verdict=verdict, + remediation_ref=( + _ref(tmp_path / "remediation.md") if verdict is AuditVerdict.NO_GO else None + ), + generated_at="2026-07-23T00:00:00Z", + ) + + +def _head(authority: AuditCycleAuthority, *, successor: str | None = None) -> AuditCycleHead: + return AuditCycleHead( + execution_generation=authority.execution_generation, + cycle_id=authority.cycle_id, + plan_set_id=authority.plan_set_id, + scope_id=authority.scope_id, + part_id=authority.part_id, + current_authority_digest=authority.authority_digest, + audit_round=authority.audit_round, + verdict=authority.verdict, + authorized_successor_part_id=successor, + ) + + +def _dispositions(*, carry_step: str = "Step 2.1") -> tuple[PlanDispositionRow, ...]: + return ( + PlanDispositionRow.create( + requirement_id="ITEM-A", + disposition="satisfied-by-round-2", + ), + PlanDispositionRow.create( + requirement_id="ITEM-B", + disposition="carried@step", + implementation_step=carry_step, + ), + ) + + +def _report( + tmp_path: Path, + authority: AuditCycleAuthority, + *, + rows: tuple[PlanDispositionRow, ...] | None = None, + cycle_id: str | None = None, + generation: str | None = None, + scope_id: str | None = None, + part_id: str | None = None, + inventory_digest: str | None = None, + findings_digest: str | None = None, +) -> PlanDispositionReport: + if rows is None: + rows = _dispositions() + plan_text = _plan_text(rows) + plan_digest = compute_bytes_hash(plan_text.encode()) + return PlanDispositionReport.create( + execution_generation=generation or authority.execution_generation, + cycle_id=cycle_id or authority.cycle_id, + plan_set_id=authority.plan_set_id, + scope_id=scope_id or authority.scope_id, + part_id=part_id or authority.part_id, + audit_round=authority.audit_round, + parent_authority_digest=authority.authority_digest, + inventory_digest=inventory_digest or authority.inventory_ref.content_digest, + findings_digest=findings_digest or authority.findings_digest, + current_plan_ref=_ref( + tmp_path / "current-plan.md", + plan_digest, + len(plan_text.encode()), + ), + dispositions=rows, + generated_at="2026-07-23T00:01:00Z", + ) + + +def _plan_text( + rows: tuple[PlanDispositionRow, ...], + *, + include_step: bool = True, +) -> str: + table_rows = "\n".join( + f"| {row.requirement_id} | {row.disposition} | {row.implementation_step or '—'} |" + for row in rows + ) + steps = ( + "\n## Implementation Steps\n\n" + "### Step 2.1: Implement blocking requirement\n\n" + "Implement ITEM-B in the current remediation.\n" + if include_step + else "" + ) + return ( + "# Plan\n\n" + "## Requirements Map\n\n" + "| Requirement ID | Disposition | Implementation Step |\n" + "|---|---|---|\n" + f"{table_rows}\n" + f"{steps}" + ) + + +def _evaluate( + authority: AuditCycleAuthority | None, + report: PlanDispositionReport | None, + *, + head: AuditCycleHead | None = None, + inventory_ids: tuple[str, ...] = ("ITEM-A", "ITEM-B"), + plan_text: str | None = None, + expected_part: str = "part-a", +) -> InventoryAdmissionDecision: + if authority is not None and head is None: + head = _head(authority) + return InventoryAdmissionEvaluator().evaluate( + authority=authority, + trusted_head=head, + report=report, + expected_generation="generation-1", + expected_plan_set_id="plans-1", + expected_scope_id="scope-1", + expected_part_id=expected_part, + current_plan_ref=report.current_plan_ref if report is not None else None, + inventory_requirement_ids=inventory_ids, + current_plan_text=plan_text or (_plan_text(report.dispositions) if report else ""), + ) + + +def test_absent_authority_and_report_omits() -> None: + decision = _evaluate(None, None) + assert decision.status is AdmissionStatus.OMIT + assert decision.reason is AdmissionReason.NO_AUTHORITY + + +def test_report_without_authority_rejects(tmp_path: Path) -> None: + authority = _authority(tmp_path) + decision = _evaluate(None, _report(tmp_path, authority)) + assert decision.status is AdmissionStatus.REJECT + assert decision.reason is AdmissionReason.REPORT_WITHOUT_AUTHORITY + + +def test_complete_two_disposition_truth_table_passes(tmp_path: Path) -> None: + authority = _authority(tmp_path) + report = _report(tmp_path, authority) + plan = _plan_text(report.dispositions) + unchanged = plan[:] + decision = _evaluate(authority, report, plan_text=plan) + assert decision.status is AdmissionStatus.PASS + assert decision.reason is AdmissionReason.ADMITTED + assert [row.disposition for row in decision.dispositions] == [ + "satisfied-by-round-2", + "carried@step", + ] + assert plan == unchanged + + +def test_blocking_requirement_without_real_step_rejects(tmp_path: Path) -> None: + authority = _authority(tmp_path) + report = _report(tmp_path, authority) + decision = _evaluate( + authority, + report, + plan_text=_plan_text(report.dispositions, include_step=False), + ) + assert decision.status is AdmissionStatus.REJECT + assert decision.reason is AdmissionReason.IMPLEMENTATION_STEP_MISSING + + +def test_authoritative_empty_findings_satisfies_every_row(tmp_path: Path) -> None: + authority = _authority( + tmp_path, + rows=( + _assessment("ITEM-A", AuditAssessment.COVERED), + _assessment("ITEM-B", AuditAssessment.COVERED), + ), + ) + rows = ( + PlanDispositionRow.create(requirement_id="ITEM-A", disposition="satisfied-by-round-2"), + PlanDispositionRow.create(requirement_id="ITEM-B", disposition="satisfied-by-round-2"), + ) + report = _report(tmp_path, authority, rows=rows) + decision = _evaluate(authority, report, plan_text=_plan_text(rows, include_step=False)) + assert decision.status is AdmissionStatus.PASS + + +@pytest.mark.parametrize( + ("override", "reason"), + [ + ({"cycle_id": "cycle-other"}, AdmissionReason.CYCLE_MISMATCH), + ({"generation": "generation-other"}, AdmissionReason.GENERATION_MISMATCH), + ({"scope_id": "scope-other"}, AdmissionReason.SCOPE_MISMATCH), + ({"part_id": "part-b"}, AdmissionReason.PART_MISMATCH), + ({"inventory_digest": _HASH_A}, AdmissionReason.INVENTORY_MISMATCH), + ( + {"findings_digest": "sha256:" + "c" * 64}, + AdmissionReason.FINDINGS_MISMATCH, + ), + ], +) +def test_active_no_go_identity_mismatches_reject_before_comparison( + tmp_path: Path, + override: dict[str, str], + reason: AdmissionReason, +) -> None: + authority = _authority(tmp_path) + report = _report(tmp_path, authority, **override) + decision = _evaluate(authority, report) + assert decision.status is AdmissionStatus.REJECT + assert decision.reason is reason + + +@pytest.mark.parametrize( + "ids", + [ + ("ITEM-B", "ITEM-A"), + ("ITEM-A",), + ("ITEM-A", "ITEM-B", "ITEM-C"), + ("ITEM-A", "ITEM-A"), + ], +) +def test_inventory_order_duplicates_missing_and_extra_reject( + tmp_path: Path, ids: tuple[str, ...] +) -> None: + authority = _authority(tmp_path) + report = _report(tmp_path, authority) + decision = _evaluate(authority, report, inventory_ids=ids) + assert decision.status is AdmissionStatus.REJECT + assert decision.reason in { + AdmissionReason.INVENTORY_INVALID, + AdmissionReason.REQUIREMENT_ORDER_MISMATCH, + } + + +def test_report_row_reorder_rejects(tmp_path: Path) -> None: + authority = _authority(tmp_path) + rows = tuple(reversed(_dispositions())) + report = _report(tmp_path, authority, rows=rows) + decision = _evaluate(authority, report, plan_text=_plan_text(rows)) + assert decision.reason is AdmissionReason.REQUIREMENT_ORDER_MISMATCH + + +def test_satisfied_row_must_name_current_audit_round(tmp_path: Path) -> None: + authority = _authority(tmp_path) + rows = ( + PlanDispositionRow.create(requirement_id="ITEM-A", disposition="satisfied-by-round-1"), + _dispositions()[1], + ) + report = _report(tmp_path, authority, rows=rows) + decision = _evaluate(authority, report, plan_text=_plan_text(rows)) + assert decision.reason is AdmissionReason.SATISFIED_ROUND_MISMATCH + + +def test_trusted_go_successor_omits_without_inventory_read(tmp_path: Path) -> None: + authority = _authority( + tmp_path, + verdict=AuditVerdict.GO, + rows=( + _assessment("ITEM-A", AuditAssessment.COVERED), + _assessment("ITEM-B", AuditAssessment.COVERED), + ), + ) + authority_path = tmp_path / "authority.json" + reads: list[Path] = [] + + def reader( + path: str | Path, + root: str | Path, + *, + max_size_bytes: int, + ) -> tuple[Path, bytes]: + del root, max_size_bytes + reads.append(Path(path)) + if Path(path) == authority_path: + return authority_path, authority.canonical_bytes + raise AssertionError(f"unexpected inventory read: {path}") + + decision = AuditCycleVerifier(tmp_path, reader=reader).evaluate_paths( + authority_path=authority_path, + report_path=None, + trusted_head=_head(authority, successor="part-b"), + current_plan_path=tmp_path / "part-b.md", + expected_generation=authority.execution_generation, + expected_plan_set_id=authority.plan_set_id, + expected_scope_id=authority.scope_id, + expected_part_id="part-b", + ) + assert decision.status is AdmissionStatus.OMIT + assert decision.reason is AdmissionReason.TRUSTED_GO_SUCCESSOR + assert reads == [authority_path] + + +def test_stale_no_go_authority_rejects(tmp_path: Path) -> None: + authority = _authority(tmp_path) + report = _report(tmp_path, authority) + stale_head = AuditCycleHead( + execution_generation=authority.execution_generation, + cycle_id=authority.cycle_id, + plan_set_id=authority.plan_set_id, + scope_id=authority.scope_id, + part_id=authority.part_id, + current_authority_digest="sha256:" + "d" * 64, + audit_round=authority.audit_round, + verdict=authority.verdict, + ) + decision = _evaluate(authority, report, head=stale_head) + assert decision.reason is AdmissionReason.AUTHORITY_NOT_CURRENT diff --git a/tests/test_test_filter_core_cascade.py b/tests/test_test_filter_core_cascade.py index fb913d873..cca685867 100644 --- a/tests/test_test_filter_core_cascade.py +++ b/tests/test_test_filter_core_cascade.py @@ -120,7 +120,9 @@ def test_all_entries_present(self) -> None: "_execution_marker", "git_remote", "bash_write_targets", + "_type_audit_cycle", "_type_closure_report", + "audit_cycle_verifier", "closure_hashing", "path_containment", "closure_verifier", @@ -129,6 +131,11 @@ def test_all_entries_present(self) -> None: } assert set(MODULE_CASCADE_CORE.keys()) == expected_stems + def test_audit_cycle_cascade(self) -> None: + expected = frozenset({"core", "recipe", "server"}) + assert MODULE_CASCADE_CORE["_type_audit_cycle"] == expected + assert MODULE_CASCADE_CORE["audit_cycle_verifier"] == expected + def test_type_resume_cascade(self) -> None: assert MODULE_CASCADE_CORE["_type_resume"] == frozenset( {"core", "cli", "execution", "fleet"} From 865f49f5cde3b540195535cbd03a8cdea5f173f4 Mon Sep 17 00:00:00 2001 From: Trecek Date: Thu, 23 Jul 2026 17:43:37 -0700 Subject: [PATCH 02/89] feat(recipe): compile structured skill invocations --- src/autoskillit/core/AGENTS.md | 1 + src/autoskillit/core/__init__.pyi | 18 + src/autoskillit/core/io.py | 43 +- src/autoskillit/core/tool_registry.py | 371 ++++++++++ src/autoskillit/core/types/AGENTS.md | 1 + src/autoskillit/core/types/__init__.py | 3 + .../core/types/_type_recipe_binding.py | 238 +++++++ src/autoskillit/recipe/AGENTS.md | 1 + src/autoskillit/recipe/__init__.py | 3 + src/autoskillit/recipe/_analysis.py | 13 + src/autoskillit/recipe/_api.py | 25 +- src/autoskillit/recipe/_binding.py | 662 ++++++++++++++++++ src/autoskillit/recipe/_contracts_manifest.py | 8 +- src/autoskillit/recipe/_contracts_types.py | 10 +- src/autoskillit/recipe/io.py | 125 +++- .../recipe/rules/rules_contracts.py | 36 +- src/autoskillit/recipe/rules/rules_inputs.py | 158 +++-- src/autoskillit/recipe/rules/rules_tools.py | 254 +------ src/autoskillit/recipe/schema.py | 39 +- tests/_test_filter.py | 3 + tests/core/test_io.py | 21 +- tests/recipe/AGENTS.md | 1 + tests/recipe/test_rules_tools.py | 8 +- tests/recipe/test_skill_invocation_binding.py | 301 ++++++++ tests/server/AGENTS.md | 1 + tests/server/test_tool_registry_parity.py | 64 ++ 26 files changed, 2045 insertions(+), 363 deletions(-) create mode 100644 src/autoskillit/core/tool_registry.py create mode 100644 src/autoskillit/core/types/_type_recipe_binding.py create mode 100644 src/autoskillit/recipe/_binding.py create mode 100644 tests/recipe/test_skill_invocation_binding.py create mode 100644 tests/server/test_tool_registry_parity.py diff --git a/src/autoskillit/core/AGENTS.md b/src/autoskillit/core/AGENTS.md index 19dd8e846..c303ec380 100644 --- a/src/autoskillit/core/AGENTS.md +++ b/src/autoskillit/core/AGENTS.md @@ -34,6 +34,7 @@ Sub-packages: types/ (see types/AGENTS.md) and runtime/ (see runtime/AGENTS.md). | `closure_verifier.py` | Independent verifier for closure-mode reports (stdlib-only, IL-0) | | `context_admission.py` | Pure protocol-v1 cumulative context-admission reducer, replay, and coverage resolution | | `audit_cycle_verifier.py` | Bounded verifier and pure evaluator for provenance-bound audit-cycle inventory admission | +| `tool_registry.py` | Canonical stdlib-only MCP tool and parameter definitions shared by recipe compilation and server parity guards | ## Architecture Notes diff --git a/src/autoskillit/core/__init__.pyi b/src/autoskillit/core/__init__.pyi index 60e8faf13..1eaaa7d8b 100644 --- a/src/autoskillit/core/__init__.pyi +++ b/src/autoskillit/core/__init__.pyi @@ -48,6 +48,10 @@ from .audit_cycle_verifier import AuditCycleVerificationError as AuditCycleVerif from .audit_cycle_verifier import AuditCycleVerifier as AuditCycleVerifier from .audit_cycle_verifier import InventoryAdmissionEvaluator as InventoryAdmissionEvaluator from .audit_cycle_verifier import VerifiedAuditCycle as VerifiedAuditCycle +from .tool_registry import TOOL_REGISTRY as TOOL_REGISTRY +from .tool_registry import all_tool_names as all_tool_names +from .tool_registry import get_tool_def as get_tool_def +from .tool_registry import unsupported_tool_params as unsupported_tool_params from .bash_write_targets import extract_bash_write_targets as extract_bash_write_targets from .branch_guard import is_protected_branch as is_protected_branch from .claude_conventions import ClaudeDirectoryConventions as ClaudeDirectoryConventions @@ -172,6 +176,16 @@ from .types import AUTOSKILLIT_PRIVATE_ENV_VARS as AUTOSKILLIT_PRIVATE_ENV_VARS from .types import AUTOSKILLIT_SKILL_PREFIX as AUTOSKILLIT_SKILL_PREFIX from .types import AUTOSKILLIT_WRITE_GUARD_TOOL_NAMES as AUTOSKILLIT_WRITE_GUARD_TOOL_NAMES from .types import BACKEND_CAPABILITY_INGREDIENTS as BACKEND_CAPABILITY_INGREDIENTS +from .types import ABSENT_BOUND_VALUE as ABSENT_BOUND_VALUE +from .types import AbsentBoundValue as AbsentBoundValue +from .types import BindingFailure as BindingFailure +from .types import BindingFailureCode as BindingFailureCode +from .types import BindingMode as BindingMode +from .types import BoundScalar as BoundScalar +from .types import BoundStepInvocation as BoundStepInvocation +from .types import BoundValue as BoundValue +from .types import BoundValueOrigin as BoundValueOrigin +from .types import BoundValueState as BoundValueState from .types import CAMPAIGN_ID_ENV_VAR as CAMPAIGN_ID_ENV_VAR from .types import CAPABILITY_GATE_CALLABLES as CAPABILITY_GATE_CALLABLES from .types import CAPABILITY_INGREDIENT_MAP as CAPABILITY_INGREDIENT_MAP @@ -524,6 +538,7 @@ from .types import RecipeDeliveryEvidenceDef as RecipeDeliveryEvidenceDef from .types import RecipeDeliveryMode as RecipeDeliveryMode from .types import RecipeDeliveryRequest as RecipeDeliveryRequest from .types import RecipeDeliverySurfaceDef as RecipeDeliverySurfaceDef +from .types import RecipeBindingProjection as RecipeBindingProjection from .types import RecipeIdentity as RecipeIdentity from .types import RecipeLoadError as RecipeLoadError from .types import RecipeNotFoundError as RecipeNotFoundError @@ -611,6 +626,9 @@ from .types import TerminationAction as TerminationAction from .types import TerminationReason as TerminationReason from .types import TestResult as TestResult from .types import TestRunner as TestRunner +from .types import ToolDef as ToolDef +from .types import ToolParamDef as ToolParamDef +from .types import ToolWireType as ToolWireType from .types import TimingLog as TimingLog from .types import TokenFactory as TokenFactory from .types import TokenizerIdentity as TokenizerIdentity diff --git a/src/autoskillit/core/io.py b/src/autoskillit/core/io.py index a2e77cdfb..c959c9aa9 100644 --- a/src/autoskillit/core/io.py +++ b/src/autoskillit/core/io.py @@ -32,6 +32,45 @@ except ImportError: _Loader = yaml.SafeLoader # type: ignore[misc,assignment] + +class _UniqueKeyLoader(_Loader): + """Safe loader that rejects duplicate mapping keys before construction.""" + + +def _construct_unique_mapping( + loader: _UniqueKeyLoader, + node: yaml.MappingNode, + deep: bool = False, +) -> dict[Any, Any]: + loader.flatten_mapping(node) + mapping: dict[Any, Any] = {} + for key_node, value_node in node.value: + key = loader.construct_object(key_node, deep=deep) + try: + duplicate = key in mapping + except TypeError as exc: + raise yaml.constructor.ConstructorError( + "while constructing a mapping", + node.start_mark, + "found an unhashable key", + key_node.start_mark, + ) from exc + if duplicate: + raise yaml.constructor.ConstructorError( + "while constructing a mapping", + node.start_mark, + f"found duplicate key {key!r}", + key_node.start_mark, + ) + mapping[key] = loader.construct_object(value_node, deep=deep) + return mapping + + +_UniqueKeyLoader.add_constructor( + yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, + _construct_unique_mapping, +) + try: from yaml import CDumper as _Dumper except ImportError: @@ -443,8 +482,8 @@ def load_yaml(source: os.PathLike[str] | str) -> Any: """ if isinstance(source, os.PathLike): with open(source, "rb") as fh: - return yaml.load(fh, Loader=_Loader) - return yaml.load(source, Loader=_Loader) + return yaml.load(fh, Loader=_UniqueKeyLoader) + return yaml.load(source, Loader=_UniqueKeyLoader) def compose_yaml(source: str) -> yaml.Node | None: diff --git a/src/autoskillit/core/tool_registry.py b/src/autoskillit/core/tool_registry.py new file mode 100644 index 000000000..a7e562f10 --- /dev/null +++ b/src/autoskillit/core/tool_registry.py @@ -0,0 +1,371 @@ +"""Canonical stdlib-only MCP tool metadata. + +Handler parameters mirror the public ``@mcp.tool`` signatures. ``skill_inputs`` +is the sole compiler-owned structured recipe parameter; the next server phase +will expose that already-compiled channel on ``run_skill``. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from types import MappingProxyType + +from .types._type_recipe_binding import ToolDef, ToolParamDef, ToolWireType + +__all__ = [ + "TOOL_REGISTRY", + "all_tool_names", + "get_tool_def", + "unsupported_tool_params", +] + + +def _tool( + name: str, + params: tuple[str, ...] = (), + *, + required: tuple[str, ...] = (), +) -> ToolDef: + required_set = frozenset(required) + return ToolDef( + name=name, + params=tuple(ToolParamDef(param, required=param in required_set) for param in params), + ) + + +def _run_skill() -> ToolDef: + string_params = ( + "skill_command", + "cwd", + "model", + "step_name", + "step_provider", + "order_id", + "output_dir", + "resume_session_id", + "closure_authority_path", + "closure_authority_hash", + "closure_plan_paths", + "closure_base_sha", + "closure_diff_sha", + "closure_target_sha", + ) + params = [ + ToolParamDef( + name, + wire_type=ToolWireType.STRING, + required=name in {"skill_command", "cwd"}, + ) + for name in string_params + ] + params[6:6] = [ + ToolParamDef("stale_threshold", ToolWireType.INTEGER), + ToolParamDef("idle_output_timeout", ToolWireType.INTEGER), + ] + params.append( + ToolParamDef( + "skill_inputs", + ToolWireType.OBJECT, + structured_skill_inputs=True, + handler_parameter=False, + ) + ) + return ToolDef(name="run_skill", params=tuple(params)) + + +_TOOL_DEFS = ( + _tool("unlock_agent_pack", ("pack_name",), required=("pack_name",)), + _tool( + "set_commit_status", + ("sha", "state", "context", "description", "target_url", "repo", "cwd"), + required=("sha", "state", "context"), + ), + _tool( + "check_repo_merge_state", + ("branch", "cwd", "remote_url", "step_name", "base_branch"), + required=("branch",), + ), + _tool( + "toggle_auto_merge", + ("pr_number", "target_branch", "cwd", "repo", "remote_url"), + required=("pr_number", "target_branch", "cwd"), + ), + _tool( + "enqueue_pr", + ( + "pr_number", + "target_branch", + "cwd", + "auto_merge_available", + "repo", + "remote_url", + "step_name", + ), + required=("pr_number", "target_branch", "cwd", "auto_merge_available"), + ), + _tool( + "wait_for_merge_queue", + ( + "pr_number", + "target_branch", + "cwd", + "repo", + "remote_url", + "timeout_seconds", + "poll_interval", + "stall_grace_period", + "max_stall_retries", + "not_in_queue_confirmation_cycles", + "max_inconclusive_retries", + "auto_merge_available", + "max_merge_group_drops", + "merge_group_drop_backoff", + "step_name", + ), + required=("pr_number", "target_branch", "cwd"), + ), + _tool( + "wait_for_ci", + ( + "branch", + "repo", + "remote_url", + "head_sha", + "workflow", + "event", + "timeout_seconds", + "lookback_seconds", + "cwd", + "step_name", + "auto_trigger", + ), + required=("branch",), + ), + _tool("get_ci_status", ("branch", "run_id", "repo", "workflow", "event", "cwd")), + _tool( + "clone_repo", + ("source_dir", "run_name", "branch", "strategy", "remote_url", "step_name"), + required=("source_dir", "run_name"), + ), + _tool("remove_clone", ("clone_path", "keep", "step_name"), required=("clone_path",)), + _tool( + "push_to_remote", + ("clone_path", "branch", "source_dir", "remote_url", "force", "step_name"), + required=("clone_path", "branch"), + ), + _tool( + "register_clone_status", + ("clone_path", "status", "registry_path", "step_name"), + required=("clone_path", "status"), + ), + _tool( + "batch_cleanup_clones", + ("registry_path", "all_owners", "owner_filter", "step_name"), + ), + _tool( + "bootstrap_clone", + ("source_dir", "run_name", "base_branch", "branch", "strategy", "remote_url", "step_name"), + required=("source_dir", "run_name", "base_branch"), + ), + _tool( + "configure_fleet", + ( + "max_concurrent_dispatches", + "max_total_issues", + "default_timeout_sec", + "max_extension_seconds", + "idle_output_timeout", + "acquire_timeout_sec", + "max_issues_per_food_truck", + "enable_deadline_extension", + "inspector_model", + "default_model", + ), + ), + _tool( + "configure_order", + ( + "timeout", + "stale_threshold", + "idle_output_timeout", + "max_suppression_seconds", + "default_model", + ), + ), + _tool("run_cmd", ("cmd", "cwd", "timeout", "step_name"), required=("cmd", "cwd")), + _tool("run_python", ("callable", "args", "timeout", "work_dir"), required=("callable",)), + _run_skill(), + _tool( + "dispatch_food_truck", + ( + "recipe", + "task", + "ingredients", + "dispatch_name", + "timeout_sec", + "capture", + "resume_session_id", + "resume_checkpoint", + "idle_output_timeout", + "prior_dispatch_id", + "skip_when", + "resume_message", + "caller_instructions", + "backend", + ), + required=("recipe", "task"), + ), + _tool( + "record_gate_dispatch", + ("dispatch_name", "approved"), + required=("dispatch_name", "approved"), + ), + _tool( + "reset_dispatch", + ("dispatch_id", "reset_to", "force", "destroy_artifacts"), + required=("dispatch_id",), + ), + _tool( + "merge_worktree", + ("worktree_path", "base_branch", "step_name"), + required=("worktree_path", "base_branch"), + ), + _tool( + "classify_fix", + ("worktree_path", "base_branch", "step_name"), + required=("worktree_path", "base_branch"), + ), + _tool( + "create_unique_branch", + ("slug", "issue_number", "remote", "cwd", "base_branch_name", "step_name"), + ), + _tool("check_pr_mergeable", ("pr_number", "cwd", "repo"), required=("pr_number", "cwd")), + _tool( + "create_and_publish_branch", + ("issue_slug", "run_name", "issue_number", "work_dir", "remote_url", "step_name"), + required=("issue_slug", "run_name", "issue_number", "work_dir", "remote_url"), + ), + _tool( + "commit_files", + ("paths", "message", "cwd", "step_name"), + required=("paths", "message", "cwd"), + ), + _tool( + "fetch_github_issue", + ("issue_url", "include_comments"), + required=("issue_url",), + ), + _tool("get_issue_title", ("issue_url",), required=("issue_url",)), + _tool( + "report_bug", + ("error_context", "cwd", "severity", "model", "step_name"), + required=("error_context", "cwd"), + ), + _tool( + "claim_and_resolve_issue", + ("issue_url", "label", "allow_reentry"), + required=("issue_url",), + ), + _tool( + "prepare_issue", + ("title", "body", "repo", "labels", "dry_run", "split"), + required=("title", "body"), + ), + _tool("enrich_issues", ("issue_number", "batch", "dry_run", "repo")), + _tool("claim_issue", ("issue_url", "label", "allow_reentry"), required=("issue_url",)), + _tool( + "release_issue", + ("issue_url", "label", "target_branch", "staged_label", "fail_label", "close_issue"), + required=("issue_url",), + ), + _tool("open_kitchen", ("name", "overrides", "ingredients_only", "delivery_request")), + _tool("close_kitchen"), + _tool("disable_quota_guard"), + _tool("lock_ingredients", ("locked", "pipeline_id", "unlock")), + _tool("reload_session"), + _tool("record_pipeline_step", ("pipeline_id", "op", "dependencies", "step_name")), + _tool("get_pr_reviews", ("pr_number", "cwd", "repo"), required=("pr_number", "cwd")), + _tool( + "bulk_close_issues", + ("issue_numbers", "comment", "cwd"), + required=("issue_numbers", "comment", "cwd"), + ), + _tool("list_recipes"), + _tool( + "load_recipe", + ("name", "overrides", "ingredients_only", "delivery_request"), + required=("name",), + ), + _tool( + "get_recipe_section", + ( + "section", + "recipe_name", + "producer_tool", + "descriptor_version", + "schema_version", + "payload_sha256", + "artifact_blob_sha256", + "artifact_blob_size_bytes", + "body_sha256", + "body_size_bytes", + "part", + ), + required=( + "section", + "recipe_name", + "producer_tool", + "descriptor_version", + "schema_version", + "payload_sha256", + "artifact_blob_sha256", + "artifact_blob_size_bytes", + "body_sha256", + "body_size_bytes", + ), + ), + _tool("validate_recipe", ("script_path",), required=("script_path",)), + _tool("migrate_recipe", ("name",), required=("name",)), + _tool("kitchen_status"), + _tool("get_pipeline_report", ("clear",)), + _tool("get_token_summary", ("clear", "format", "order_id")), + _tool("get_timing_summary", ("clear", "format", "order_id")), + _tool("analyze_tool_sequences", ("recipe", "format", "top_n", "min_count")), + _tool("get_quota_events", ("n",)), + _tool("write_telemetry_files", ("output_dir",), required=("output_dir",)), + _tool("read_db", ("db_path", "query", "params", "timeout"), required=("db_path", "query")), + _tool("test_check", ("worktree_path", "step_name"), required=("worktree_path",)), + _tool("reset_test_dir", ("test_dir", "force", "step_name"), required=("test_dir",)), + _tool("reset_workspace", ("test_dir",), required=("test_dir",)), +) + + +def _build_registry(tool_defs: tuple[ToolDef, ...]) -> Mapping[str, ToolDef]: + registry: dict[str, ToolDef] = {} + for tool_def in tool_defs: + if tool_def.name in registry: + raise ValueError(f"Duplicate canonical ToolDef: {tool_def.name!r}") + registry[tool_def.name] = tool_def + return MappingProxyType(registry) + + +TOOL_REGISTRY: Mapping[str, ToolDef] = _build_registry(_TOOL_DEFS) + + +def get_tool_def(tool_name: str) -> ToolDef | None: + return TOOL_REGISTRY.get(tool_name) + + +def all_tool_names() -> frozenset[str]: + return frozenset(TOOL_REGISTRY) + + +def unsupported_tool_params( + tool_name: str, + params: Mapping[str, object] | frozenset[str] | set[str], +) -> frozenset[str]: + tool_def = get_tool_def(tool_name) + keys = frozenset(params) + if tool_def is None: + return keys + return keys - tool_def.param_set diff --git a/src/autoskillit/core/types/AGENTS.md b/src/autoskillit/core/types/AGENTS.md index 377ba8f0d..a0bdfc7d8 100644 --- a/src/autoskillit/core/types/AGENTS.md +++ b/src/autoskillit/core/types/AGENTS.md @@ -22,6 +22,7 @@ Type re-export hub and all typed building blocks for the autoskillit package (IL | `_type_results.py` | Core result dataclasses: `SkillResult`, `ProviderOutcome`, `LoadResult`, `FailureRecord`, `WriteBehaviorSpec`, `ClosureAuthoritySpec`, `closure_authority_spec_from_args` | | `_type_closure_report.py` | Closure report dataclasses: `ClosureRow`, `ClosureReport`, `CLOSURE_REPORT_SCHEMA_VERSION` | | `_type_audit_cycle.py` | Frozen audit-cycle authority, artifact reference, disposition, head, and admission decision models | +| `_type_recipe_binding.py` | Frozen tool definitions, binding values/failures, compiled step invocations, and immutable binding projections | | `_type_protocols_logging.py` | Protocols: `AuditLog`, `TokenLog`, `TimingLog`, `McpResponseLog`, `GitHubApiLog`, `SupportsDebug`, `SupportsLogger` | | `_type_protocols_execution.py` | Protocols: `TestRunner`, `HeadlessExecutor`, `OutputPatternResolver`, `WriteExpectedResolver` | | `_type_protocols_github.py` | Protocols: `GitHubFetcher`, `CIWatcher`, `MergeQueueWatcher` | diff --git a/src/autoskillit/core/types/__init__.py b/src/autoskillit/core/types/__init__.py index 0e6fc09ea..ea215a53c 100644 --- a/src/autoskillit/core/types/__init__.py +++ b/src/autoskillit/core/types/__init__.py @@ -60,6 +60,8 @@ from ._type_protocols_recipe import __all__ as _protocols_recipe_all from ._type_protocols_workspace import * # noqa: F401, F403 from ._type_protocols_workspace import __all__ as _protocols_workspace_all +from ._type_recipe_binding import * # noqa: F401, F403 +from ._type_recipe_binding import __all__ as _recipe_binding_all from ._type_recipe_delivery import * # noqa: F401, F403 from ._type_recipe_delivery import __all__ as _recipe_delivery_all from ._type_recipe_sections import * # noqa: F401, F403 @@ -111,6 +113,7 @@ + _protocols_backend_all + _results_all + _results_execution_all + + _recipe_binding_all + _recipe_delivery_all + _recipe_sections_all + _resume_all diff --git a/src/autoskillit/core/types/_type_recipe_binding.py b/src/autoskillit/core/types/_type_recipe_binding.py new file mode 100644 index 000000000..3ec57553b --- /dev/null +++ b/src/autoskillit/core/types/_type_recipe_binding.py @@ -0,0 +1,238 @@ +"""Frozen value objects for canonical recipe-step binding. + +This module is IL-0 and stdlib-only. Recipe compilation, semantic validation, +and server dispatch share these values without importing one another. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from enum import StrEnum +from types import MappingProxyType +from typing import TypeAlias + +__all__ = [ + "ABSENT_BOUND_VALUE", + "AbsentBoundValue", + "BindingFailure", + "BindingFailureCode", + "BindingMode", + "BoundScalar", + "BoundStepInvocation", + "BoundValue", + "BoundValueOrigin", + "BoundValueState", + "RecipeBindingProjection", + "ToolDef", + "ToolParamDef", + "ToolWireType", +] + + +BoundScalar: TypeAlias = str | int | float | bool + + +class ToolWireType(StrEnum): + """JSON-wire shapes accepted by an MCP parameter.""" + + STRING = "string" + INTEGER = "integer" + NUMBER = "number" + BOOLEAN = "boolean" + SCALAR = "scalar" + OBJECT = "object" + ARRAY = "array" + + +@dataclass(frozen=True, slots=True) +class ToolParamDef: + """Static definition of one public MCP handler parameter.""" + + name: str + wire_type: ToolWireType = ToolWireType.SCALAR + required: bool = False + structured_skill_inputs: bool = False + handler_parameter: bool = True + + +@dataclass(frozen=True, slots=True) +class ToolDef: + """Static definition of one registered MCP tool.""" + + name: str + params: tuple[ToolParamDef, ...] + + def __post_init__(self) -> None: + names = tuple(param.name for param in self.params) + if len(names) != len(set(names)): + raise ValueError(f"ToolDef {self.name!r} contains duplicate parameters") + structured = tuple(param.name for param in self.params if param.structured_skill_inputs) + if structured not in {(), ("skill_inputs",)}: + raise ValueError( + f"ToolDef {self.name!r} has invalid structured parameters: {structured!r}" + ) + if structured and self.name != "run_skill": + raise ValueError("Only run_skill may declare structured skill_inputs") + + @property + def param_names(self) -> tuple[str, ...]: + return tuple(param.name for param in self.params) + + @property + def param_set(self) -> frozenset[str]: + return frozenset(self.param_names) + + @property + def handler_param_set(self) -> frozenset[str]: + return frozenset(param.name for param in self.params if param.handler_parameter) + + def param_def(self, name: str) -> ToolParamDef | None: + return next((param for param in self.params if param.name == name), None) + + +class BindingMode(StrEnum): + """Binding trust boundary.""" + + RECIPE = "recipe" + STANDALONE = "standalone" + + +class BoundValueState(StrEnum): + PRESENT = "present" + ABSENT = "absent" + + +class BoundValueOrigin(StrEnum): + """Semantic origin determined from the declaration, never the replacement.""" + + LITERAL = "literal" + RECIPE_INPUT = "recipe_input" + CONTEXT = "context" + TEMPLATE = "template" + ABSENT = "absent" + + +class AbsentBoundValue(StrEnum): + """Dedicated optional-absence sentinel. + + It is intentionally not ``None`` or a falsey scalar, so ``""``, ``False``, + and ``0`` remain contract-valid present values. + """ + + TOKEN = "absent" + + +ABSENT_BOUND_VALUE = AbsentBoundValue.TOKEN + + +@dataclass(frozen=True, slots=True) +class BoundValue: + """One aligned declared/effective value in a named binding slot.""" + + name: str + declared_value: BoundScalar | AbsentBoundValue + effective_value: BoundScalar | AbsentBoundValue + state: BoundValueState + origin: BoundValueOrigin + context_dependencies: tuple[str, ...] = () + input_dependencies: tuple[str, ...] = () + + @classmethod + def absent(cls, name: str) -> BoundValue: + return cls( + name=name, + declared_value=ABSENT_BOUND_VALUE, + effective_value=ABSENT_BOUND_VALUE, + state=BoundValueState.ABSENT, + origin=BoundValueOrigin.ABSENT, + ) + + @property + def is_present(self) -> bool: + return self.state is BoundValueState.PRESENT + + +class BindingFailureCode(StrEnum): + UNKNOWN_TOOL = "unknown_tool" + UNKNOWN_TOOL_PARAMETER = "unknown_tool_parameter" + MISSING_TOOL_PARAMETER = "missing_tool_parameter" + INVALID_TOOL_PARAMETER_TYPE = "invalid_tool_parameter_type" + UNKNOWN_SKILL = "unknown_skill" + UNKNOWN_SKILL_INPUT = "unknown_skill_input" + MISSING_SKILL_INPUT = "missing_skill_input" + DEAD_SKILL_INPUT = "dead_skill_input" + AMBIGUOUS_SKILL_INPUT = "ambiguous_skill_input" + INVALID_SKILL_INPUT_TYPE = "invalid_skill_input_type" + INVALID_SKILL_COMMAND = "invalid_skill_command" + + +@dataclass(frozen=True, slots=True) +class BindingFailure: + code: BindingFailureCode + step_name: str + name: str + message: str + + +@dataclass(frozen=True, slots=True) +class BoundStepInvocation: + """Canonical compile result for one recipe step.""" + + step_name: str + tool_name: str + mode: BindingMode + skill_name: str | None + mcp_kwargs: tuple[BoundValue, ...] + skill_inputs: tuple[BoundValue, ...] + failures: tuple[BindingFailure, ...] = () + + @property + def is_valid(self) -> bool: + return not self.failures + + @property + def attested(self) -> bool: + """Whether this binding is eligible for later recipe attestation.""" + + return self.mode is BindingMode.RECIPE and self.is_valid + + @property + def canonical_child_invocation(self) -> tuple[tuple[str, BoundScalar], ...]: + """Ordered, named child inputs with absent optionals omitted. + + This is data, not a shell command. No quoting, interpolation, or + positional compaction occurs at this boundary. + """ + + result: list[tuple[str, BoundScalar]] = [] + for value in self.skill_inputs: + if not value.is_present: + continue + effective = value.effective_value + if isinstance(effective, AbsentBoundValue): + continue + result.append((value.name, effective)) + return tuple(result) + + def skill_input(self, name: str) -> BoundValue | None: + return next((value for value in self.skill_inputs if value.name == name), None) + + +@dataclass(frozen=True, slots=True) +class RecipeBindingProjection: + """Immutable step-name projection for one validation boundary.""" + + invocations: Mapping[str, BoundStepInvocation] + + def __post_init__(self) -> None: + object.__setattr__(self, "invocations", MappingProxyType(dict(self.invocations))) + + def for_step(self, step_name: str) -> BoundStepInvocation | None: + return self.invocations.get(step_name) + + @property + def failures(self) -> tuple[BindingFailure, ...]: + return tuple( + failure for invocation in self.invocations.values() for failure in invocation.failures + ) diff --git a/src/autoskillit/recipe/AGENTS.md b/src/autoskillit/recipe/AGENTS.md index c4bedec01..f74caf77b 100644 --- a/src/autoskillit/recipe/AGENTS.md +++ b/src/autoskillit/recipe/AGENTS.md @@ -36,6 +36,7 @@ Sub-package: rules/ (see rules/AGENTS.md). | `registry.py` | `RuleFinding`, `RuleDef`, `BlockRuleDef`, `semantic_rule` decorator | | `repository.py` | `RecipeRepository` implementation | | `_analysis.py` | `ValidationContext` + `make_validation_context` | +| `_binding.py` | Pure canonical compiler for MCP kwargs and ordered structured child-skill inputs | | `_analysis_graph.py` | `RouteEdge` + `build_recipe_graph` + step graph primitives | | `_analysis_bfs.py` | `bfs_reachable` + symbolic BFS fact propagation | | `_analysis_blocks.py` | `extract_blocks` — group steps by block annotation | diff --git a/src/autoskillit/recipe/__init__.py b/src/autoskillit/recipe/__init__.py index 633e55990..0e80491eb 100644 --- a/src/autoskillit/recipe/__init__.py +++ b/src/autoskillit/recipe/__init__.py @@ -18,6 +18,7 @@ load_and_validate, validate_from_path, ) +from autoskillit.recipe._binding import bind_recipe, bind_step_invocation # noqa: E402 from autoskillit.recipe._recipe_ingredients import ( # noqa: E402 ListRecipesResult, LoadRecipeResult, @@ -340,6 +341,8 @@ "DefaultRecipeRepository", "parse_recipe_metadata", "load_and_validate", + "bind_recipe", + "bind_step_invocation", "validate_from_path", "list_all", "format_ingredients_table", diff --git a/src/autoskillit/recipe/_analysis.py b/src/autoskillit/recipe/_analysis.py index d43f686eb..996e8c702 100644 --- a/src/autoskillit/recipe/_analysis.py +++ b/src/autoskillit/recipe/_analysis.py @@ -14,6 +14,8 @@ from pathlib import Path from typing import TYPE_CHECKING +from autoskillit.core import BoundScalar, RecipeBindingProjection + if TYPE_CHECKING: from autoskillit.core import BackendCapabilities, SkillResolver @@ -32,6 +34,7 @@ _is_infrastructure_step, build_recipe_graph, ) +from autoskillit.recipe._binding import bind_recipe from autoskillit.recipe.io import iter_steps_with_context # noqa: F401 — re-exported for rules from autoskillit.recipe.schema import ( DataFlowReport, @@ -86,6 +89,9 @@ class ValidationContext: backend_origin_map: dict[str, str] | None = None blocks: tuple[RecipeBlock, ...] = field(default_factory=tuple) predecessors: dict[str, set[str]] = field(default_factory=dict) + binding_projection: RecipeBindingProjection = field( + default_factory=lambda: RecipeBindingProjection({}) + ) # --------------------------------------------------------------------------- @@ -139,6 +145,8 @@ def make_validation_context( effective_backend_map: dict[str, str] | None = None, backend_capabilities_map: dict[str, BackendCapabilities] | None = None, backend_origin_map: dict[str, str] | None = None, + binding_projection: RecipeBindingProjection | None = None, + binding_ingredient_values: dict[str, BoundScalar] | None = None, ) -> ValidationContext: """Build a ``ValidationContext`` from a recipe. @@ -171,4 +179,9 @@ def make_validation_context( backend_origin_map=backend_origin_map, blocks=extract_blocks(recipe, step_graph, predecessors=predecessors), predecessors=predecessors, + binding_projection=( + binding_projection + if binding_projection is not None + else bind_recipe(recipe, ingredient_values=binding_ingredient_values) + ), ) diff --git a/src/autoskillit/recipe/_api.py b/src/autoskillit/recipe/_api.py index 733b7586d..f8a8b7339 100644 --- a/src/autoskillit/recipe/_api.py +++ b/src/autoskillit/recipe/_api.py @@ -45,6 +45,7 @@ list_all, validate_from_path, ) +from autoskillit.recipe._binding import bind_recipe from autoskillit.recipe._recipe_composition import ( _assert_content_integrity, _build_active_recipe, @@ -85,7 +86,7 @@ from autoskillit.recipe.io import ( RecipeInfo, _assert_no_raw_placeholders, - _load_recipe_dict, + _load_recipe_dict_with_declarations, _parse_recipe, builtin_recipes_dir, builtin_sub_recipes_dir, @@ -318,8 +319,8 @@ def load_and_validate( if match is None: raise RecipeNotFoundError(f"No recipe named '{name}' found") - raw = match.content if match.content is not None else match.path.read_text() - raw = substitute_temp_placeholder(raw, _temp_relpath) + raw_declared = match.content if match.content is not None else match.path.read_text() + raw = substitute_temp_placeholder(raw_declared, _temp_relpath) raw = substitute_scripts_placeholder(raw) suggestions: list[dict[str, Any]] = [] valid = False @@ -339,11 +340,15 @@ def load_and_validate( try: # Stage: yaml parse - data = _load_recipe_dict(match.path, raw_text=raw, temp_dir_relpath=_temp_relpath) + data, declared_data = _load_recipe_dict_with_declarations( + match.path, + raw_text=raw_declared, + temp_dir_relpath=_temp_relpath, + ) t0 = _t("yaml_parse", t0, name) if isinstance(data, dict): - recipe = _parse_recipe(data) + recipe = _parse_recipe(data, declared_data=declared_data) from autoskillit.recipe.identity import compute_composite_hash # noqa: PLC0415 @@ -393,6 +398,10 @@ def load_and_validate( # must also fire pre-prune so the filter intersection retains their # findings. Pre-prune context receives skill_resolver and effective_backend_map # for this reason — see test_filter_pruning_scope. + _pre_prune_bindings = bind_recipe( + active_recipe, + ingredient_values=ingredient_overrides, + ) _pre_prune_val_ctx = make_validation_context( active_recipe, project_dir=_pdir, @@ -401,6 +410,7 @@ def load_and_validate( effective_backend_map=effective_backend_map, backend_capabilities_map=backend_capabilities_map, backend_origin_map=backend_origin_map, + binding_projection=_pre_prune_bindings, ) _pre_prune_findings = run_semantic_rules(_pre_prune_val_ctx) _pre_prune_steps = dict(active_recipe.steps) @@ -461,6 +471,10 @@ def load_and_validate( project_sub_dir = _pdir / ".autoskillit" / "recipes" / "sub-recipes" if project_sub_dir.is_dir(): known_sub_recipes |= frozenset(p.stem for p in project_sub_dir.glob("*.yaml")) + _post_prune_bindings = bind_recipe( + active_recipe, + ingredient_values=ingredient_overrides, + ) val_ctx = make_validation_context( active_recipe, available_recipes=known, @@ -472,6 +486,7 @@ def load_and_validate( effective_backend_map=effective_backend_map, backend_capabilities_map=backend_capabilities_map, backend_origin_map=backend_origin_map, + binding_projection=_post_prune_bindings, ) semantic_findings = run_semantic_rules(val_ctx) semantic_suggestions = findings_to_dicts(semantic_findings) diff --git a/src/autoskillit/recipe/_binding.py b/src/autoskillit/recipe/_binding.py new file mode 100644 index 000000000..4d1c3d77a --- /dev/null +++ b/src/autoskillit/recipe/_binding.py @@ -0,0 +1,662 @@ +"""Pure compiler for recipe tool and child-skill invocations.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, Final, TypeGuard + +import regex as re + +from autoskillit.core import ( + BindingFailure, + BindingFailureCode, + BindingMode, + BoundScalar, + BoundStepInvocation, + BoundValue, + BoundValueOrigin, + BoundValueState, + RecipeBindingProjection, + ToolParamDef, + ToolWireType, + get_tool_def, + resolve_skill_name, +) +from autoskillit.recipe._contracts_manifest import ( + get_callable_contract, + get_skill_contract, + load_bundled_manifest, +) +from autoskillit.recipe._contracts_types import SkillContract, SkillInput + +__all__ = [ + "bind_recipe", + "bind_step_invocation", +] + + +_CONTEXT_REF_RE: Final = re.compile(r"\$\{\{\s*context\.([A-Za-z_]\w*)\s*\}\}") +_INPUT_REF_RE: Final = re.compile(r"\$\{\{\s*inputs\.([A-Za-z_]\w*)\s*\}\}") +_EXACT_CONTEXT_REF_RE: Final = re.compile(r"^\$\{\{\s*context\.([A-Za-z_]\w*)\s*\}\}$") +_EXACT_INPUT_REF_RE: Final = re.compile(r"^\$\{\{\s*inputs\.([A-Za-z_]\w*)\s*\}\}$") +_SCALAR_TYPES = (str, int, float, bool) + + +def _is_scalar(value: object) -> TypeGuard[BoundScalar]: + return isinstance(value, _SCALAR_TYPES) and value is not None + + +def _origin_for( + declared: BoundScalar, +) -> tuple[ + BoundValueOrigin, + tuple[str, ...], + tuple[str, ...], +]: + if not isinstance(declared, str): + return BoundValueOrigin.LITERAL, (), () + context_dependencies = tuple(dict.fromkeys(_CONTEXT_REF_RE.findall(declared))) + input_dependencies = tuple(dict.fromkeys(_INPUT_REF_RE.findall(declared))) + if _EXACT_CONTEXT_REF_RE.fullmatch(declared): + origin = BoundValueOrigin.CONTEXT + elif _EXACT_INPUT_REF_RE.fullmatch(declared): + origin = BoundValueOrigin.RECIPE_INPUT + elif ( + context_dependencies + or input_dependencies + or "${{" in declared + or "{{AUTOSKILLIT_" in declared + ): + origin = BoundValueOrigin.TEMPLATE + else: + origin = BoundValueOrigin.LITERAL + return origin, context_dependencies, input_dependencies + + +def _bound_value(name: str, declared: BoundScalar, effective: BoundScalar) -> BoundValue: + origin, context_dependencies, input_dependencies = _origin_for(declared) + return BoundValue( + name=name, + declared_value=declared, + effective_value=effective, + state=BoundValueState.PRESENT, + origin=origin, + context_dependencies=context_dependencies, + input_dependencies=input_dependencies, + ) + + +def _failure( + code: BindingFailureCode, + step_name: str, + name: str, + message: str, +) -> BindingFailure: + return BindingFailure(code=code, step_name=step_name, name=name, message=message) + + +def _wire_value_is_valid(value: BoundScalar, param: ToolParamDef) -> bool: + match param.wire_type: + case ToolWireType.STRING: + return isinstance(value, str) + case ToolWireType.INTEGER: + return isinstance(value, int) and not isinstance(value, bool) + case ToolWireType.NUMBER: + return isinstance(value, (int, float)) and not isinstance(value, bool) + case ToolWireType.BOOLEAN: + return isinstance(value, bool) + case ToolWireType.SCALAR: + return _is_scalar(value) + case ToolWireType.OBJECT | ToolWireType.ARRAY: + return False + + +def _skill_value_is_valid(value: BoundScalar, input_def: SkillInput) -> bool: + normalized = input_def.type + if normalized in { + "str", + "string", + "optional_string", + "file_path", + "file_path_list", + "directory_path", + }: + return isinstance(value, str) + if normalized == "integer": + return isinstance(value, int) and not isinstance(value, bool) + if normalized in {"number", "float"}: + return isinstance(value, (int, float)) and not isinstance(value, bool) + if normalized in {"boolean", "bool"}: + return isinstance(value, bool) + return False + + +def _resolve_hidden_value( + declared: BoundScalar, + effective: BoundScalar, + *, + hidden_inputs: frozenset[str], + ingredient_values: Mapping[str, BoundScalar], +) -> BoundScalar: + """Resolve hidden inputs while retaining declaration-derived provenance.""" + + if not isinstance(declared, str): + return effective + exact = _EXACT_INPUT_REF_RE.fullmatch(declared) + if exact and exact.group(1) in hidden_inputs: + return ingredient_values.get(exact.group(1), effective) + if not isinstance(effective, str): + return effective + + def replace(match: re.Match[str]) -> str: + name = match.group(1) + if name not in hidden_inputs or name not in ingredient_values: + return match.group(0) + return str(ingredient_values[name]) + + return _INPUT_REF_RE.sub(replace, effective) + + +def _tokenize_skill_command(command: str) -> tuple[str, ...]: + """Tokenize without evaluating shell syntax and keep template refs atomic.""" + + tokens: list[str] = [] + current: list[str] = [] + quote: str | None = None + index = 0 + while index < len(command): + char = command[index] + if quote is not None: + if char == "\\" and index + 1 < len(command): + current.extend((char, command[index + 1])) + index += 2 + continue + current.append(char) + if char == quote: + quote = None + index += 1 + continue + if char in {"'", '"'}: + quote = char + current.append(char) + index += 1 + continue + if command.startswith("${{", index): + end = command.find("}}", index + 3) + if end < 0: + current.append(command[index:]) + index = len(command) + else: + current.append(command[index : end + 2]) + index = end + 2 + continue + if char.isspace(): + if current: + tokens.append("".join(current)) + current = [] + index += 1 + continue + current.append(char) + index += 1 + if quote is not None: + raise ValueError("unterminated quoted skill argument") + if current: + tokens.append("".join(current)) + return tuple(tokens) + + +def _unquote(value: str) -> str: + if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: + return value[1:-1] + return value + + +def _split_named_token(token: str) -> tuple[str, str] | None: + if "=" not in token: + return None + name, value = token.split("=", 1) + if not re.fullmatch(r"[A-Za-z_]\w*", name): + return None + return name, _unquote(value) + + +def _inline_skill_inputs( + *, + step_name: str, + declared_command: str, + effective_command: str, + contract: SkillContract, +) -> tuple[tuple[BoundValue, ...], tuple[BindingFailure, ...]]: + try: + declared_tokens = _tokenize_skill_command(declared_command) + effective_tokens = _tokenize_skill_command(effective_command) + except ValueError as exc: + return (), ( + _failure( + BindingFailureCode.INVALID_SKILL_COMMAND, + step_name, + "skill_command", + str(exc), + ), + ) + declared_args = declared_tokens[1:] + effective_args = effective_tokens[1:] + if len(declared_args) != len(effective_args): + return (), ( + _failure( + BindingFailureCode.INVALID_SKILL_COMMAND, + step_name, + "skill_command", + "declared and effective skill arguments do not align", + ), + ) + + input_defs = contract.inputs + input_by_name = {input_def.name: input_def for input_def in input_defs} + assigned: dict[str, tuple[BoundScalar, BoundScalar]] = {} + failures: list[BindingFailure] = [] + position = 0 + for declared_token, effective_token in zip(declared_args, effective_args, strict=True): + declared_named = _split_named_token(declared_token) + effective_named = _split_named_token(effective_token) + if declared_named is not None: + name, declared_value = declared_named + if effective_named is None or effective_named[0] != name: + failures.append( + _failure( + BindingFailureCode.INVALID_SKILL_COMMAND, + step_name, + name, + "declared and effective named arguments do not align", + ) + ) + continue + effective_value = effective_named[1] + if name not in input_by_name: + failures.append( + _failure( + BindingFailureCode.UNKNOWN_SKILL_INPUT, + step_name, + name, + f"skill input {name!r} is not declared by the selected contract", + ) + ) + continue + else: + while position < len(input_defs) and input_defs[position].name in assigned: + position += 1 + if position >= len(input_defs): + failures.append( + _failure( + BindingFailureCode.DEAD_SKILL_INPUT, + step_name, + f"arg{position}", + "skill command contains more positional values than the contract", + ) + ) + position += 1 + continue + name = input_defs[position].name + declared_value = _unquote(declared_token) + effective_value = _unquote(effective_token) + position += 1 + if name in assigned: + failures.append( + _failure( + BindingFailureCode.AMBIGUOUS_SKILL_INPUT, + step_name, + name, + f"skill input {name!r} is supplied more than once", + ) + ) + continue + if declared_value == "-": + continue + assigned[name] = (declared_value, effective_value) + + bound: list[BoundValue] = [] + for input_def in input_defs: + pair = assigned.get(input_def.name) + if pair is None: + bound.append(BoundValue.absent(input_def.name)) + if input_def.required: + failures.append( + _failure( + BindingFailureCode.MISSING_SKILL_INPUT, + step_name, + input_def.name, + f"required skill input {input_def.name!r} is absent", + ) + ) + continue + bound_declared, bound_effective = pair + value = _bound_value(input_def.name, bound_declared, bound_effective) + bound.append(value) + unresolved = bool(value.context_dependencies or value.input_dependencies) + if not unresolved and not _skill_value_is_valid(bound_effective, input_def): + failures.append( + _failure( + BindingFailureCode.INVALID_SKILL_INPUT_TYPE, + step_name, + input_def.name, + f"skill input {input_def.name!r} expects {input_def.type!r}", + ) + ) + return tuple(bound), tuple(failures) + + +def _structured_skill_inputs( + *, + step_name: str, + declared_values: Mapping[str, object], + effective_values: Mapping[str, object], + contract: SkillContract, + hidden_inputs: frozenset[str], + ingredient_values: Mapping[str, BoundScalar], +) -> tuple[tuple[BoundValue, ...], tuple[BindingFailure, ...]]: + input_by_name = {input_def.name: input_def for input_def in contract.inputs} + failures: list[BindingFailure] = [] + for name in declared_values: + if name not in input_by_name: + failures.append( + _failure( + BindingFailureCode.UNKNOWN_SKILL_INPUT, + step_name, + name, + f"skill input {name!r} is not declared by the selected contract", + ) + ) + + bound: list[BoundValue] = [] + for input_def in contract.inputs: + if input_def.name not in declared_values: + bound.append(BoundValue.absent(input_def.name)) + if input_def.required: + failures.append( + _failure( + BindingFailureCode.MISSING_SKILL_INPUT, + step_name, + input_def.name, + f"required skill input {input_def.name!r} is absent", + ) + ) + continue + declared = declared_values[input_def.name] + effective = effective_values.get(input_def.name, declared) + if not _is_scalar(declared) or not _is_scalar(effective): + failures.append( + _failure( + BindingFailureCode.INVALID_SKILL_INPUT_TYPE, + step_name, + input_def.name, + f"skill input {input_def.name!r} must be a strict scalar", + ) + ) + bound.append(BoundValue.absent(input_def.name)) + continue + resolved = _resolve_hidden_value( + declared, + effective, + hidden_inputs=hidden_inputs, + ingredient_values=ingredient_values, + ) + value = _bound_value(input_def.name, declared, resolved) + bound.append(value) + unresolved = bool(value.context_dependencies or value.input_dependencies) + if not unresolved and not _skill_value_is_valid(resolved, input_def): + failures.append( + _failure( + BindingFailureCode.INVALID_SKILL_INPUT_TYPE, + step_name, + input_def.name, + f"skill input {input_def.name!r} expects {input_def.type!r}", + ) + ) + return tuple(bound), tuple(failures) + + +def bind_step_invocation( + step_name: str, + step: Any, + *, + manifest: dict[str, Any] | None = None, + ingredient_values: Mapping[str, BoundScalar] | None = None, + hidden_inputs: frozenset[str] = frozenset(), + mode: BindingMode = BindingMode.RECIPE, +) -> BoundStepInvocation: + """Compile one recipe step without mutating its source model.""" + + tool_name = step.tool or "" + effective_with: Mapping[str, object] = step.with_args or {} + declared_with: Mapping[str, object] = ( + getattr(step, "declared_with_args", None) or effective_with + ) + tool_def = get_tool_def(tool_name) + if tool_def is None: + failure = _failure( + BindingFailureCode.UNKNOWN_TOOL, + step_name, + tool_name, + f"tool {tool_name!r} is not present in the canonical registry", + ) + return BoundStepInvocation(step_name, tool_name, mode, None, (), (), (failure,)) + + failures: list[BindingFailure] = [] + active_manifest = manifest or load_bundled_manifest() + authorable_params = tool_def.param_set + if tool_name == "run_python": + callable_name = effective_with.get("callable") + callable_contract = ( + get_callable_contract(callable_name, active_manifest) + if isinstance(callable_name, str) + else None + ) + if callable_contract is not None: + authorable_params |= frozenset( + input_def.name for input_def in callable_contract.inputs + ) + unknown_params = frozenset(declared_with) - authorable_params + for name in sorted(unknown_params): + failures.append( + _failure( + BindingFailureCode.UNKNOWN_TOOL_PARAMETER, + step_name, + name, + f"tool {tool_name!r} has no parameter named {name!r}", + ) + ) + + ingredients = ingredient_values or {} + mcp_kwargs: list[BoundValue] = [] + for param in tool_def.params: + if param.structured_skill_inputs: + continue + if param.name not in declared_with: + if param.required: + failures.append( + _failure( + BindingFailureCode.MISSING_TOOL_PARAMETER, + step_name, + param.name, + f"required tool parameter {param.name!r} is absent", + ) + ) + continue + declared = declared_with[param.name] + effective = effective_with.get(param.name, declared) + if not _is_scalar(declared) or not _is_scalar(effective): + failures.append( + _failure( + BindingFailureCode.INVALID_TOOL_PARAMETER_TYPE, + step_name, + param.name, + f"tool parameter {param.name!r} must be a strict scalar", + ) + ) + continue + resolved = _resolve_hidden_value( + declared, + effective, + hidden_inputs=hidden_inputs, + ingredient_values=ingredients, + ) + value = _bound_value(param.name, declared, resolved) + mcp_kwargs.append(value) + unresolved = bool(value.context_dependencies or value.input_dependencies) + if not unresolved and not _wire_value_is_valid(resolved, param): + failures.append( + _failure( + BindingFailureCode.INVALID_TOOL_PARAMETER_TYPE, + step_name, + param.name, + f"tool parameter {param.name!r} expects {param.wire_type.value!r}", + ) + ) + + if tool_name != "run_skill": + return BoundStepInvocation( + step_name, + tool_name, + mode, + None, + tuple(mcp_kwargs), + (), + tuple(failures), + ) + + declared_command = declared_with.get("skill_command") + effective_command = effective_with.get("skill_command", declared_command) + if not isinstance(declared_command, str) or not isinstance(effective_command, str): + failures.append( + _failure( + BindingFailureCode.INVALID_SKILL_COMMAND, + step_name, + "skill_command", + "run_skill.skill_command must be a string", + ) + ) + return BoundStepInvocation( + step_name, + tool_name, + mode, + None, + tuple(mcp_kwargs), + (), + tuple(failures), + ) + + skill_name = resolve_skill_name(effective_command) + contract = get_skill_contract(skill_name, active_manifest) if skill_name else None + if skill_name is None or contract is None: + failures.append( + _failure( + BindingFailureCode.UNKNOWN_SKILL, + step_name, + skill_name or "skill_command", + "selected skill has no canonical contract", + ) + ) + return BoundStepInvocation( + step_name, + tool_name, + mode, + skill_name, + tuple(mcp_kwargs), + (), + tuple(failures), + ) + + declared_structured = declared_with.get("skill_inputs") + effective_structured = effective_with.get("skill_inputs", declared_structured) + try: + has_inline_args = bool(_tokenize_skill_command(declared_command)[1:]) + except ValueError: + has_inline_args = True + if declared_structured is not None and has_inline_args: + failures.append( + _failure( + BindingFailureCode.AMBIGUOUS_SKILL_INPUT, + step_name, + "skill_inputs", + "run_skill may not mix inline arguments with structured skill_inputs", + ) + ) + skill_inputs = tuple(BoundValue.absent(item.name) for item in contract.inputs) + elif declared_structured is not None: + if not isinstance(declared_structured, Mapping) or not isinstance( + effective_structured, Mapping + ): + failures.append( + _failure( + BindingFailureCode.INVALID_SKILL_INPUT_TYPE, + step_name, + "skill_inputs", + "run_skill.skill_inputs must be a mapping", + ) + ) + skill_inputs = () + else: + skill_inputs, skill_failures = _structured_skill_inputs( + step_name=step_name, + declared_values=declared_structured, + effective_values=effective_structured, + contract=contract, + hidden_inputs=hidden_inputs, + ingredient_values=ingredients, + ) + failures.extend(skill_failures) + else: + skill_inputs, skill_failures = _inline_skill_inputs( + step_name=step_name, + declared_command=declared_command, + effective_command=effective_command, + contract=contract, + ) + failures.extend(skill_failures) + + return BoundStepInvocation( + step_name=step_name, + tool_name=tool_name, + mode=mode, + skill_name=skill_name, + mcp_kwargs=tuple(mcp_kwargs), + skill_inputs=skill_inputs, + failures=tuple(failures), + ) + + +def bind_recipe( + recipe: Any, + *, + manifest: dict[str, Any] | None = None, + ingredient_values: Mapping[str, BoundScalar] | None = None, + mode: BindingMode = BindingMode.RECIPE, +) -> RecipeBindingProjection: + """Compile a fresh immutable projection for the supplied recipe snapshot.""" + + values: dict[str, BoundScalar] = {} + for name, ingredient in (recipe.ingredients or {}).items(): + default = getattr(ingredient, "default", None) + if _is_scalar(default): + values[name] = default + if ingredient_values: + values.update(ingredient_values) + hidden_inputs = frozenset( + name + for name, ingredient in (recipe.ingredients or {}).items() + if getattr(ingredient, "hidden", False) + ) + active_manifest = manifest or load_bundled_manifest() + invocations = { + step_name: bind_step_invocation( + step_name, + step, + manifest=active_manifest, + ingredient_values=values, + hidden_inputs=hidden_inputs, + mode=mode, + ) + for step_name, step in recipe.steps.items() + if step.tool is not None + } + return RecipeBindingProjection(invocations) diff --git a/src/autoskillit/recipe/_contracts_manifest.py b/src/autoskillit/recipe/_contracts_manifest.py index eeb999922..bde47c2d3 100644 --- a/src/autoskillit/recipe/_contracts_manifest.py +++ b/src/autoskillit/recipe/_contracts_manifest.py @@ -47,7 +47,7 @@ def get_skill_contract(skill_name: str, manifest: dict[str, Any]) -> SkillContra skill_data = skills.get(skill_name) if skill_data is None: return None - inputs = [ + inputs = tuple( SkillInput( name=inp["name"], type=inp["type"], @@ -56,7 +56,7 @@ def get_skill_contract(skill_name: str, manifest: dict[str, Any]) -> SkillContra recommended=inp.get("recommended", False), ) for inp in skill_data.get("inputs", []) - ] + ) outputs = [ SkillOutput( name=out["name"], @@ -184,7 +184,7 @@ def get_callable_contract( entry = callables.get(dotted_path) if entry is None: return None - inputs = [ + inputs = tuple( SkillInput( name=inp["name"], type=inp["type"], @@ -193,7 +193,7 @@ def get_callable_contract( nullable=inp.get("nullable", True), ) for inp in entry.get("inputs", []) - ] + ) outputs = [ SkillOutput( name=out["name"], diff --git a/src/autoskillit/recipe/_contracts_types.py b/src/autoskillit/recipe/_contracts_types.py index 731e7ba1f..80a479f02 100644 --- a/src/autoskillit/recipe/_contracts_types.py +++ b/src/autoskillit/recipe/_contracts_types.py @@ -12,7 +12,7 @@ RESULT_CAPTURE_RE = re.compile(r"\$\{\{\s*result\.([\w-]+)\s*\}\}") -@dataclasses.dataclass +@dataclasses.dataclass(frozen=True, slots=True) class SkillInput: name: str type: str @@ -49,7 +49,7 @@ class SuccessQualifierEntry: @dataclasses.dataclass class SkillContract: - inputs: list[SkillInput] + inputs: tuple[SkillInput, ...] outputs: list[SkillOutput] expected_output_patterns: list[str] = dataclasses.field(default_factory=list) pattern_examples: list[str] = dataclasses.field(default_factory=list) @@ -61,6 +61,12 @@ class SkillContract: outcome_invariants: list[OutcomeInvariantEntry] = dataclasses.field(default_factory=list) success_qualifiers: list[SuccessQualifierEntry] = dataclasses.field(default_factory=list) + def __post_init__(self) -> None: + # Tests and project integrations may still construct contracts from a + # list. Canonicalize once at the boundary; consumers always observe an + # explicit immutable declaration order. + self.inputs = tuple(self.inputs) + @dataclasses.dataclass(frozen=True, slots=True) class ToolOutputFieldSpec: diff --git a/src/autoskillit/recipe/io.py b/src/autoskillit/recipe/io.py index 7732af719..1b8ab9f27 100644 --- a/src/autoskillit/recipe/io.py +++ b/src/autoskillit/recipe/io.py @@ -123,16 +123,62 @@ def _load_recipe_dict( temp_dir_relpath: When set, ``{{AUTOSKILLIT_TEMP}}`` is replaced in the text before parsing (applies to both JSON and YAML paths). """ + effective, _declared = _load_recipe_dict_with_declarations( + yaml_path, + raw_text=raw_text, + temp_dir_relpath=temp_dir_relpath, + ) + return effective + + +def _substitute_recipe_values( + value: Any, + *, + temp_dir_relpath: str | None, +) -> Any: + if isinstance(value, str): + resolved = ( + substitute_temp_placeholder(value, temp_dir_relpath) + if temp_dir_relpath is not None + else value + ) + return substitute_scripts_placeholder(resolved) + if isinstance(value, dict): + return { + key: _substitute_recipe_values(item, temp_dir_relpath=temp_dir_relpath) + for key, item in value.items() + } + if isinstance(value, list): + return [ + _substitute_recipe_values(item, temp_dir_relpath=temp_dir_relpath) for item in value + ] + return value + + +def _load_recipe_dict_with_declarations( + yaml_path: Path, + *, + raw_text: str | None = None, + temp_dir_relpath: str | None = None, +) -> tuple[dict[str, Any], dict[str, Any]]: + """Load aligned effective and declared mappings. + + Placeholder replacement is applied to parsed values, not source text, so + the declaration remains available for binding provenance. + """ json_path = yaml_path.with_suffix(".json") try: if json_path.stat().st_mtime_ns >= yaml_path.stat().st_mtime_ns: text = json_path.read_text(encoding="utf-8") - if temp_dir_relpath is not None: - text = substitute_temp_placeholder(text, temp_dir_relpath) - text = substitute_scripts_placeholder(text) data = _fast_loads(text) if isinstance(data, dict): - return data + return ( + _substitute_recipe_values( + data, + temp_dir_relpath=temp_dir_relpath, + ), + data, + ) logger.warning( "Pre-compiled JSON is not a mapping, falling back to YAML: %s", json_path ) @@ -142,13 +188,13 @@ def _load_recipe_dict( pass if raw_text is None: raw_text = yaml_path.read_text(encoding="utf-8") - if temp_dir_relpath is not None: - raw_text = substitute_temp_placeholder(raw_text, temp_dir_relpath) - raw_text = substitute_scripts_placeholder(raw_text) data = load_yaml(raw_text) if not isinstance(data, dict): raise ValueError(f"Recipe file must contain a YAML mapping: {yaml_path}") - return data + return ( + _substitute_recipe_values(data, temp_dir_relpath=temp_dir_relpath), + data, + ) def load_recipe(path: Path, temp_dir_relpath: str = ".autoskillit/temp") -> Recipe: @@ -163,8 +209,11 @@ def load_recipe(path: Path, temp_dir_relpath: str = ".autoskillit/temp") -> Reci ``_analysis.py`` imports ``iter_steps_with_context`` from this module, so a top-level import here would create a cycle. """ - data = _load_recipe_dict(path, temp_dir_relpath=temp_dir_relpath) - recipe = _parse_recipe(data) + data, declared_data = _load_recipe_dict_with_declarations( + path, + temp_dir_relpath=temp_dir_relpath, + ) + recipe = _parse_recipe(data, declared_data=declared_data) from autoskillit.recipe.staleness_cache import compute_recipe_hash # noqa: PLC0415 recipe.content_hash = compute_recipe_hash(path) @@ -368,6 +417,7 @@ def load_campaign_recipes_in_packs( "python", "constant", "with_args", + "declared_with_args", "on_success", "on_failure", "on_context_limit", @@ -491,7 +541,11 @@ def _parse_capture_spec(capture_raw: Any) -> dict[str, CaptureEntrySpec]: return result -def _parse_recipe(data: dict[str, Any]) -> Recipe: +def _parse_recipe( + data: dict[str, Any], + *, + declared_data: dict[str, Any] | None = None, +) -> Recipe: name = data.get("name", "") description = data.get("description", "") summary = data.get("summary", "") @@ -512,9 +566,14 @@ def _parse_recipe(data: dict[str, Any]) -> Recipe: _steps_raw = data.get("steps") if _steps_raw is not None and not isinstance(_steps_raw, dict): raise ValueError(f"'steps' must be a mapping, got {type(_steps_raw).__name__!r}") + declared_steps = declared_data.get("steps", {}) if isinstance(declared_data, dict) else {} for step_name, step_data in (_steps_raw or {}).items(): if isinstance(step_data, dict): - step = _parse_step(step_data) + declared_step = declared_steps.get(step_name, step_data) + step = _parse_step( + step_data, + declared_data=declared_step if isinstance(declared_step, dict) else step_data, + ) step.name = step_name steps[step_name] = step @@ -625,7 +684,34 @@ def _ensure_list(value: Any) -> list[str]: return [] -def _parse_step(data: dict[str, Any]) -> RecipeStep: +def _normalize_step_with_args( + tool: str | None, + raw_with: object, +) -> dict[str, Any]: + if raw_with is None: + return {} + if not isinstance(raw_with, dict): + raise TypeError("Recipe step 'with' must be a mapping") + normalized = dict(raw_with) + # Precompiled JSON normalizes run_python callable arguments beneath + # ``args``. Restore the canonical scalar recipe representation so fresh + # YAML and JSON paths produce identical RecipeStep values. + if tool == "run_python" and isinstance(normalized.get("args"), dict): + args = normalized.pop("args") + overlap = normalized.keys() & args.keys() + if overlap: + raise ValueError( + f"run_python precompiled args duplicate outer keys: {sorted(overlap)}" + ) + normalized.update(args) + return normalized + + +def _parse_step( + data: dict[str, Any], + *, + declared_data: dict[str, Any] | None = None, +) -> RecipeStep: if "retry" in data: raise ValueError( "The 'retry:' block is no longer supported. " @@ -652,12 +738,21 @@ def _parse_step(data: dict[str, Any]) -> RecipeStep: if conditions: on_result = StepResultRoute(conditions=conditions) + tool = data.get("tool") + declared_source = declared_data or data + with_args = _normalize_step_with_args(tool, data.get("with", {})) + declared_with_args = _normalize_step_with_args( + declared_source.get("tool", tool), + declared_source.get("with", {}), + ) + return RecipeStep( - tool=data.get("tool"), + tool=tool, action=data.get("action"), python=data.get("python"), constant=data.get("constant"), - with_args=data.get("with", {}), + with_args=with_args, + declared_with_args=declared_with_args, on_success=data.get("on_success"), on_failure=data.get("on_failure"), on_context_limit=data.get("on_context_limit"), diff --git a/src/autoskillit/recipe/rules/rules_contracts.py b/src/autoskillit/recipe/rules/rules_contracts.py index 87d161259..aa1b852fc 100644 --- a/src/autoskillit/recipe/rules/rules_contracts.py +++ b/src/autoskillit/recipe/rules/rules_contracts.py @@ -9,7 +9,6 @@ from autoskillit.recipe.contracts import ( get_skill_contract, load_bundled_manifest, - resolve_skill_name, ) from autoskillit.recipe.registry import RuleFinding, make_finding, semantic_rule @@ -33,6 +32,11 @@ _DELIM_BACKTICK_RE = re.compile(r"`(---/?\w[\w:.-]*---)`") +def _bound_skill_name(ctx: ValidationContext, step_name: str) -> str | None: + invocation = ctx.binding_projection.for_step(step_name) + return invocation.skill_name if invocation is not None else None + + def _normalize_for_pattern_match(text: str) -> str: """Collapse HR-split delimiters and strip delimiter decorators. @@ -60,8 +64,7 @@ def _check_missing_output_patterns(ctx: ValidationContext) -> list[RuleFinding]: if step.tool != "run_skill": continue - skill_cmd = step.with_args.get("skill_command", "") - name = resolve_skill_name(skill_cmd) + name = _bound_skill_name(ctx, step_name) if not name: continue @@ -101,8 +104,7 @@ def _check_pattern_examples_match(ctx: ValidationContext) -> list[RuleFinding]: for step_name, step in ctx.recipe.steps.items(): if step.tool != "run_skill": continue - skill_cmd = step.with_args.get("skill_command", "") - name = resolve_skill_name(skill_cmd) + name = _bound_skill_name(ctx, step_name) if not name: continue contract = get_skill_contract(name, manifest) @@ -153,8 +155,7 @@ def _check_missing_pattern_examples(ctx: ValidationContext) -> list[RuleFinding] for step_name, step in ctx.recipe.steps.items(): if step.tool != "run_skill": continue - skill_cmd = step.with_args.get("skill_command", "") - name = resolve_skill_name(skill_cmd) + name = _bound_skill_name(ctx, step_name) if not name: continue contract = get_skill_contract(name, manifest) @@ -192,8 +193,7 @@ def _check_write_behavior_consistency(ctx: ValidationContext) -> list[RuleFindin if step.tool != "run_skill": continue - skill_cmd = step.with_args.get("skill_command", "") - skill_name = resolve_skill_name(skill_cmd) + skill_name = _bound_skill_name(ctx, step_name) if not skill_name: continue @@ -298,8 +298,7 @@ def _check_always_has_no_write_exit(ctx: ValidationContext) -> list[RuleFinding] if step.tool != "run_skill": continue - skill_cmd = step.with_args.get("skill_command", "") - name = resolve_skill_name(skill_cmd) + name = _bound_skill_name(ctx, step_name) if not name: continue @@ -384,8 +383,7 @@ def _check_result_field_drift(ctx: ValidationContext) -> list[RuleFinding]: if step.tool != "run_skill": continue - skill_cmd = step.with_args.get("skill_command", "") - name = resolve_skill_name(skill_cmd) + name = _bound_skill_name(ctx, step_name) if not name or name not in skill_schemas: continue @@ -450,8 +448,7 @@ def _check_example_covers_all_allowed_values(ctx: ValidationContext) -> list[Rul for step_name, step in ctx.recipe.steps.items(): if step.tool != "run_skill": continue - skill_cmd = (step.with_args or {}).get("skill_command", "") - name = resolve_skill_name(skill_cmd) + name = _bound_skill_name(ctx, step_name) if not name: continue contract = get_skill_contract(name, manifest) @@ -511,8 +508,7 @@ def _check_all_examples_match_all_patterns(ctx: ValidationContext) -> list[RuleF for step_name, step in ctx.recipe.steps.items(): if step.tool != "run_skill": continue - skill_cmd = (step.with_args or {}).get("skill_command", "") - name = resolve_skill_name(skill_cmd) + name = _bound_skill_name(ctx, step_name) if not name: continue contract = get_skill_contract(name, manifest) @@ -569,8 +565,7 @@ def _check_path_output_recovery_coverage(ctx: ValidationContext) -> list[RuleFin for step_name, step in ctx.recipe.steps.items(): if step.tool != "run_skill": continue - skill_cmd = (step.with_args or {}).get("skill_command", "") - name = resolve_skill_name(skill_cmd) + name = _bound_skill_name(ctx, step_name) if not name: continue contract = get_skill_contract(name, manifest) @@ -632,8 +627,7 @@ def _check_write_skill_requires_source_output_dir(ctx: ValidationContext) -> lis for step_name, step in ctx.recipe.steps.items(): if step.tool not in SKILL_TOOLS: continue - skill_cmd = (step.with_args or {}).get("skill_command", "") - name = resolve_skill_name(skill_cmd) + name = _bound_skill_name(ctx, step_name) if not name: continue contract = get_skill_contract(name, manifest) diff --git a/src/autoskillit/recipe/rules/rules_inputs.py b/src/autoskillit/recipe/rules/rules_inputs.py index 621cdd8b7..30c787348 100644 --- a/src/autoskillit/recipe/rules/rules_inputs.py +++ b/src/autoskillit/recipe/rules/rules_inputs.py @@ -9,18 +9,15 @@ AUTOSKILLIT_INSTALLED_VERSION, CONFIG_AUTHORITY_KEYS, SKILL_TOOLS, + BindingFailureCode, + BoundValueOrigin, Severity, get_logger, ) from autoskillit.recipe._analysis import ValidationContext from autoskillit.recipe.contracts import ( - classify_step_arg_style, - count_positional_args, - extract_context_refs, - extract_input_refs, get_skill_contract, load_bundled_manifest, - resolve_skill_name, ) from autoskillit.recipe.io import iter_steps_with_context from autoskillit.recipe.registry import RuleFinding, make_finding, semantic_rule @@ -62,55 +59,77 @@ def _check_outdated_version(ctx: ValidationContext) -> list[RuleFinding]: def _check_unsatisfied_skill_input(ctx: ValidationContext) -> list[RuleFinding]: wf = ctx.recipe findings: list[RuleFinding] = [] - manifest = load_bundled_manifest() ingredient_names = set(wf.ingredients.keys()) for step_name, step, available_context in iter_steps_with_context(wf): - if step.tool in SKILL_TOOLS: - skill_cmd = step.with_args.get("skill_command", "") - skill_name = resolve_skill_name(skill_cmd) - if skill_name: - contract = get_skill_contract(skill_name, manifest) - if contract: - all_input_names = {i.name for i in contract.inputs} - if classify_step_arg_style(skill_cmd, all_input_names) != "named": - continue - - ctx_refs = extract_context_refs(step) - inp_refs = extract_input_refs(step) - provided = ctx_refs | inp_refs - - for req_input in contract.inputs: - if not req_input.required: - continue - name = req_input.name - if name not in provided: - if name in available_context or name in ingredient_names: - msg = ( - f"Step '{step_name}' invokes {skill_name} which requires " - f"'{name}', and '{name}' is available in the recipe " - f"context, but the step does not reference it. Add " - f"'${{{{ context.{name} }}}}' to the step's skill_command " - f"or with: block." - ) - else: - msg = ( - f"Step '{step_name}' invokes {skill_name} which requires " - f"'{name}', but '{name}' is not available at this point " - f"in the recipe. No prior step captures it and it is " - f"not a recipe ingredient." - ) - findings.append( - make_finding( - rule_name="missing-ingredient", - step_name=step_name, - message=msg, - ) - ) + if step.tool not in SKILL_TOOLS: + continue + invocation = ctx.binding_projection.for_step(step_name) + if invocation is None: + continue + for failure in invocation.failures: + if failure.code is not BindingFailureCode.MISSING_SKILL_INPUT: + continue + name = failure.name + skill_name = invocation.skill_name or "selected skill" + if name in available_context or name in ingredient_names: + msg = ( + f"Step '{step_name}' invokes {skill_name} which requires " + f"'{name}', and '{name}' is available in the recipe context, " + "but the compiled child invocation does not bind it by name." + ) + else: + msg = ( + f"Step '{step_name}' invokes {skill_name} which requires " + f"'{name}', but '{name}' is not available at this point in the recipe." + ) + findings.append( + make_finding( + rule_name="missing-ingredient", + step_name=step_name, + message=msg, + ) + ) return findings +@semantic_rule( + name="invalid-skill-invocation-binding", + description="Structured child-skill inputs must bind unambiguously to the selected contract.", + severity=Severity.ERROR, +) +def _check_invalid_skill_invocation_binding( + ctx: ValidationContext, +) -> list[RuleFinding]: + findings: list[RuleFinding] = [] + reported_codes = { + BindingFailureCode.UNKNOWN_SKILL, + BindingFailureCode.UNKNOWN_SKILL_INPUT, + BindingFailureCode.DEAD_SKILL_INPUT, + BindingFailureCode.AMBIGUOUS_SKILL_INPUT, + BindingFailureCode.INVALID_SKILL_INPUT_TYPE, + BindingFailureCode.INVALID_SKILL_COMMAND, + } + for step_name, step in ctx.recipe.steps.items(): + if step.tool not in SKILL_TOOLS: + continue + invocation = ctx.binding_projection.for_step(step_name) + if invocation is None: + continue + for failure in invocation.failures: + if failure.code not in reported_codes: + continue + findings.append( + make_finding( + rule_name="invalid-skill-invocation-binding", + step_name=step_name, + message=failure.message, + ) + ) + return findings + + @semantic_rule( name="missing-recommended-input", description="Skill steps should provide recommended inputs for full-quality output.", @@ -119,33 +138,29 @@ def _check_unsatisfied_skill_input(ctx: ValidationContext) -> list[RuleFinding]: def _check_missing_recommended_input(ctx: ValidationContext) -> list[RuleFinding]: wf = ctx.recipe findings: list[RuleFinding] = [] - manifest = load_bundled_manifest() - for step_name, step, _available_context in iter_steps_with_context(wf): if step.tool not in SKILL_TOOLS: continue - skill_cmd = step.with_args.get("skill_command", "") if step.with_args else "" - if not skill_cmd: + invocation = ctx.binding_projection.for_step(step_name) + if invocation is None or invocation.skill_name is None: continue - skill_name = resolve_skill_name(skill_cmd) - if not skill_name: - continue - contract = get_skill_contract(skill_name, manifest) + contract = get_skill_contract(invocation.skill_name, load_bundled_manifest()) if not contract: continue for inp in contract.inputs: if not inp.recommended or inp.required: continue - if not re.search(rf"(?:^|\s){re.escape(inp.name)}=", skill_cmd): + bound = invocation.skill_input(inp.name) + if bound is None or not bound.is_present: findings.append( make_finding( rule_name="missing-recommended-input", step_name=step_name, - message=f"Step '{step_name}' invokes {skill_name} which recommends " + message=f"Step '{step_name}' invokes {invocation.skill_name} " + f"which recommends " f"'{inp.name}' for full-quality output, but the step does not " - f"pass it. Add '{inp.name}=${{{{ context.{inp.name} }}}}' to " - f"the skill_command or add a pre-computation step.", + "bind it in the compiled child invocation.", ) ) @@ -164,34 +179,27 @@ def _check_missing_recommended_input(ctx: ValidationContext) -> list[RuleFinding def _check_shadowed_required_inputs(ctx: ValidationContext) -> list[RuleFinding]: wf = ctx.recipe findings: list[RuleFinding] = [] - manifest = load_bundled_manifest() ingredient_names = set(wf.ingredients.keys()) for step_name, step, available_context in iter_steps_with_context(wf): if step.tool not in SKILL_TOOLS: continue - skill_cmd = step.with_args.get("skill_command", "") if step.with_args else "" - if not skill_cmd: - continue - # Only applies when there are positional (non-template) args. - # Steps with count == 0 are already handled by missing-ingredient. - if count_positional_args(skill_cmd) == 0: + invocation = ctx.binding_projection.for_step(step_name) + if invocation is None or invocation.skill_name is None: continue - skill_name = resolve_skill_name(skill_cmd) - if not skill_name: - continue - contract = get_skill_contract(skill_name, manifest) + contract = get_skill_contract(invocation.skill_name, load_bundled_manifest()) if not contract: continue - used_refs = extract_context_refs(step) | extract_input_refs(step) - for req_input in contract.inputs: if not req_input.required: continue name = req_input.name - if name in used_refs: - continue # Correctly passed as template ref + bound = invocation.skill_input(name) + if bound is None or not bound.is_present: + continue + if bound.origin is not BoundValueOrigin.LITERAL: + continue # Only fire when the input IS available — if it's not in context yet, # the missing-ingredient rule (or runtime) will surface that separately. if name not in available_context and name not in ingredient_names: @@ -200,7 +208,7 @@ def _check_shadowed_required_inputs(ctx: ValidationContext) -> list[RuleFinding] make_finding( rule_name="shadowed-required-input", step_name=step_name, - message=f"Step '{step_name}' invokes /{skill_name} which requires " + message=f"Step '{step_name}' invokes /{invocation.skill_name} which requires " f"'{name}' (type: {req_input.type}), and '{name}' is available " f"in the recipe context, but the skill_command passes prose text " f"instead of the template reference. " diff --git a/src/autoskillit/recipe/rules/rules_tools.py b/src/autoskillit/recipe/rules/rules_tools.py index 914b6c146..4bc62ce83 100644 --- a/src/autoskillit/recipe/rules/rules_tools.py +++ b/src/autoskillit/recipe/rules/rules_tools.py @@ -6,13 +6,13 @@ from pathlib import PurePosixPath from autoskillit.core import ( - GATED_TOOLS, - HEADLESS_TOOLS, SKILL_TOOLS, TOOL_SUBSET_TAGS, - UNGATED_TOOLS, + BindingFailureCode, Severity, + all_tool_names, get_logger, + get_tool_def, ) from autoskillit.recipe._analysis import ValidationContext from autoskillit.recipe._rule_helpers import _MAX_HOPS @@ -25,227 +25,12 @@ logger = get_logger(__name__) -_ALL_TOOLS: frozenset[str] = GATED_TOOLS | UNGATED_TOOLS | HEADLESS_TOOLS +_ALL_TOOLS: frozenset[str] = all_tool_names() _RUN_PYTHON_PATH_LIKE_ARGS: frozenset[str] = frozenset( {"output_dir", "workspace", "diagnostics_log_dir"} ) -# Known parameter signatures for MCP tools that accept `with:` args in recipes. -# Intentionally hardcoded — recipe validation runs without a live MCP server. -_TOOL_PARAMS: dict[str, frozenset[str]] = { - # --- Execution tools --- - "run_skill": frozenset( - { - "skill_command", - "cwd", - "model", - "step_name", - "step_provider", - "order_id", - "stale_threshold", - "idle_output_timeout", - "output_dir", - "resume_session_id", - "closure_authority_path", - "closure_authority_hash", - "closure_plan_paths", - "closure_base_sha", - "closure_diff_sha", - "closure_target_sha", - } - ), - "run_cmd": frozenset({"cmd", "cwd", "timeout", "step_name"}), - "run_python": frozenset({"callable", "args", "timeout", "work_dir"}), - # --- Workspace tools --- - "test_check": frozenset({"worktree_path", "step_name"}), - "merge_worktree": frozenset({"worktree_path", "base_branch", "step_name"}), - "reset_test_dir": frozenset({"test_dir", "force", "step_name"}), - "classify_fix": frozenset({"worktree_path", "base_branch", "step_name"}), - "reset_workspace": frozenset({"test_dir"}), - # --- Recipe tools --- - "validate_recipe": frozenset({"script_path"}), - "migrate_recipe": frozenset({"name"}), - "load_recipe": frozenset({"name", "overrides", "ingredients_only", "delivery_request"}), - "list_recipes": frozenset(), - # --- Clone tools --- - "clone_repo": frozenset( - { - "source_dir", - "run_name", - "branch", - "strategy", - "remote_url", - "step_name", - } - ), - "remove_clone": frozenset({"clone_path", "keep", "step_name"}), - "push_to_remote": frozenset( - { - "clone_path", - "branch", - "source_dir", - "remote_url", - "force", - "step_name", - } - ), - "register_clone_status": frozenset( - { - "clone_path", - "status", - "registry_path", - "step_name", - } - ), - "batch_cleanup_clones": frozenset( - { - "registry_path", - "all_owners", - "owner_filter", - "step_name", - } - ), - # --- CI tools --- - "wait_for_ci": frozenset( - { - "auto_trigger", - "branch", - "repo", - "remote_url", - "head_sha", - "workflow", - "event", - "timeout_seconds", - "lookback_seconds", - "cwd", - "step_name", - } - ), - "wait_for_merge_queue": frozenset( - { - "pr_number", - "target_branch", - "cwd", - "repo", - "remote_url", - "timeout_seconds", - "poll_interval", - "stall_grace_period", - "max_stall_retries", - "not_in_queue_confirmation_cycles", - "max_inconclusive_retries", - "auto_merge_available", - "max_merge_group_drops", - "merge_group_drop_backoff", - "step_name", - } - ), - "enqueue_pr": frozenset( - { - "pr_number", - "target_branch", - "cwd", - "auto_merge_available", - "repo", - "remote_url", - "step_name", - } - ), - "get_ci_status": frozenset({"branch", "run_id", "repo", "workflow", "event", "cwd"}), - "set_commit_status": frozenset( - { - "sha", - "state", - "context", - "description", - "target_url", - "repo", - "cwd", - } - ), - "check_repo_merge_state": frozenset( - { - "branch", - "cwd", - "remote_url", - "step_name", - "base_branch", - } - ), - # --- Git tools --- - "create_unique_branch": frozenset( - { - "slug", - "issue_number", - "remote", - "cwd", - "base_branch_name", - "step_name", - } - ), - "check_pr_mergeable": frozenset({"pr_number", "cwd", "repo"}), - # --- Integration tools --- - "report_bug": frozenset( - { - "error_context", - "cwd", - "severity", - "model", - "step_name", - } - ), - "prepare_issue": frozenset( - { - "title", - "body", - "repo", - "labels", - "dry_run", - "split", - } - ), - "enrich_issues": frozenset({"issue_number", "batch", "dry_run", "repo"}), - "claim_issue": frozenset({"issue_url", "label", "allow_reentry"}), - "release_issue": frozenset( - {"issue_url", "label", "target_branch", "staged_label", "fail_label", "close_issue"} - ), - "fetch_github_issue": frozenset({"issue_url", "include_comments"}), - "get_issue_title": frozenset({"issue_url"}), - # --- Status tools --- - "get_quota_events": frozenset({"n"}), - "write_telemetry_files": frozenset({"output_dir"}), - "get_pr_reviews": frozenset({"pr_number", "cwd", "repo"}), - "bulk_close_issues": frozenset({"issue_numbers", "comment", "cwd"}), - "unlock_agent_pack": frozenset({"pack_name"}), - "configure_fleet": frozenset( - { - "max_concurrent_dispatches", - "max_total_issues", - "default_timeout_sec", - "max_extension_seconds", - "idle_output_timeout", - "acquire_timeout_sec", - "max_issues_per_food_truck", - "enable_deadline_extension", - "default_model", - "inspector_model", - } - ), - "configure_order": frozenset( - { - "timeout", - "stale_threshold", - "idle_output_timeout", - "max_suppression_seconds", - "default_model", - } - ), - "lock_ingredients": frozenset({"locked", "pipeline_id", "unlock"}), - "record_pipeline_step": frozenset({"pipeline_id", "op", "dependencies", "step_name"}), -} - - # Registry of context variables that are captured upstream and MUST be forwarded # to any tool step that accepts them. Each entry maps a tool name to the set of # context-param names that the tool relies on. When a recipe captures one of @@ -394,25 +179,30 @@ def _check_subset_disabled_tool(ctx: ValidationContext) -> list[RuleFinding]: @semantic_rule( name="dead-with-param", description="with: key does not match any known parameter of the step's tool", - severity=Severity.WARNING, + severity=Severity.ERROR, ) def _check_dead_with_params(ctx: ValidationContext) -> list[RuleFinding]: findings: list[RuleFinding] = [] for step_name, step in ctx.recipe.steps.items(): - if step.tool is None or step.tool not in _TOOL_PARAMS: + if step.tool is None: continue - known_params = _TOOL_PARAMS[step.tool] - for key in step.with_args: - if key not in known_params: - findings.append( - make_finding( - rule_name="dead-with-param", - step_name=step_name, - message=f"step '{step_name}': with key '{key}' is not a known " - f"parameter of tool '{step.tool}'. " - f"Known parameters: {sorted(known_params)}", - ) + invocation = ctx.binding_projection.for_step(step_name) + if invocation is None: + continue + tool_def = get_tool_def(step.tool) + known_params = sorted(tool_def.param_set) if tool_def is not None else [] + for failure in invocation.failures: + if failure.code is not BindingFailureCode.UNKNOWN_TOOL_PARAMETER: + continue + findings.append( + make_finding( + rule_name="dead-with-param", + step_name=step_name, + message=f"step '{step_name}': with key '{failure.name}' is not a known " + f"parameter of tool '{step.tool}'. " + f"Known parameters: {known_params}", ) + ) return findings diff --git a/src/autoskillit/recipe/schema.py b/src/autoskillit/recipe/schema.py index e5d8256f8..2a667b006 100644 --- a/src/autoskillit/recipe/schema.py +++ b/src/autoskillit/recipe/schema.py @@ -7,7 +7,7 @@ from dataclasses import dataclass, field from enum import StrEnum from pathlib import Path -from typing import Final +from typing import Any, Final import regex as re @@ -23,6 +23,8 @@ AUTOSKILLIT_VERSION_KEY: Final = "autoskillit_version" RECIPE_VERSION_KEY: Final = "recipe_version" CAMPAIGN_REF_RE: Final = re.compile(r"\$\{\{\s*campaign\.(\w+)\s*\}\}") +RecipeScalar = str | int | float | bool +RecipeArgValue = RecipeScalar | dict[str, RecipeScalar] class RecipeKind(StrEnum): @@ -115,7 +117,11 @@ class RecipeStep: action: str | None = None # Built-in action: "route", "stop", "confirm" python: str | None = None constant: str | None = None # Literal output value — no subprocess or MCP call - with_args: dict[str, str] = field(default_factory=dict) + # Runtime validation below enforces RecipeArgValue. ``Any`` keeps existing + # scalar consumers from falsely treating the sole nested skill_inputs + # channel as if every key could be a mapping. + with_args: dict[str, Any] = field(default_factory=dict) + declared_with_args: dict[str, Any] = field(default_factory=dict, repr=False) on_success: str | None = None on_failure: str | None = None on_context_limit: str | None = None @@ -147,6 +153,35 @@ class RecipeStep: skip_when_true: str | None = None def __post_init__(self) -> None: + if not isinstance(self.with_args, dict): + raise TypeError("RecipeStep.with_args must be a mapping") + for key, value in self.with_args.items(): + if isinstance(value, dict): + if self.tool != "run_skill" or key != "skill_inputs": + raise TypeError( + "Only run_skill.with.skill_inputs may contain a mapping; " + f"got mapping at {self.tool or ''}.{key}" + ) + if not all( + isinstance(input_name, str) + and isinstance(input_value, (str, int, float, bool)) + and input_value is not None + for input_name, input_value in value.items() + ): + raise TypeError( + "run_skill.with.skill_inputs must map names to strict scalar values" + ) + elif not isinstance(value, (str, int, float, bool)) or value is None: + raise TypeError( + f"RecipeStep.with.{key} must be a strict scalar, got {type(value).__name__}" + ) + if self.declared_with_args: + if self.declared_with_args.keys() != self.with_args.keys(): + raise ValueError( + "RecipeStep declared/effective with mappings must have identical keys" + ) + else: + self.declared_with_args = dict(self.with_args) self.capture = _coerce_capture_dict(self.capture) self.capture_list = _coerce_capture_dict(self.capture_list) if self.capture_list and self.retries > 0: diff --git a/tests/_test_filter.py b/tests/_test_filter.py index 6b4fd3d2c..28b2a1eb0 100644 --- a/tests/_test_filter.py +++ b/tests/_test_filter.py @@ -280,9 +280,11 @@ class ImportContext(enum.StrEnum): "bash_write_targets": frozenset({"core", "execution", "server"}), "_delivery_bounds": frozenset({"core", "execution", "server"}), "_type_audit_cycle": frozenset({"core", "recipe", "server"}), + "_type_recipe_binding": frozenset({"core", "recipe", "server"}), "_type_closure_report": frozenset({"core"}), "context_admission": frozenset({"core"}), "audit_cycle_verifier": frozenset({"core", "recipe", "server"}), + "tool_registry": frozenset({"core", "recipe", "server"}), "closure_hashing": frozenset({"core"}), "path_containment": frozenset({"core"}), "closure_verifier": frozenset({"core", "execution"}), @@ -488,6 +490,7 @@ class ImportContext(enum.StrEnum): "rules_skill_write_path_alignment": frozenset({"recipe"}), # --- Internal utility modules (no external src importers) --- "_analysis": frozenset({"recipe"}), + "_binding": frozenset({"recipe", "server"}), "_analysis_bfs": frozenset({"recipe"}), "_analysis_blocks": frozenset({"recipe"}), "_analysis_detectors": frozenset({"recipe"}), diff --git a/tests/core/test_io.py b/tests/core/test_io.py index 735671552..9a6a41b31 100644 --- a/tests/core/test_io.py +++ b/tests/core/test_io.py @@ -80,7 +80,26 @@ def spy(data, *, Loader=None, **kw): else: load_yaml("key: val") - assert captured["Loader"] is _yaml.CSafeLoader + assert issubclass(captured["Loader"], _yaml.CSafeLoader) # type: ignore[arg-type] + + @pytest.mark.parametrize("input_kind", ["string", "path"]) + def test_load_yaml_rejects_duplicate_mapping_keys( + self, + tmp_path: Path, + input_kind: str, + ) -> None: + from autoskillit.core.io import YAMLError, load_yaml + + source = "steps:\n verify:\n tool: run_skill\n tool: run_cmd\n" + if input_kind == "path": + path = tmp_path / "duplicate.yaml" + path.write_text(source, encoding="utf-8") + candidate: str | Path = path + else: + candidate = source + + with pytest.raises(YAMLError, match="duplicate key 'tool'"): + load_yaml(candidate) def test_loader_is_csafe_or_safe(self): import yaml as _yaml diff --git a/tests/recipe/AGENTS.md b/tests/recipe/AGENTS.md index d499f0a32..4d5043702 100644 --- a/tests/recipe/AGENTS.md +++ b/tests/recipe/AGENTS.md @@ -69,6 +69,7 @@ Recipe I/O, validation, semantic rules, schema, and bundled recipe tests. | `test_io_discovery.py` | Tests for recipe I/O discovery (list_recipes, recipe iteration) | | `test_io_parsing.py` | Tests for recipe YAML parsing and load_recipe | | `test_io_schema_fields.py` | Tests for recipe schema field validation in I/O layer | +| `test_skill_invocation_binding.py` | Canonical tool/skill namespace binding, absence, order, provenance, and YAML/JSON/prune parity | | `test_io_json_precompile.py` | Tests for JSON pre-compilation fast path in recipe I/O | | `test_issue_url_pipeline.py` | Tests for issue URL pipeline in recipe steps | | `test_loader.py` | Tests for path-based recipe metadata utilities | diff --git a/tests/recipe/test_rules_tools.py b/tests/recipe/test_rules_tools.py index 75ba004f7..d5170607b 100644 --- a/tests/recipe/test_rules_tools.py +++ b/tests/recipe/test_rules_tools.py @@ -103,7 +103,7 @@ def _make_recipe_with_args(tool: str, with_args: dict[str, str] | None = None) - def test_dead_with_param_detects_unknown_key() -> None: - """with key 'add_dir' on run_skill produces dead-with-param WARNING.""" + """with key 'add_dir' on run_skill produces dead-with-param ERROR.""" recipe = _make_recipe_with_args( "run_skill", {"skill_command": "/autoskillit:investigate", "cwd": "/tmp", "add_dir": "/some/path"}, @@ -111,7 +111,7 @@ def test_dead_with_param_detects_unknown_key() -> None: findings = run_semantic_rules(recipe) dead = [f for f in findings if f.rule == "dead-with-param"] assert dead, "Expected dead-with-param finding for 'add_dir'" - assert all(f.severity == Severity.WARNING for f in dead) + assert all(f.severity == Severity.ERROR for f in dead) assert any("add_dir" in f.message for f in dead) @@ -165,10 +165,10 @@ def test_run_python_rejects_callable_path_param() -> None: def test_run_python_accepts_callable_param() -> None: - recipe = _make_recipe_with_args("run_python", {"callable": "mod.fn", "args": {}}) + recipe = _make_recipe_with_args("run_python", {"callable": "mod.fn"}) findings = run_semantic_rules(recipe) dead = [f for f in findings if f.rule == "dead-with-param"] - assert not dead, "valid params 'callable'/'args' must not trigger dead-with-param" + assert not dead, "valid param 'callable' must not trigger dead-with-param" def test_clone_repo_rejects_stale_params() -> None: diff --git a/tests/recipe/test_skill_invocation_binding.py b/tests/recipe/test_skill_invocation_binding.py new file mode 100644 index 000000000..6c111316a --- /dev/null +++ b/tests/recipe/test_skill_invocation_binding.py @@ -0,0 +1,301 @@ +"""Canonical recipe invocation compiler coverage.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +import pytest + +from autoskillit.core import ( + BindingFailureCode, + BindingMode, + BoundValueOrigin, + BoundValueState, +) +from autoskillit.recipe._binding import bind_recipe, bind_step_invocation +from autoskillit.recipe._contracts_manifest import get_skill_contract +from autoskillit.recipe._recipe_composition import _prune_skipped_steps +from autoskillit.recipe.io import load_recipe +from autoskillit.recipe.schema import RecipeStep + +pytestmark = [pytest.mark.layer("recipe"), pytest.mark.small] + + +def _manifest() -> dict[str, object]: + return { + "skills": { + "dry-walkthrough": { + "inputs": [ + {"name": "plan_path", "type": "file_path", "required": True}, + {"name": "issue_url", "type": "string", "required": True}, + {"name": "optional_note", "type": "string"}, + {"name": "audit_cycle_path", "type": "file_path", "required": True}, + { + "name": "plan_disposition_path", + "type": "file_path", + "required": True, + }, + {"name": "enabled", "type": "boolean"}, + {"name": "round", "type": "integer"}, + ] + } + } + } + + +def _step( + skill_inputs: dict[str, str | int | float | bool], + **extra: str, +) -> RecipeStep: + return RecipeStep( + name="verify", + tool="run_skill", + with_args={ + "skill_command": "/autoskillit:dry-walkthrough", + "cwd": "/repo", + "skill_inputs": skill_inputs, + **extra, + }, + ) + + +def _required_inputs() -> dict[str, str]: + return { + "plan_path": "/tmp/plans/current plan.md", + "issue_url": "https://example.test/issues/42?x=a&y=b", + "audit_cycle_path": "/tmp/audit/cycle.json", + "plan_disposition_path": "/tmp/audit/plan disposition.json", + } + + +def test_contract_input_order_is_explicit_and_immutable() -> None: + contract = get_skill_contract("dry-walkthrough", _manifest()) + assert contract is not None + assert isinstance(contract.inputs, tuple) + assert tuple(value.name for value in contract.inputs) == ( + "plan_path", + "issue_url", + "optional_note", + "audit_cycle_path", + "plan_disposition_path", + "enabled", + "round", + ) + + +def test_structured_binding_uses_contract_order_not_mapping_order() -> None: + values = _required_inputs() + step = _step(dict(reversed(tuple(values.items())))) + invocation = bind_step_invocation("verify", step, manifest=_manifest()) + + assert invocation.is_valid + assert tuple(name for name, _value in invocation.canonical_child_invocation) == ( + "plan_path", + "issue_url", + "audit_cycle_path", + "plan_disposition_path", + ) + + +def test_optional_absence_does_not_shift_later_values() -> None: + invocation = bind_step_invocation( + "verify", + _step(_required_inputs()), + manifest=_manifest(), + ) + + optional = invocation.skill_input("optional_note") + audit_cycle = invocation.skill_input("audit_cycle_path") + assert optional is not None and optional.state is BoundValueState.ABSENT + assert audit_cycle is not None + assert audit_cycle.effective_value == "/tmp/audit/cycle.json" + + +def test_falsey_values_remain_present() -> None: + values: dict[str, str | int | float | bool] = _required_inputs() + values.update(optional_note="", enabled=False, round=0) + invocation = bind_step_invocation( + "verify", + _step(values), + manifest=_manifest(), + ) + + assert invocation.is_valid + assert invocation.skill_input("optional_note").effective_value == "" # type: ignore[union-attr] + assert invocation.skill_input("enabled").effective_value is False # type: ignore[union-attr] + assert invocation.skill_input("round").effective_value == 0 # type: ignore[union-attr] + + +def test_spaces_and_shell_metacharacters_round_trip_as_data() -> None: + values = _required_inputs() + values["optional_note"] = "a path; $(touch never) && 'quoted value'" + invocation = bind_step_invocation( + "verify", + _step(values), + manifest=_manifest(), + ) + + assert invocation.is_valid + assert dict(invocation.canonical_child_invocation)["optional_note"] == values["optional_note"] + + +def test_unknown_tool_and_skill_namespaces_are_distinct() -> None: + outer = bind_step_invocation( + "verify", + _step(_required_inputs(), inert_sibling="value"), + manifest=_manifest(), + ) + inner_values = _required_inputs() + inner_values["unknown_child"] = "value" + inner = bind_step_invocation( + "verify", + _step(inner_values), + manifest=_manifest(), + ) + + assert BindingFailureCode.UNKNOWN_TOOL_PARAMETER in { + failure.code for failure in outer.failures + } + assert BindingFailureCode.UNKNOWN_SKILL_INPUT not in { + failure.code for failure in outer.failures + } + assert BindingFailureCode.UNKNOWN_SKILL_INPUT in {failure.code for failure in inner.failures} + + +def test_missing_and_inline_plus_structured_inputs_reject() -> None: + missing = _required_inputs() + del missing["plan_path"] + missing_invocation = bind_step_invocation( + "verify", + _step(missing), + manifest=_manifest(), + ) + ambiguous_step = _step(_required_inputs()) + ambiguous_step.with_args["skill_command"] = "/autoskillit:dry-walkthrough /tmp/inline-plan.md" + ambiguous_step.declared_with_args["skill_command"] = ( + "/autoskillit:dry-walkthrough /tmp/inline-plan.md" + ) + ambiguous = bind_step_invocation( + "verify", + ambiguous_step, + manifest=_manifest(), + ) + + assert BindingFailureCode.MISSING_SKILL_INPUT in { + failure.code for failure in missing_invocation.failures + } + assert BindingFailureCode.AMBIGUOUS_SKILL_INPUT in { + failure.code for failure in ambiguous.failures + } + + +def test_standalone_binding_cannot_claim_recipe_attestation() -> None: + invocation = bind_step_invocation( + "verify", + _step(_required_inputs()), + manifest=_manifest(), + mode=BindingMode.STANDALONE, + ) + + assert invocation.is_valid + assert not invocation.attested + + +@pytest.mark.parametrize("use_json", [False, True]) +def test_declared_effective_provenance_survives_load_and_prune( + tmp_path: Path, + use_json: bool, +) -> None: + yaml_path = tmp_path / "binding.yaml" + yaml_path.write_text( + """ +name: binding +description: binding provenance +kitchen_rules: [keep provenance] +ingredients: + private_root: + description: private root + default: /default + hidden: true + keep: + description: keep step + default: "true" +steps: + verify: + tool: run_skill + with: + skill_command: /autoskillit:dry-walkthrough + cwd: /repo + skill_inputs: + plan_path: "{{AUTOSKILLIT_TEMP}}/plans/current.md" + issue_url: https://example.test/issues/42 + audit_cycle_path: "${{ inputs.private_root }}/cycle.json" + plan_disposition_path: "{{AUTOSKILLIT_TEMP}}/plans/disposition.json" + skip_when_false: inputs.keep +""".lstrip(), + encoding="utf-8", + ) + if use_json: + parsed = { + "name": "binding", + "description": "binding provenance", + "kitchen_rules": ["keep provenance"], + "ingredients": { + "private_root": { + "description": "private root", + "default": "/default", + "hidden": True, + }, + "keep": {"description": "keep step", "default": "true"}, + }, + "steps": { + "verify": { + "tool": "run_skill", + "with": { + "skill_command": "/autoskillit:dry-walkthrough", + "cwd": "/repo", + "skill_inputs": { + "plan_path": "{{AUTOSKILLIT_TEMP}}/plans/current.md", + "issue_url": "https://example.test/issues/42", + "audit_cycle_path": "${{ inputs.private_root }}/cycle.json", + "plan_disposition_path": ( + "{{AUTOSKILLIT_TEMP}}/plans/disposition.json" + ), + }, + }, + "skip_when_false": "inputs.keep", + } + }, + } + json_path = yaml_path.with_suffix(".json") + json_path.write_text(json.dumps(parsed), encoding="utf-8") + newer = yaml_path.stat().st_mtime_ns + 1_000_000 + os.utime(json_path, ns=(newer, newer)) + + recipe = load_recipe(yaml_path, temp_dir_relpath="/custom temp") + pre = bind_recipe( + recipe, + manifest=_manifest(), + ingredient_values={"private_root": "/private root", "keep": "true"}, + ) + pruned, _resolutions = _prune_skipped_steps(recipe, {"keep": "true"}) + post = bind_recipe( + pruned, + manifest=_manifest(), + ingredient_values={"private_root": "/private root", "keep": "true"}, + ) + + for projection in (pre, post): + invocation = projection.for_step("verify") + assert invocation is not None + plan = invocation.skill_input("plan_path") + cycle = invocation.skill_input("audit_cycle_path") + assert plan is not None + assert plan.declared_value == "{{AUTOSKILLIT_TEMP}}/plans/current.md" + assert plan.effective_value == "/custom temp/plans/current.md" + assert cycle is not None + assert cycle.origin is BoundValueOrigin.TEMPLATE + assert cycle.input_dependencies == ("private_root",) + assert cycle.effective_value == "/private root/cycle.json" diff --git a/tests/server/AGENTS.md b/tests/server/AGENTS.md index 4e18f8e7d..0ff6d05e6 100644 --- a/tests/server/AGENTS.md +++ b/tests/server/AGENTS.md @@ -137,6 +137,7 @@ Server tool handler unit tests — kitchen, execution, CI, clone, workspace tool | `test_tools_pr_ops.py` | Tests for server/tools_pr_ops.py | | `test_tools_recipe.py` | Tests for autoskillit server validate_recipe tool and recipe docstring contracts | | `test_tools_recipe_pull.py` | Tests for the `get_recipe_section` pull tool and bounded envelope architecture (Part B #4304) | +| `test_tool_registry_parity.py` | Bidirectional AST parity between canonical IL-0 tool metadata and live MCP handler signatures | | `test_tools_report_bug.py` | Tests for report_bug MCP tool handler and supporting helpers (_parse_fingerprint, _extract_block, _parse_prepare_result, _parse_enrich_result) | | `test_tools_run_cmd.py` | Tests for run_cmd and run_python MCP tool handlers | | `test_tools_run_cmd_invariants.py` | Server-side invariant tests for run_cmd: recipe-read prohibition and write-target boundary | diff --git a/tests/server/test_tool_registry_parity.py b/tests/server/test_tool_registry_parity.py new file mode 100644 index 000000000..18b9f1e5b --- /dev/null +++ b/tests/server/test_tool_registry_parity.py @@ -0,0 +1,64 @@ +"""Canonical IL-0 tool registry parity with live MCP handlers.""" + +from __future__ import annotations + +import ast +from pathlib import Path + +import pytest + +from autoskillit.core import TOOL_REGISTRY + +pytestmark = [pytest.mark.layer("server"), pytest.mark.small] + + +def _handler_signatures() -> dict[str, tuple[tuple[str, bool], ...]]: + tools_dir = Path(__file__).resolve().parents[2] / "src" / "autoskillit" / "server" / "tools" + handlers: dict[str, tuple[tuple[str, bool], ...]] = {} + for path in sorted(tools_dir.glob("tools_*.py")): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for node in tree.body: + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + if not any( + isinstance(decorator, ast.Call) + and isinstance(decorator.func, ast.Attribute) + and decorator.func.attr == "tool" + for decorator in node.decorator_list + ): + continue + positional = [*node.args.posonlyargs, *node.args.args] + defaults: list[ast.expr | None] = [None] * (len(positional) - len(node.args.defaults)) + defaults.extend(node.args.defaults) + pairs = [ + *zip(positional, defaults, strict=True), + *zip(node.args.kwonlyargs, node.args.kw_defaults, strict=True), + ] + handlers[node.name] = tuple( + (argument.arg, default is None) + for argument, default in pairs + if argument.arg != "ctx" + ) + return handlers + + +def test_registry_matches_handler_names_bidirectionally() -> None: + assert set(TOOL_REGISTRY) == set(_handler_signatures()) + + +def test_registry_matches_handler_order_and_requiredness() -> None: + for name, handler_params in _handler_signatures().items(): + registry_params = tuple( + (param.name, param.required) + for param in TOOL_REGISTRY[name].params + if param.handler_parameter + ) + assert registry_params == handler_params, name + + +def test_run_skill_has_one_compiler_owned_structured_input_channel() -> None: + structured = tuple( + param for param in TOOL_REGISTRY["run_skill"].params if param.structured_skill_inputs + ) + assert tuple(param.name for param in structured) == ("skill_inputs",) + assert not structured[0].handler_parameter From b2f35514af735a8305a1ed5d7aec2f81f7dd4d02 Mon Sep 17 00:00:00 2001 From: Trecek Date: Thu, 23 Jul 2026 18:18:29 -0700 Subject: [PATCH 03/89] feat(server): attest compiled recipe execution --- src/autoskillit/core/__init__.pyi | 14 + src/autoskillit/core/tool_registry.py | 6 +- src/autoskillit/core/types/AGENTS.md | 1 + src/autoskillit/core/types/__init__.py | 3 + .../core/types/_type_protocols_recipe.py | 1 + .../core/types/_type_recipe_execution.py | 263 +++++++++ src/autoskillit/pipeline/context.py | 13 + src/autoskillit/recipe/_api.py | 14 +- src/autoskillit/recipe/_contracts_manifest.py | 1 + src/autoskillit/recipe/_contracts_types.py | 1 + src/autoskillit/recipe/_recipe_ingredients.py | 8 +- src/autoskillit/recipe/repository.py | 2 + src/autoskillit/server/AGENTS.md | 1 + src/autoskillit/server/_factory.py | 33 +- src/autoskillit/server/_recipe_delivery.py | 106 +++- src/autoskillit/server/_recipe_execution.py | 531 ++++++++++++++++++ .../server/tools/_serve_helpers.py | 26 +- .../server/tools/tools_execution.py | 231 +++++++- src/autoskillit/server/tools/tools_kitchen.py | 13 + src/autoskillit/server/tools/tools_recipe.py | 8 + tests/arch/test_subpackage_isolation.py | 2 + tests/server/AGENTS.md | 1 + .../test_audit_cycle_delivery_integration.py | 423 ++++++++++++++ tests/server/test_tool_registry_parity.py | 2 +- 24 files changed, 1670 insertions(+), 34 deletions(-) create mode 100644 src/autoskillit/core/types/_type_recipe_execution.py create mode 100644 src/autoskillit/server/_recipe_execution.py create mode 100644 tests/server/test_audit_cycle_delivery_integration.py diff --git a/src/autoskillit/core/__init__.pyi b/src/autoskillit/core/__init__.pyi index 1eaaa7d8b..7023c4424 100644 --- a/src/autoskillit/core/__init__.pyi +++ b/src/autoskillit/core/__init__.pyi @@ -367,6 +367,7 @@ from .types import AuditAssessment as AuditAssessment from .types import AuditAssessmentRow as AuditAssessmentRow from .types import AuditCycleAuthority as AuditCycleAuthority from .types import AuditCycleHead as AuditCycleHead +from .types import AuditCycleHeadStore as AuditCycleHeadStore from .types import AuditLog as AuditLog from .types import AuthoritySourceId as AuthoritySourceId from .types import AuthorityUnavailableEffect as AuthorityUnavailableEffect @@ -471,6 +472,9 @@ from .types import IdempotencyRecord as IdempotencyRecord from .types import InfraExitCategory as InfraExitCategory from .types import InfraOutcome as InfraOutcome from .types import InputContractResolver as InputContractResolver +from .types import InputPreflightResolver as InputPreflightResolver +from .types import InstalledRecipeExecution as InstalledRecipeExecution +from .types import InvocationTemplate as InvocationTemplate from .types import InputSpec as InputSpec from .types import InputSpecType as InputSpecType from .types import InspectorCallback as InspectorCallback @@ -514,6 +518,8 @@ from .types import PrepareBatchEvent as PrepareBatchEvent from .types import ProcessedEventRecord as ProcessedEventRecord from .types import PlanDispositionReport as PlanDispositionReport from .types import PlanDispositionRow as PlanDispositionRow +from .types import PreflightEvidence as PreflightEvidence +from .types import PreflightKind as PreflightKind from .types import ProcessStaleError as ProcessStaleError from .types import ProducerCoverageDef as ProducerCoverageDef from .types import ProducerInstanceId as ProducerInstanceId @@ -539,6 +545,7 @@ from .types import RecipeDeliveryMode as RecipeDeliveryMode from .types import RecipeDeliveryRequest as RecipeDeliveryRequest from .types import RecipeDeliverySurfaceDef as RecipeDeliverySurfaceDef from .types import RecipeBindingProjection as RecipeBindingProjection +from .types import RecipeExecutionSnapshot as RecipeExecutionSnapshot from .types import RecipeIdentity as RecipeIdentity from .types import RecipeLoadError as RecipeLoadError from .types import RecipeNotFoundError as RecipeNotFoundError @@ -629,6 +636,8 @@ from .types import TestRunner as TestRunner from .types import ToolDef as ToolDef from .types import ToolParamDef as ToolParamDef from .types import ToolWireType as ToolWireType +from .types import VerifiedInputPreflightRequest as VerifiedInputPreflightRequest +from .types import VerifiedInputPreflightResult as VerifiedInputPreflightResult from .types import TimingLog as TimingLog from .types import TokenFactory as TokenFactory from .types import TokenizerIdentity as TokenizerIdentity @@ -651,6 +660,11 @@ 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_findings_digest as compute_findings_digest +from .types import compute_invocation_template_digest as compute_invocation_template_digest +from .types import ( + compute_recipe_execution_snapshot_digest as compute_recipe_execution_snapshot_digest, +) +from .types import compute_runtime_binding_digest as compute_runtime_binding_digest 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 diff --git a/src/autoskillit/core/tool_registry.py b/src/autoskillit/core/tool_registry.py index a7e562f10..80b360b1a 100644 --- a/src/autoskillit/core/tool_registry.py +++ b/src/autoskillit/core/tool_registry.py @@ -39,6 +39,8 @@ def _run_skill() -> ToolDef: "cwd", "model", "step_name", + "recipe_execution_id", + "invocation_template_digest", "step_provider", "order_id", "output_dir", @@ -58,7 +60,7 @@ def _run_skill() -> ToolDef: ) for name in string_params ] - params[6:6] = [ + params[8:8] = [ ToolParamDef("stale_threshold", ToolWireType.INTEGER), ToolParamDef("idle_output_timeout", ToolWireType.INTEGER), ] @@ -67,7 +69,7 @@ def _run_skill() -> ToolDef: "skill_inputs", ToolWireType.OBJECT, structured_skill_inputs=True, - handler_parameter=False, + handler_parameter=True, ) ) return ToolDef(name="run_skill", params=tuple(params)) diff --git a/src/autoskillit/core/types/AGENTS.md b/src/autoskillit/core/types/AGENTS.md index a0bdfc7d8..7f2c56549 100644 --- a/src/autoskillit/core/types/AGENTS.md +++ b/src/autoskillit/core/types/AGENTS.md @@ -23,6 +23,7 @@ Type re-export hub and all typed building blocks for the autoskillit package (IL | `_type_closure_report.py` | Closure report dataclasses: `ClosureRow`, `ClosureReport`, `CLOSURE_REPORT_SCHEMA_VERSION` | | `_type_audit_cycle.py` | Frozen audit-cycle authority, artifact reference, disposition, head, and admission decision models | | `_type_recipe_binding.py` | Frozen tool definitions, binding values/failures, compiled step invocations, and immutable binding projections | +| `_type_recipe_execution.py` | Frozen compiled-execution snapshots, domain-separated invocation/runtime digests, and audit/preflight service protocols | | `_type_protocols_logging.py` | Protocols: `AuditLog`, `TokenLog`, `TimingLog`, `McpResponseLog`, `GitHubApiLog`, `SupportsDebug`, `SupportsLogger` | | `_type_protocols_execution.py` | Protocols: `TestRunner`, `HeadlessExecutor`, `OutputPatternResolver`, `WriteExpectedResolver` | | `_type_protocols_github.py` | Protocols: `GitHubFetcher`, `CIWatcher`, `MergeQueueWatcher` | diff --git a/src/autoskillit/core/types/__init__.py b/src/autoskillit/core/types/__init__.py index ea215a53c..741b42a5b 100644 --- a/src/autoskillit/core/types/__init__.py +++ b/src/autoskillit/core/types/__init__.py @@ -64,6 +64,8 @@ from ._type_recipe_binding import __all__ as _recipe_binding_all from ._type_recipe_delivery import * # noqa: F401, F403 from ._type_recipe_delivery import __all__ as _recipe_delivery_all +from ._type_recipe_execution import * # noqa: F401, F403 +from ._type_recipe_execution import __all__ as _recipe_execution_all from ._type_recipe_sections import * # noqa: F401, F403 from ._type_recipe_sections import __all__ as _recipe_sections_all from ._type_results import * # noqa: F401, F403 @@ -114,6 +116,7 @@ + _results_all + _results_execution_all + _recipe_binding_all + + _recipe_execution_all + _recipe_delivery_all + _recipe_sections_all + _resume_all diff --git a/src/autoskillit/core/types/_type_protocols_recipe.py b/src/autoskillit/core/types/_type_protocols_recipe.py index 1697c310e..1c24edd61 100644 --- a/src/autoskillit/core/types/_type_protocols_recipe.py +++ b/src/autoskillit/core/types/_type_protocols_recipe.py @@ -70,6 +70,7 @@ def load_and_validate( effective_backend_map: dict[str, str] | None = None, backend_capabilities_map: dict[str, BackendCapabilities] | None = None, backend_origin_map: dict[str, str] | None = None, + include_compiled_bindings: bool = False, ) -> dict[str, Any]: """Load and validate a recipe. diff --git a/src/autoskillit/core/types/_type_recipe_execution.py b/src/autoskillit/core/types/_type_recipe_execution.py new file mode 100644 index 000000000..5f5f4f616 --- /dev/null +++ b/src/autoskillit/core/types/_type_recipe_execution.py @@ -0,0 +1,263 @@ +"""Immutable recipe-execution attestation and admission contracts. + +This module is IL-0 and stdlib-only. It deliberately separates invocation +template identity from runtime-bound values and from the final delivery payload +identity, preventing a digest embedded in a payload from depending on that +payload's eventual hash. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from enum import StrEnum +from types import MappingProxyType +from typing import Protocol, runtime_checkable + +from ..closure_hashing import compute_canonical_hash +from ._type_audit_cycle import AuditCycleAuthority, AuditCycleHead, InventoryAdmissionDecision +from ._type_recipe_binding import ( + AbsentBoundValue, + BoundScalar, + BoundStepInvocation, + BoundValue, +) + +__all__ = [ + "AuditCycleHeadStore", + "InputPreflightResolver", + "InstalledRecipeExecution", + "InvocationTemplate", + "PreflightEvidence", + "PreflightKind", + "RecipeExecutionSnapshot", + "VerifiedInputPreflightRequest", + "VerifiedInputPreflightResult", + "compute_invocation_template_digest", + "compute_recipe_execution_snapshot_digest", + "compute_runtime_binding_digest", +] + +_INVOCATION_TEMPLATE_DOMAIN = "autoskillit:recipe-invocation-template:v1:sha256" +_RECIPE_EXECUTION_SNAPSHOT_DOMAIN = "autoskillit:recipe-execution-snapshot:v1:sha256" +_RUNTIME_BINDING_DOMAIN = "autoskillit:recipe-runtime-binding:v1:sha256" + + +def _bound_scalar_payload(value: BoundScalar | AbsentBoundValue) -> object: + if isinstance(value, AbsentBoundValue): + return {"absent": True} + return value + + +def _bound_value_payload(value: BoundValue) -> dict[str, object]: + return { + "context_dependencies": list(value.context_dependencies), + "declared_value": _bound_scalar_payload(value.declared_value), + "effective_value": _bound_scalar_payload(value.effective_value), + "input_dependencies": list(value.input_dependencies), + "name": value.name, + "origin": value.origin.value, + "state": value.state.value, + } + + +def compute_invocation_template_digest( + *, + execution_id: str, + recipe_name: str, + content_hash: str, + composite_hash: str, + invocation: BoundStepInvocation, + tool_contract_identity: str, + skill_contract_identity: str, +) -> str: + """Hash one immutable invocation template under its own domain.""" + payload = { + "composite_hash": composite_hash, + "content_hash": content_hash, + "execution_id": execution_id, + "mcp_kwargs": [_bound_value_payload(value) for value in invocation.mcp_kwargs], + "recipe_name": recipe_name, + "skill_contract_identity": skill_contract_identity, + "skill_inputs": [_bound_value_payload(value) for value in invocation.skill_inputs], + "skill_name": invocation.skill_name, + "step_name": invocation.step_name, + "tool_contract_identity": tool_contract_identity, + "tool_name": invocation.tool_name, + } + return compute_canonical_hash(payload, domain=_INVOCATION_TEMPLATE_DOMAIN) + + +@dataclass(frozen=True, slots=True) +class InvocationTemplate: + invocation: BoundStepInvocation + tool_contract_identity: str + skill_contract_identity: str + template_digest: str + + +def compute_recipe_execution_snapshot_digest( + *, + execution_id: str, + recipe_name: str, + content_hash: str, + composite_hash: str, + templates: Mapping[str, InvocationTemplate], +) -> str: + """Hash the compiled execution snapshot without any delivery hash.""" + payload = { + "composite_hash": composite_hash, + "content_hash": content_hash, + "execution_id": execution_id, + "recipe_name": recipe_name, + "templates": [ + { + "digest": template.template_digest, + "skill_contract_identity": template.skill_contract_identity, + "step_name": step_name, + "tool_contract_identity": template.tool_contract_identity, + } + for step_name, template in templates.items() + ], + } + return compute_canonical_hash(payload, domain=_RECIPE_EXECUTION_SNAPSHOT_DOMAIN) + + +@dataclass(frozen=True, slots=True) +class RecipeExecutionSnapshot: + execution_id: str + recipe_name: str + content_hash: str + composite_hash: str + templates: Mapping[str, InvocationTemplate] + snapshot_digest: str + + def __post_init__(self) -> None: + if not self.execution_id or not self.recipe_name: + raise ValueError("recipe execution identity must be non-empty") + copied = dict(self.templates) + if any(name != template.invocation.step_name for name, template in copied.items()): + raise ValueError("recipe execution template keys must match step names") + object.__setattr__(self, "templates", MappingProxyType(copied)) + expected = compute_recipe_execution_snapshot_digest( + execution_id=self.execution_id, + recipe_name=self.recipe_name, + content_hash=self.content_hash, + composite_hash=self.composite_hash, + templates=copied, + ) + if self.snapshot_digest != expected: + raise ValueError("recipe execution snapshot digest does not match content") + + @property + def template_digests(self) -> Mapping[str, str]: + return MappingProxyType( + {name: template.template_digest for name, template in self.templates.items()} + ) + + +class PreflightKind(StrEnum): + AUDIT_CYCLE_INVENTORY = "audit_cycle_inventory" + + +@dataclass(frozen=True, slots=True) +class VerifiedInputPreflightRequest: + execution_generation: str + step_name: str + skill_name: str + plan_path: str + audit_cycle_path: str | None + plan_disposition_path: str | None + expected_plan_set_id: str = "" + expected_scope_id: str = "" + expected_part_id: str = "" + + +@dataclass(frozen=True, slots=True) +class PreflightEvidence: + name: str + value: BoundScalar + + +@dataclass(frozen=True, slots=True) +class VerifiedInputPreflightResult: + decision: InventoryAdmissionDecision + evidence: tuple[PreflightEvidence, ...] = () + + +@runtime_checkable +class AuditCycleHeadStore(Protocol): + """Server-owned compare-and-swap ledger for trusted audit-cycle heads.""" + + def get( + self, + *, + execution_generation: str, + plan_set_id: str, + scope_id: str, + part_id: str, + ) -> AuditCycleHead | None: ... + + def publish( + self, + authority: AuditCycleAuthority, + *, + expected_parent_digest: str | None, + expected_round: int, + authorized_successor_part_id: str | None = None, + ) -> AuditCycleHead: ... + + def clear_generation(self, execution_generation: str) -> None: ... + + def clear_all(self) -> None: ... + + +@runtime_checkable +class InputPreflightResolver(Protocol): + def resolve( + self, + request: VerifiedInputPreflightRequest, + ) -> VerifiedInputPreflightResult: ... + + +@dataclass(frozen=True, slots=True) +class InstalledRecipeExecution: + """One atomically replaceable active execution generation.""" + + snapshot: RecipeExecutionSnapshot + runtime_binding_digests: Mapping[str, str] + audit_cycle_heads: AuditCycleHeadStore + input_preflight_resolver: InputPreflightResolver + + def __post_init__(self) -> None: + object.__setattr__( + self, + "runtime_binding_digests", + MappingProxyType(dict(self.runtime_binding_digests)), + ) + + +def compute_runtime_binding_digest( + *, + execution_id: str, + step_name: str, + template_digest: str, + bound_inputs: tuple[tuple[str, BoundScalar], ...], + preflight: InventoryAdmissionDecision | None, +) -> str: + """Hash actual ordered values independently from the template/payload.""" + payload = { + "bound_inputs": [{"name": name, "value": value} for name, value in bound_inputs], + "execution_id": execution_id, + "preflight": ( + None + if preflight is None + else { + "reason": preflight.reason.value, + "status": preflight.status.value, + } + ), + "step_name": step_name, + "template_digest": template_digest, + } + return compute_canonical_hash(payload, domain=_RUNTIME_BINDING_DOMAIN) diff --git a/src/autoskillit/pipeline/context.py b/src/autoskillit/pipeline/context.py index 645c28be6..d018a124c 100644 --- a/src/autoskillit/pipeline/context.py +++ b/src/autoskillit/pipeline/context.py @@ -7,12 +7,14 @@ from __future__ import annotations +import threading from dataclasses import dataclass, field from pathlib import Path from typing import Any from autoskillit.config import AutomationConfig from autoskillit.core import ( + AuditCycleHeadStore, AuditLog, BackgroundSupervisor, CampaignProtector, @@ -28,6 +30,8 @@ GitHubFetcher, HeadlessExecutor, InputContractResolver, + InputPreflightResolver, + InstalledRecipeExecution, McpResponseLog, MergeQueueWatcher, MigrationService, @@ -131,6 +135,8 @@ class ToolContext: active_recipe_ingredients: frozenset[str] | None — ingredient keys declared by the loaded recipe (frozenset() when kitchen open but no recipe loaded; None when closed) + active_recipe_execution: atomically installed compiled execution snapshot, runtime + binding digests, trusted audit heads, and verified input resolver. temp_dir: Resolved temp directory for this project. Sentinel-guarded: raises TypeError if not supplied explicitly. Use make_context() or pass temp_dir=. @@ -171,6 +177,8 @@ class ToolContext: input_contract_resolver: InputContractResolver | None = field(default=None) completion_required_resolver: CompletionRequiredResolver | None = field(default=None) skill_contract_resolver: SkillContractResolver | None = field(default=None) + audit_cycle_head_store: AuditCycleHeadStore | None = field(default=None) + input_preflight_resolver: InputPreflightResolver | None = field(default=None) backend: CodingAgentBackend | None = field(default=None) session_skill_manager: SessionSkillManager | None = field(default=None) skill_resolver: SkillResolver | None = field(default=None) @@ -192,6 +200,11 @@ class ToolContext: fleet_lock: FleetLock | None = field(default=None) build_protected_campaign_ids: CampaignProtector | None = field(default=None) ephemeral_root: Path | None = field(default_factory=lambda: None) + active_recipe_execution: InstalledRecipeExecution | None = field(default_factory=lambda: None) + recipe_execution_lock: threading.RLock = field( + default_factory=threading.RLock, + repr=False, + ) def __post_init__(self) -> None: if self.temp_dir is _MISSING: diff --git a/src/autoskillit/recipe/_api.py b/src/autoskillit/recipe/_api.py index f8a8b7339..f75d7905b 100644 --- a/src/autoskillit/recipe/_api.py +++ b/src/autoskillit/recipe/_api.py @@ -203,6 +203,7 @@ def load_and_validate( effective_backend_map: dict[str, str] | None = None, backend_capabilities_map: dict[str, BackendCapabilities] | None = None, backend_origin_map: dict[str, str] | None = None, + include_compiled_bindings: bool = False, ) -> LoadRecipeResult: """Load a recipe by name and run full validation. @@ -303,7 +304,10 @@ def load_and_validate( and rs == cached.recipe_size ): logger.debug("load_recipe_cache_hit", recipe=name) - return cast(LoadRecipeResult, _api_cache._LOAD_CACHE.copy_result(cached.result)) + cached_result = _api_cache._LOAD_CACHE.copy_result(cached.result) + if not include_compiled_bindings: + cached_result.pop("_compiled_bindings", None) + return cast(LoadRecipeResult, cached_result) t0 = time.perf_counter() @@ -329,6 +333,7 @@ def load_and_validate( active_recipe = None _skip_resolutions: dict[str, bool | None] = {} _pre_prune_steps: dict[str, Any] = {} + _post_prune_bindings = None _dispatch_feasible = True _infeasible_steps: list[str] = [] @@ -685,6 +690,8 @@ def load_and_validate( if _deferred_guard_list: result["deferred_guards"] = _deferred_guard_list if active_recipe is not None: + if _post_prune_bindings is not None: + result["_compiled_bindings"] = _post_prune_bindings result["post_prune_step_names"] = list(active_recipe.steps.keys()) _step_names_set = set(active_recipe.steps) result["post_prune_routing_edges"] = sorted( @@ -715,4 +722,7 @@ def load_and_validate( if result.get("valid", False): _api_cache._refresh_staleness_baseline() - return cast(LoadRecipeResult, _api_cache._LOAD_CACHE.copy_result(result)) + caller_result = _api_cache._LOAD_CACHE.copy_result(result) + if not include_compiled_bindings: + caller_result.pop("_compiled_bindings", None) + return cast(LoadRecipeResult, caller_result) diff --git a/src/autoskillit/recipe/_contracts_manifest.py b/src/autoskillit/recipe/_contracts_manifest.py index bde47c2d3..eb023c750 100644 --- a/src/autoskillit/recipe/_contracts_manifest.py +++ b/src/autoskillit/recipe/_contracts_manifest.py @@ -130,6 +130,7 @@ def get_skill_contract(skill_name: str, manifest: dict[str, Any]) -> SkillContra result_fields=result_fields, outcome_invariants=outcome_invariants, success_qualifiers=success_qualifiers, + input_preflight=skill_data.get("input_preflight"), ) diff --git a/src/autoskillit/recipe/_contracts_types.py b/src/autoskillit/recipe/_contracts_types.py index 80a479f02..62300f946 100644 --- a/src/autoskillit/recipe/_contracts_types.py +++ b/src/autoskillit/recipe/_contracts_types.py @@ -60,6 +60,7 @@ class SkillContract: result_fields: list[ResultFieldSpec] = dataclasses.field(default_factory=list) outcome_invariants: list[OutcomeInvariantEntry] = dataclasses.field(default_factory=list) success_qualifiers: list[SuccessQualifierEntry] = dataclasses.field(default_factory=list) + input_preflight: str | None = None def __post_init__(self) -> None: # Tests and project integrations may still construct contracts from a diff --git a/src/autoskillit/recipe/_recipe_ingredients.py b/src/autoskillit/recipe/_recipe_ingredients.py index c9025e8e8..8f45c4c5a 100644 --- a/src/autoskillit/recipe/_recipe_ingredients.py +++ b/src/autoskillit/recipe/_recipe_ingredients.py @@ -2,13 +2,16 @@ from __future__ import annotations -from typing import Any, NotRequired, TypedDict +from typing import TYPE_CHECKING, Any, NotRequired, TypedDict from autoskillit.core import ( TerminalColumn, _render_gfm_table, ) +if TYPE_CHECKING: + from autoskillit.core import RecipeBindingProjection + # --------------------------------------------------------------------------- # GFM ingredient table column specs # --------------------------------------------------------------------------- @@ -132,6 +135,9 @@ class LoadRecipeResult(TypedDict, total=False): dispatch_feasible: bool infeasible_steps: list[str] warnings: NotRequired[list[str]] + # Internal-only. Recipe API callers receive this only when they explicitly + # request the attested server-delivery carrier. + _compiled_bindings: RecipeBindingProjection class OpenKitchenResult(TypedDict, total=False): diff --git a/src/autoskillit/recipe/repository.py b/src/autoskillit/recipe/repository.py index 9a27b7eaa..b5ab5ab94 100644 --- a/src/autoskillit/recipe/repository.py +++ b/src/autoskillit/recipe/repository.py @@ -99,6 +99,7 @@ def load_and_validate( effective_backend_map: dict[str, str] | None = None, backend_capabilities_map: dict[str, BackendCapabilities] | None = None, backend_origin_map: dict[str, str] | None = None, + include_compiled_bindings: bool = False, ) -> dict[str, Any]: project_dir = Path(project_dir) result = self._get_list(project_dir) @@ -120,6 +121,7 @@ def load_and_validate( effective_backend_map=effective_backend_map, backend_capabilities_map=backend_capabilities_map, backend_origin_map=backend_origin_map, + include_compiled_bindings=include_compiled_bindings, ), ) diff --git a/src/autoskillit/server/AGENTS.md b/src/autoskillit/server/AGENTS.md index 9827ee06a..09186d79b 100644 --- a/src/autoskillit/server/AGENTS.md +++ b/src/autoskillit/server/AGENTS.md @@ -17,6 +17,7 @@ Sub-package: tools/ (see tools/AGENTS.md). | `_response_budget.py` | Lossless response spill, exact canonical projection finalization, measured exemptions, and privacy-safe budget telemetry | | `_response_conformance.py` | Post-FastMCP-conversion conformance gate for registered string tool responses | | `_recipe_delivery.py` | Unified recipe finalization, content-addressed generations, pull integrity, and receipt completion | +| `_recipe_execution.py` | Compiled execution snapshot lifecycle, trusted audit-head ledger, runtime binding, and input preflight | | `_recipe_section_pagination.py` | Typed recipe-section selection, deterministic byte-bounded page planning, rendering, and verified-plan caching | | `recipe_section/__init__.py` | Package marker for focused recipe-section planning support | | `recipe_section/_verification.py` | Post-digest descriptor, reconstruction, ordering, and rendered-bound invariant proof | diff --git a/src/autoskillit/server/_factory.py b/src/autoskillit/server/_factory.py index e8018f52c..2025a7f2f 100644 --- a/src/autoskillit/server/_factory.py +++ b/src/autoskillit/server/_factory.py @@ -1,5 +1,5 @@ """Composition Root: make_context() is the only location that legally instantiates -all 24 service contracts simultaneously. +the server service contracts simultaneously. server/ is IL-3 — the only layer permitted to import from both IL-1 (pipeline/) and IL-2 (recipe/, migration/) at the same time. This module is the canonical @@ -17,8 +17,12 @@ from autoskillit.config import AutomationConfig from autoskillit.core import ( + MARKETPLACE_PREFIX, + AuditCycleHeadStore, DirectInstall, FleetLock, + InputPreflightResolver, + MarketplaceInstall, PluginSource, SkillExecutionRole, SubprocessRunner, @@ -80,6 +84,28 @@ logger = get_logger(__name__) + +def make_audit_cycle_head_store() -> AuditCycleHeadStore: + """Construct the server-owned trusted-head ledger implementation.""" + from autoskillit.server._recipe_execution import DefaultAuditCycleHeadStore + + return DefaultAuditCycleHeadStore() + + +def make_input_preflight_resolver( + *, + allowed_root: Path, + head_store: AuditCycleHeadStore, +) -> InputPreflightResolver: + """Construct the bounded verifier backed by the supplied trusted ledger.""" + from autoskillit.server._recipe_execution import DefaultInputPreflightResolver + + return DefaultInputPreflightResolver( + allowed_root=allowed_root, + head_store=head_store, + ) + + # Optional-override: _UNSET means "not provided, use factory-computed default" (None is valid). _UNSET: Any = object() @@ -390,6 +416,11 @@ def _resolve_skill_contract(skill_command: str) -> SkillContract | None: ctx.completion_required_resolver = _resolve_completion_required ctx.skill_contract_resolver = _resolve_skill_contract ctx.input_contract_resolver = resolve_input_specs + ctx.audit_cycle_head_store = make_audit_cycle_head_store() + ctx.input_preflight_resolver = make_input_preflight_resolver( + allowed_root=ctx.temp_dir, + head_store=ctx.audit_cycle_head_store, + ) ctx.token_factory = token_factory ctx.build_protected_campaign_ids = build_protected_campaign_ids ctx.executor = DefaultHeadlessExecutor(ctx) diff --git a/src/autoskillit/server/_recipe_delivery.py b/src/autoskillit/server/_recipe_delivery.py index 2c15dd1c9..2ed18ef5e 100644 --- a/src/autoskillit/server/_recipe_delivery.py +++ b/src/autoskillit/server/_recipe_delivery.py @@ -43,7 +43,12 @@ from autoskillit.server.recipe_section._lifecycle import notify_kitchen_retired if TYPE_CHECKING: - from autoskillit.core import RecipeDeliveryBudgetDef, RecipeDeliveryEvidenceDef + from autoskillit.core import ( + RecipeBindingProjection, + RecipeDeliveryBudgetDef, + RecipeDeliveryEvidenceDef, + RecipeExecutionSnapshot, + ) from autoskillit.pipeline import ToolContext RECIPE_ARTIFACT_DESCRIPTOR_VERSION = 1 @@ -113,6 +118,9 @@ class FinalizedRecipeResponse: decision: RecipeDeliveryDecision receipt_handle: RecipeReceiptHandle | None = None receipt_ledger: RecipeDeliveryReceiptLedger | None = None + artifact_generation: RecipeArtifactGeneration | None = None + execution_snapshot: RecipeExecutionSnapshot | None = None + tool_ctx: ToolContext | None = None def _qualified_sha256(data: bytes) -> str: @@ -752,8 +760,48 @@ def finalize_recipe_delivery( supported_evidence: RecipeDeliveryEvidenceDef | None = None, receipt_ledger: RecipeDeliveryReceiptLedger | None = None, now_unix: int | None = None, + compiled_bindings: RecipeBindingProjection | None = None, ) -> FinalizedRecipeResponse: """Persist, decide, shape, and transactionally reserve one recipe response.""" + execution_snapshot = None + candidate_payload = dict(payload) + if compiled_bindings is not None: + from autoskillit.server._recipe_execution import ( + build_recipe_execution_snapshot, + clear_recipe_execution, + ) + + clear_recipe_execution(tool_ctx) + try: + execution_snapshot = build_recipe_execution_snapshot( + recipe_name=recipe_name, + content_hash=str(candidate_payload.get("content_hash", "")), + composite_hash=str(candidate_payload.get("composite_hash", "")), + projection=compiled_bindings, + ) + except (TypeError, ValueError): + decision = _failure_decision( + producer=RECIPE_DELIVERY_SURFACE_REGISTRY[surface].producer_tool, + reason="recipe_execution_compilation_failed", + selected_limit=resolve_general_output_token_limit( + tool_ctx.backend.capabilities + if tool_ctx.backend is not None + else CLAUDE_CODE_CAPABILITIES + ), + contract_digest="", + ) + return FinalizedRecipeResponse( + rendered=json.dumps( + {"success": False, "error": "recipe_execution_unavailable"}, + separators=(",", ":"), + ), + decision=decision, + ) + candidate_payload["recipe_execution"] = { + "execution_id": execution_snapshot.execution_id, + "invocation_template_digests": dict(execution_snapshot.template_digests), + "snapshot_digest": execution_snapshot.snapshot_digest, + } surface_definition = RECIPE_DELIVERY_SURFACE_REGISTRY[surface] candidate_capabilities = ( getattr(tool_ctx.backend, "capabilities", None) if tool_ctx.backend is not None else None @@ -778,7 +826,7 @@ def finalize_recipe_delivery( kitchen_id=tool_ctx.kitchen_id, producer_tool=surface_definition.producer_tool, recipe_name=recipe_name, - payload=payload, + payload=candidate_payload, ) except (OSError, RecipeArtifactError, TypeError, ValueError): decision = _failure_decision( @@ -795,13 +843,17 @@ def finalize_recipe_delivery( decision=decision, ) - ordinary_rendered = json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + ordinary_rendered = json.dumps( + candidate_payload, + ensure_ascii=False, + separators=(",", ":"), + ) candidate_evidence = supported_evidence if surface_definition.negotiation_eligible else None candidate_attestation = attestation if surface_definition.negotiation_eligible else None candidate_request = delivery_request if surface_definition.negotiation_eligible else None high_rendered = ( _attested_render( - payload, + candidate_payload, generation, budget=delivery_budget, evidence_identity=( @@ -900,7 +952,7 @@ def finalize_recipe_delivery( envelope_bound_bytes = min(envelope_bound_bytes, response_ceiling_bytes) rendered = json.dumps( build_recipe_envelope( - payload, + candidate_payload, recipe_name=recipe_name, generation=generation, skeleton_source=tool_ctx, @@ -914,6 +966,9 @@ def finalize_recipe_delivery( decision=decision, receipt_handle=receipt_handle, receipt_ledger=receipt_ledger if receipt_handle is not None else None, + artifact_generation=generation, + execution_snapshot=execution_snapshot, + tool_ctx=tool_ctx if execution_snapshot is not None else None, ) @@ -928,16 +983,43 @@ def complete_finalized_recipe_response( ledger = finalized.receipt_ledger if enforced == finalized.rendered: if handle is None: - return enforced - if ledger is not None and ledger.commit( + completed = enforced + elif ledger is not None and ledger.commit( handle, now_unix=int(time.time()) if now_unix is None else now_unix, ): - return enforced - enforced = json.dumps( - {"success": False, "error": "recipe_delivery_receipt_commit_failed"}, - separators=(",", ":"), - ) + completed = enforced + else: + completed = json.dumps( + {"success": False, "error": "recipe_delivery_receipt_commit_failed"}, + separators=(",", ":"), + ) + if completed == finalized.rendered: + if finalized.execution_snapshot is not None and finalized.tool_ctx is not None: + try: + from autoskillit.server._recipe_execution import install_recipe_execution + + install_recipe_execution( + finalized.tool_ctx, + snapshot=finalized.execution_snapshot, + ) + except Exception: + from autoskillit.server._recipe_execution import clear_recipe_execution + + clear_recipe_execution(finalized.tool_ctx) + return json.dumps( + { + "success": False, + "error": "recipe_execution_install_failed", + }, + separators=(",", ":"), + ) + return completed + enforced = completed + if finalized.execution_snapshot is not None and finalized.tool_ctx is not None: + from autoskillit.server._recipe_execution import clear_recipe_execution + + clear_recipe_execution(finalized.tool_ctx) if handle is not None and (ledger is None or not ledger.abort(handle)): return json.dumps( {"success": False, "error": "recipe_delivery_receipt_abort_failed"}, diff --git a/src/autoskillit/server/_recipe_execution.py b/src/autoskillit/server/_recipe_execution.py new file mode 100644 index 000000000..dd7362b05 --- /dev/null +++ b/src/autoskillit/server/_recipe_execution.py @@ -0,0 +1,531 @@ +"""Server-owned compiled recipe execution and audit admission state.""" + +from __future__ import annotations + +import hashlib +import json +import threading +from collections.abc import Mapping +from dataclasses import replace +from pathlib import Path +from types import MappingProxyType +from typing import TYPE_CHECKING, Any +from uuid import uuid4 + +from autoskillit.core import ( + AdmissionReason, + AuditCycleAuthority, + AuditCycleHead, + AuditCycleVerifier, + AuditVerdict, + BoundScalar, + BoundValueOrigin, + BoundValueState, + InstalledRecipeExecution, + InventoryAdmissionDecision, + InvocationTemplate, + PreflightEvidence, + RecipeBindingProjection, + RecipeExecutionSnapshot, + VerifiedInputPreflightRequest, + VerifiedInputPreflightResult, + compute_invocation_template_digest, + compute_recipe_execution_snapshot_digest, + resolve_skill_name, +) +from autoskillit.recipe import get_skill_contract, load_bundled_manifest + +if TYPE_CHECKING: + from autoskillit.core import AuditCycleHeadStore + from autoskillit.pipeline import ToolContext + +__all__ = [ + "AuditCycleHeadConflict", + "DefaultAuditCycleHeadStore", + "DefaultInputPreflightResolver", + "RecipeExecutionAdmissionError", + "bind_attested_runtime_invocation", + "build_bound_child_prompt", + "build_recipe_execution_snapshot", + "clear_recipe_execution", + "get_recipe_execution", + "install_recipe_execution", + "publish_verified_audit_cycle", + "record_runtime_binding_digest", +] + +_TOOL_CONTRACT_IDENTITY = "autoskillit:tool-registry:v1:run_skill" +_SKILL_CONTRACT_IDENTITY_DOMAIN = b"autoskillit:skill-contract:v1\0" + + +class AuditCycleHeadConflict(RuntimeError): + """A candidate authority failed the trusted-head compare-and-swap.""" + + +class RecipeExecutionAdmissionError(RuntimeError): + """Stable pre-launch attested-execution rejection.""" + + def __init__(self, code: str, message: str) -> None: + super().__init__(message) + self.code = code + + +class DefaultAuditCycleHeadStore: + """Lock-safe in-memory trusted-head ledger.""" + + def __init__(self) -> None: + self._heads: dict[tuple[str, str, str, str], AuditCycleHead] = {} + self._lock = threading.RLock() + + @staticmethod + def _key( + execution_generation: str, + plan_set_id: str, + scope_id: str, + part_id: str, + ) -> tuple[str, str, str, str]: + return execution_generation, plan_set_id, scope_id, part_id + + def get( + self, + *, + execution_generation: str, + plan_set_id: str, + scope_id: str, + part_id: str, + ) -> AuditCycleHead | None: + with self._lock: + return self._heads.get(self._key(execution_generation, plan_set_id, scope_id, part_id)) + + def publish( + self, + authority: AuditCycleAuthority, + *, + expected_parent_digest: str | None, + expected_round: int, + authorized_successor_part_id: str | None = None, + ) -> AuditCycleHead: + key = self._key( + authority.execution_generation, + authority.plan_set_id, + authority.scope_id, + authority.part_id, + ) + with self._lock: + current = self._heads.get(key) + if current is None: + if ( + expected_parent_digest is not None + or expected_round != 0 + or authority.parent_authority_digest is not None + or authority.audit_round != 1 + ): + raise AuditCycleHeadConflict( + "initial authority requires an empty parent and round one" + ) + else: + if ( + current.current_authority_digest != expected_parent_digest + or current.audit_round != expected_round + ): + raise AuditCycleHeadConflict("audit-cycle head compare-and-swap failed") + try: + AuditCycleVerifier.verify_successor(authority, current) + except Exception as exc: + raise AuditCycleHeadConflict(str(exc)) from exc + if ( + authorized_successor_part_id is not None + and authority.verdict is not AuditVerdict.GO + ): + raise AuditCycleHeadConflict("only a terminal GO may authorize a successor part") + head = AuditCycleHead( + execution_generation=authority.execution_generation, + cycle_id=authority.cycle_id, + plan_set_id=authority.plan_set_id, + scope_id=authority.scope_id, + part_id=authority.part_id, + current_authority_digest=authority.authority_digest, + audit_round=authority.audit_round, + verdict=authority.verdict, + authorized_successor_part_id=authorized_successor_part_id, + ) + self._heads[key] = head + return head + + def clear_generation(self, execution_generation: str) -> None: + with self._lock: + self._heads = { + key: value for key, value in self._heads.items() if key[0] != execution_generation + } + + def clear_all(self) -> None: + with self._lock: + self._heads.clear() + + +class DefaultInputPreflightResolver: + """Verify audit-cycle input provenance before any child construction.""" + + def __init__(self, *, allowed_root: Path, head_store: AuditCycleHeadStore) -> None: + self._verifier = AuditCycleVerifier(allowed_root) + self._head_store = head_store + + @staticmethod + def _result(decision: InventoryAdmissionDecision) -> VerifiedInputPreflightResult: + evidence: tuple[PreflightEvidence, ...] = ( + PreflightEvidence("inventory_admission_status", decision.status.value), + PreflightEvidence("inventory_admission_reason", decision.reason.value), + PreflightEvidence( + "inventory_dispositions", + json.dumps( + [ + { + "disposition": row.disposition, + "implementation_step": row.implementation_step, + "requirement_id": row.requirement_id, + } + for row in decision.dispositions + ], + ensure_ascii=False, + separators=(",", ":"), + ), + ), + ) + return VerifiedInputPreflightResult(decision=decision, evidence=evidence) + + def resolve( + self, + request: VerifiedInputPreflightRequest, + ) -> VerifiedInputPreflightResult: + authority_path = request.audit_cycle_path or None + report_path = request.plan_disposition_path or None + if authority_path is None and report_path is None: + return self._result(InventoryAdmissionDecision.omit(AdmissionReason.NO_AUTHORITY)) + if authority_path is None: + return self._result( + InventoryAdmissionDecision.reject( + AdmissionReason.REPORT_WITHOUT_AUTHORITY, + "a disposition report cannot activate without authority", + ) + ) + try: + authority = self._verifier.load_authority(authority_path) + except Exception as exc: + return self._result( + InventoryAdmissionDecision.reject( + AdmissionReason.AUTHORITY_NOT_CURRENT, + f"authority verification failed: {exc}", + ) + ) + if authority.execution_generation != request.execution_generation: + return self._result( + InventoryAdmissionDecision.reject( + AdmissionReason.GENERATION_MISMATCH, + "authority is from another execution generation", + ) + ) + head = self._head_store.get( + execution_generation=authority.execution_generation, + plan_set_id=authority.plan_set_id, + scope_id=authority.scope_id, + part_id=authority.part_id, + ) + expected_plan_set_id = request.expected_plan_set_id or authority.plan_set_id + expected_scope_id = request.expected_scope_id or authority.scope_id + expected_part_id = request.expected_part_id or authority.part_id + if authority.verdict is AuditVerdict.GO and report_path is not None: + return self._result( + InventoryAdmissionDecision.reject( + AdmissionReason.DISPOSITION_MISMATCH, + "a terminal GO cannot carry a plan disposition report", + ) + ) + decision = self._verifier.evaluate_paths( + authority_path=authority_path, + report_path=report_path, + trusted_head=head, + current_plan_path=request.plan_path, + expected_generation=request.execution_generation, + expected_plan_set_id=expected_plan_set_id, + expected_scope_id=expected_scope_id, + expected_part_id=expected_part_id, + ) + return self._result(decision) + + +def _skill_contract_identity(skill_name: str) -> str: + manifest = load_bundled_manifest() + contract = get_skill_contract(skill_name, manifest) + if contract is None: + raise ValueError(f"skill contract is unavailable for {skill_name!r}") + payload = json.dumps( + { + "completion_required": contract.completion_required, + "input_preflight": getattr(contract, "input_preflight", None), + "inputs": [ + { + "name": item.name, + "nullable": item.nullable, + "required": item.required, + "type": item.type, + } + for item in contract.inputs + ], + "skill_name": skill_name, + }, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return "sha256:" + hashlib.sha256(_SKILL_CONTRACT_IDENTITY_DOMAIN + payload).hexdigest() + + +def build_recipe_execution_snapshot( + *, + recipe_name: str, + content_hash: str, + composite_hash: str, + projection: RecipeBindingProjection, + execution_id: str | None = None, +) -> RecipeExecutionSnapshot: + """Create a fresh attested snapshot from the exact post-prune projection.""" + active_execution_id = execution_id or uuid4().hex + templates: dict[str, InvocationTemplate] = {} + for step_name, invocation in projection.invocations.items(): + if invocation.tool_name != "run_skill": + continue + if not invocation.attested or invocation.skill_name is None: + raise ValueError(f"run_skill step {step_name!r} is not valid for attestation") + skill_identity = _skill_contract_identity(invocation.skill_name) + digest = compute_invocation_template_digest( + execution_id=active_execution_id, + recipe_name=recipe_name, + content_hash=content_hash, + composite_hash=composite_hash, + invocation=invocation, + tool_contract_identity=_TOOL_CONTRACT_IDENTITY, + skill_contract_identity=skill_identity, + ) + templates[step_name] = InvocationTemplate( + invocation=invocation, + tool_contract_identity=_TOOL_CONTRACT_IDENTITY, + skill_contract_identity=skill_identity, + template_digest=digest, + ) + snapshot_digest = compute_recipe_execution_snapshot_digest( + execution_id=active_execution_id, + recipe_name=recipe_name, + content_hash=content_hash, + composite_hash=composite_hash, + templates=templates, + ) + return RecipeExecutionSnapshot( + execution_id=active_execution_id, + recipe_name=recipe_name, + content_hash=content_hash, + composite_hash=composite_hash, + templates=templates, + snapshot_digest=snapshot_digest, + ) + + +def get_recipe_execution(tool_ctx: ToolContext) -> InstalledRecipeExecution | None: + with tool_ctx.recipe_execution_lock: + return tool_ctx.active_recipe_execution + + +def install_recipe_execution( + tool_ctx: ToolContext, + *, + snapshot: RecipeExecutionSnapshot, +) -> InstalledRecipeExecution: + """Atomically install a snapshot, empty runtime map, and empty head ledger.""" + from autoskillit.server._factory import ( # circular-break + make_audit_cycle_head_store, + make_input_preflight_resolver, + ) + + head_store = make_audit_cycle_head_store() + resolver = make_input_preflight_resolver( + allowed_root=tool_ctx.temp_dir, + head_store=head_store, + ) + installed = InstalledRecipeExecution( + snapshot=snapshot, + runtime_binding_digests={}, + audit_cycle_heads=head_store, + input_preflight_resolver=resolver, + ) + with tool_ctx.recipe_execution_lock: + previous = tool_ctx.active_recipe_execution + previous_store = tool_ctx.audit_cycle_head_store + tool_ctx.active_recipe_execution = installed + tool_ctx.audit_cycle_head_store = head_store + tool_ctx.input_preflight_resolver = resolver + if previous is not None: + previous.audit_cycle_heads.clear_generation(previous.snapshot.execution_id) + elif previous_store is not None: + previous_store.clear_all() + return installed + + +def clear_recipe_execution(tool_ctx: ToolContext) -> None: + """Clear the complete active attestation generation in one locked transition.""" + with tool_ctx.recipe_execution_lock: + previous = tool_ctx.active_recipe_execution + previous_store = tool_ctx.audit_cycle_head_store + tool_ctx.active_recipe_execution = None + tool_ctx.audit_cycle_head_store = None + tool_ctx.input_preflight_resolver = None + if previous is not None: + previous.audit_cycle_heads.clear_generation(previous.snapshot.execution_id) + elif previous_store is not None: + previous_store.clear_all() + + +def record_runtime_binding_digest( + tool_ctx: ToolContext, + *, + execution_id: str, + step_name: str, + digest: str, +) -> None: + with tool_ctx.recipe_execution_lock: + installed = tool_ctx.active_recipe_execution + if installed is None or installed.snapshot.execution_id != execution_id: + raise RecipeExecutionAdmissionError( + "recipe_execution_replaced", + "active recipe execution changed before runtime binding was recorded", + ) + updated = dict(installed.runtime_binding_digests) + updated[step_name] = digest + tool_ctx.active_recipe_execution = replace( + installed, + runtime_binding_digests=MappingProxyType(updated), + ) + + +def _is_dynamic(value: Any) -> bool: + return bool(value.context_dependencies or value.input_dependencies) or ( + value.origin is not BoundValueOrigin.LITERAL + ) + + +def bind_attested_runtime_invocation( + installed: InstalledRecipeExecution, + *, + execution_id: str, + step_name: str, + template_digest: str, + skill_command: str, + skill_inputs: Mapping[str, BoundScalar] | None, + actual_mcp_kwargs: Mapping[str, BoundScalar], +) -> tuple[tuple[tuple[str, BoundScalar], ...], InvocationTemplate]: + """Validate identity/static shape, then bind only declared dynamic slots.""" + snapshot = installed.snapshot + if execution_id != snapshot.execution_id: + raise RecipeExecutionAdmissionError( + "recipe_execution_id_mismatch", + "recipe execution ID is missing, stale, or replaced", + ) + template = snapshot.templates.get(step_name) + if template is None: + raise RecipeExecutionAdmissionError( + "recipe_execution_step_mismatch", + "step is not present in the active compiled execution", + ) + if template_digest != template.template_digest: + raise RecipeExecutionAdmissionError( + "invocation_template_digest_mismatch", + "invocation template digest does not match the active step", + ) + invocation = template.invocation + if resolve_skill_name(skill_command) != invocation.skill_name: + raise RecipeExecutionAdmissionError( + "recipe_execution_skill_mismatch", + "runtime skill identity differs from the compiled template", + ) + supplied = dict(skill_inputs or {}) + if any( + not isinstance(value, (str, int, float, bool)) or value is None + for value in supplied.values() + ): + raise RecipeExecutionAdmissionError( + "recipe_execution_input_type", + "skill_inputs values must be strict JSON scalars", + ) + expected_names = tuple( + value.name for value in invocation.skill_inputs if value.state is BoundValueState.PRESENT + ) + if frozenset(supplied) != frozenset(expected_names): + raise RecipeExecutionAdmissionError( + "recipe_execution_input_shape", + "skill_inputs keys do not exactly match the compiled template", + ) + bound_inputs: list[tuple[str, BoundScalar]] = [] + for value in invocation.skill_inputs: + if value.state is not BoundValueState.PRESENT: + continue + actual = supplied[value.name] + if not _is_dynamic(value) and actual != value.effective_value: + raise RecipeExecutionAdmissionError( + "recipe_execution_static_input_mismatch", + f"static skill input {value.name!r} differs from the template", + ) + bound_inputs.append((value.name, actual)) + for value in invocation.mcp_kwargs: + if value.name not in actual_mcp_kwargs: + raise RecipeExecutionAdmissionError( + "recipe_execution_tool_shape", + f"compiled tool parameter {value.name!r} is absent", + ) + actual = actual_mcp_kwargs[value.name] + if not _is_dynamic(value) and actual != value.effective_value: + raise RecipeExecutionAdmissionError( + "recipe_execution_static_tool_mismatch", + f"static tool parameter {value.name!r} differs from the template", + ) + return tuple(bound_inputs), template + + +def build_bound_child_prompt( + skill_command: str, + bound_inputs: tuple[tuple[str, BoundScalar], ...], + preflight: VerifiedInputPreflightResult | None, +) -> str: + """Serialize one non-shell child prompt from ordered bound data.""" + payload: dict[str, object] = { + "skill_inputs": [{"name": name, "value": value} for name, value in bound_inputs] + } + if preflight is not None: + payload["verified_input_preflight"] = { + "evidence": [{"name": item.name, "value": item.value} for item in preflight.evidence], + "reason": preflight.decision.reason.value, + "status": preflight.decision.status.value, + } + return f"{skill_command.strip()}\n\nAUTOSKILLIT_BOUND_INVOCATION_V1\n" + json.dumps( + payload, ensure_ascii=False, separators=(",", ":") + ) + + +def publish_verified_audit_cycle( + tool_ctx: ToolContext, + *, + authority_path: str, + expected_parent_digest: str | None, + expected_round: int, + authorized_successor_part_id: str | None = None, +) -> AuditCycleHead: + """Verify an explicit child output, then CAS-publish it as trusted.""" + installed = get_recipe_execution(tool_ctx) + if installed is None: + raise AuditCycleHeadConflict("no active recipe execution") + authority = AuditCycleVerifier(tool_ctx.temp_dir).load_authority(authority_path) + if authority.execution_generation != installed.snapshot.execution_id: + raise AuditCycleHeadConflict("authority crosses recipe execution generations") + return installed.audit_cycle_heads.publish( + authority, + expected_parent_digest=expected_parent_digest, + expected_round=expected_round, + authorized_successor_part_id=authorized_successor_part_id, + ) diff --git a/src/autoskillit/server/tools/_serve_helpers.py b/src/autoskillit/server/tools/_serve_helpers.py index 5dd755782..1c767c808 100644 --- a/src/autoskillit/server/tools/_serve_helpers.py +++ b/src/autoskillit/server/tools/_serve_helpers.py @@ -21,7 +21,11 @@ from autoskillit.server._misc import SkillProjectionContext, project_agent_skill_document if TYPE_CHECKING: - from autoskillit.core import BackendCapabilities, CodingAgentBackend + from autoskillit.core import ( + BackendCapabilities, + CodingAgentBackend, + RecipeBindingProjection, + ) from autoskillit.pipeline.context import ToolContext @@ -108,6 +112,16 @@ def render_served_response(payload: dict[str, Any]) -> str: return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) +def pop_compiled_bindings( + payload: dict[str, Any], +) -> RecipeBindingProjection | None: + """Remove and return the internal post-prune carrier before serialization.""" + from autoskillit.core import RecipeBindingProjection + + candidate = payload.pop("_compiled_bindings", None) + return candidate if isinstance(candidate, RecipeBindingProjection) else None + + def build_open_kitchen_recipe_payload(result: dict[str, Any], *, version: str) -> dict[str, Any]: """Add the routing fields shared by every recipe-bearing open-kitchen response.""" payload = dict(result) @@ -163,6 +177,9 @@ def reset_session_serve_overrides(ctx: ToolContext) -> None: """ ctx.session_serve_overrides = None ctx.session_serve_defer_unresolved = False + from autoskillit.server._recipe_execution import clear_recipe_execution + + clear_recipe_execution(ctx) def serve_recipe( @@ -218,4 +235,9 @@ def serve_recipe( kwargs["temp_dir_relpath"] = temp_dir_relpath if ctx.recipes is None: raise RuntimeError("serve_recipe() called with ctx.recipes=None") - return ctx.recipes.load_and_validate(name, ctx.project_dir, **kwargs) + return ctx.recipes.load_and_validate( + name, + ctx.project_dir, + include_compiled_bindings=True, + **kwargs, + ) diff --git a/src/autoskillit/server/tools/tools_execution.py b/src/autoskillit/server/tools/tools_execution.py index 8a241ba03..ee389b8e4 100644 --- a/src/autoskillit/server/tools/tools_execution.py +++ b/src/autoskillit/server/tools/tools_execution.py @@ -25,16 +25,23 @@ DISPATCH_ID_ENV_VAR, SKILL_COMMAND_DISPLAY_MAX, WORKTREE_SKILLS, + AdmissionStatus, + BindingMode, + BoundScalar, + ClaudeDirectoryConventions, ClosureAuthoritySpec, CodingAgentBackend, EffectiveSkillInvocationAuthority, + PreflightKind, SkillContractError, SkillExecutionRole, SkillResult, TerminationReason, ValidatedAddDir, + VerifiedInputPreflightRequest, WriteBehaviorSpec, closure_authority_spec_from_args, + compute_runtime_binding_digest, execution_marker, extract_skill_name, find_caller_session_id, @@ -51,6 +58,7 @@ ) from autoskillit.pipeline import canonical_step_name as _canonical_step_name from autoskillit.pipeline import gate_error_result +from autoskillit.recipe import RecipeStep, bind_step_invocation from autoskillit.server import mcp from autoskillit.server._guards import ( _check_dry_walkthrough, @@ -72,6 +80,13 @@ get_backend as _get_backend, ) from autoskillit.server._notify import _notify, track_response_size +from autoskillit.server._recipe_execution import ( + RecipeExecutionAdmissionError, + bind_attested_runtime_invocation, + build_bound_child_prompt, + get_recipe_execution, + record_runtime_binding_digest, +) from autoskillit.server._subprocess import _run_subprocess_captured from autoskillit.server.tools._backend_compat import ( _check_backend_compat, @@ -155,6 +170,16 @@ DEPENDENCY_DENY_PREFIX = "DEPENDENCY UNMET" +def _recipe_execution_deny(code: str, message: str) -> str: + return json.dumps( + deny_envelope( + f"RECIPE EXECUTION REJECTED [{code}]: {message}", + stage="preflight:recipe_execution", + retriable=False, + ) + ) + + def _is_absolute_path(path: str) -> bool: """Return True if path is an absolute filesystem path.""" return Path(path).is_absolute() @@ -588,6 +613,8 @@ async def run_skill( cwd: str, model: str = "", step_name: str = "", + recipe_execution_id: str = "", + invocation_template_digest: str = "", step_provider: str = "", order_id: str = "", stale_threshold: int | None = None, @@ -600,6 +627,7 @@ async def run_skill( closure_base_sha: str = "", closure_diff_sha: str = "", closure_target_sha: str = "", + skill_inputs: dict[str, str | int | float | bool] | None = None, ctx: Context = CurrentContext(), ) -> str: """Delegate one already-selected recipe step to a separate L1 headless coding-agent worker. @@ -660,6 +688,26 @@ 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 not (recipe_execution_id or invocation_template_digest) + 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 @@ -667,6 +715,7 @@ async def run_skill( _cleanup_session_id: str | None = None tool_ctx = _get_ctx() + _installed_execution = get_recipe_execution(tool_ctx) _contract_store = tool_ctx.skill_session_contract_store contract_lifecycle.store = _contract_store _stored_contract_entry = None @@ -704,15 +753,6 @@ async def run_skill( 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: - return _dep_denial - if ( - _plan_path_denial := _check_review_approach_plan_path(step_name, skill_command) - ) is not None: - return _plan_path_denial _effective_skill_resolver = tool_ctx.skill_resolver if _effective_skill_resolver is None: _effective_skill_resolver = _make_project_skill_resolver() @@ -741,7 +781,11 @@ async def run_skill( skill_command=skill_command, order_id=order_id, ).to_json() - if not step_name and tool_ctx.active_recipe_steps: + if ( + _installed_execution is None + and not step_name + and tool_ctx.active_recipe_steps + ): _resolved, _ambiguous = _resolve_step_name_from_recipe( skill_command, tool_ctx.active_recipe_steps ) @@ -802,6 +846,167 @@ async def run_skill( ) if invocation is None or projection_context is None: raise SkillContractError("Skill dispatch branches did not produce a bound contract") + + _preflight_result = None + _bound_recipe_inputs: tuple[tuple[str, BoundScalar], ...] = () + child_skill_command = skill_command + _claims_recipe_execution = bool(recipe_execution_id or invocation_template_digest) + if _installed_execution is not None: + if not recipe_execution_id or not invocation_template_digest: + return _recipe_execution_deny( + "recipe_execution_attestation_missing", + ( + "an active recipe requires recipe_execution_id and " + "invocation_template_digest" + ), + ) + if not step_name: + return _recipe_execution_deny( + "recipe_execution_step_missing", + "an attested recipe invocation requires its exact step_name", + ) + _actual_mcp_kwargs: dict[str, BoundScalar] = { + "skill_command": skill_command, + "cwd": cwd, + "model": model, + "step_name": step_name, + "recipe_execution_id": recipe_execution_id, + "invocation_template_digest": invocation_template_digest, + "step_provider": step_provider, + "order_id": order_id, + "output_dir": output_dir, + "resume_session_id": resume_session_id, + "closure_authority_path": closure_authority_path, + "closure_authority_hash": closure_authority_hash, + "closure_plan_paths": closure_plan_paths, + "closure_base_sha": closure_base_sha, + "closure_diff_sha": closure_diff_sha, + "closure_target_sha": closure_target_sha, + } + if stale_threshold is not None: + _actual_mcp_kwargs["stale_threshold"] = stale_threshold + if idle_output_timeout is not None: + _actual_mcp_kwargs["idle_output_timeout"] = idle_output_timeout + try: + _bound_recipe_inputs, _invocation_template = bind_attested_runtime_invocation( + _installed_execution, + execution_id=recipe_execution_id, + step_name=step_name, + template_digest=invocation_template_digest, + skill_command=skill_command, + skill_inputs=skill_inputs, + actual_mcp_kwargs=_actual_mcp_kwargs, + ) + except RecipeExecutionAdmissionError as exc: + return _recipe_execution_deny(exc.code, str(exc)) + _contract = ( + tool_ctx.skill_contract_resolver(skill_command) + if tool_ctx.skill_contract_resolver is not None + else None + ) + _preflight_name = getattr(_contract, "input_preflight", None) + if isinstance(_preflight_name, str) and _preflight_name: + if _preflight_name != PreflightKind.AUDIT_CYCLE_INVENTORY.value: + return _recipe_execution_deny( + "recipe_execution_preflight_unknown", + f"unsupported input preflight {_preflight_name!r}", + ) + _bound_input_map = dict(_bound_recipe_inputs) + _plan_path = _bound_input_map.get("plan_path") + _audit_cycle_path = _bound_input_map.get("audit_cycle_path") + _plan_disposition_path = _bound_input_map.get("plan_disposition_path") + if not isinstance(_plan_path, str): + return _recipe_execution_deny( + "recipe_execution_preflight_input", + "audit-cycle preflight requires a bound string plan_path", + ) + if _audit_cycle_path is not None and not isinstance(_audit_cycle_path, str): + return _recipe_execution_deny( + "recipe_execution_preflight_input", + "audit_cycle_path must be a string when present", + ) + if _plan_disposition_path is not None and not isinstance( + _plan_disposition_path, str + ): + return _recipe_execution_deny( + "recipe_execution_preflight_input", + "plan_disposition_path must be a string when present", + ) + _preflight_result = _installed_execution.input_preflight_resolver.resolve( + VerifiedInputPreflightRequest( + execution_generation=recipe_execution_id, + step_name=step_name, + skill_name=_invocation_template.invocation.skill_name or "", + plan_path=_plan_path, + audit_cycle_path=_audit_cycle_path or None, + plan_disposition_path=_plan_disposition_path or None, + ) + ) + if _preflight_result.decision.status is AdmissionStatus.REJECT: + return _recipe_execution_deny( + f"input_preflight_{_preflight_result.decision.reason.value}", + ( + _preflight_result.decision.details[0] + if _preflight_result.decision.details + else "verified input preflight rejected the invocation" + ), + ) + child_skill_command = build_bound_child_prompt( + skill_command, + _bound_recipe_inputs, + _preflight_result, + ) + _runtime_digest = compute_runtime_binding_digest( + execution_id=recipe_execution_id, + step_name=step_name, + template_digest=invocation_template_digest, + bound_inputs=_bound_recipe_inputs, + preflight=(_preflight_result.decision if _preflight_result is not None else None), + ) + try: + record_runtime_binding_digest( + tool_ctx, + execution_id=recipe_execution_id, + step_name=step_name, + digest=_runtime_digest, + ) + except RecipeExecutionAdmissionError as exc: + return _recipe_execution_deny(exc.code, str(exc)) + elif _claims_recipe_execution: + return _recipe_execution_deny( + "recipe_execution_inactive", + "standalone mode cannot claim recipe attestation", + ) + elif not resume_session_id: + _standalone_with: dict[str, object] = { + "skill_command": skill_command, + "cwd": cwd, + } + if skill_inputs is not None: + _standalone_with["skill_inputs"] = skill_inputs + _standalone_binding = bind_step_invocation( + "standalone", + RecipeStep( + name="standalone", + tool="run_skill", + with_args=_standalone_with, + declared_with_args=dict(_standalone_with), + ), + mode=BindingMode.STANDALONE, + ) + if _standalone_binding.failures: + failure = _standalone_binding.failures[0] + return _recipe_execution_deny( + f"standalone_{failure.code.value}", + failure.message, + ) + if skill_inputs is not None: + child_skill_command = build_bound_child_prompt( + skill_command, + _standalone_binding.canonical_child_invocation, + None, + ) + with structlog.contextvars.bound_contextvars(tool="run_skill", cwd=cwd): logger.info("run_skill", command=skill_command[:80], cwd=cwd) await _notify( @@ -821,7 +1026,7 @@ async def run_skill( # requiring recipe authors to thread it through every run_skill call. effective_order_id = order_id or os.environ.get(DISPATCH_ID_ENV_VAR, "") - if not resume_session_id: + if not resume_session_id and _installed_execution is None and skill_inputs is None: if ( input_error := _check_input_contracts( skill_command, cwd, tool_ctx.input_contract_resolver @@ -829,7 +1034,7 @@ async def run_skill( ) is not None: return input_error - if _get_config().safety.require_dry_walkthrough: + if _get_config().safety.require_dry_walkthrough and _installed_execution is None: if (gate_error := _check_dry_walkthrough(skill_command, cwd)) is not None: return gate_error @@ -900,7 +1105,7 @@ async def run_skill( resolved_command = ( _stored_contract.resolved_command if _stored_contract is not None - else skill_command + else child_skill_command ) _effective_skill_contract = invocation if invocation is not None else _stored_contract diff --git a/src/autoskillit/server/tools/tools_kitchen.py b/src/autoskillit/server/tools/tools_kitchen.py index 44b77b69f..b74ca7c57 100644 --- a/src/autoskillit/server/tools/tools_kitchen.py +++ b/src/autoskillit/server/tools/tools_kitchen.py @@ -73,6 +73,7 @@ finalize_recipe_delivery, retire_recipe_artifacts, ) +from autoskillit.server._recipe_execution import clear_recipe_execution from autoskillit.server.tools._authority_feedback import ( build_authority_clobber_warnings, build_authority_rejection_envelope, @@ -91,6 +92,7 @@ from autoskillit.server.tools._serve_helpers import ( build_backend_capabilities_map, build_open_kitchen_recipe_payload, + pop_compiled_bindings, project_orchestrator_guidance, render_served_response, response_backstop_tool_meta, @@ -468,6 +470,7 @@ async def _open_kitchen_handler() -> str | None: ctx.active_recipe_features = frozenset() ctx.active_recipe_steps = {} ctx.active_recipe_ingredients = frozenset() + clear_recipe_execution(ctx) logger.info("open_kitchen", gate_state="open", kitchen_id=ctx.kitchen_id) _supports_quota = _backend_supports_quota(ctx) @@ -596,6 +599,7 @@ def _close_kitchen_handler() -> None: ctx.recipe_content_hash = "" ctx.recipe_composite_hash = "" ctx.recipe_version = "" + clear_recipe_execution(ctx) ctx.gate_infrastructure_ready = False logger.info("close_kitchen", gate_state="closed") if (log := ctx.github_api_log) is not None: @@ -672,6 +676,7 @@ def get_recipe(name: str) -> str: ctx = _get_ctx_or_none() if ctx is None or ctx.recipes is None: return json.dumps({"error": "Kitchen not open."}) + clear_recipe_execution(ctx) match = ctx.recipes.find(name, ctx.project_dir) if match is None: return json.dumps({"error": f"No recipe named '{name}'."}) @@ -720,6 +725,7 @@ def get_recipe(name: str) -> str: backend_capabilities_map=_backend_capabilities_map, backend_origin_map=_backend_origin_map, ) + _resource_compiled_bindings = pop_compiled_bindings(result) except ProcessStaleError: logger.warning("get_recipe_failure", recipe=name, stage="process_stale", exc_info=True) return json.dumps({"error": f"Recipe '{name}' composition failed — process stale."}) @@ -748,6 +754,7 @@ def get_recipe(name: str) -> str: surface="get_recipe", recipe_name=name, tool_ctx=ctx, + compiled_bindings=_resource_compiled_bindings, ) return enforce_recipe_resource_response(finalized, tool_ctx=ctx) @@ -913,6 +920,8 @@ async def open_kitchen( if name is not None: tool_ctx = _get_ctx() + if not ingredients_only: + clear_recipe_execution(tool_ctx) if tool_ctx.recipes is None: return _kitchen_failure_envelope( RuntimeError("Server not initialized"), @@ -989,6 +998,7 @@ async def open_kitchen( backend_capabilities_map=_backend_capabilities_map, backend_origin_map=_backend_origin_map, ) + _deferred_compiled_bindings = pop_compiled_bindings(result) except ProcessStaleError as exc: logger.warning("open_kitchen_failure", stage="process_stale", exc_info=True) return _kitchen_failure_envelope(exc, stage="process_stale") @@ -1095,6 +1105,7 @@ async def open_kitchen( recipe_name=name, tool_ctx=tool_ctx, delivery_request=delivery_request, + compiled_bindings=_deferred_compiled_bindings, ), ) return render_served_response(result) @@ -1113,6 +1124,7 @@ async def open_kitchen( backend_capabilities_map=_backend_capabilities_map, backend_origin_map=_backend_origin_map, ) + _normal_compiled_bindings = pop_compiled_bindings(result) except ProcessStaleError as exc: logger.warning("open_kitchen_failure", stage="process_stale", exc_info=True) return _kitchen_failure_envelope(exc, stage="process_stale") @@ -1259,6 +1271,7 @@ async def open_kitchen( recipe_name=name, tool_ctx=tool_ctx, delivery_request=delivery_request, + compiled_bindings=_normal_compiled_bindings, ), ) diff --git a/src/autoskillit/server/tools/tools_recipe.py b/src/autoskillit/server/tools/tools_recipe.py index a75e752b8..7feb64008 100644 --- a/src/autoskillit/server/tools/tools_recipe.py +++ b/src/autoskillit/server/tools/tools_recipe.py @@ -48,6 +48,7 @@ recipe_pull_producers, recipe_recreation_producers, ) +from autoskillit.server._recipe_execution import clear_recipe_execution from autoskillit.server._recipe_section_pagination import ( RecipeSectionBoundError, RecipeSectionNonConvergenceError, @@ -70,6 +71,7 @@ from autoskillit.server.tools._serve_helpers import ( build_backend_capabilities_map, build_open_kitchen_recipe_payload, + pop_compiled_bindings, render_served_response, response_backstop_tool_meta, serve_recipe, @@ -336,6 +338,8 @@ async def load_recipe( tool_ctx = _get_ctx_or_none() if tool_ctx is None or tool_ctx.recipes is None: return json.dumps({"error": "Server not initialized"}) + if not ingredients_only: + clear_recipe_execution(tool_ctx) suppressed = tool_ctx.config.migration.suppressed _defaults = resolve_ingredient_defaults(tool_ctx.project_dir) _recipe_info_pre = tool_ctx.recipes.find(name, tool_ctx.project_dir) @@ -389,6 +393,7 @@ async def load_recipe( backend_capabilities_map=_backend_capabilities_map, backend_origin_map=_backend_origin_map, ) + _compiled_bindings = pop_compiled_bindings(result) recipe_info = _recipe_info_pre result = await _apply_triage_gate(result, name, recipe_info=recipe_info) if not result.get("valid", False): @@ -448,6 +453,9 @@ async def load_recipe( recipe_name=name, tool_ctx=tool_ctx, delivery_request=delivery_request, + compiled_bindings=( + _compiled_bindings if result.get("valid", False) else None + ), ), ) return render_served_response(result) diff --git a/tests/arch/test_subpackage_isolation.py b/tests/arch/test_subpackage_isolation.py index 3bd4ea394..0250e95b1 100644 --- a/tests/arch/test_subpackage_isolation.py +++ b/tests/arch/test_subpackage_isolation.py @@ -1353,6 +1353,7 @@ def test_tool_context_service_fields_use_protocol_types() -> None: "core/types/_type_protocols_recipe.py", "core/types/_type_protocols_infra.py", "core/types/_type_protocols_backend.py", + "core/types/_type_recipe_execution.py", "core/types/_type_subprocess.py", ): types_path = AUTOSKILLIT_ROOT / types_filename @@ -1378,6 +1379,7 @@ def test_tool_context_service_fields_use_protocol_types() -> None: "active_recipe_features", "active_recipe_steps", "active_recipe_ingredients", + "active_recipe_execution", "temp_dir", "project_dir", "ephemeral_root", diff --git a/tests/server/AGENTS.md b/tests/server/AGENTS.md index 0ff6d05e6..d53aafa0f 100644 --- a/tests/server/AGENTS.md +++ b/tests/server/AGENTS.md @@ -138,6 +138,7 @@ Server tool handler unit tests — kitchen, execution, CI, clone, workspace tool | `test_tools_recipe.py` | Tests for autoskillit server validate_recipe tool and recipe docstring contracts | | `test_tools_recipe_pull.py` | Tests for the `get_recipe_section` pull tool and bounded envelope architecture (Part B #4304) | | `test_tool_registry_parity.py` | Bidirectional AST parity between canonical IL-0 tool metadata and live MCP handler signatures | +| `test_audit_cycle_delivery_integration.py` | Attested payload installation, exact runtime binding, trusted-head CAS, and zero-read preflight | | `test_tools_report_bug.py` | Tests for report_bug MCP tool handler and supporting helpers (_parse_fingerprint, _extract_block, _parse_prepare_result, _parse_enrich_result) | | `test_tools_run_cmd.py` | Tests for run_cmd and run_python MCP tool handlers | | `test_tools_run_cmd_invariants.py` | Server-side invariant tests for run_cmd: recipe-read prohibition and write-target boundary | diff --git a/tests/server/test_audit_cycle_delivery_integration.py b/tests/server/test_audit_cycle_delivery_integration.py new file mode 100644 index 000000000..80abaf66a --- /dev/null +++ b/tests/server/test_audit_cycle_delivery_integration.py @@ -0,0 +1,423 @@ +"""Attested recipe delivery, runtime binding, and trusted audit-head lifecycle.""" + +from __future__ import annotations + +import json +from dataclasses import replace +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from autoskillit.core import ( + AdmissionReason, + ArtifactRef, + AuditAssessment, + AuditAssessmentRow, + AuditCycleAuthority, + AuditVerdict, + BindingMode, + BoundStepInvocation, + BoundValue, + BoundValueOrigin, + BoundValueState, + InventoryAdmissionDecision, + RecipeBindingProjection, + VerifiedInputPreflightRequest, + VerifiedInputPreflightResult, +) +from autoskillit.server._recipe_delivery import ( + complete_finalized_recipe_response, + finalize_recipe_delivery, + load_recipe_artifact, +) +from autoskillit.server._recipe_execution import ( + AuditCycleHeadConflict, + DefaultAuditCycleHeadStore, + DefaultInputPreflightResolver, + bind_attested_runtime_invocation, + build_bound_child_prompt, + build_recipe_execution_snapshot, + get_recipe_execution, + install_recipe_execution, +) +from autoskillit.server.tools.tools_execution import run_skill + +pytestmark = [pytest.mark.layer("server"), pytest.mark.small] + +_HASH_A = "sha256:" + "a" * 64 +_HASH_B = "sha256:" + "b" * 64 + + +def _present( + name: str, + value: str | int | float | bool, + *, + origin: BoundValueOrigin = BoundValueOrigin.LITERAL, + dependencies: tuple[str, ...] = (), +) -> BoundValue: + return BoundValue( + name=name, + declared_value=value, + effective_value=value, + state=BoundValueState.PRESENT, + origin=origin, + context_dependencies=dependencies, + ) + + +def _projection() -> RecipeBindingProjection: + invocation = BoundStepInvocation( + step_name="dry", + tool_name="run_skill", + mode=BindingMode.RECIPE, + skill_name="dry-walkthrough", + mcp_kwargs=( + _present("skill_command", "/dry-walkthrough"), + _present( + "cwd", + "${{ context.worktree_path }}", + origin=BoundValueOrigin.CONTEXT, + dependencies=("worktree_path",), + ), + ), + skill_inputs=( + _present( + "plan_path", + "${{ context.plan_path }}", + origin=BoundValueOrigin.CONTEXT, + dependencies=("plan_path",), + ), + _present("issue_url", ""), + _present("audit_cycle_path", False), + _present("plan_disposition_path", 0), + ), + ) + return RecipeBindingProjection({"dry": invocation}) + + +def _preflight_projection() -> RecipeBindingProjection: + invocation = _projection().invocations["dry"] + return RecipeBindingProjection( + { + "dry": replace( + invocation, + skill_inputs=( + invocation.skill_inputs[0], + invocation.skill_inputs[1], + BoundValue.absent("audit_cycle_path"), + BoundValue.absent("plan_disposition_path"), + ), + ) + } + ) + + +def _artifact(path: Path, digest: str) -> ArtifactRef: + return ArtifactRef( + locator=str(path), + media_type="application/json", + schema_version=1, + byte_size=1, + content_digest=digest, + ) + + +def _authority( + tmp_path: Path, + *, + generation: str, + round_: int, + parent: str | None, + verdict: AuditVerdict, +) -> AuditCycleAuthority: + assessment = AuditAssessmentRow.create( + requirement_id="REQ-001", + requirement_text="requirement", + assessment=AuditAssessment.COVERED, + evidence_summary="covered", + ) + return AuditCycleAuthority.create( + execution_generation=generation, + cycle_id="cycle-1", + plan_set_id="plans-1", + scope_id="scope-1", + part_id="part-a", + audit_round=round_, + parent_authority_digest=parent, + audited_plan_refs=(_artifact(tmp_path / "plan.md", _HASH_A),), + inventory_ref=_artifact(tmp_path / "inventory.json", _HASH_B), + assessments=(assessment,), + verdict=verdict, + remediation_ref=( + _artifact(tmp_path / "remediation.md", _HASH_A) + if verdict is AuditVerdict.NO_GO + else None + ), + generated_at="2026-07-23T00:00:00Z", + ) + + +def test_delivery_persists_and_installs_matching_execution( + minimal_ctx, +) -> None: + finalized = finalize_recipe_delivery( + { + "content": "name: demo\n", + "content_hash": _HASH_A, + "composite_hash": _HASH_B, + "valid": True, + }, + surface="load_recipe", + recipe_name="demo", + tool_ctx=minimal_ctx, + compiled_bindings=_projection(), + ) + assert finalized.artifact_generation is not None + assert finalized.execution_snapshot is not None + persisted = load_recipe_artifact( + minimal_ctx.temp_dir, + kitchen_id=minimal_ctx.kitchen_id, + identity=finalized.artifact_generation, + ) + execution_payload = persisted["recipe_execution"] + assert execution_payload["execution_id"] == finalized.execution_snapshot.execution_id + assert execution_payload["invocation_template_digests"] == dict( + finalized.execution_snapshot.template_digests + ) + assert get_recipe_execution(minimal_ctx) is None + + assert complete_finalized_recipe_response(finalized, finalized.rendered) == finalized.rendered + installed = get_recipe_execution(minimal_ctx) + assert installed is not None + assert installed.snapshot is finalized.execution_snapshot + assert installed.runtime_binding_digests == {} + assert ( + installed.audit_cycle_heads.get( + execution_generation=installed.snapshot.execution_id, + plan_set_id="plans-1", + scope_id="scope-1", + part_id="part-a", + ) + is None + ) + + +def test_transformed_delivery_never_installs_execution(minimal_ctx) -> None: + finalized = finalize_recipe_delivery( + { + "content": "name: demo\n", + "content_hash": _HASH_A, + "composite_hash": _HASH_B, + "valid": True, + }, + surface="load_recipe", + recipe_name="demo", + tool_ctx=minimal_ctx, + compiled_bindings=_projection(), + ) + assert complete_finalized_recipe_response(finalized, "bounded replacement") == ( + "bounded replacement" + ) + assert get_recipe_execution(minimal_ctx) is None + + +def test_bound_prompt_preserves_falsey_and_metacharacter_values() -> None: + snapshot = build_recipe_execution_snapshot( + recipe_name="demo", + content_hash=_HASH_A, + composite_hash=_HASH_B, + projection=_projection(), + execution_id="execution-1", + ) + template = snapshot.templates["dry"] + store = DefaultAuditCycleHeadStore() + resolver = DefaultInputPreflightResolver( + allowed_root=Path("/tmp"), + head_store=store, + ) + from autoskillit.core import InstalledRecipeExecution + + installed = InstalledRecipeExecution( + snapshot=snapshot, + runtime_binding_digests={}, + audit_cycle_heads=store, + input_preflight_resolver=resolver, + ) + values = { + "plan_path": "/tmp/a path/$(touch nope);x.md", + "issue_url": "", + "audit_cycle_path": False, + "plan_disposition_path": 0, + } + bound, _ = bind_attested_runtime_invocation( + installed, + execution_id="execution-1", + step_name="dry", + template_digest=template.template_digest, + skill_command="/dry-walkthrough", + skill_inputs=values, + actual_mcp_kwargs={ + "skill_command": "/dry-walkthrough", + "cwd": "/tmp/work tree", + }, + ) + prompt = build_bound_child_prompt("/dry-walkthrough", bound, None) + encoded = json.loads(prompt.split("AUTOSKILLIT_BOUND_INVOCATION_V1\n", 1)[1]) + assert encoded["skill_inputs"] == [ + {"name": "plan_path", "value": values["plan_path"]}, + {"name": "issue_url", "value": ""}, + {"name": "audit_cycle_path", "value": False}, + {"name": "plan_disposition_path", "value": 0}, + ] + + +def test_head_publication_is_monotonic_compare_and_swap(tmp_path: Path) -> None: + store = DefaultAuditCycleHeadStore() + first = _authority( + tmp_path, + generation="execution-1", + round_=1, + parent=None, + verdict=AuditVerdict.NO_GO, + ) + first_head = store.publish( + first, + expected_parent_digest=None, + expected_round=0, + ) + successor = _authority( + tmp_path, + generation="execution-1", + round_=2, + parent=first.authority_digest, + verdict=AuditVerdict.GO, + ) + with pytest.raises(AuditCycleHeadConflict, match="compare-and-swap"): + store.publish( + successor, + expected_parent_digest=_HASH_A, + expected_round=1, + ) + terminal = store.publish( + successor, + expected_parent_digest=first_head.current_authority_digest, + expected_round=1, + authorized_successor_part_id="part-b", + ) + assert terminal.current_authority_digest == successor.authority_digest + assert terminal.authorized_successor_part_id == "part-b" + + +def test_omit_preflight_performs_zero_artifact_reads(tmp_path: Path) -> None: + resolver = DefaultInputPreflightResolver( + allowed_root=tmp_path, + head_store=DefaultAuditCycleHeadStore(), + ) + reads = 0 + + def fail_reader(*args, **kwargs): + nonlocal reads + reads += 1 + raise AssertionError("OMIT must not read inventory or authority artifacts") + + resolver._verifier._reader = fail_reader # noqa: SLF001 + result = resolver.resolve( + VerifiedInputPreflightRequest( + execution_generation="execution-1", + step_name="dry", + skill_name="dry-walkthrough", + plan_path=str(tmp_path / "plan.md"), + audit_cycle_path=None, + plan_disposition_path=None, + ) + ) + assert result.decision.status.value == "OMIT" + assert reads == 0 + + +@pytest.mark.anyio +async def test_runtime_attestation_rejects_before_executor( + tool_ctx_kitchen_open, + tmp_path: Path, +) -> None: + snapshot = build_recipe_execution_snapshot( + recipe_name="demo", + content_hash=_HASH_A, + composite_hash=_HASH_B, + projection=_projection(), + execution_id="execution-1", + ) + install_recipe_execution(tool_ctx_kitchen_open, snapshot=snapshot) + calls_before = len(tool_ctx_kitchen_open.runner.call_args_list) + result = json.loads( + await run_skill( + "/dry-walkthrough", + str(tmp_path), + step_name="dry", + recipe_execution_id="execution-1", + invocation_template_digest="sha256:" + "f" * 64, + skill_inputs={ + "plan_path": str(tmp_path / "plan path;$(nope).md"), + "issue_url": "", + "audit_cycle_path": False, + "plan_disposition_path": 0, + }, + ) + ) + assert result["success"] is False + assert result["stage"] == "preflight:recipe_execution" + assert "invocation_template_digest_mismatch" in result["error"] + assert len(tool_ctx_kitchen_open.runner.call_args_list) == calls_before + + +@pytest.mark.anyio +async def test_preflight_rejects_before_executor( + tool_ctx_kitchen_open, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + class RejectingResolver: + def resolve(self, request): + return VerifiedInputPreflightResult( + InventoryAdmissionDecision.reject( + AdmissionReason.PLAN_MISMATCH, + "verified plan mismatch", + ) + ) + + snapshot = build_recipe_execution_snapshot( + recipe_name="demo", + content_hash=_HASH_A, + composite_hash=_HASH_B, + projection=_preflight_projection(), + execution_id="execution-1", + ) + installed = install_recipe_execution(tool_ctx_kitchen_open, snapshot=snapshot) + monkeypatch.setattr( + tool_ctx_kitchen_open, + "active_recipe_execution", + replace(installed, input_preflight_resolver=RejectingResolver()), + ) + monkeypatch.setattr( + tool_ctx_kitchen_open, + "skill_contract_resolver", + lambda command: SimpleNamespace(input_preflight="audit_cycle_inventory"), + ) + calls_before = len(tool_ctx_kitchen_open.runner.call_args_list) + result = json.loads( + await run_skill( + "/dry-walkthrough", + str(tmp_path), + step_name="dry", + recipe_execution_id="execution-1", + invocation_template_digest=snapshot.templates["dry"].template_digest, + skill_inputs={ + "plan_path": str(tmp_path / "plan.md"), + "issue_url": "", + }, + ) + ) + assert result["success"] is False + assert "input_preflight_plan_mismatch" in result["error"] + assert len(tool_ctx_kitchen_open.runner.call_args_list) == calls_before diff --git a/tests/server/test_tool_registry_parity.py b/tests/server/test_tool_registry_parity.py index 18b9f1e5b..63307b837 100644 --- a/tests/server/test_tool_registry_parity.py +++ b/tests/server/test_tool_registry_parity.py @@ -61,4 +61,4 @@ def test_run_skill_has_one_compiler_owned_structured_input_channel() -> None: param for param in TOOL_REGISTRY["run_skill"].params if param.structured_skill_inputs ) assert tuple(param.name for param in structured) == ("skill_inputs",) - assert not structured[0].handler_parameter + assert structured[0].handler_parameter From 993f9bbe46b51f82ba051174be1fd35088213d85 Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 24 Jul 2026 10:51:16 -0700 Subject: [PATCH 04/89] feat(audit): enforce provenance-bound cycle preflight --- src/autoskillit/core/__init__.pyi | 72 ++-- src/autoskillit/core/audit_cycle_verifier.py | 3 + src/autoskillit/core/tool_registry.py | 57 +++- .../core/types/_type_recipe_binding.py | 9 +- .../core/types/_type_recipe_execution.py | 15 +- src/autoskillit/pipeline/context.py | 5 +- src/autoskillit/recipe/AGENTS.md | 1 + src/autoskillit/recipe/_api.py | 1 + src/autoskillit/recipe/_binding.py | 138 +++++++- src/autoskillit/recipe/_contracts_card.py | 27 +- src/autoskillit/recipe/_recipe_ingredients.py | 6 +- src/autoskillit/recipe/schema.py | 19 +- src/autoskillit/recipe/skill_contracts.yaml | 146 +++++++- src/autoskillit/server/_factory.py | 8 +- src/autoskillit/server/_recipe_delivery.py | 18 +- src/autoskillit/server/_recipe_execution.py | 70 +++- .../server/tools/_serve_helpers.py | 10 +- .../server/tools/tools_execution.py | 38 +-- src/autoskillit/server/tools/tools_recipe.py | 14 +- .../skills_extended/audit-impl/SKILL.md | 89 +++-- .../skills_extended/dry-walkthrough/SKILL.md | 69 ++-- .../skills_extended/make-plan/SKILL.md | 98 ++++-- .../test_pyright_suppression_allowlist.py | 4 +- tests/arch/test_subpackage_isolation.py | 21 +- tests/arch/test_subpackage_structure.py | 3 + tests/contracts/AGENTS.md | 4 +- tests/contracts/test_audit_impl_inventory.py | 72 ---- .../test_dry_walkthrough_plan_coverage.py | 122 ++----- .../test_make_plan_remediation_inventory.py | 55 ++-- tests/contracts/test_skill_contracts.py | 18 +- tests/recipe/AGENTS.md | 2 +- .../test_false_positive_escape_valve.py | 114 ------- tests/recipe/test_skill_invocation_binding.py | 25 ++ .../test_audit_cycle_delivery_integration.py | 311 +++++++++++++++++- .../server/test_explicit_backend_override.py | 2 +- tests/server/test_factory.py | 2 +- .../test_open_kitchen_deferred_recall.py | 96 ++++++ .../test_tools_kitchen_gate_features.py | 1 + tests/server/test_tools_load_recipe.py | 5 +- tests/server/test_tools_run_skill_retry.py | 2 +- tests/skills/test_make_plan_false_positive.py | 32 -- 41 files changed, 1203 insertions(+), 601 deletions(-) delete mode 100644 tests/contracts/test_audit_impl_inventory.py delete mode 100644 tests/recipe/test_false_positive_escape_valve.py delete mode 100644 tests/skills/test_make_plan_false_positive.py diff --git a/src/autoskillit/core/__init__.pyi b/src/autoskillit/core/__init__.pyi index 7023c4424..bd68a0bd5 100644 --- a/src/autoskillit/core/__init__.pyi +++ b/src/autoskillit/core/__init__.pyi @@ -48,16 +48,21 @@ from .audit_cycle_verifier import AuditCycleVerificationError as AuditCycleVerif from .audit_cycle_verifier import AuditCycleVerifier as AuditCycleVerifier from .audit_cycle_verifier import InventoryAdmissionEvaluator as InventoryAdmissionEvaluator from .audit_cycle_verifier import VerifiedAuditCycle as VerifiedAuditCycle -from .tool_registry import TOOL_REGISTRY as TOOL_REGISTRY -from .tool_registry import all_tool_names as all_tool_names -from .tool_registry import get_tool_def as get_tool_def -from .tool_registry import unsupported_tool_params as unsupported_tool_params from .bash_write_targets import extract_bash_write_targets as extract_bash_write_targets from .branch_guard import is_protected_branch as is_protected_branch from .claude_conventions import ClaudeDirectoryConventions as ClaudeDirectoryConventions from .claude_conventions import LayoutError as LayoutError from .claude_conventions import validate_add_dir as validate_add_dir from .claude_conventions import validate_worktree_path as validate_worktree_path +from .closure_hashing import HASH_RE as HASH_RE +from .closure_hashing import canonical_json_bytes as canonical_json_bytes +from .closure_hashing import compute_bytes_hash as compute_bytes_hash +from .closure_hashing import compute_canonical_hash as compute_canonical_hash +from .closure_hashing import compute_file_hash as compute_file_hash +from .closure_hashing import compute_report_hash as compute_report_hash +from .closure_hashing import compute_request_hash as compute_request_hash +from .closure_hashing import compute_row_hash as compute_row_hash +from .closure_hashing import parse_canonical_json_bytes as parse_canonical_json_bytes from .closure_verifier import VerificationResult as VerificationResult from .closure_verifier import verify_closure_report as verify_closure_report from .context_admission import ( @@ -103,6 +108,10 @@ from .io import write_canonical_versioned_json as write_canonical_versioned_json from .io import write_versioned_json as write_versioned_json from .logging import configure_logging as configure_logging from .logging import get_logger as get_logger +from .path_containment import ContainmentError as ContainmentError +from .path_containment import check_metadata_stable as check_metadata_stable +from .path_containment import read_stable_contained_bytes as read_stable_contained_bytes +from .path_containment import resolve_contained_path as resolve_contained_path from .paths import GENERATED_FILES as GENERATED_FILES from .paths import claude_code_log_path as claude_code_log_path from .paths import claude_code_project_dir as claude_code_project_dir @@ -142,6 +151,10 @@ from .runtime.session_registry import bridge_claude_session_id as bridge_claude_ from .runtime.session_registry import read_registry as read_registry from .runtime.session_registry import registry_path as registry_path from .runtime.session_registry import write_registry_entry as write_registry_entry +from .tool_registry import TOOL_REGISTRY as TOOL_REGISTRY +from .tool_registry import all_tool_names as all_tool_names +from .tool_registry import get_tool_def as get_tool_def +from .tool_registry import unsupported_tool_params as unsupported_tool_params from .tool_sequence_analysis import DFG as DFG from .tool_sequence_analysis import AnalysisResult as AnalysisResult from .tool_sequence_analysis import AssistantTurn as AssistantTurn @@ -161,8 +174,8 @@ from .tool_sequence_analysis import ( from .tool_sequence_analysis import render_adjacency_table as render_adjacency_table from .tool_sequence_analysis import render_dot as render_dot from .tool_sequence_analysis import render_mermaid as render_mermaid +from .types import ABSENT_BOUND_VALUE as ABSENT_BOUND_VALUE from .types import ADMIRAL_DISPATCH_SECTIONS as ADMIRAL_DISPATCH_SECTIONS -from .types import AUDIT_CYCLE_SCHEMA_VERSION as AUDIT_CYCLE_SCHEMA_VERSION from .types import AGENT_BACKEND_CLAUDE_CODE as AGENT_BACKEND_CLAUDE_CODE from .types import AGENT_BACKEND_CODEX as AGENT_BACKEND_CODEX from .types import AGENT_BACKEND_DYNACONF_ENV_VAR as AGENT_BACKEND_DYNACONF_ENV_VAR @@ -170,22 +183,13 @@ from .types import AGENT_BACKEND_ENV_VAR as AGENT_BACKEND_ENV_VAR from .types import AGENT_PACK_REGISTRY as AGENT_PACK_REGISTRY from .types import ALL_PROJECT_LOCAL_SKILL_SEARCH_DIRS as ALL_PROJECT_LOCAL_SKILL_SEARCH_DIRS from .types import ALL_VISIBILITY_TAGS as ALL_VISIBILITY_TAGS +from .types import AUDIT_CYCLE_SCHEMA_VERSION as AUDIT_CYCLE_SCHEMA_VERSION from .types import AUTOSKILLIT_APPLICABLE_GUARDS as AUTOSKILLIT_APPLICABLE_GUARDS from .types import AUTOSKILLIT_INSTALLED_VERSION as AUTOSKILLIT_INSTALLED_VERSION from .types import AUTOSKILLIT_PRIVATE_ENV_VARS as AUTOSKILLIT_PRIVATE_ENV_VARS from .types import AUTOSKILLIT_SKILL_PREFIX as AUTOSKILLIT_SKILL_PREFIX from .types import AUTOSKILLIT_WRITE_GUARD_TOOL_NAMES as AUTOSKILLIT_WRITE_GUARD_TOOL_NAMES from .types import BACKEND_CAPABILITY_INGREDIENTS as BACKEND_CAPABILITY_INGREDIENTS -from .types import ABSENT_BOUND_VALUE as ABSENT_BOUND_VALUE -from .types import AbsentBoundValue as AbsentBoundValue -from .types import BindingFailure as BindingFailure -from .types import BindingFailureCode as BindingFailureCode -from .types import BindingMode as BindingMode -from .types import BoundScalar as BoundScalar -from .types import BoundStepInvocation as BoundStepInvocation -from .types import BoundValue as BoundValue -from .types import BoundValueOrigin as BoundValueOrigin -from .types import BoundValueState as BoundValueState from .types import CAMPAIGN_ID_ENV_VAR as CAMPAIGN_ID_ENV_VAR from .types import CAPABILITY_GATE_CALLABLES as CAPABILITY_GATE_CALLABLES from .types import CAPABILITY_INGREDIENT_MAP as CAPABILITY_INGREDIENT_MAP @@ -332,6 +336,7 @@ from .types import UNGATED_TOOLS as UNGATED_TOOLS from .types import VALID_INPUT_SPEC_TYPES as VALID_INPUT_SPEC_TYPES from .types import VARIADIC_CLAUDE_FLAGS as VARIADIC_CLAUDE_FLAGS from .types import WORKTREE_SKILLS as WORKTREE_SKILLS +from .types import AbsentBoundValue as AbsentBoundValue from .types import AcceptInputEvent as AcceptInputEvent from .types import ActiveContextAdmissionState as ActiveContextAdmissionState from .types import AdmissionAttemptId as AdmissionAttemptId @@ -355,12 +360,12 @@ from .types import AdmissionState as AdmissionState from .types import AdmissionTransition as AdmissionTransition from .types import AdmissionWitness as AdmissionWitness from .types import AdmissionWitnessId as AdmissionWitnessId +from .types import AdmissionReason as AdmissionReason +from .types import AdmissionStatus as AdmissionStatus from .types import AgentInstanceId as AgentInstanceId from .types import AgentPackDef as AgentPackDef from .types import AgentSessionResult as AgentSessionResult from .types import AggregateRevision as AggregateRevision -from .types import AdmissionReason as AdmissionReason -from .types import AdmissionStatus as AdmissionStatus from .types import ApiRetryOutcome as ApiRetryOutcome from .types import ArtifactRef as ArtifactRef from .types import AuditAssessment as AuditAssessment @@ -378,6 +383,14 @@ from .types import BackendConventions as BackendConventions from .types import BackendEventKind as BackendEventKind from .types import BackgroundSupervisor as BackgroundSupervisor from .types import BareResume as BareResume +from .types import BindingFailure as BindingFailure +from .types import BindingFailureCode as BindingFailureCode +from .types import BindingMode as BindingMode +from .types import BoundScalar as BoundScalar +from .types import BoundStepInvocation as BoundStepInvocation +from .types import BoundValue as BoundValue +from .types import BoundValueOrigin as BoundValueOrigin +from .types import BoundValueState as BoundValueState from .types import CampaignProtector as CampaignProtector from .types import CanonicalRepresentationManifest as CanonicalRepresentationManifest from .types import CanonicalSpanId as CanonicalSpanId @@ -473,16 +486,16 @@ from .types import InfraExitCategory as InfraExitCategory from .types import InfraOutcome as InfraOutcome from .types import InputContractResolver as InputContractResolver from .types import InputPreflightResolver as InputPreflightResolver -from .types import InstalledRecipeExecution as InstalledRecipeExecution -from .types import InvocationTemplate as InvocationTemplate from .types import InputSpec as InputSpec from .types import InputSpecType as InputSpecType from .types import InspectorCallback as InspectorCallback from .types import InspectorEvidence as InspectorEvidence from .types import InspectorVerdict as InspectorVerdict +from .types import InstalledRecipeExecution as InstalledRecipeExecution from .types import IntakeRuleDef as IntakeRuleDef from .types import InvariantDef as InvariantDef from .types import InventoryAdmissionDecision as InventoryAdmissionDecision +from .types import InvocationTemplate as InvocationTemplate from .types import IssueLabelState as IssueLabelState from .types import KillReason as KillReason from .types import LabelDef as LabelDef @@ -513,13 +526,13 @@ from .types import OutputPatternResolver as OutputPatternResolver from .types import PackDef as PackDef from .types import PhoropterPhaseSkip as PhoropterPhaseSkip from .types import PhoropterPrescription as PhoropterPrescription -from .types import PluginSource as PluginSource -from .types import PrepareBatchEvent as PrepareBatchEvent -from .types import ProcessedEventRecord as ProcessedEventRecord from .types import PlanDispositionReport as PlanDispositionReport from .types import PlanDispositionRow as PlanDispositionRow +from .types import PluginSource as PluginSource from .types import PreflightEvidence as PreflightEvidence from .types import PreflightKind as PreflightKind +from .types import PrepareBatchEvent as PrepareBatchEvent +from .types import ProcessedEventRecord as ProcessedEventRecord from .types import ProcessStaleError as ProcessStaleError from .types import ProducerCoverageDef as ProducerCoverageDef from .types import ProducerInstanceId as ProducerInstanceId @@ -537,6 +550,7 @@ from .types import QuotaRefreshTask as QuotaRefreshTask from .types import ReadinessProbe as ReadinessProbe from .types import ReadingToken as ReadingToken from .types import ReadOnlyResolver as ReadOnlyResolver +from .types import RecipeBindingProjection as RecipeBindingProjection from .types import RecipeDeliveryAttestation as RecipeDeliveryAttestation from .types import RecipeDeliveryBudgetDef as RecipeDeliveryBudgetDef from .types import RecipeDeliveryDecision as RecipeDeliveryDecision @@ -544,7 +558,7 @@ from .types import RecipeDeliveryEvidenceDef as RecipeDeliveryEvidenceDef from .types import RecipeDeliveryMode as RecipeDeliveryMode from .types import RecipeDeliveryRequest as RecipeDeliveryRequest from .types import RecipeDeliverySurfaceDef as RecipeDeliverySurfaceDef -from .types import RecipeBindingProjection as RecipeBindingProjection +from .types import RecipeExecutionLock as RecipeExecutionLock from .types import RecipeExecutionSnapshot as RecipeExecutionSnapshot from .types import RecipeIdentity as RecipeIdentity from .types import RecipeLoadError as RecipeLoadError @@ -633,16 +647,14 @@ from .types import TerminationAction as TerminationAction from .types import TerminationReason as TerminationReason from .types import TestResult as TestResult from .types import TestRunner as TestRunner -from .types import ToolDef as ToolDef -from .types import ToolParamDef as ToolParamDef -from .types import ToolWireType as ToolWireType -from .types import VerifiedInputPreflightRequest as VerifiedInputPreflightRequest -from .types import VerifiedInputPreflightResult as VerifiedInputPreflightResult from .types import TimingLog as TimingLog from .types import TokenFactory as TokenFactory from .types import TokenizerIdentity as TokenizerIdentity from .types import TokenLog as TokenLog from .types import ToolCallId as ToolCallId +from .types import ToolDef as ToolDef +from .types import ToolParamDef as ToolParamDef +from .types import ToolWireType as ToolWireType from .types import TraditionManifest as TraditionManifest from .types import TurnId as TurnId from .types import ( @@ -650,6 +662,8 @@ from .types import ( ) from .types import ValidatedAddDir as ValidatedAddDir from .types import ValidatedWorktreePath as ValidatedWorktreePath +from .types import VerifiedInputPreflightRequest as VerifiedInputPreflightRequest +from .types import VerifiedInputPreflightResult as VerifiedInputPreflightResult from .types import WindowEpochId as WindowEpochId from .types import WitnessKind as WitnessKind from .types import WorkspaceManager as WorkspaceManager @@ -664,8 +678,8 @@ from .types import compute_invocation_template_digest as compute_invocation_temp from .types import ( compute_recipe_execution_snapshot_digest as compute_recipe_execution_snapshot_digest, ) -from .types import compute_runtime_binding_digest as compute_runtime_binding_digest from .types import compute_remaining as compute_remaining +from .types import compute_runtime_binding_digest as compute_runtime_binding_digest 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 diff --git a/src/autoskillit/core/audit_cycle_verifier.py b/src/autoskillit/core/audit_cycle_verifier.py index 8e46537c9..8a7a993ff 100644 --- a/src/autoskillit/core/audit_cycle_verifier.py +++ b/src/autoskillit/core/audit_cycle_verifier.py @@ -9,6 +9,7 @@ from .closure_hashing import compute_bytes_hash from .io import decode_versioned_json_bytes +from .logging import get_logger from .path_containment import ContainmentError, read_stable_contained_bytes from .types._type_audit_cycle import ( AUDIT_CYCLE_SCHEMA_VERSION, @@ -31,6 +32,7 @@ ] _REQUIREMENTS_HEADER = ("Requirement ID", "Disposition", "Implementation Step") +logger = get_logger(__name__) _STEP_HEADING_RE = re.compile( r"^###\s+(Step\s+[1-9][0-9]*(?:\.[1-9][0-9]*)*)(?::[^\n]*)?$", re.MULTILINE, @@ -165,6 +167,7 @@ def evaluate( current_plan_text=current_plan_text, ) except Exception as exc: + logger.error("inventory admission evaluation failed", exc_info=True) return _reject(AdmissionReason.INTERNAL_ERROR, f"inventory admission failed: {exc}") def _evaluate( diff --git a/src/autoskillit/core/tool_registry.py b/src/autoskillit/core/tool_registry.py index 80b360b1a..79adf9c3a 100644 --- a/src/autoskillit/core/tool_registry.py +++ b/src/autoskillit/core/tool_registry.py @@ -10,6 +10,7 @@ from collections.abc import Mapping from types import MappingProxyType +from .types._type_constants_registries import HEADLESS_TOOLS from .types._type_recipe_binding import ToolDef, ToolParamDef, ToolWireType __all__ = [ @@ -25,11 +26,20 @@ def _tool( params: tuple[str, ...] = (), *, required: tuple[str, ...] = (), + wire_types: Mapping[str, ToolWireType] | None = None, ) -> ToolDef: required_set = frozenset(required) + declared_wire_types = wire_types or {} return ToolDef( name=name, - params=tuple(ToolParamDef(param, required=param in required_set) for param in params), + params=tuple( + ToolParamDef( + param, + wire_type=declared_wire_types.get(param, ToolWireType.SCALAR), + required=param in required_set, + ) + for param in params + ), ) @@ -64,6 +74,13 @@ def _run_skill() -> ToolDef: ToolParamDef("stale_threshold", ToolWireType.INTEGER), ToolParamDef("idle_output_timeout", ToolWireType.INTEGER), ] + params.append( + ToolParamDef( + "dispatch_items", + ToolWireType.STRING, + handler_parameter=False, + ) + ) params.append( ToolParamDef( "skill_inputs", @@ -195,7 +212,12 @@ def _run_skill() -> ToolDef: ), ), _tool("run_cmd", ("cmd", "cwd", "timeout", "step_name"), required=("cmd", "cwd")), - _tool("run_python", ("callable", "args", "timeout", "work_dir"), required=("callable",)), + _tool( + "run_python", + ("callable", "args", "timeout", "work_dir"), + required=("callable",), + wire_types={"args": ToolWireType.OBJECT}, + ), _run_skill(), _tool( "dispatch_food_truck", @@ -216,6 +238,11 @@ def _run_skill() -> ToolDef: "backend", ), required=("recipe", "task"), + wire_types={ + "ingredients": ToolWireType.OBJECT, + "capture": ToolWireType.OBJECT, + "resume_checkpoint": ToolWireType.OBJECT, + }, ), _tool( "record_gate_dispatch", @@ -280,7 +307,14 @@ def _run_skill() -> ToolDef: ("issue_url", "label", "target_branch", "staged_label", "fail_label", "close_issue"), required=("issue_url",), ), - _tool("open_kitchen", ("name", "overrides", "ingredients_only", "delivery_request")), + _tool( + "open_kitchen", + ("name", "overrides", "ingredients_only", "delivery_request"), + wire_types={ + "overrides": ToolWireType.OBJECT, + "delivery_request": ToolWireType.OBJECT, + }, + ), _tool("close_kitchen"), _tool("disable_quota_guard"), _tool("lock_ingredients", ("locked", "pipeline_id", "unlock")), @@ -291,12 +325,17 @@ def _run_skill() -> ToolDef: "bulk_close_issues", ("issue_numbers", "comment", "cwd"), required=("issue_numbers", "comment", "cwd"), + wire_types={"issue_numbers": ToolWireType.ARRAY}, ), _tool("list_recipes"), _tool( "load_recipe", ("name", "overrides", "ingredients_only", "delivery_request"), required=("name",), + wire_types={ + "overrides": ToolWireType.OBJECT, + "delivery_request": ToolWireType.OBJECT, + }, ), _tool( "get_recipe_section", @@ -335,7 +374,12 @@ def _run_skill() -> ToolDef: _tool("analyze_tool_sequences", ("recipe", "format", "top_n", "min_count")), _tool("get_quota_events", ("n",)), _tool("write_telemetry_files", ("output_dir",), required=("output_dir",)), - _tool("read_db", ("db_path", "query", "params", "timeout"), required=("db_path", "query")), + _tool( + "read_db", + ("db_path", "query", "params", "timeout"), + required=("db_path", "query"), + wire_types={"params": ToolWireType.ARRAY}, + ), _tool("test_check", ("worktree_path", "step_name"), required=("worktree_path",)), _tool("reset_test_dir", ("test_dir", "force", "step_name"), required=("test_dir",)), _tool("reset_workspace", ("test_dir",), required=("test_dir",)), @@ -352,6 +396,11 @@ def _build_registry(tool_defs: tuple[ToolDef, ...]) -> Mapping[str, ToolDef]: TOOL_REGISTRY: Mapping[str, ToolDef] = _build_registry(_TOOL_DEFS) +if not HEADLESS_TOOLS <= TOOL_REGISTRY.keys(): + raise RuntimeError( + "Canonical tool registry is missing headless tools: " + f"{sorted(HEADLESS_TOOLS - TOOL_REGISTRY.keys())}" + ) def get_tool_def(tool_name: str) -> ToolDef | None: diff --git a/src/autoskillit/core/types/_type_recipe_binding.py b/src/autoskillit/core/types/_type_recipe_binding.py index 3ec57553b..c21ea5b82 100644 --- a/src/autoskillit/core/types/_type_recipe_binding.py +++ b/src/autoskillit/core/types/_type_recipe_binding.py @@ -131,12 +131,13 @@ class BoundValue: """One aligned declared/effective value in a named binding slot.""" name: str - declared_value: BoundScalar | AbsentBoundValue - effective_value: BoundScalar | AbsentBoundValue + declared_value: object | AbsentBoundValue + effective_value: object | AbsentBoundValue state: BoundValueState origin: BoundValueOrigin context_dependencies: tuple[str, ...] = () input_dependencies: tuple[str, ...] = () + template_dependencies: tuple[str, ...] = () @classmethod def absent(cls, name: str) -> BoundValue: @@ -195,7 +196,7 @@ def is_valid(self) -> bool: def attested(self) -> bool: """Whether this binding is eligible for later recipe attestation.""" - return self.mode is BindingMode.RECIPE and self.is_valid + return self.mode is BindingMode.RECIPE and self.is_valid and self.skill_name is not None @property def canonical_child_invocation(self) -> tuple[tuple[str, BoundScalar], ...]: @@ -212,6 +213,8 @@ def canonical_child_invocation(self) -> tuple[tuple[str, BoundScalar], ...]: effective = value.effective_value if isinstance(effective, AbsentBoundValue): continue + if not isinstance(effective, (str, int, float, bool)): + raise TypeError(f"child-skill input {value.name!r} is not a strict scalar") result.append((value.name, effective)) return tuple(result) diff --git a/src/autoskillit/core/types/_type_recipe_execution.py b/src/autoskillit/core/types/_type_recipe_execution.py index 5f5f4f616..2e0d1d947 100644 --- a/src/autoskillit/core/types/_type_recipe_execution.py +++ b/src/autoskillit/core/types/_type_recipe_execution.py @@ -12,7 +12,7 @@ from dataclasses import dataclass from enum import StrEnum from types import MappingProxyType -from typing import Protocol, runtime_checkable +from typing import Any, Protocol, runtime_checkable from ..closure_hashing import compute_canonical_hash from ._type_audit_cycle import AuditCycleAuthority, AuditCycleHead, InventoryAdmissionDecision @@ -30,6 +30,7 @@ "InvocationTemplate", "PreflightEvidence", "PreflightKind", + "RecipeExecutionLock", "RecipeExecutionSnapshot", "VerifiedInputPreflightRequest", "VerifiedInputPreflightResult", @@ -43,7 +44,16 @@ _RUNTIME_BINDING_DOMAIN = "autoskillit:recipe-runtime-binding:v1:sha256" -def _bound_scalar_payload(value: BoundScalar | AbsentBoundValue) -> object: +@runtime_checkable +class RecipeExecutionLock(Protocol): + """Context-manager contract for atomic recipe-execution state changes.""" + + def __enter__(self) -> Any: ... + + def __exit__(self, *args: Any) -> Any: ... + + +def _bound_scalar_payload(value: object | AbsentBoundValue) -> object: if isinstance(value, AbsentBoundValue): return {"absent": True} return value @@ -58,6 +68,7 @@ def _bound_value_payload(value: BoundValue) -> dict[str, object]: "name": value.name, "origin": value.origin.value, "state": value.state.value, + "template_dependencies": list(value.template_dependencies), } diff --git a/src/autoskillit/pipeline/context.py b/src/autoskillit/pipeline/context.py index d018a124c..a4c2133b6 100644 --- a/src/autoskillit/pipeline/context.py +++ b/src/autoskillit/pipeline/context.py @@ -39,6 +39,7 @@ PluginSource, QuotaRefreshTask, ReadOnlyResolver, + RecipeExecutionLock, RecipeRepository, ServeOverridesSnapshot, SessionSkillManager, @@ -137,6 +138,8 @@ class ToolContext: closed) active_recipe_execution: atomically installed compiled execution snapshot, runtime binding digests, trusted audit heads, and verified input resolver. + recipe_execution_lock: RecipeExecutionLock — serializes installation, lookup, and + cleanup of the active compiled execution state. temp_dir: Resolved temp directory for this project. Sentinel-guarded: raises TypeError if not supplied explicitly. Use make_context() or pass temp_dir=. @@ -201,7 +204,7 @@ class ToolContext: build_protected_campaign_ids: CampaignProtector | None = field(default=None) ephemeral_root: Path | None = field(default_factory=lambda: None) active_recipe_execution: InstalledRecipeExecution | None = field(default_factory=lambda: None) - recipe_execution_lock: threading.RLock = field( + recipe_execution_lock: RecipeExecutionLock = field( default_factory=threading.RLock, repr=False, ) diff --git a/src/autoskillit/recipe/AGENTS.md b/src/autoskillit/recipe/AGENTS.md index f74caf77b..976d5804b 100644 --- a/src/autoskillit/recipe/AGENTS.md +++ b/src/autoskillit/recipe/AGENTS.md @@ -13,6 +13,7 @@ Sub-package: rules/ (see rules/AGENTS.md). | `_contracts_manifest.py` | Manifest loading + ref extraction utilities | | `_contracts_card.py` | Card generation, loading, validation | | `_contracts_staleness.py` | Staleness detection + MCP suggestions | +| `_io_loading.py` | Declaration-preserving YAML/JSON loading and placeholder substitution | | `io.py` | `load_recipe`, `list_recipes`, `iter_steps_with_context` | | `order.py` | `BUNDLED_RECIPE_ORDER` — stable display order registry for Group 0 recipes | | `loader.py` | Path-based recipe metadata utilities | diff --git a/src/autoskillit/recipe/_api.py b/src/autoskillit/recipe/_api.py index f75d7905b..e7598fa15 100644 --- a/src/autoskillit/recipe/_api.py +++ b/src/autoskillit/recipe/_api.py @@ -272,6 +272,7 @@ def load_and_validate( backend_name, tuple(sorted(effective_backend_map.items())) if effective_backend_map else (), tuple(sorted(backend_capabilities_map.items())) if backend_capabilities_map else (), + include_compiled_bindings, _manifest_mtime, _manifest_size, _budgets_mtime, diff --git a/src/autoskillit/recipe/_binding.py b/src/autoskillit/recipe/_binding.py index 4d1c3d77a..3b8c8b508 100644 --- a/src/autoskillit/recipe/_binding.py +++ b/src/autoskillit/recipe/_binding.py @@ -37,6 +37,7 @@ _CONTEXT_REF_RE: Final = re.compile(r"\$\{\{\s*context\.([A-Za-z_]\w*)\s*\}\}") _INPUT_REF_RE: Final = re.compile(r"\$\{\{\s*inputs\.([A-Za-z_]\w*)\s*\}\}") +_AUTOSKILLIT_TEMPLATE_RE: Final = re.compile(r"\{\{(AUTOSKILLIT_[A-Z0-9_]+)\}\}") _EXACT_CONTEXT_REF_RE: Final = re.compile(r"^\$\{\{\s*context\.([A-Za-z_]\w*)\s*\}\}$") _EXACT_INPUT_REF_RE: Final = re.compile(r"^\$\{\{\s*inputs\.([A-Za-z_]\w*)\s*\}\}$") _SCALAR_TYPES = (str, int, float, bool) @@ -52,11 +53,13 @@ def _origin_for( BoundValueOrigin, tuple[str, ...], tuple[str, ...], + tuple[str, ...], ]: if not isinstance(declared, str): - return BoundValueOrigin.LITERAL, (), () + return BoundValueOrigin.LITERAL, (), (), () context_dependencies = tuple(dict.fromkeys(_CONTEXT_REF_RE.findall(declared))) input_dependencies = tuple(dict.fromkeys(_INPUT_REF_RE.findall(declared))) + template_dependencies = tuple(dict.fromkeys(_AUTOSKILLIT_TEMPLATE_RE.findall(declared))) if _EXACT_CONTEXT_REF_RE.fullmatch(declared): origin = BoundValueOrigin.CONTEXT elif _EXACT_INPUT_REF_RE.fullmatch(declared): @@ -70,11 +73,65 @@ def _origin_for( origin = BoundValueOrigin.TEMPLATE else: origin = BoundValueOrigin.LITERAL - return origin, context_dependencies, input_dependencies + return origin, context_dependencies, input_dependencies, template_dependencies def _bound_value(name: str, declared: BoundScalar, effective: BoundScalar) -> BoundValue: - origin, context_dependencies, input_dependencies = _origin_for(declared) + ( + origin, + context_dependencies, + input_dependencies, + template_dependencies, + ) = _origin_for(declared) + return BoundValue( + name=name, + declared_value=declared, + effective_value=effective, + state=BoundValueState.PRESENT, + origin=origin, + context_dependencies=context_dependencies, + input_dependencies=input_dependencies, + template_dependencies=template_dependencies, + ) + + +def _structured_dependencies( + value: object, +) -> tuple[tuple[str, ...], tuple[str, ...], tuple[str, ...]]: + context: list[str] = [] + inputs: list[str] = [] + templates: list[str] = [] + + def visit(item: object) -> None: + if isinstance(item, str): + context.extend(_CONTEXT_REF_RE.findall(item)) + inputs.extend(_INPUT_REF_RE.findall(item)) + templates.extend(_AUTOSKILLIT_TEMPLATE_RE.findall(item)) + elif isinstance(item, Mapping): + for key, nested in item.items(): + visit(key) + visit(nested) + elif isinstance(item, (list, tuple)): + for nested in item: + visit(nested) + + visit(value) + return ( + tuple(dict.fromkeys(context)), + tuple(dict.fromkeys(inputs)), + tuple(dict.fromkeys(templates)), + ) + + +def _bound_structured_value(name: str, declared: object, effective: object) -> BoundValue: + context_dependencies, input_dependencies, template_dependencies = _structured_dependencies( + declared + ) + origin = ( + BoundValueOrigin.TEMPLATE + if context_dependencies or input_dependencies or template_dependencies + else BoundValueOrigin.LITERAL + ) return BoundValue( name=name, declared_value=declared, @@ -83,6 +140,7 @@ def _bound_value(name: str, declared: BoundScalar, effective: BoundScalar) -> Bo origin=origin, context_dependencies=context_dependencies, input_dependencies=input_dependencies, + template_dependencies=template_dependencies, ) @@ -95,7 +153,7 @@ def _failure( return BindingFailure(code=code, step_name=step_name, name=name, message=message) -def _wire_value_is_valid(value: BoundScalar, param: ToolParamDef) -> bool: +def _wire_value_is_valid(value: object, param: ToolParamDef) -> bool: match param.wire_type: case ToolWireType.STRING: return isinstance(value, str) @@ -107,8 +165,10 @@ def _wire_value_is_valid(value: BoundScalar, param: ToolParamDef) -> bool: return isinstance(value, bool) case ToolWireType.SCALAR: return _is_scalar(value) - case ToolWireType.OBJECT | ToolWireType.ARRAY: - return False + case ToolWireType.OBJECT: + return isinstance(value, Mapping) + case ToolWireType.ARRAY: + return isinstance(value, (list, tuple)) def _skill_value_is_valid(value: BoundScalar, input_def: SkillInput) -> bool: @@ -253,6 +313,16 @@ def _inline_skill_inputs( input_defs = contract.inputs input_by_name = {input_def.name: input_def for input_def in input_defs} + if ( + len(input_defs) == 1 + and declared_args + and all(_split_named_token(token) is None for token in declared_args) + ): + # Slash-command callers conventionally pass a free-form prose tail for a + # single input. Preserve that complete tail as one value instead of + # treating each word as a separate positional input. + declared_args = (" ".join(declared_args),) + effective_args = (" ".join(effective_args),) assigned: dict[str, tuple[BoundScalar, BoundScalar]] = {} failures: list[BindingFailure] = [] position = 0 @@ -455,6 +525,12 @@ def bind_step_invocation( authorable_params |= frozenset( input_def.name for input_def in callable_contract.inputs ) + if isinstance(callable_name, str): + # Outer run_python keys are the callable's argument channel. A + # manifest contract describes output capture, but all callable + # arguments are packed into the run_python handler's canonical + # ``args`` object rather than becoming MCP parameters. + authorable_params |= frozenset(declared_with) unknown_params = frozenset(declared_with) - authorable_params for name in sorted(unknown_params): failures.append( @@ -484,6 +560,21 @@ def bind_step_invocation( continue declared = declared_with[param.name] effective = effective_with.get(param.name, declared) + if param.wire_type in {ToolWireType.OBJECT, ToolWireType.ARRAY}: + if not _wire_value_is_valid(declared, param) or not _wire_value_is_valid( + effective, param + ): + failures.append( + _failure( + BindingFailureCode.INVALID_TOOL_PARAMETER_TYPE, + step_name, + param.name, + f"tool parameter {param.name!r} expects {param.wire_type.value!r}", + ) + ) + continue + mcp_kwargs.append(_bound_structured_value(param.name, declared, effective)) + continue if not _is_scalar(declared) or not _is_scalar(effective): failures.append( _failure( @@ -546,8 +637,41 @@ def bind_step_invocation( ) skill_name = resolve_skill_name(effective_command) + declared_structured = declared_with.get("skill_inputs") + effective_structured = effective_with.get("skill_inputs", declared_structured) + contract = get_skill_contract(skill_name, active_manifest) if skill_name else None if skill_name is None or contract is None: + if mode is BindingMode.RECIPE and not declared_command.lstrip().startswith("/"): + # LLM-orchestrated recipe steps may use a non-executable placeholder + # command while their note defines a bounded fan-out of tool calls. + return BoundStepInvocation( + step_name, + tool_name, + mode, + None, + tuple(mcp_kwargs), + (), + tuple(failures), + ) + if mode is BindingMode.RECIPE and "{" in declared_command: + generic_inputs: list[BoundValue] = [] + if isinstance(declared_structured, Mapping) and isinstance( + effective_structured, Mapping + ): + for name, declared in declared_structured.items(): + effective = effective_structured.get(name, declared) + if isinstance(name, str) and _is_scalar(declared) and _is_scalar(effective): + generic_inputs.append(_bound_value(name, declared, effective)) + return BoundStepInvocation( + step_name, + tool_name, + mode, + None, + tuple(mcp_kwargs), + tuple(generic_inputs), + tuple(failures), + ) failures.append( _failure( BindingFailureCode.UNKNOWN_SKILL, @@ -566,8 +690,6 @@ def bind_step_invocation( tuple(failures), ) - declared_structured = declared_with.get("skill_inputs") - effective_structured = effective_with.get("skill_inputs", declared_structured) try: has_inline_args = bool(_tokenize_skill_command(declared_command)[1:]) except ValueError: diff --git a/src/autoskillit/recipe/_contracts_card.py b/src/autoskillit/recipe/_contracts_card.py index 280a0acbd..d96e4d00c 100644 --- a/src/autoskillit/recipe/_contracts_card.py +++ b/src/autoskillit/recipe/_contracts_card.py @@ -14,6 +14,7 @@ get_logger, load_yaml, ) +from autoskillit.recipe._binding import bind_recipe from autoskillit.recipe._contracts_manifest import ( classify_step_arg_style, compute_skill_hash, @@ -119,6 +120,7 @@ def generate_recipe_card( data = load_yaml(pipeline_path) recipe = _parse_recipe(data) manifest = load_bundled_manifest() + binding_projection = bind_recipe(recipe, manifest=manifest) skill_hashes: dict[str, str] = {} skills: dict[str, dict] = {} @@ -137,7 +139,13 @@ def generate_recipe_card( if step.tool in SKILL_TOOLS: skill_cmd = step.with_args.get("skill_command", "") - skill_name = resolve_skill_name(skill_cmd) + invocation = binding_projection.for_step(step_name) + structured = "skill_inputs" in step.with_args + skill_name = ( + invocation.skill_name + if structured and invocation is not None + else resolve_skill_name(skill_cmd) + ) if skill_name: contract = get_skill_contract(skill_name, manifest) if contract: @@ -162,9 +170,22 @@ def generate_recipe_card( if contract.read_only: skill_entry["read_only"] = True skills[skill_name] = skill_entry - all_input_names = {i.name for i in contract.inputs} + all_input_names = {item.name for item in contract.inputs} arg_style = classify_step_arg_style(skill_cmd, all_input_names) - if arg_style == "positional_text": + if structured and invocation is not None: + entry["structured_inputs"] = [ + name for name, _value in invocation.canonical_child_invocation + ] + entry["required"] = [ + item.name + for item in contract.inputs + if item.required + and ( + (bound := invocation.skill_input(item.name)) is None + or not bound.is_present + ) + ] + elif arg_style == "positional_text": entry["required"] = [] entry["positional_args"] = count_positional_args(skill_cmd) elif arg_style == "positional_template": diff --git a/src/autoskillit/recipe/_recipe_ingredients.py b/src/autoskillit/recipe/_recipe_ingredients.py index 8f45c4c5a..cacec7736 100644 --- a/src/autoskillit/recipe/_recipe_ingredients.py +++ b/src/autoskillit/recipe/_recipe_ingredients.py @@ -2,16 +2,14 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, NotRequired, TypedDict +from typing import Any, NotRequired, TypedDict from autoskillit.core import ( + RecipeBindingProjection, TerminalColumn, _render_gfm_table, ) -if TYPE_CHECKING: - from autoskillit.core import RecipeBindingProjection - # --------------------------------------------------------------------------- # GFM ingredient table column specs # --------------------------------------------------------------------------- diff --git a/src/autoskillit/recipe/schema.py b/src/autoskillit/recipe/schema.py index 2a667b006..1d5b6fbc1 100644 --- a/src/autoskillit/recipe/schema.py +++ b/src/autoskillit/recipe/schema.py @@ -23,8 +23,6 @@ AUTOSKILLIT_VERSION_KEY: Final = "autoskillit_version" RECIPE_VERSION_KEY: Final = "recipe_version" CAMPAIGN_REF_RE: Final = re.compile(r"\$\{\{\s*campaign\.(\w+)\s*\}\}") -RecipeScalar = str | int | float | bool -RecipeArgValue = RecipeScalar | dict[str, RecipeScalar] class RecipeKind(StrEnum): @@ -117,9 +115,8 @@ class RecipeStep: action: str | None = None # Built-in action: "route", "stop", "confirm" python: str | None = None constant: str | None = None # Literal output value — no subprocess or MCP call - # Runtime validation below enforces RecipeArgValue. ``Any`` keeps existing - # scalar consumers from falsely treating the sole nested skill_inputs - # channel as if every key could be a mapping. + # Tool-specific binding validates ordinary values. Runtime validation below + # reserves the one generic nested channel for structured child-skill inputs. with_args: dict[str, Any] = field(default_factory=dict) declared_with_args: dict[str, Any] = field(default_factory=dict, repr=False) on_success: str | None = None @@ -156,11 +153,11 @@ def __post_init__(self) -> None: if not isinstance(self.with_args, dict): raise TypeError("RecipeStep.with_args must be a mapping") for key, value in self.with_args.items(): - if isinstance(value, dict): - if self.tool != "run_skill" or key != "skill_inputs": + if key == "skill_inputs": + if self.tool != "run_skill" or not isinstance(value, dict): raise TypeError( - "Only run_skill.with.skill_inputs may contain a mapping; " - f"got mapping at {self.tool or ''}.{key}" + "Only run_skill.with.skill_inputs may declare structured " + "child-skill inputs" ) if not all( isinstance(input_name, str) @@ -171,10 +168,6 @@ def __post_init__(self) -> None: raise TypeError( "run_skill.with.skill_inputs must map names to strict scalar values" ) - elif not isinstance(value, (str, int, float, bool)) or value is None: - raise TypeError( - f"RecipeStep.with.{key} must be a strict scalar, got {type(value).__name__}" - ) if self.declared_with_args: if self.declared_with_args.keys() != self.with_args.keys(): raise ValueError( diff --git a/src/autoskillit/recipe/skill_contracts.yaml b/src/autoskillit/recipe/skill_contracts.yaml index 78606d18c..1d4728d7e 100644 --- a/src/autoskillit/recipe/skill_contracts.yaml +++ b/src/autoskillit/recipe/skill_contracts.yaml @@ -80,6 +80,9 @@ skills: - name: experiment_plan type: file_path required: true + - name: revision_guidance + type: directory_path + required: false outputs: - name: resolution type: string @@ -104,6 +107,12 @@ skills: - name: base_branch type: string required: true + - name: ci_conclusion + type: string + required: false + - name: ci_failed_jobs + type: string + required: false - name: diagnosis_path type: file_path required: false @@ -204,11 +213,18 @@ skills: - name: issue_url type: string required: false - - name: remediation_path + - name: review_path + type: file_path + required: false + - name: audit_cycle_path + type: file_path + required: false + - name: plan_disposition_path type: file_path required: false outputs: [] write_behavior: always + input_preflight: audit_cycle_inventory implement-worktree: inputs: - name: plan_path @@ -280,24 +296,33 @@ skills: - name: task type: string required: true + - name: issue_url + type: string + required: false + - name: adversarial_review_level + type: string + required: false + - name: audit_cycle_path + type: file_path + required: false outputs: - name: verdict type: string allowed_values: - plan - - false_positive - name: plan_path type: file_path - name: plan_parts type: file_path_list + - name: plan_disposition_path + type: file_path expected_output_patterns: - - "verdict[ \\t]*=[ \\t]*(plan|false_positive)" - pattern_examples: - - "verdict = plan\nplan_path = /tmp/plan.md\nplan_parts = /tmp/plan.md\n%%ORDER_UP%%" - - "verdict = false_positive\n%%ORDER_UP%%" - write_behavior: conditional - write_expected_when: - "verdict[ \\t]*=[ \\t]*plan" + - "%%ORDER_UP::[a-f0-9]+%%" + pattern_examples: + - "verdict = plan\nplan_path = /tmp/plan.md\nplan_parts = /tmp/plan.md\n%%ORDER_UP::a1b2c3d4%%" + write_behavior: always + completion_required: true write-recipe: inputs: [] outputs: @@ -416,19 +441,27 @@ skills: - name: closure_authority_hash type: string required: false + - name: deviation_manifest_path + type: file_path + required: false + - name: prior_audit_cycle_path + type: file_path + required: false outputs: - name: verdict type: string allowed_values: [GO, "NO GO"] - name: remediation_path type: file_path + - name: audit_cycle_path + type: file_path - name: verified_verdict type: string expected_output_patterns: - "verdict[ \\t]*=[ \\t]*(GO|NO GO)" pattern_examples: - - "verdict = GO\n%%ORDER_UP%%" - - "verdict = NO GO\nremediation_path = /tmp/remediation.md\n%%ORDER_UP%%" + - "verdict = GO\naudit_cycle_path = /tmp/cycles/round-1/authority.json\n%%ORDER_UP%%" + - "verdict = NO GO\nremediation_path = /tmp/remediation.md\naudit_cycle_path = /tmp/cycles/round-1/authority.json\n%%ORDER_UP%%" migrate-recipes: inputs: [] outputs: [] @@ -669,6 +702,9 @@ skills: - name: mode type: string required: false + - name: pr_head_sha + type: string + required: false outputs: - name: verdict type: string @@ -1637,6 +1673,9 @@ skills: - name: worktree_path type: directory_path required: true + - name: adjust + type: boolean + required: false outputs: - name: results_path type: file_path @@ -1668,6 +1707,33 @@ skills: - name: results_path type: file_path required: true + - name: inconclusive + type: boolean + required: false + - name: output_mode + type: string + required: false + - name: issue_url + type: string + required: false + - name: experiment_type + type: string + required: false + - name: methodology_traditions + type: string + required: false + - name: design_review_verdict + type: string + required: false + - name: disambiguation_rule_applied + type: string + required: false + - name: tier_c_lens + type: string + required: false + - name: classification_timestamp + type: string + required: false - name: group_manifest type: file_path required: false @@ -1854,6 +1920,10 @@ skills: - name: pr_url type: string required: true + - name: annotated_diff_path + type: string + required: false + recommended: true - name: hunk_ranges_path type: string required: false @@ -2203,7 +2273,19 @@ skills: - "diagram_path = /tmp/diagram.md\n%%ORDER_UP%%" build-execution-map: - inputs: [] + inputs: + - name: issue_numbers + type: string + required: true + - name: base_ref + type: string + required: false + - name: assess_review_approach + type: boolean + required: false + - name: max_parallel + type: integer + required: false outputs: - name: execution_map type: file_path @@ -2253,13 +2335,31 @@ skills: write_expected_when: - "html_path[ \\t]*=[ \\t]*\\S+" planner-analyze: - inputs: [] + inputs: + - name: planner_dir + type: directory_path + required: true outputs: [] planner-extract-domain: - inputs: [] + inputs: + - name: analysis_path + type: file_path + required: true + - name: task_file_path + type: file_path + required: true outputs: [] planner-generate-phases: - inputs: [] + inputs: + - name: analysis_path + type: file_path + required: true + - name: domain_knowledge_path + type: file_path + required: true + - name: task_file_path + type: file_path + required: true outputs: - name: phase_manifest_path type: file_path @@ -2332,10 +2432,19 @@ skills: - "phase_wps_result_dir = /tmp/autoskillit/planner/run-20260101-120000/work_packages\n%%ORDER_UP%%" write_behavior: always planner-reconcile-deps: - inputs: [] + inputs: + - name: planner_dir + type: directory_path + required: true outputs: [] planner-refine: - inputs: [] + inputs: + - name: validation_path + type: file_path + required: true + - name: planner_dir + type: directory_path + required: true outputs: - name: issues_fixed type: string @@ -3024,6 +3133,9 @@ callable_contracts: - name: plan_parts type: str required: true + - name: audit_cycle_path + type: str + required: false outputs: - name: verdict type: str @@ -3032,6 +3144,8 @@ callable_contracts: type: str - name: plan_path type: str + - name: plan_disposition_path + type: str autoskillit.recipe._cmd_rpc.review_path_rebase: inputs: - name: work_dir diff --git a/src/autoskillit/server/_factory.py b/src/autoskillit/server/_factory.py index 2025a7f2f..df7ac2405 100644 --- a/src/autoskillit/server/_factory.py +++ b/src/autoskillit/server/_factory.py @@ -87,7 +87,9 @@ def make_audit_cycle_head_store() -> AuditCycleHeadStore: """Construct the server-owned trusted-head ledger implementation.""" - from autoskillit.server._recipe_execution import DefaultAuditCycleHeadStore + from autoskillit.server._recipe_execution import ( # circular-break + DefaultAuditCycleHeadStore, + ) return DefaultAuditCycleHeadStore() @@ -98,7 +100,9 @@ def make_input_preflight_resolver( head_store: AuditCycleHeadStore, ) -> InputPreflightResolver: """Construct the bounded verifier backed by the supplied trusted ledger.""" - from autoskillit.server._recipe_execution import DefaultInputPreflightResolver + from autoskillit.server._recipe_execution import ( # circular-break + DefaultInputPreflightResolver, + ) return DefaultInputPreflightResolver( allowed_root=allowed_root, diff --git a/src/autoskillit/server/_recipe_delivery.py b/src/autoskillit/server/_recipe_delivery.py index 2ed18ef5e..45732a2c0 100644 --- a/src/autoskillit/server/_recipe_delivery.py +++ b/src/autoskillit/server/_recipe_delivery.py @@ -766,7 +766,7 @@ def finalize_recipe_delivery( execution_snapshot = None candidate_payload = dict(payload) if compiled_bindings is not None: - from autoskillit.server._recipe_execution import ( + from autoskillit.server._recipe_execution import ( # circular-break build_recipe_execution_snapshot, clear_recipe_execution, ) @@ -997,14 +997,22 @@ def complete_finalized_recipe_response( if completed == finalized.rendered: if finalized.execution_snapshot is not None and finalized.tool_ctx is not None: try: - from autoskillit.server._recipe_execution import install_recipe_execution + from autoskillit.server._recipe_execution import ( # circular-break + install_recipe_execution, + ) install_recipe_execution( finalized.tool_ctx, snapshot=finalized.execution_snapshot, ) except Exception: - from autoskillit.server._recipe_execution import clear_recipe_execution + get_logger(__name__).error( + "recipe execution snapshot installation failed", + exc_info=True, + ) + from autoskillit.server._recipe_execution import ( # circular-break + clear_recipe_execution, + ) clear_recipe_execution(finalized.tool_ctx) return json.dumps( @@ -1017,7 +1025,9 @@ def complete_finalized_recipe_response( return completed enforced = completed if finalized.execution_snapshot is not None and finalized.tool_ctx is not None: - from autoskillit.server._recipe_execution import clear_recipe_execution + from autoskillit.server._recipe_execution import ( # circular-break + clear_recipe_execution, + ) clear_recipe_execution(finalized.tool_ctx) if handle is not None and (ledger is None or not ledger.abort(handle)): diff --git a/src/autoskillit/server/_recipe_execution.py b/src/autoskillit/server/_recipe_execution.py index dd7362b05..d08025e14 100644 --- a/src/autoskillit/server/_recipe_execution.py +++ b/src/autoskillit/server/_recipe_execution.py @@ -18,6 +18,8 @@ AuditCycleHead, AuditCycleVerifier, AuditVerdict, + BindingFailureCode, + BindingMode, BoundScalar, BoundValueOrigin, BoundValueState, @@ -31,9 +33,15 @@ VerifiedInputPreflightResult, compute_invocation_template_digest, compute_recipe_execution_snapshot_digest, + get_logger, resolve_skill_name, ) -from autoskillit.recipe import get_skill_contract, load_bundled_manifest +from autoskillit.recipe import ( + RecipeStep, + bind_step_invocation, + get_skill_contract, + load_bundled_manifest, +) if TYPE_CHECKING: from autoskillit.core import AuditCycleHeadStore @@ -47,6 +55,7 @@ "bind_attested_runtime_invocation", "build_bound_child_prompt", "build_recipe_execution_snapshot", + "build_standalone_child_prompt", "clear_recipe_execution", "get_recipe_execution", "install_recipe_execution", @@ -124,6 +133,10 @@ def publish( "initial authority requires an empty parent and round one" ) else: + if current.verdict is AuditVerdict.GO: + raise AuditCycleHeadConflict( + "terminal GO authority cannot be advanced within the same part" + ) if ( current.current_authority_digest != expected_parent_digest or current.audit_round != expected_round @@ -211,6 +224,10 @@ def resolve( try: authority = self._verifier.load_authority(authority_path) except Exception as exc: + get_logger(__name__).error( + "audit-cycle authority verification failed", + exc_info=True, + ) return self._result( InventoryAdmissionDecision.reject( AdmissionReason.AUTHORITY_NOT_CURRENT, @@ -292,10 +309,10 @@ def build_recipe_execution_snapshot( active_execution_id = execution_id or uuid4().hex templates: dict[str, InvocationTemplate] = {} for step_name, invocation in projection.invocations.items(): - if invocation.tool_name != "run_skill": + if invocation.tool_name != "run_skill" or not invocation.attested: continue - if not invocation.attested or invocation.skill_name is None: - raise ValueError(f"run_skill step {step_name!r} is not valid for attestation") + if invocation.skill_name is None: + raise AssertionError("attested invocation is missing its skill identity") skill_identity = _skill_contract_identity(invocation.skill_name) digest = compute_invocation_template_digest( execution_id=active_execution_id, @@ -508,6 +525,51 @@ def build_bound_child_prompt( ) +def build_standalone_child_prompt( + skill_command: str, + cwd: str, + skill_inputs: Mapping[str, str | int | float | bool] | None, +) -> str: + """Validate standalone inputs without requiring attested recipe state. + + An unknown skill with no structured inputs may still be resolved from a + project/plugin installation later in the normal dispatch path. Structured + inputs require a canonical contract because their names and order otherwise + cannot be validated safely. + """ + with_args: dict[str, object] = { + "skill_command": skill_command, + "cwd": cwd, + } + if skill_inputs is not None: + with_args["skill_inputs"] = skill_inputs + binding = bind_step_invocation( + "standalone", + RecipeStep( + name="standalone", + tool="run_skill", + with_args=with_args, + declared_with_args=dict(with_args), + ), + mode=BindingMode.STANDALONE, + ) + if binding.failures: + failure = binding.failures[0] + if failure.code is BindingFailureCode.UNKNOWN_SKILL and skill_inputs is None: + return skill_command + raise RecipeExecutionAdmissionError( + f"standalone_{failure.code.value}", + failure.message, + ) + if skill_inputs is None: + return skill_command + return build_bound_child_prompt( + skill_command, + binding.canonical_child_invocation, + None, + ) + + def publish_verified_audit_cycle( tool_ctx: ToolContext, *, diff --git a/src/autoskillit/server/tools/_serve_helpers.py b/src/autoskillit/server/tools/_serve_helpers.py index 1c767c808..687a35acc 100644 --- a/src/autoskillit/server/tools/_serve_helpers.py +++ b/src/autoskillit/server/tools/_serve_helpers.py @@ -16,6 +16,7 @@ from autoskillit.core import ( RESPONSE_BACKSTOP_EXEMPTION_REGISTRY, RESPONSE_BACKSTOP_EXEMPTION_REGISTRY_DIGEST, + RecipeBindingProjection, SkillExecutionRole, ) from autoskillit.server._misc import SkillProjectionContext, project_agent_skill_document @@ -24,7 +25,6 @@ from autoskillit.core import ( BackendCapabilities, CodingAgentBackend, - RecipeBindingProjection, ) from autoskillit.pipeline.context import ToolContext @@ -109,15 +109,15 @@ def response_backstop_tool_meta( def render_served_response(payload: dict[str, Any]) -> str: """Render the authoritative pre-backstop response used by recipe serve tools.""" - return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + public_payload = dict(payload) + public_payload.pop("_compiled_bindings", None) + return json.dumps(public_payload, ensure_ascii=False, separators=(",", ":")) def pop_compiled_bindings( payload: dict[str, Any], ) -> RecipeBindingProjection | None: """Remove and return the internal post-prune carrier before serialization.""" - from autoskillit.core import RecipeBindingProjection - candidate = payload.pop("_compiled_bindings", None) return candidate if isinstance(candidate, RecipeBindingProjection) else None @@ -177,7 +177,7 @@ def reset_session_serve_overrides(ctx: ToolContext) -> None: """ ctx.session_serve_overrides = None ctx.session_serve_defer_unresolved = False - from autoskillit.server._recipe_execution import clear_recipe_execution + from autoskillit.server._recipe_execution import clear_recipe_execution # circular-break clear_recipe_execution(ctx) diff --git a/src/autoskillit/server/tools/tools_execution.py b/src/autoskillit/server/tools/tools_execution.py index ee389b8e4..e1d4ef270 100644 --- a/src/autoskillit/server/tools/tools_execution.py +++ b/src/autoskillit/server/tools/tools_execution.py @@ -26,7 +26,6 @@ SKILL_COMMAND_DISPLAY_MAX, WORKTREE_SKILLS, AdmissionStatus, - BindingMode, BoundScalar, ClaudeDirectoryConventions, ClosureAuthoritySpec, @@ -58,7 +57,6 @@ ) from autoskillit.pipeline import canonical_step_name as _canonical_step_name from autoskillit.pipeline import gate_error_result -from autoskillit.recipe import RecipeStep, bind_step_invocation from autoskillit.server import mcp from autoskillit.server._guards import ( _check_dry_walkthrough, @@ -84,6 +82,7 @@ RecipeExecutionAdmissionError, bind_attested_runtime_invocation, build_bound_child_prompt, + build_standalone_child_prompt, get_recipe_execution, record_runtime_binding_digest, ) @@ -978,33 +977,16 @@ async def run_skill( "standalone mode cannot claim recipe attestation", ) elif not resume_session_id: - _standalone_with: dict[str, object] = { - "skill_command": skill_command, - "cwd": cwd, - } - if skill_inputs is not None: - _standalone_with["skill_inputs"] = skill_inputs - _standalone_binding = bind_step_invocation( - "standalone", - RecipeStep( - name="standalone", - tool="run_skill", - with_args=_standalone_with, - declared_with_args=dict(_standalone_with), - ), - mode=BindingMode.STANDALONE, - ) - if _standalone_binding.failures: - failure = _standalone_binding.failures[0] - return _recipe_execution_deny( - f"standalone_{failure.code.value}", - failure.message, - ) - if skill_inputs is not None: - child_skill_command = build_bound_child_prompt( + try: + child_skill_command = build_standalone_child_prompt( skill_command, - _standalone_binding.canonical_child_invocation, - None, + cwd, + skill_inputs, + ) + except RecipeExecutionAdmissionError as exc: + return _recipe_execution_deny( + exc.code, + str(exc), ) with structlog.contextvars.bound_contextvars(tool="run_skill", cwd=cwd): diff --git a/src/autoskillit/server/tools/tools_recipe.py b/src/autoskillit/server/tools/tools_recipe.py index 7feb64008..bdb50aaf0 100644 --- a/src/autoskillit/server/tools/tools_recipe.py +++ b/src/autoskillit/server/tools/tools_recipe.py @@ -48,7 +48,7 @@ recipe_pull_producers, recipe_recreation_producers, ) -from autoskillit.server._recipe_execution import clear_recipe_execution +from autoskillit.server._recipe_execution import clear_recipe_execution, get_recipe_execution from autoskillit.server._recipe_section_pagination import ( RecipeSectionBoundError, RecipeSectionNonConvergenceError, @@ -611,6 +611,7 @@ async def get_recipe_section( resolved_defaults=_defaults, ingredients_only=False, ) + pop_compiled_bindings(_recreate) if not _recreate.get("valid", False): return _recipe_section_failure( "recipe_artifact_unavailable", @@ -620,6 +621,17 @@ async def get_recipe_section( _recreate = build_open_kitchen_recipe_payload( _recreate, version=__version__ ) + installed_execution = get_recipe_execution(tool_ctx) + if ( + installed_execution is not None + and installed_execution.snapshot.recipe_name == requested_recipe_name + ): + snapshot = installed_execution.snapshot + _recreate["recipe_execution"] = { + "execution_id": snapshot.execution_id, + "invocation_template_digests": dict(snapshot.template_digests), + "snapshot_digest": snapshot.snapshot_digest, + } try: recreated_generation = persist_recipe_artifact( diff --git a/src/autoskillit/skills_extended/audit-impl/SKILL.md b/src/autoskillit/skills_extended/audit-impl/SKILL.md index f5f55e3c2..4b4800ee4 100644 --- a/src/autoskillit/skills_extended/audit-impl/SKILL.md +++ b/src/autoskillit/skills_extended/audit-impl/SKILL.md @@ -28,6 +28,7 @@ requirements, scope creep, and unexpected changes. Produces a GO or NO GO verdic ``` {plans_input} {branch_name} {base_branch} [conflict_report_paths] +[prior_audit_cycle_path=] ``` - `plans_input` — one of: @@ -50,6 +51,10 @@ requirements, scope creep, and unexpected changes. Produces a GO or NO GO verdic - `closure_authority_hash=` (optional) — SHA-256 hash (`sha256:` followed by 64 lowercase hex chars) of the authority file content. Must be independently computed by the caller before invocation. Activates closure mode when paired with `closure_authority_path`. +- `prior_audit_cycle_path=` (optional) — Explicit authority for the immediate + remediation ancestor. The server verifies that it is the trusted current `NO GO` head and + that its execution generation, plan set, scope, part, round, parent, and audited-plan + lineage authorize this descendant. A sibling or unrelated plan starts a new scope instead. ## Critical Constraints @@ -58,7 +63,7 @@ requirements, scope creep, and unexpected changes. Produces a GO or NO GO verdic - Modify source files, plan files, or any other files — read-only audit only - Run tests — this skill audits, it does not fix -- Create files outside `{{AUTOSKILLIT_TEMP}}/audit-impl/` +- Create files outside the identity-scoped directory selected below - Emit a GO verdict when any `MISSING` or `CONFLICT` finding exists - Run subagents in the background (`run_in_background: true` is prohibited) - Issue subagent Task calls sequentially — ALL must be in a single parallel message @@ -69,15 +74,17 @@ requirements, scope creep, and unexpected changes. Produces a GO or NO GO verdic - Spawn all subagents via `Agent(model="sonnet")` - Resolve all plan files before starting (abort early if any are missing) - Issue all Task calls in a single message to maximize parallelism -- On a NO GO verdict, after writing the remediation file, emit the **absolute path** as a - structured output token as your final output. Resolve the relative - `temp/audit-impl/...` save path to absolute by prepending the full CWD: +- On every verdict, write one immutable `AuditCycleAuthority` and emit its **absolute path** + as `audit_cycle_path`. On `NO GO`, also emit the remediation path: ``` verdict = NO GO - remediation_path = /absolute/cwd/temp/audit-impl/{filename}.md + verified_verdict = NO GO + remediation_path = /absolute/cycle/directory/remediation.md + audit_cycle_path = /absolute/cycle/directory/authority.json ``` - On a GO verdict, emit only `verdict = GO` (no remediation_path token). - The remediation_path token is MANDATORY on NO GO — the pipeline cannot proceed without it. + On `GO`, omit `remediation_path` but still emit `audit_cycle_path`. The server verifies + and publishes the candidate authority to its current-head ledger; the child artifact + cannot appoint itself current. ## Workflow @@ -86,6 +93,8 @@ requirements, scope creep, and unexpected changes. Produces a GO or NO GO verdic **Named keyword arguments:** Before positional parsing, scan all arguments for tokens containing `=`. Extract each as a key-value pair and remove from the positional argument list. Currently recognized kwargs: - `deviation_manifest_path=` — Absolute path to a deviation manifest JSON file written by `resolve-failures` or `retry-worktree`. When the value is empty or the argument is absent, skip Step 3.5 (backward compatible). +- `prior_audit_cycle_path=` — Optional explicit remediation ancestor. Never infer this + value or a round from directory contents or from a singleton file. After extraction, validate the manifest (only when a non-empty path was provided): 1. File exists (use `ls` or `Glob` to confirm) @@ -144,14 +153,14 @@ sibling parallel-call cancellations. ## Closure Mode -Closure mode activates hash-bound verification of the audit verdict. It is designed for -pipelines where the requirements inventory must be frozen at audit time and the verdict -must be independently verifiable after the session ends. +Closure mode activates hash-bound verification of the audit verdict. Normal and closure +mode both produce the same immutable `AuditCycleAuthority`; closure mode references the +verified `ClosureReport` as evidence rather than duplicating it. **Activation gate:** - If exactly one of `closure_authority_path`/`closure_authority_hash` is provided (XOR) → emit error and stop (fail closed). Both must be present or both absent. -- If both absent → continue with normal mutable-inventory mode (all subsequent steps unchanged). +- If both absent → continue with normal mode (all subsequent steps unchanged). - If both present → activate closure mode. **Authority verification (must complete before any audit step):** @@ -162,7 +171,7 @@ must be independently verifiable after the session ends. - Parse as JSON. Validate it has `schema_version`, `requirement_ids`, `requirements` fields. **Inventory isolation (critical):** -- In closure mode, NEVER read, write, extend, or consult `requirements_inventory.json`. +- In closure mode, NEVER discover or consult an ambient `requirements_inventory.json`. - The authority file IS the frozen inventory. It is read-only. - Step 1 (Requirements Extraction) is SKIPPED entirely. Requirements are loaded from the authority file only — no extraction subagents are invoked. @@ -174,9 +183,8 @@ must be independently verifiable after the session ends. against authority requirements. - Step 3.5 (Deviation Evaluation): Unchanged if `deviation_manifest_path` provided. - Step 4 (Verdict Determination): Unchanged — determine GO/NO GO from findings. -- Step 5 (Output — modified): In addition to emitting plain-text `verdict = GO/NO GO` tokens - (for backward compatibility), produce a canonical JSON report: - - Write to `{{AUTOSKILLIT_TEMP}}/audit-impl/closure_report.json` +- Step 5 (Output — modified): Produce a canonical `ClosureReport` before the authority: + - Write it inside the current identity-scoped audit-cycle directory. - Report must contain all fields per the `ClosureReport` schema (`schema_version`, `request_hash`, `authority_hash`, `plan_hashes`, `base_sha`, `diff_sha`, `target_sha`, `requirement_ids`, `rows`, `verdict`, `report_hash`, `remediation_path`, `generated_at`) @@ -195,12 +203,17 @@ must be independently verifiable after the session ends. Do not output any prose between subagent dispatches. Immediately proceed to the next tool call. -**Round detection:** Before launching extraction subagents, check for the existence of `{{AUTOSKILLIT_TEMP}}/audit-impl/requirements_inventory.json`. +**Authority and round selection:** Never scan for a latest file and never activate a round +from singleton existence. When `prior_audit_cycle_path` is absent, create a new cycle/scope +at round 1 and extract the full inventory. When it is present, consume only the +server-verified prior authority, require its trusted verdict to be `NO GO`, require an +exact parent/scope/part/plan-lineage match, and set the candidate round to prior round + 1. +Reuse the prior inventory only after verifying its `ArtifactRef`. A sibling/new plan must +start a new scope instead of extending the prior inventory. -- If the file exists, this is round ≥2 — read the pinned inventory instead of re-extracting. -- If the file does not exist, this is round 1 — proceed with extraction and persist the result. - -**Union extraction (round 1 only):** Launch ≥2 independent Explore subagents per plan file (not 1). Each extracts requirements independently. Take the union of all extracted requirements (deduplicate by semantic equivalence). This raises extraction recall — a requirement missed by one extractor is caught by another. +**Union extraction (new scope only):** Launch ≥2 independent Explore subagents per plan file +(not 1). Each extracts requirements independently. Take the union of all extracted +requirements (deduplicate by semantic equivalence). Each Explore subagent returns: @@ -214,7 +227,7 @@ Each Explore subagent returns: { "schema_version": 1, "generated_at": "ISO-8601", - "all_plan_paths": "comma-separated plan file paths (provenance key)", + "plan_set_id": "identity derived from the explicit ordered audited plan refs", "requirements": [ { "id": "REQ-001", @@ -227,9 +240,17 @@ Each Explore subagent returns: } ``` -**Provenance guard:** When reading an existing inventory on round ≥2, validate that the inventory's `all_plan_paths` field matches the current `context.all_plan_paths`. If mismatched (new plans added), extend the inventory with requirements extracted from new plan files only. +Write inventory, remediation (when any), closure report (when any), and authority under: + +``` +{{AUTOSKILLIT_TEMP}}/audit-impl/cycles/{execution_generation}/{plan_set_id}/{scope_id}/{part_id}/round-{N}/ +``` -Aggregate into a unified requirements inventory and write it to `{{AUTOSKILLIT_TEMP}}/audit-impl/requirements_inventory.json` on round 1. +The `AuditCycleAuthority` must include the explicit execution generation, cycle ID, +plan-set/scope/part IDs, round, parent authority digest, audited plan refs, inventory ref, +ordered assessment rows, findings digest, verdict, generated timestamp, and authority +digest. `NO GO` requires a remediation `ArtifactRef`; `GO` requires `remediation_ref=null`. +Never rewrite an authority after hashing it. ### Step 2 — Load Implementation Diff @@ -332,9 +353,9 @@ audited. Do NOT use Read, Grep, or Glob — subagents are tool-restricted to Bas their agent definition, blocking filesystem reads at the platform level. When full file content is needed beyond the diff, subagents use `git show {implementation_ref}:{path}`. -Slice from the pinned inventory (written in Step 1) into up to 3 slices. On round ≥2, read the -pinned inventory from `{{AUTOSKILLIT_TEMP}}/audit-impl/requirements_inventory.json` rather -than re-extracting. Each slice receives a subset of `requirements[].id` values. Launch parallel +Slice the inventory verified or written for this exact cycle into up to 3 slices. For a +remediation descendant, use only the inventory `ArtifactRef` from the verified prior +authority. Each slice receives a subset of `requirements[].id` values. Launch parallel subagents using `Agent(subagent_type="autoskillit:audit-impl-slice-auditor")`, each receiving its slice, the full diff, and the `implementation_ref`. Each subagent checks: @@ -448,6 +469,7 @@ last line of your text output: ``` verdict = GO verified_verdict = GO +audit_cycle_path = {absolute_path_to_authority_file} ``` --- @@ -543,18 +565,23 @@ last lines of your text output: verdict = NO GO verified_verdict = NO GO remediation_path = {absolute_path_to_remediation_file} +audit_cycle_path = {absolute_path_to_authority_file} ``` The `verdict` token must be exactly `GO` or `NO GO` — this is the value the recipe's -`on_result: field: verdict` routing matches against. The `remediation_path` token must -be the absolute path to the remediation file written in this session (only emitted for -NO GO; omit the `remediation_path=` line entirely on GO). +`on_result: field: verdict` routing matches against. `audit_cycle_path` is mandatory on +both verdicts. `remediation_path` is mandatory only on `NO GO` and must be omitted on +`GO`. Publication is complete only after the server verifies the immutable candidate +and atomically advances the matching current-head entry. ## Output Location ``` -{{AUTOSKILLIT_TEMP}}/audit-impl/ -└── remediation_{topic}_{YYYY-MM-DD_HHMMSS}.md (written on NO GO only) +{{AUTOSKILLIT_TEMP}}/audit-impl/cycles/{generation}/{plan_set}/{scope}/{part}/round-{N}/ +├── authority.json +├── inventory.json +├── remediation.md (NO GO only) +└── closure_report.json (closure mode only) ``` ## Related Skills diff --git a/src/autoskillit/skills_extended/dry-walkthrough/SKILL.md b/src/autoskillit/skills_extended/dry-walkthrough/SKILL.md index cef2c8eae..0296b3d2a 100644 --- a/src/autoskillit/skills_extended/dry-walkthrough/SKILL.md +++ b/src/autoskillit/skills_extended/dry-walkthrough/SKILL.md @@ -30,9 +30,14 @@ The plan file must remain a **clean, self-contained implementation instruction s ## Arguments -`{plan_path}` — Absolute path to the plan file to validate (optional: falls back to most recent {{AUTOSKILLIT_TEMP}}/ artifact if omitted) -`{issue_url}` — (Optional) GitHub issue URL. When provided, enables plan-vs-issue coverage check in Step 4.6. -`{remediation_path}` — (Optional) Absolute path to the current audit-impl remediation findings file. When provided together with a pinned inventory, enables the two-disposition plan-vs-inventory gate in Step 4.7. +- `plan_path` — Required absolute path to the exact plan file to validate. +- `issue_url` (optional) — GitHub issue URL consumed by Step 4.6. +- `review_path` (optional) — Exact review-approach report whose accepted + recommendations must be reflected by the plan. +- `audit_cycle_path` (optional) — Exact authority supplied to the server-side + `audit_cycle_inventory` input preflight. +- `plan_disposition_path` (optional) — Exact `PlanDispositionReport` paired with the + authority and plan by input preflight. ## Critical Constraints @@ -53,6 +58,8 @@ The plan file must remain a **clean, self-contained implementation instruction s - Run subagents in the background (`run_in_background: true` is prohibited) - Write plan content, corrections, or the verification marker to any file other than the original plan file path provided as input. If the Edit tool is denied on the plan file, do NOT create a copy elsewhere — output a failure message instead. - Issue subagent Task calls sequentially — ALL must be in a single parallel message +- Discover a latest plan, audit cycle, inventory, or disposition report +- Open audit-cycle artifacts directly in Step 4.7 or reinterpret the verified preflight result **ALWAYS:** - Keep the plan as clean implementation instructions only (information/background helpful to implementation is okay) @@ -78,10 +85,10 @@ limited blast radius. The downstream step will restart the walkthrough on retry. ### Step 1: Load the Plan -Read the plan from: -- Path provided by user -- Plan content pasted directly -- Most recent plan in {{AUTOSKILLIT_TEMP}}/ subdirectories +Read only the exact `plan_path` supplied by the caller. If it is absent, stop with an input +error; never search `{{AUTOSKILLIT_TEMP}}` for a recent or singleton artifact. +When `review_path` is supplied, read that exact report and verify accepted recommendations +against the plan during the walkthrough; never discover a replacement review artifact. ### Multi-Part Plan Detection @@ -280,43 +287,38 @@ If `issue_url` or `issue_number` was provided to this skill, verify that the pla ### Step 4.7: Plan-vs-Inventory Coverage Check -When `{{AUTOSKILLIT_TEMP}}/audit-impl/requirements_inventory.json` exists (indicating the pipeline has run at least one audit round), perform a two-disposition plan-vs-inventory coverage check. This mode fires in remediation context where the remediation plan must cover both the delta findings AND carry forward all original requirements. +This check consumes only the server-provided `audit_cycle_inventory` preflight evidence. The +server has already verified recipe identity, current-head authority, exact artifact bytes, +plan/report/parent lineage, and the production evaluator decision. Do not open, search for, +or reconstruct any inventory, remediation, authority, or report. -**Precondition:** This disposition depends on audit-impl re-evaluating the entire requirements inventory each round (not a diff-only subset). If audit-impl were changed to emit partial findings, silence would become ambiguous rather than confirmatory. +1. If preflight is `OMIT`, record its stable reason (`no_authority`, `trusted_go`, or + `trusted_go_successor`) and omit Step 4.7. Absence is distinct from an authoritative + empty inventory; do not convert one into the other. +2. If preflight is `PASS`, copy its exact ordered rows into the walkthrough record. Preserve + every `satisfied-by-round-N` and `carried@step` value and the cited implementation step + without reclassification. +3. If preflight is `REJECT`, or any returned row is `UNMAPPED`, do not stamp or edit the plan + to cure the decision. Output: -1. **Guard:** If `requirements_inventory.json` does not exist, omit this check with note: "Plan-vs-inventory coverage check omitted — requirements_inventory.json not present." - -2. **Read the pinned inventory** from `{{AUTOSKILLIT_TEMP}}/audit-impl/requirements_inventory.json`. Each entry contains a `REQ-NNN` id, requirement text, and source location. - -3. **Read the current remediation findings** from the `remediation_path` argument (threaded from recipe context). If `remediation_path` was not provided, treat the findings as empty — every inventory requirement is therefore UNMAPPED and the gate blocks. - -4. **For each `REQ-NNN` in the inventory**, classify using a two-disposition evaluation: - - **satisfied** — the requirement is NOT cited in the current remediation findings. This means audit-impl round N re-verified this requirement and did not flag it; the prior round already confirmed completion. Pass. - - **carried** — the requirement IS cited in the current remediation findings, AND the plan contains a matching Implementation Steps directive that addresses it. Pass. - - **UNMAPPED** — the requirement IS cited in the current remediation findings, BUT no matching Implementation Steps directive exists in the plan. Blocking failure. - -5. **If any requirement is UNMAPPED:** Do NOT stamp the plan. Output a blocking failure: ``` ## Dry Walkthrough FAILED — Plan-vs-Inventory Coverage Gap **Plan:** {path} - **Inventory:** {{AUTOSKILLIT_TEMP}}/audit-impl/requirements_inventory.json - **Findings:** {remediation_path} - **Status:** FAILED — plan does not cover all remediation-cited requirements + **Status:** FAILED — audit-cycle inventory admission rejected + **Reason:** {stable_preflight_reason} ### Unmapped Items - - {req_id}: {requirement_text} — cited by finding but no Implementation Steps directive addresses it + - {exact evaluator row and detail} - The remediation plan must carry forward every requirement cited in the current - audit findings. If a finding was a false positive, update the finding's status - via the audit-impl verdict mechanism rather than dropping the requirement from - the plan. + The evaluator result is authoritative. Resolve disputed findings through a successor + audit-impl verdict; do not drop or pad requirements here. ``` - Stop execution — do not proceed to Step 5. -6. **If all requirements are satisfied or carried:** Record the disposition summary and proceed to Step 5. + Stop execution — do not proceed to Step 5. -This check composes with the existing plan-vs-issue check in Step 4.6 (both can fire independently if both inputs are present). The two-disposition gate prevents the one-sided failure mode where audit-impl round >=2 structurally fails because every delta-only plan is rejected as UNMAPPED. +4. Only `PASS` and `OMIT` may proceed to Step 5. This check composes independently with the + plan-vs-issue check in Step 4.6. ### Step 5: Fix the Plan @@ -324,6 +326,9 @@ For each issue found: 1. Directly edit the plan file to fix it 2. Do NOT add any "gap analysis" or "issues" sections to the plan 3. The plan should read as if it was correct from the start +4. Never add carry-forward padding, add/remove Requirements Map rows, or change a + `satisfied-by-round-N`/`carried@step` result to make Step 4.7 pass. Step 5 cannot + override the evaluator. ### Step 6: Mark Plan as Verified diff --git a/src/autoskillit/skills_extended/make-plan/SKILL.md b/src/autoskillit/skills_extended/make-plan/SKILL.md index 6a0af4c60..b7b00fb9f 100644 --- a/src/autoskillit/skills_extended/make-plan/SKILL.md +++ b/src/autoskillit/skills_extended/make-plan/SKILL.md @@ -22,6 +22,16 @@ Create focused, actionable implementation plans that recommend the technically b - User wants an "implementation plan" for a feature or fix - User asks to "plan out" a task or migration +## Arguments + +- `task` — Task or source document for an ordinary plan. +- `issue_url` (optional) — GitHub issue context consumed as described below. +- `adversarial_review_level` (optional) — `auto`, `full`, or `none`. +- `audit_cycle_path` (optional) — The explicit current audit-cycle authority. Its presence, + not prose flags or ambient files, activates remediation mode. Before reading any referenced + artifact, verify that this authority is the server-published current `NO GO` head and that + its generation, plan set, scope, part, round, parent, and audited-plan lineage match this run. + ## Core Values - CRITICAL The ONLY criterion for choosing an approach is **technical quality and correctness of design**. A well-designed system is the goal. Nothing else matters. @@ -237,6 +247,10 @@ handles correctly. - Reject an approach because it's harder - Create files outside `{{AUTOSKILLIT_TEMP}}/make-plan/` directory - Propagate pipeline stamps or markers from input files into the plan output. Specifically, never include `Dry-walkthrough verified = TRUE` as the first line of the output plan — this stamp is written exclusively by the dry-walkthrough skill after validation +- Discover a latest audit, read an ambient `requirements_inventory.json`, or treat a loose + remediation path as authority +- Emit `false_positive` or otherwise close an active `NO GO`; only a successor audit-impl + authority may close that lineage - **Use `git merge` in implementation plans.** When a plan needs to bring in changes from another branch, use `git cherry-pick ` for individual commits or `git checkout -- ` for specific files. `merge_worktree` requires linear commit history — merge commits cannot be rebased and will cause `WORKTREE_INTACT_MERGE_COMMITS_DETECTED` failure. See "Conflict-Resolution Plan Requirements" section for full guidance. - Run subagents in the background (`run_in_background: true` is prohibited) - Issue subagent Task calls sequentially — ALL must be in a single parallel message @@ -249,8 +263,10 @@ handles correctly. ``` plan_path = /absolute/cwd/{{AUTOSKILLIT_TEMP}}/make-plan/{filename}.md plan_parts = /absolute/cwd/{{AUTOSKILLIT_TEMP}}/make-plan/{filename}.md + plan_disposition_path = /absolute/cycle/directory/dispositions/{plan_digest}.json ``` - This token is MANDATORY — the pipeline cannot capture the output without it. + `plan_path` and `plan_parts` are mandatory for every run. In remediation mode, + `plan_disposition_path` is also mandatory. - Spawn all subagents via `Agent(model="sonnet")` - Recommend the single best technical solution - Ground decisions in design quality and correctness @@ -267,6 +283,14 @@ handles correctly. 3. If a constraint has no corresponding step, add one. Never leave behavioral requirements as prose-only. 4. Include a `## Requirements Map` section at the end of the plan listing each constraint with its corresponding step reference. +## Context Limit Behavior + +Before a context-limited session terminates, preserve every completed plan file and, +in remediation mode, its matching disposition and association artifacts. Emit only +paths for artifacts whose final bytes and hashes have already been verified. Never +invent a partial disposition report or treat a prose-only plan as successful output; +the caller must retry planning when the required artifact tuple is incomplete. + ## Output If the plan exceeds 500 lines, split it into multiple files (`_part_a`, `_part_b`, etc.) at natural section boundaries. Use as many parts as needed. @@ -282,45 +306,47 @@ If the plan exceeds 500 lines, split it into multiple files (`_part_a`, `_part_b Save the plan to: `{{AUTOSKILLIT_TEMP}}/make-plan/{task_name}_plan_{YYYY-MM-DD_HHMMSS}.md` (relative to the current working directory) -**Structured output:** After saving the file(s), emit the following lines so pipeline orchestrators can capture both fields: +**Structured output:** After saving the file(s), emit the following lines so pipeline +orchestrators can capture the plan and, in remediation mode, its verified disposition: **Verdict emission:** Every run MUST emit a `verdict` token as the first structured output line: -- `verdict = plan` — a valid implementation plan was produced. Emit `plan_path` and `plan_parts` tokens after. -- `verdict = false_positive` — all remediation findings are false positives; no implementation is needed. Do NOT emit `plan_path` or `plan_parts` tokens. Do NOT write a plan file. - -**When to emit `false_positive`:** Emit `verdict = false_positive` ONLY when ALL of the following are true: -1. ARGUMENTS contains `audit_remediation_mode=true` (injected by the recipe orchestrator when in remediation context) -2. The remediation file describes findings that are demonstrably incorrect — the implementation already satisfies the requirement through an equivalent mechanism (e.g., naming deviation following an established codebase convention, structural equivalent, functionally identical approach) -3. There are zero genuine gaps requiring code changes - -If `audit_remediation_mode=true` is NOT present in ARGUMENTS, NEVER emit `false_positive`. Always emit `verdict = plan` and produce a plan file. - -**Remediation mode detection:** Scan ARGUMENTS for a line matching `audit_remediation_mode=true`. When present, the skill is operating in remediation context and MAY emit `verdict = false_positive` if all findings are false positives. When absent, the skill MUST emit `verdict = plan` regardless of assessment. - -**Remediation-mode inventory awareness:** When `audit_remediation_mode=true` is present AND `{{AUTOSKILLIT_TEMP}}/audit-impl/requirements_inventory.json` exists: +- `verdict = plan` — the only successful verdict. Emit `plan_path` and `plan_parts`; + remediation mode must also emit `plan_disposition_path`. -1. Read the pinned inventory from `{{AUTOSKILLIT_TEMP}}/audit-impl/requirements_inventory.json`. Each entry contains a `REQ-NNN` id, requirement text, and source location. +**Remediation-mode authority and disposition production:** -2. Read the remediation findings file — the path passed as the positional argument to this skill (the audit-impl remediation output). - -3. For each `REQ-NNN` in the inventory, classify: - - **carried** — cited in the current remediation findings AND addressed by a new Implementation Steps directive in this plan. - - **satisfied-by-prior-round** — NOT cited in the current remediation findings. This means audit-impl round N re-verified this requirement and did not flag it; the prior round already confirmed completion. No new directive is needed. - -4. Emit a `## Requirements Disposition Table` section in the plan body (before the Implementation Steps directives): +1. Activate remediation mode only when `audit_cycle_path` is present. Missing authority is + not equivalent to an authoritative empty inventory. +2. Verify the canonical `AuditCycleAuthority`, trusted current-head identity, and `NO GO` + verdict before opening its inventory or remediation `ArtifactRef`. Reject a stale, + superseded, cross-generation, cross-scope, cross-part, tampered, or `GO` authority. +3. Verify every referenced artifact's locator, byte size, content digest, schema, and + containment from the exact bytes read. +4. For every inventory row, write exactly one row in the plan's `## Requirements Map`: ``` - ## Requirements Disposition Table + ## Requirements Map - | REQ | Disposition | Evidence | - |-----|-------------|----------| - | REQ-001 | satisfied-by-prior-round | Not cited in remediation findings | - | REQ-007 | carried | MISSING finding: "mktemp not used" → Step 3 adds mktemp | + | Requirement ID | Disposition | Implementation Step | + |---|---|---| + | REQ-001 | satisfied-by-round-1 | — | + | REQ-007 | carried@step | Step 3 | ``` - This table makes the plan-vs-inventory gate in dry-walkthrough Step 4.7 decidable: requirements not listed in the table will be treated as UNMAPPED. Every inventory entry must appear in the table with a disposition of either `carried` or `satisfied-by-prior-round`. - -5. If `audit_remediation_mode=true` is present but the inventory file does NOT exist, proceed without the disposition table (this is the round-1 case where audit-impl has not yet pinned an inventory). + `carried@step` must cite the concrete current `Step N`/`Step N.M` that implements the + same REQ ID. `satisfied-by-round-N` must name the verified prior audit round. No other + vocabulary, duplicate IDs, omitted rows, or invented padding is allowed. +5. After the final plan bytes are stable, create a canonical immutable + `PlanDispositionReport` bound to the parent authority digest, full cycle identity, + verified plan `ArtifactRef`, exact ordered disposition rows, timestamp, and report digest. + Verify the report against the plan with the production inventory-admission evaluator. +6. In the current cycle directory, write exactly one immutable association at + `associations/{verified_plan_content_digest}.json`. It contains exactly the verified + plan ref, disposition ref, parent authority digest, schema version, and association + digest. Refuse an existing different record; never search for or synthesize a latest + report. +7. Absence, duplication, evaluator rejection, or Markdown/report drift is an output-contract + failure. Do not emit successful structured tokens. For a single-part plan: @@ -334,8 +360,12 @@ For a single-part plan: verdict = plan plan_path = {absolute_path} plan_parts = {absolute_path} +plan_disposition_path = {absolute_path_when_in_remediation_mode} ``` +After the structured paths, emit the completion marker supplied by the active order +as `%%ORDER_UP::%%`. + For a multi-part plan (list all part paths in alphabetical order): ``` verdict = plan @@ -343,11 +373,7 @@ plan_path = {path_to_part_a} plan_parts = {path_to_part_a} {path_to_part_b} {path_to_part_c} -``` - -For a false positive verdict (remediation mode only): -``` -verdict = false_positive +plan_disposition_path = {absolute_path_when_in_remediation_mode} ``` **Plan structure (single-part):** diff --git a/tests/arch/test_pyright_suppression_allowlist.py b/tests/arch/test_pyright_suppression_allowlist.py index a934ddfb9..e4abef609 100644 --- a/tests/arch/test_pyright_suppression_allowlist.py +++ b/tests/arch/test_pyright_suppression_allowlist.py @@ -20,9 +20,9 @@ PRODUCTION_ALLOWLIST: dict[tuple[str, int], str] = { ( "recipe/__init__.py", - 288, + 289, ): "lazy-registry: method added by _register_rule_module() side effects", - ("recipe/_api.py", 286): "lazy-registry: RULE_REGISTRY_HASH set by _finalize_registry()", + ("recipe/_api.py", 289): "lazy-registry: RULE_REGISTRY_HASH set by _finalize_registry()", } TEST_ALLOWLIST: dict[tuple[str, int], str] = { diff --git a/tests/arch/test_subpackage_isolation.py b/tests/arch/test_subpackage_isolation.py index 0250e95b1..cb72a5069 100644 --- a/tests/arch/test_subpackage_isolation.py +++ b/tests/arch/test_subpackage_isolation.py @@ -99,6 +99,7 @@ def _get_call_func_name(node: ast.Call) -> str | None: # module-load self-check block (#4351). "_type_intake_policy", "_type_constants_registries", # measured response-exemption registry digest + "tool_registry", # immutable canonical MCP tool definition registry "_codex_config", # Codex output ceiling derived from measured exemptions "_fmt_response_spill", # standalone spill schema and exemption mirror digests "_response_budget", # canonical spill schema digest @@ -897,11 +898,11 @@ def test_no_subpackage_exceeds_10_files() -> None: logic on DispatchRecord.from_dict. Exempt at 15 files. """ EXEMPTIONS: dict[str, int] = { - "server": 16, # +_recipe_section_pagination deterministic bounded planner + "server": 17, # +_recipe_section_pagination planner +_recipe_execution attestation "recipe": 42, # was 33; +9 from CI/graph/dataflow splits "execution": 18, - "core": 26, # +_context_admission pure reducer - "core/types": 37, # +_type_intake_policy evidence-bound Codex intake rule registry + "core": 28, # +context admission +audit-cycle verifier/tool registry + "core/types": 40, # context, recipe-section, audit, binding, execution, intake types "cli": 21, "cli/doctor": 11, # +_doctor_skills capability declaration authenticity checks "workspace": 14, # +_install_state (single install-state consistency authority, @@ -1013,6 +1014,13 @@ def test_data_directories_are_not_python_packages() -> None: "fleet-resume-precondition-chokepoint plan: prepare_resume chokepoint, " "closure-scoped _spawn_error, and _write_pid fail-closed contract add ~33 lines", ), + "server/_recipe_delivery.py": ( + 1100, + "REQ-CNST-010-E12: immutable recipe generation persistence, host-attested delivery " + "selection, receipt reservation, and compiled-execution publication form one " + "transactional authority boundary; the snapshot carrier keeps installation after " + "durable delivery commit without introducing a second finalization path", + ), "tools_kitchen.py": ( 1660, "REQ-CNST-010-E7: kitchen tool handlers — open_kitchen and lock_ingredients require " @@ -1059,7 +1067,7 @@ def test_data_directories_are_not_python_packages() -> None: "maybe_envelope_recipe_response call (#4304 Part B, +24 net lines)", ), "tools_execution.py": ( - 1650, + 1800, "REQ-CNST-010-E8: execution tool handlers — run_cmd/run_python/run_skill are the " "three primary execution paths; fail-closed existence gate, empty-closure gate " "for fabricated skill name rejection, _check_backend_compat fail-closed gate " @@ -1089,7 +1097,10 @@ def test_data_directories_are_not_python_packages() -> None: "; server-authoritative step completion: _mark_step_complete_server_side helper " "called at the run_skill adjudication point, shared resolve_tracker_order_id " "resolver, and deny_envelope conversion of all pre-flight deny sites " - "(#4293 pipeline tracker split-brain, +65 net lines)", + "(#4293 pipeline tracker split-brain, +65 net lines)" + "; attested recipe execution identity/template verification, structured skill-input " + "binding, runtime-binding digest capture, and inventory-admission preflight keep all " + "run_skill launch denial paths before command construction (+139 net lines)", ), "execution/backends/codex.py": ( 1800, diff --git a/tests/arch/test_subpackage_structure.py b/tests/arch/test_subpackage_structure.py index ba91a60ef..b414b9afe 100644 --- a/tests/arch/test_subpackage_structure.py +++ b/tests/arch/test_subpackage_structure.py @@ -15,6 +15,7 @@ def test_core_types_is_package(self): def test_core_types_has_all_type_modules(self): expected = { + "_type_audit_cycle", "_type_backend", "_type_checkpoint", "_type_closure_report", @@ -44,6 +45,8 @@ def test_core_types_has_all_type_modules(self): "_type_results", "_type_results_execution", "_type_recipe_delivery", + "_type_recipe_binding", + "_type_recipe_execution", "_type_recipe_sections", "_type_resume", "_type_session_env", diff --git a/tests/contracts/AGENTS.md b/tests/contracts/AGENTS.md index 7ddcbc492..0164fd157 100644 --- a/tests/contracts/AGENTS.md +++ b/tests/contracts/AGENTS.md @@ -36,7 +36,7 @@ Protocol satisfaction, package gateway, and skill contract compliance tests. | `test_download_data_contracts.py` | Contract tests for download-data SKILL.md — external dataset acquisition step | | `test_dry_walkthrough_arch_catalog_reference.py` | Contract test: dry-walkthrough SKILL.md Step 4 must reference the Architectural Constraint Catalog | | `test_dry_walkthrough_issue_coverage.py` | Contract test: dry-walkthrough SKILL.md must contain plan-vs-issue coverage check step (Step 4.6) | -| `test_dry_walkthrough_plan_coverage.py` | Contract test: dry-walkthrough SKILL.md must contain plan-vs-inventory coverage gate (Step 4.7) | +| `test_dry_walkthrough_plan_coverage.py` | Focused contract: Step 4.7 consumes verified audit-cycle preflight evidence and cannot self-heal evaluator results | | `test_dry_walkthrough_transformation_extent.py` | Contract test: dry-walkthrough SKILL.md Step 2 must check transformation extent/scope | | `test_enrich_issues_contracts.py` | Contract tests for the enrich-issues skill SKILL.md | | `test_environment_setup_design_contracts.py` | Contract tests verifying the environment-setup skill design doc completeness | @@ -61,7 +61,7 @@ Protocol satisfaction, package gateway, and skill contract compliance tests. | `test_l1_packages.py` | Package export surface tests for the L1 sub-packages | | `test_make_campaign_skill_contracts.py` | Contract tests: structural invariants for the make-campaign SKILL.md | | `test_make_plan_echo_rule.py` | Contract test: make-plan SKILL.md must contain requirement echo validation rule and Requirements Map template section | -| `test_make_plan_remediation_inventory.py` | Contract tests for make-plan SKILL.md remediation-mode inventory awareness | +| `test_make_plan_remediation_inventory.py` | Focused contract: make-plan binds remediation output to explicit authority, exact dispositions, and immutable association | | `test_mermaid_palette_contracts.py` | Contract: any SKILL.md that generates mermaid diagrams must embed the canonical 9-class palette | | `test_no_interpreter_writes_in_skills.py` | Contract: no SKILL.md may prescribe interpreter-mediated file writes (python3 -c / heredoc with write APIs) | | `test_no_pagination_file_read.py` | Contract tests for no-pagination file read instruction in high-turn SKILL.md files | diff --git a/tests/contracts/test_audit_impl_inventory.py b/tests/contracts/test_audit_impl_inventory.py deleted file mode 100644 index 9bfe7708f..000000000 --- a/tests/contracts/test_audit_impl_inventory.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Contract test: audit-impl SKILL.md must reference pinned requirement inventory.""" - -from __future__ import annotations - -from pathlib import Path - -import pytest - -pytestmark = [pytest.mark.layer("contracts"), pytest.mark.small] - -_SKILL_MD = ( - Path(__file__).resolve().parent.parent.parent - / "src" - / "autoskillit" - / "skills_extended" - / "audit-impl" - / "SKILL.md" -) - - -def _read_skill_md() -> str: - return _SKILL_MD.read_text() - - -def _step1_section() -> str: - """Extract Step 1 section of audit-impl SKILL.md.""" - content = _read_skill_md() - start = content.find("### Step 1") - end = content.find("### Step 2") - assert start != -1, "Step 1 section must exist in audit-impl SKILL.md" - assert end != -1, "Step 2 section must exist in audit-impl SKILL.md" - return content[start:end] - - -def test_audit_impl_references_inventory_persistence() -> None: - """Step 1 must reference writing requirements_inventory.json to AUTOSKILLIT_TEMP.""" - section = _step1_section() - assert "requirements_inventory.json" in section, ( - "audit-impl SKILL.md Step 1 must reference 'requirements_inventory.json'" - ) - assert "write" in section.lower() or "persist" in section.lower(), ( - "audit-impl SKILL.md Step 1 must reference writing or persisting the inventory" - ) - assert "AUTOSKILLIT_TEMP" in section or "{{AUTOSKILLIT_TEMP}}" in section, ( - "audit-impl SKILL.md Step 1 must reference AUTOSKILLIT_TEMP for inventory path" - ) - - -def test_audit_impl_references_round_detection() -> None: - """Step 1 must detect round >=2 by inventory file existence.""" - section = _step1_section() - assert "round" in section.lower(), "audit-impl SKILL.md Step 1 must reference round detection" - assert "exist" in section.lower() or "presence" in section.lower(), ( - "audit-impl SKILL.md Step 1 must reference checking for existing inventory file" - ) - - -def test_audit_impl_references_union_extraction() -> None: - """Round-1 extraction must use >=2 independent extractors with union merge.""" - content = _read_skill_md() - assert "union" in content.lower() or "independent" in content.lower(), ( - "audit-impl SKILL.md must reference union extraction or independent extractors" - ) - - -def test_audit_impl_inventory_schema_fields() -> None: - """Step 1 inventory schema must include all required fields.""" - section = _step1_section() - for field in ("id", "text", "source_file", "source_line", "source_section"): - assert field in section, ( - f"audit-impl SKILL.md Step 1 inventory schema must include '{field}' field" - ) diff --git a/tests/contracts/test_dry_walkthrough_plan_coverage.py b/tests/contracts/test_dry_walkthrough_plan_coverage.py index fbdbcb07f..a9b59a3e4 100644 --- a/tests/contracts/test_dry_walkthrough_plan_coverage.py +++ b/tests/contracts/test_dry_walkthrough_plan_coverage.py @@ -1,8 +1,7 @@ -"""Contract test: dry-walkthrough SKILL.md plan-vs-inventory gate (Step 4.7).""" +"""Focused prose contract for dry-walkthrough Step 4.7.""" from __future__ import annotations -import re from pathlib import Path import pytest @@ -10,7 +9,7 @@ pytestmark = [pytest.mark.layer("contracts"), pytest.mark.small] _SKILL_MD = ( - Path(__file__).resolve().parent.parent.parent + Path(__file__).resolve().parents[2] / "src" / "autoskillit" / "skills_extended" @@ -19,108 +18,29 @@ ) -def _read_skill_md() -> str: - return _SKILL_MD.read_text() - - -def _step47_section() -> str: - content = _read_skill_md() - start = content.find("### Step 4.7") - end = content.find("### Step 5:") - assert start != -1, "Step 4.7 section must exist in dry-walkthrough SKILL.md" - assert end != -1, "Step 5 section must exist in dry-walkthrough SKILL.md" +def _step47() -> str: + content = _SKILL_MD.read_text() + start = content.index("### Step 4.7") + end = content.index("### Step 5:") return content[start:end] -def test_dry_walkthrough_has_inventory_coverage_step() -> None: - """dry-walkthrough SKILL.md must contain Step 4.7 titled Plan-vs-Inventory Coverage Check.""" - content = _read_skill_md() - assert re.search( - r"###\s+Step\s+4\.7[\s:].*Plan-vs-Inventory\s+Coverage\s+Check", - content, - re.DOTALL, - ), ( - "dry-walkthrough/SKILL.md must contain a '### Step 4.7' section titled " - "'Plan-vs-Inventory Coverage Check'" - ) - - -def test_dry_walkthrough_inventory_coverage_positioned() -> None: - """Step 4.7 must appear between Step 4.6 and Step 5.""" - content = _read_skill_md() - step46_pos = content.find("### Step 4.6") - step47_pos = content.find("### Step 4.7") - step5_pos = content.find("### Step 5:") - assert step46_pos != -1, "Step 4.6 section must exist" - assert step47_pos != -1, "Step 4.7 section must exist" - assert step5_pos != -1, "Step 5 section must exist" - assert step46_pos < step47_pos < step5_pos, ( - "Step 4.7 must be positioned between Step 4.6 and Step 5 in dry-walkthrough/SKILL.md" - ) - - -def test_dry_walkthrough_inventory_coverage_references_file() -> None: - """Step 4.7 must reference requirements_inventory.json in AUTOSKILLIT_TEMP.""" - section = _step47_section() - assert "requirements_inventory.json" in section, ( - "dry-walkthrough SKILL.md Step 4.7 must reference 'requirements_inventory.json'" - ) - assert "AUTOSKILLIT_TEMP" in section, ( - "dry-walkthrough SKILL.md Step 4.7 must reference AUTOSKILLIT_TEMP for inventory path" - ) - - -def test_dry_walkthrough_inventory_coverage_guard_skip() -> None: - """Step 4.7 must specify graceful skip when inventory file does not exist.""" - section = _step47_section() - assert re.search( - r"omit|not present|does not exist", - section, - re.IGNORECASE, - ), ( - "dry-walkthrough SKILL.md Step 4.7 must specify graceful skip when " - "requirements_inventory.json does not exist" - ) - - -def test_dry_walkthrough_inventory_coverage_two_dispositions() -> None: - """Step 4.7 must define two dispositions: satisfied and carried.""" - section = _step47_section() - assert re.search(r"satisfied", section, re.IGNORECASE), ( - "dry-walkthrough SKILL.md Step 4.7 must define a 'satisfied' disposition " - "for requirements verified complete by prior audit round" - ) - assert re.search(r"carried", section, re.IGNORECASE), ( - "dry-walkthrough SKILL.md Step 4.7 must define a 'carried' disposition " - "for requirements addressed by new Implementation Steps" - ) - - -def test_dry_walkthrough_inventory_coverage_blocks_on_unmapped() -> None: - """Step 4.7 must specify blocking/failing when requirements are UNMAPPED.""" - section = _step47_section() - assert re.search( - r"UNMAPPED|FAIL|block|do not proceed|Stop execution", - section, - re.IGNORECASE, - ), ( - "dry-walkthrough SKILL.md Step 4.7 must specify blocking behavior when " - "requirements are not mapped to plan steps" - ) +def test_step47_consumes_preflight_and_preserves_rows() -> None: + section = _step47() + assert "`audit_cycle_inventory` preflight evidence" in section + assert "`satisfied-by-round-N`" in section + assert "`carried@step`" in section + assert "requirements_inventory.json" not in section -def test_dry_walkthrough_inventory_coverage_deterministic_stop() -> None: - """Step 4.7 must contain an explicit deterministic stop directive.""" - section = _step47_section() - assert "Stop execution" in section or "do not proceed to Step 5" in section, ( - "dry-walkthrough SKILL.md Step 4.7 must contain an explicit deterministic " - "stop directive on UNMAPPED failure" - ) +def test_step47_distinguishes_absence_and_stops_on_reject() -> None: + section = _step47() + assert "Absence is distinct from an authoritative\n empty inventory" in section + assert "Stop execution — do not proceed to Step 5" in section -def test_dry_walkthrough_inventory_coverage_composes_with_46() -> None: - """Step 4.7 must reference composition with Step 4.6.""" - section = _step47_section() - assert "4.6" in section or "compose" in section.lower(), ( - "dry-walkthrough SKILL.md Step 4.7 must reference composition with Step 4.6" - ) +def test_step5_cannot_self_heal_evaluator_results() -> None: + content = _SKILL_MD.read_text() + step5 = content[content.index("### Step 5:") : content.index("### Step 6:")] + assert "Never add carry-forward padding" in step5 + assert "Step 5 cannot\n override the evaluator" in step5 diff --git a/tests/contracts/test_make_plan_remediation_inventory.py b/tests/contracts/test_make_plan_remediation_inventory.py index beea69d4f..972da4910 100644 --- a/tests/contracts/test_make_plan_remediation_inventory.py +++ b/tests/contracts/test_make_plan_remediation_inventory.py @@ -1,4 +1,4 @@ -"""Contract test: make-plan SKILL.md must contain remediation-mode inventory awareness.""" +"""Focused prose contract for authority-bound make-plan remediation output.""" from __future__ import annotations @@ -9,7 +9,7 @@ pytestmark = [pytest.mark.layer("contracts"), pytest.mark.small] _SKILL_MD = ( - Path(__file__).resolve().parent.parent.parent + Path(__file__).resolve().parents[2] / "src" / "autoskillit" / "skills_extended" @@ -18,36 +18,27 @@ ) -def _read_skill_md() -> str: +def _content() -> str: return _SKILL_MD.read_text() -def _remediation_section() -> str: - """Extract the section of make-plan SKILL.md that discusses remediation mode.""" - content = _read_skill_md() - start = content.find("audit_remediation_mode") - assert start != -1, "make-plan SKILL.md must contain 'audit_remediation_mode' section" - return content[start:] - - -def test_make_plan_references_inventory_in_remediation() -> None: - """make-plan SKILL.md must reference requirements_inventory in remediation context.""" - section = _remediation_section() - assert "requirements_inventory" in section, ( - "make-plan SKILL.md must reference 'requirements_inventory' in its " - "remediation-mode section" - ) - - -def test_make_plan_disposition_table() -> None: - """make-plan SKILL.md must emit a disposition table with satisfied + carried vocabulary.""" - content = _read_skill_md() - assert "disposition" in content.lower() or "Disposition" in content, ( - "make-plan SKILL.md must emit a Disposition section/table for requirements" - ) - assert "satisfied" in content.lower(), ( - "make-plan SKILL.md disposition vocabulary must include 'satisfied'" - ) - assert "carried" in content.lower(), ( - "make-plan SKILL.md disposition vocabulary must include 'carried'" - ) +def test_remediation_is_explicit_authority_bound() -> None: + content = _content() + assert "`audit_cycle_path`" in content + assert "current `NO GO` head" in content + assert "ambient `requirements_inventory.json`" in content + + +def test_requirements_map_uses_evaluator_vocabulary() -> None: + content = _content() + assert "| Requirement ID | Disposition | Implementation Step |" in content + assert "satisfied-by-round-N" in content + assert "carried@step" in content + + +def test_plan_and_report_are_immutably_associated() -> None: + content = _content() + assert "PlanDispositionReport" in content + assert "associations/{verified_plan_content_digest}.json" in content + assert "plan_disposition_path =" in content + assert "verdict = false_positive" not in content diff --git a/tests/contracts/test_skill_contracts.py b/tests/contracts/test_skill_contracts.py index 6caa40425..bba6e5316 100644 --- a/tests/contracts/test_skill_contracts.py +++ b/tests/contracts/test_skill_contracts.py @@ -650,23 +650,23 @@ def test_delimiter_patterns_have_hr_split_example(skills: dict[str, Any]) -> Non # --------------------------------------------------------------------------- -# make-plan false-positive escape valve tests +# make-plan audit-remediation authority tests # --------------------------------------------------------------------------- def test_make_plan_verdict_output(skills): - """make-plan must declare a verdict output with plan/false_positive values.""" + """make-plan cannot close a NO GO lineage with a false-positive verdict.""" mp = skills["make-plan"] verdict_outputs = [o for o in mp["outputs"] if o["name"] == "verdict"] assert len(verdict_outputs) == 1, "make-plan must have exactly one verdict output" - assert set(verdict_outputs[0]["allowed_values"]) == {"plan", "false_positive"} + assert verdict_outputs[0]["allowed_values"] == ["plan"] -def test_make_plan_conditional_write_behavior(skills): - """make-plan must use conditional write_behavior gated on verdict=plan.""" +def test_make_plan_always_write_behavior(skills): + """make-plan has no successful no-write verdict.""" mp = skills["make-plan"] - assert mp["write_behavior"] == "conditional" - assert mp["write_expected_when"] == ["verdict[ \\t]*=[ \\t]*plan"] + assert mp["write_behavior"] == "always" + assert mp["completion_required"] is True def test_validate_audit_verdict_allowed_values(skills): @@ -688,8 +688,8 @@ def test_audit_impl_verdict_allowed_values(skills): def test_make_plan_examples_cover_verdicts(skills): - """pattern_examples must include examples for both verdict values.""" + """pattern examples cover the sole plan verdict.""" mp = skills["make-plan"] examples_text = "\n".join(mp.get("pattern_examples", [])) assert "verdict = plan" in examples_text - assert "verdict = false_positive" in examples_text + assert "verdict = false_positive" not in examples_text diff --git a/tests/recipe/AGENTS.md b/tests/recipe/AGENTS.md index 4d5043702..d52ad31e9 100644 --- a/tests/recipe/AGENTS.md +++ b/tests/recipe/AGENTS.md @@ -19,6 +19,7 @@ Recipe I/O, validation, semantic rules, schema, and bundled recipe tests. | `test_audit_trail_artifacts.py` | Tests for audit/ directory creation and artifact copy in create_worktree.sh | | `test_audit_trail_format_doc.py` | Tests for audit-trail-format.md documentation | | `test_audit_trail_recipe_contracts.py` | Tests for research.yaml audit-trail captures and threading | +| `test_audit_cycle_lifecycle_integration.py` | Loaded-recipe audit authority, disposition, and exact dry-child tuple integration | | `test_closure_contracts_yaml.py` | Contract tests: skill_contracts.yaml audit-impl entry must expose closure inputs/output | | `test_closure_recipe_routing.py` | Structural tests: all six consuming recipes must wire closure ingredients to audit_impl | | `test_bem_wrapper_structure.py` | Tests for BEM wrapper recipe structure | @@ -241,7 +242,6 @@ Recipe I/O, validation, semantic rules, schema, and bundled recipe tests. | `test_audit_impl_defaults.py` | Contract test: implementation.yaml and remediation.yaml must default inputs.audit_impl to 'true' | | `test_analysis_detectors_rename.py` | Tests for plan-visualization → synthesize-vis-plan rename in _OBSERVABILITY_CAPTURES | -| `test_false_positive_escape_valve.py` | Tests for false-positive escape valve routing in recipes that invoke make-plan | | `test_inline_content_semantic_rule.py` | Tests for inline-content-in-subagent-prompt semantic rule | ## Architecture Notes diff --git a/tests/recipe/test_false_positive_escape_valve.py b/tests/recipe/test_false_positive_escape_valve.py deleted file mode 100644 index 63117fdd8..000000000 --- a/tests/recipe/test_false_positive_escape_valve.py +++ /dev/null @@ -1,114 +0,0 @@ -"""Tests for false-positive escape valve routing in recipes that invoke make-plan.""" - -from __future__ import annotations - -import pytest - -from autoskillit.recipe.io import builtin_recipes_dir, load_recipe - -pytestmark = [pytest.mark.layer("recipe"), pytest.mark.small] - - -@pytest.fixture(scope="module") -def impl_recipe(): - return load_recipe(builtin_recipes_dir() / "implementation.yaml") - - -@pytest.fixture(scope="module") -def groups_recipe(): - return load_recipe(builtin_recipes_dir() / "implementation-groups.yaml") - - -@pytest.fixture(scope="module") -def remed_recipe(): - return load_recipe(builtin_recipes_dir() / "remediation.yaml") - - -def test_impl_plan_step_uses_on_result(impl_recipe): - """plan step must use on_result, not on_success.""" - step = impl_recipe.steps["plan"] - assert step.on_result is not None - assert step.on_success is None - - -def test_impl_plan_step_captures_verdict(impl_recipe): - """plan step must capture result.verdict.""" - step = impl_recipe.steps["plan"] - assert "verdict" in step.capture - assert "result.verdict" in step.capture["verdict"].from_ - - -def test_impl_plan_routes_false_positive_to_check_has_commits(impl_recipe): - """verdict=false_positive must route to check_has_commits.""" - step = impl_recipe.steps["plan"] - fp_routes = [c for c in step.on_result.conditions if c.when and "false_positive" in c.when] - assert len(fp_routes) == 1 - assert fp_routes[0].route == "check_has_commits" - - -def test_impl_plan_routes_plan_to_review_approach(impl_recipe): - """verdict=plan must route to review_approach.""" - step = impl_recipe.steps["plan"] - plan_routes = [ - c - for c in step.on_result.conditions - if c.when and "== plan" in c.when and "false_positive" not in c.when - ] - assert len(plan_routes) == 1 - assert plan_routes[0].route == "review_approach" - - -def test_groups_plan_step_uses_on_result(groups_recipe): - """plan step must use on_result, not on_success.""" - step = groups_recipe.steps["plan"] - assert step.on_result is not None - assert step.on_success is None - - -def test_groups_plan_step_captures_verdict(groups_recipe): - """plan step must capture result.verdict.""" - step = groups_recipe.steps["plan"] - assert "verdict" in step.capture - assert "result.verdict" in step.capture["verdict"].from_ - - -def test_groups_plan_routes_false_positive_to_push(groups_recipe): - """verdict=false_positive must route to push in groups recipe.""" - step = groups_recipe.steps["plan"] - fp_routes = [c for c in step.on_result.conditions if c.when and "false_positive" in c.when] - assert len(fp_routes) == 1 - assert fp_routes[0].route == "push" - - -def test_remed_make_plan_step_uses_on_result(remed_recipe): - """make_plan step must use on_result, not on_success.""" - step = remed_recipe.steps["make_plan"] - assert step.on_result is not None - assert step.on_success is None - - -def test_remed_make_plan_step_captures_verdict(remed_recipe): - """make_plan step must capture result.verdict.""" - step = remed_recipe.steps["make_plan"] - assert "verdict" in step.capture - assert "result.verdict" in step.capture["verdict"].from_ - - -def test_remed_make_plan_routes_false_positive_to_check_has_commits(remed_recipe): - """verdict=false_positive must route to check_has_commits.""" - step = remed_recipe.steps["make_plan"] - fp_routes = [c for c in step.on_result.conditions if c.when and "false_positive" in c.when] - assert len(fp_routes) == 1 - assert fp_routes[0].route == "check_has_commits" - - -def test_remed_make_plan_routes_plan_to_dry_walkthrough(remed_recipe): - """verdict=plan must route to dry_walkthrough.""" - step = remed_recipe.steps["make_plan"] - plan_routes = [ - c - for c in step.on_result.conditions - if c.when and "== plan" in c.when and "false_positive" not in c.when - ] - assert len(plan_routes) == 1 - assert plan_routes[0].route == "dry_walkthrough" diff --git a/tests/recipe/test_skill_invocation_binding.py b/tests/recipe/test_skill_invocation_binding.py index 6c111316a..cb5ce66ea 100644 --- a/tests/recipe/test_skill_invocation_binding.py +++ b/tests/recipe/test_skill_invocation_binding.py @@ -99,6 +99,30 @@ def test_structured_binding_uses_contract_order_not_mapping_order() -> None: ) +def test_single_inline_input_preserves_multiword_prose_tail() -> None: + manifest: dict[str, object] = { + "skills": { + "investigate": {"inputs": [{"name": "topic", "type": "string", "required": True}]} + } + } + invocation = bind_step_invocation( + "investigate", + RecipeStep( + name="investigate", + tool="run_skill", + with_args={ + "skill_command": "/autoskillit:investigate the failing lifecycle", + "cwd": "/repo", + }, + ), + manifest=manifest, + mode=BindingMode.STANDALONE, + ) + + assert invocation.is_valid + assert invocation.canonical_child_invocation == (("topic", "the failing lifecycle"),) + + def test_optional_absence_does_not_shift_later_values() -> None: invocation = bind_step_invocation( "verify", @@ -295,6 +319,7 @@ def test_declared_effective_provenance_survives_load_and_prune( assert plan is not None assert plan.declared_value == "{{AUTOSKILLIT_TEMP}}/plans/current.md" assert plan.effective_value == "/custom temp/plans/current.md" + assert plan.template_dependencies == ("AUTOSKILLIT_TEMP",) assert cycle is not None assert cycle.origin is BoundValueOrigin.TEMPLATE assert cycle.input_dependencies == ("private_root",) diff --git a/tests/server/test_audit_cycle_delivery_integration.py b/tests/server/test_audit_cycle_delivery_integration.py index 80abaf66a..b79e47b92 100644 --- a/tests/server/test_audit_cycle_delivery_integration.py +++ b/tests/server/test_audit_cycle_delivery_integration.py @@ -8,13 +8,24 @@ from types import SimpleNamespace import pytest +from hypothesis import settings +from hypothesis import strategies as st +from hypothesis.stateful import ( + RuleBasedStateMachine, + initialize, + invariant, + precondition, + rule, +) from autoskillit.core import ( AdmissionReason, + AdmissionStatus, ArtifactRef, AuditAssessment, AuditAssessmentRow, AuditCycleAuthority, + AuditCycleHead, AuditVerdict, BindingMode, BoundStepInvocation, @@ -22,6 +33,9 @@ BoundValueOrigin, BoundValueState, InventoryAdmissionDecision, + InventoryAdmissionEvaluator, + PlanDispositionReport, + PlanDispositionRow, RecipeBindingProjection, VerifiedInputPreflightRequest, VerifiedInputPreflightResult, @@ -130,6 +144,7 @@ def _authority( round_: int, parent: str | None, verdict: AuditVerdict, + part_id: str = "part-a", ) -> AuditCycleAuthority: assessment = AuditAssessmentRow.create( requirement_id="REQ-001", @@ -142,7 +157,7 @@ def _authority( cycle_id="cycle-1", plan_set_id="plans-1", scope_id="scope-1", - part_id="part-a", + part_id=part_id, audit_round=round_, parent_authority_digest=parent, audited_plan_refs=(_artifact(tmp_path / "plan.md", _HASH_A),), @@ -158,6 +173,44 @@ def _authority( ) +def _report(authority: AuditCycleAuthority) -> PlanDispositionReport: + return PlanDispositionReport.create( + execution_generation=authority.execution_generation, + cycle_id=authority.cycle_id, + plan_set_id=authority.plan_set_id, + scope_id=authority.scope_id, + part_id=authority.part_id, + audit_round=authority.audit_round, + parent_authority_digest=authority.authority_digest, + inventory_digest=authority.inventory_ref.content_digest, + findings_digest=authority.findings_digest, + current_plan_ref=_artifact( + Path("/virtual/autoskillit-audit-cycle-state-machine/current-plan.md"), + _HASH_A, + ), + dispositions=( + PlanDispositionRow.create( + requirement_id="REQ-001", + disposition=f"satisfied-by-round-{authority.audit_round}", + ), + ), + generated_at="2026-07-23T00:00:00Z", + ) + + +def _plan_text(round_: int) -> str: + return f"""\ +## Requirements Map +| Requirement ID | Disposition | Implementation Step | +| --- | --- | --- | +| REQ-001 | satisfied-by-round-{round_} | — | + +## Implementation Steps +### Step 1 +REQ-001 remains satisfied. +""" + + def test_delivery_persists_and_installs_matching_execution( minimal_ctx, ) -> None: @@ -309,6 +362,262 @@ def test_head_publication_is_monotonic_compare_and_swap(tmp_path: Path) -> None: assert terminal.authorized_successor_part_id == "part-b" +class AuditCycleLifecycleStateMachine(RuleBasedStateMachine): + """Model trusted-head monotonicity across retries, parts, and generations.""" + + def __init__(self) -> None: + super().__init__() + self.artifact_root = Path("/virtual/autoskillit-audit-cycle-state-machine") + self.store = DefaultAuditCycleHeadStore() + self.generation_index = 0 + self.generation = "execution-0" + self.part_id = "part-a" + self.model_heads: dict[tuple[str, str], AuditCycleHead] = {} + self.authorities: dict[str, AuditCycleAuthority] = {} + self.reports: dict[tuple[str, str], PlanDispositionReport] = {} + self.report_history: list[PlanDispositionReport] = [] + self.history: list[AuditCycleAuthority] = [] + + @initialize() + def initialize_model(self) -> None: + self.store.clear_all() + self.generation_index = 0 + self.generation = "execution-0" + self.part_id = "part-a" + self.model_heads.clear() + self.authorities.clear() + self.reports.clear() + self.report_history.clear() + self.history.clear() + + def _model_key(self) -> tuple[str, str]: + return self.generation, self.part_id + + @staticmethod + def _round_trip(authority: AuditCycleAuthority) -> AuditCycleAuthority: + payload = json.loads(authority.canonical_bytes) + return AuditCycleAuthority.from_dict(payload) + + @rule(verdict=st.sampled_from((AuditVerdict.NO_GO, AuditVerdict.GO))) + def publish_current_part(self, verdict: AuditVerdict) -> None: + current = self.model_heads.get(self._model_key()) + if current is not None and current.verdict is AuditVerdict.GO: + return + authority = self._round_trip( + _authority( + self.artifact_root, + generation=self.generation, + round_=1 if current is None else current.audit_round + 1, + parent=None if current is None else current.current_authority_digest, + verdict=verdict, + part_id=self.part_id, + ) + ) + head = self.store.publish( + authority, + expected_parent_digest=(None if current is None else current.current_authority_digest), + expected_round=0 if current is None else current.audit_round, + authorized_successor_part_id=( + f"{self.part_id}-successor" if verdict is AuditVerdict.GO else None + ), + ) + self.model_heads[self._model_key()] = head + self.authorities[authority.authority_digest] = authority + self.reports.pop(self._model_key(), None) + self.history.append(authority) + + @precondition( + lambda self: ( + (head := self.model_heads.get(self._model_key())) is not None + and head.verdict is AuditVerdict.NO_GO + ) + ) + @rule() + def publish_or_salvage_plan_report(self) -> None: + head = self.model_heads[self._model_key()] + authority = self.authorities[head.current_authority_digest] + report = PlanDispositionReport.from_dict(json.loads(_report(authority).canonical_bytes)) + self.reports[self._model_key()] = report + self.report_history.append(report) + + @precondition( + lambda self: ( + (head := self.model_heads.get(self._model_key())) is not None + and head.verdict is AuditVerdict.GO + ) + ) + @rule() + def terminal_go_rejects_retry(self) -> None: + current = self.model_heads[self._model_key()] + successor = self._round_trip( + _authority( + self.artifact_root, + generation=self.generation, + round_=current.audit_round + 1, + parent=current.current_authority_digest, + verdict=AuditVerdict.NO_GO, + part_id=self.part_id, + ) + ) + with pytest.raises(AuditCycleHeadConflict, match="terminal GO"): + self.store.publish( + successor, + expected_parent_digest=current.current_authority_digest, + expected_round=current.audit_round, + ) + + @precondition(lambda self: bool(self.history)) + @rule() + def stale_replay_and_tamper_reject(self) -> None: + current = self.model_heads.get(self._model_key()) + if current is not None: + replay = self.history[0] + with pytest.raises(AuditCycleHeadConflict): + self.store.publish( + replay, + expected_parent_digest=current.current_authority_digest, + expected_round=current.audit_round, + ) + tampered = self.history[-1].to_dict() + tampered["authority_digest"] = _HASH_A + with pytest.raises(ValueError, match="digest"): + AuditCycleAuthority.from_dict(tampered) + + @precondition(lambda self: bool(self.report_history)) + @rule() + def swapped_report_never_admits(self) -> None: + head = self.model_heads.get(self._model_key()) + current_report = self.reports.get(self._model_key()) + if head is None or head.verdict is not AuditVerdict.NO_GO: + return + authority = self.authorities[head.current_authority_digest] + stale = next( + ( + report + for report in self.report_history + if report.parent_authority_digest != authority.authority_digest + ), + None, + ) + if stale is None: + return + decision = InventoryAdmissionEvaluator().evaluate( + authority=authority, + trusted_head=head, + report=stale, + expected_generation=self.generation, + expected_plan_set_id="plans-1", + expected_scope_id="scope-1", + expected_part_id=self.part_id, + current_plan_ref=stale.current_plan_ref, + inventory_requirement_ids=("REQ-001",), + current_plan_text=_plan_text(authority.audit_round), + ) + assert decision.status is AdmissionStatus.REJECT + assert self.reports.get(self._model_key()) is current_report + + @precondition( + lambda self: ( + (head := self.model_heads.get(self._model_key())) is not None + and head.verdict is AuditVerdict.GO + and head.authorized_successor_part_id is not None + ) + ) + @rule() + def advance_to_authorized_sibling_part(self) -> None: + current = self.model_heads[self._model_key()] + assert current.authorized_successor_part_id is not None + self.part_id = current.authorized_successor_part_id + + @rule() + def replace_execution_generation(self) -> None: + old_generation = self.generation + self.store.clear_generation(old_generation) + self.model_heads = { + key: head for key, head in self.model_heads.items() if key[0] != old_generation + } + self.reports = { + key: report for key, report in self.reports.items() if key[0] != old_generation + } + self.generation_index += 1 + self.generation = f"execution-{self.generation_index}" + self.part_id = "part-a" + self.history.clear() + + @rule() + def close_and_reset(self) -> None: + self.store.clear_all() + self.model_heads.clear() + self.reports.clear() + self.history.clear() + self.part_id = "part-a" + + @invariant() + def trusted_heads_match_independent_model(self) -> None: + for (generation, part_id), expected in self.model_heads.items(): + assert ( + self.store.get( + execution_generation=generation, + plan_set_id="plans-1", + scope_id="scope-1", + part_id=part_id, + ) + == expected + ) + + @invariant() + def launch_decision_matches_current_authority_model(self) -> None: + head = self.model_heads.get(self._model_key()) + authority = self.authorities[head.current_authority_digest] if head is not None else None + expected_part = self.part_id + if head is None: + predecessor = next( + ( + candidate + for (generation, _part), candidate in self.model_heads.items() + if generation == self.generation + and candidate.verdict is AuditVerdict.GO + and candidate.authorized_successor_part_id == self.part_id + ), + None, + ) + if predecessor is not None: + head = predecessor + authority = self.authorities[head.current_authority_digest] + report = self.reports.get(self._model_key()) + decision = InventoryAdmissionEvaluator().evaluate( + authority=authority, + trusted_head=head, + report=report, + expected_generation=self.generation, + expected_plan_set_id="plans-1", + expected_scope_id="scope-1", + expected_part_id=expected_part, + current_plan_ref=report.current_plan_ref if report is not None else None, + inventory_requirement_ids=("REQ-001",) if report is not None else (), + current_plan_text=( + _plan_text(authority.audit_round) + if authority is not None and report is not None + else "" + ), + ) + if authority is None or authority.verdict is AuditVerdict.GO: + assert decision.status is AdmissionStatus.OMIT + elif report is None: + assert decision.status is AdmissionStatus.REJECT + assert decision.reason is AdmissionReason.AUTHORITY_WITHOUT_REPORT + else: + assert decision.status is AdmissionStatus.PASS + + +TestAuditCycleLifecycle = AuditCycleLifecycleStateMachine.TestCase +TestAuditCycleLifecycle.settings = settings( + max_examples=20, + stateful_step_count=25, + deadline=None, +) + + def test_omit_preflight_performs_zero_artifact_reads(tmp_path: Path) -> None: resolver = DefaultInputPreflightResolver( allowed_root=tmp_path, diff --git a/tests/server/test_explicit_backend_override.py b/tests/server/test_explicit_backend_override.py index a0062239e..cf9342b97 100644 --- a/tests/server/test_explicit_backend_override.py +++ b/tests/server/test_explicit_backend_override.py @@ -218,7 +218,7 @@ async def spy_run(*args, **kwargs): response = json.loads( await run_skill( - "/autoskillit:investigate", + "/autoskillit:investigate backend-routing-test", str(tmp_path), step_name="investigate", ) diff --git a/tests/server/test_factory.py b/tests/server/test_factory.py index 19fd55d03..9441eb7eb 100644 --- a/tests/server/test_factory.py +++ b/tests/server/test_factory.py @@ -272,7 +272,7 @@ def test_output_patterns_nonempty_for_investigate() -> None: "conditional", ["verdict"], ), - ("/autoskillit:make-plan some task", "conditional", ["verdict"]), + ("/autoskillit:make-plan some task", "always", []), ("/autoskillit:nonexistent-skill foo", None, []), ("/autoskillit:resolve-merge-conflicts", "conditional", ["conflict_report_path"]), ], diff --git a/tests/server/test_open_kitchen_deferred_recall.py b/tests/server/test_open_kitchen_deferred_recall.py index 2602806b3..ab9c92152 100644 --- a/tests/server/test_open_kitchen_deferred_recall.py +++ b/tests/server/test_open_kitchen_deferred_recall.py @@ -8,6 +8,9 @@ import pytest +from autoskillit.recipe._binding import bind_recipe +from autoskillit.recipe.schema import Recipe, RecipeStep +from autoskillit.server._recipe_execution import get_recipe_execution from tests.server.conftest import _make_mock_ctx pytestmark = [pytest.mark.layer("server"), pytest.mark.small] @@ -22,6 +25,99 @@ def _make_deferred_recall_ctx(name: str) -> MagicMock: return ctx +def _compiled_recipe_payload() -> dict: + recipe = Recipe( + name="test-recipe", + description="Compiled execution installation fixture.", + version="0.2.0", + kitchen_rules=["test"], + steps={ + "verify": RecipeStep( + tool="run_skill", + with_args={ + "skill_command": "/autoskillit:dry-walkthrough", + "cwd": "/repo", + "skill_inputs": {"plan_path": "/tmp/plan.md"}, + }, + ) + }, + ) + return { + "content": "name: test-recipe\n", + "valid": True, + "errors": [], + "requires_packs": [], + "requires_features": [], + "content_hash": "sha256:" + "a" * 64, + "composite_hash": "sha256:" + "b" * 64, + "recipe_version": "1.0", + "suggestions": [], + "post_prune_step_names": ["verify"], + "dispatch_feasible": True, + "_compiled_bindings": bind_recipe(recipe), + } + + +@pytest.mark.anyio +@pytest.mark.parametrize("deferred_recall", [False, True], ids=["normal", "deferred"]) +async def test_recipe_open_atomically_installs_compiled_execution( + tool_ctx, + deferred_recall: bool, +) -> None: + from autoskillit.server.tools import tools_kitchen + + tool_ctx.gate.enable() + tool_ctx.gate_infrastructure_ready = True + tool_ctx.recipe_name = "test-recipe" if deferred_recall else "" + recipes = MagicMock() + recipes.find.return_value = None + tool_ctx.recipes = recipes + request_ctx = MagicMock() + request_ctx.enable_components = AsyncMock() + request_ctx.disable_components = AsyncMock() + request_ctx.reset_visibility = AsyncMock() + + with ( + patch("autoskillit.server._get_ctx", return_value=tool_ctx), + patch.object( + tools_kitchen, + "serve_recipe", + side_effect=lambda *args, **kwargs: _compiled_recipe_payload(), + ), + patch.object(tools_kitchen, "_update_hook_config_with_recipe"), + patch.object(tools_kitchen, "_update_hook_config_with_git_ops_policy"), + patch.object( + tools_kitchen, + "_apply_triage_gate", + new=AsyncMock(side_effect=lambda result, *args, **kwargs: result), + ), + ): + result = await tools_kitchen.open_kitchen( + name="test-recipe", + ctx=request_ctx, + ) + + parsed = json.loads(result) + assert parsed["success"] is True, parsed + installed = get_recipe_execution(tool_ctx) + assert installed is not None + assert installed.snapshot.execution_id + assert installed.snapshot.templates["verify"].template_digest + assert installed.runtime_binding_digests == {} + assert ( + installed.audit_cycle_heads.get( + execution_generation=installed.snapshot.execution_id, + plan_set_id="plans-1", + scope_id="scope-1", + part_id="part-a", + ) + is None + ) + with patch.object(tools_kitchen, "_require_orchestrator_exact", return_value=None): + assert await tools_kitchen.close_kitchen(ctx=request_ctx) == "Kitchen is closed." + assert get_recipe_execution(tool_ctx) is None + + @pytest.mark.anyio async def test_deferred_recall_sets_active_recipe_steps_from_recipe(): """Deferred-recall path populates active_recipe_steps from the freshly loaded recipe.""" diff --git a/tests/server/test_tools_kitchen_gate_features.py b/tests/server/test_tools_kitchen_gate_features.py index 15b86d31c..ea13ac36a 100644 --- a/tests/server/test_tools_kitchen_gate_features.py +++ b/tests/server/test_tools_kitchen_gate_features.py @@ -419,6 +419,7 @@ def test_recipe_resource_returns_composed_content(): mock_ctx.recipes.load_and_validate.assert_called_once_with( "test-recipe", mock_ctx.project_dir, + include_compiled_bindings=True, ingredient_overrides={ "kitchen_id": ANY, "diagnostics_log_dir": ANY, diff --git a/tests/server/test_tools_load_recipe.py b/tests/server/test_tools_load_recipe.py index 3952d7ce1..135bf055c 100644 --- a/tests/server/test_tools_load_recipe.py +++ b/tests/server/test_tools_load_recipe.py @@ -208,7 +208,10 @@ async def test_yaml_error_surfaces_as_suggestion( recipes_dir = tmp_path / ".autoskillit" / "recipes" recipes_dir.mkdir(parents=True) (recipes_dir / "test.yaml").write_text("name: test\n") - with patch("autoskillit.recipe._api._load_recipe_dict", side_effect=YAMLError("bad yaml")): + with patch( + "autoskillit.recipe._api._load_recipe_dict_with_declarations", + side_effect=YAMLError("bad yaml"), + ): result = json.loads(await load_recipe(name="test")) assert "error" not in result assert any( diff --git a/tests/server/test_tools_run_skill_retry.py b/tests/server/test_tools_run_skill_retry.py index ea515d56e..bd4e6508d 100644 --- a/tests/server/test_tools_run_skill_retry.py +++ b/tests/server/test_tools_run_skill_retry.py @@ -150,7 +150,7 @@ async def test_context_limit_result_is_actionable(self, tool_ctx_kitchen_open, m ) tool_ctx_kitchen_open.runner.push(_make_result(returncode=1)) # clone guard snapshot tool_ctx_kitchen_open.runner.push(_make_result(1, stdout, "")) - result = json.loads(await run_skill("/investigate plan.md", "/tmp")) + result = json.loads(await run_skill("/retry-worktree plan.md /tmp", "/tmp")) assert "prompt is too long" not in result["result"].lower() assert result["needs_retry"] is True diff --git a/tests/skills/test_make_plan_false_positive.py b/tests/skills/test_make_plan_false_positive.py deleted file mode 100644 index 3d1089568..000000000 --- a/tests/skills/test_make_plan_false_positive.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Tests for false_positive verdict support in make-plan SKILL.md.""" - -from __future__ import annotations - -from pathlib import Path - -import pytest - -pytestmark = [pytest.mark.layer("skills"), pytest.mark.small] - -_REPO_ROOT = Path(__file__).resolve().parents[2] -_SKILL_MD = _REPO_ROOT / "src/autoskillit/skills_extended/make-plan/SKILL.md" - - -def test_make_plan_skill_mentions_false_positive(): - """SKILL.md must mention false_positive verdict.""" - content = _SKILL_MD.read_text() - assert "false_positive" in content - - -def test_make_plan_skill_mentions_audit_remediation_mode(): - """SKILL.md must describe audit_remediation_mode guard.""" - content = _SKILL_MD.read_text() - assert "audit_remediation_mode" in content - - -def test_make_plan_skill_restricts_false_positive_to_remediation(): - """false_positive must only be emittable in remediation context.""" - content = _SKILL_MD.read_text() - fp_idx = content.index("false_positive") - surrounding = content[max(0, fp_idx - 500) : fp_idx + 1000] - assert "audit_remediation_mode" in surrounding or "remediation" in surrounding.lower() From 41b0afcadfa658cf6210cad2fe217d3585742d17 Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 24 Jul 2026 10:52:17 -0700 Subject: [PATCH 05/89] feat(recipes): thread audit cycle authority end to end --- .../hooks/formatters/_fmt_recipe.py | 1 + src/autoskillit/recipe/_analysis_detectors.py | 21 +- src/autoskillit/recipe/_cmd_rpc_guards.py | 137 +++++++- src/autoskillit/recipes/bem-wrapper.json | 6 +- src/autoskillit/recipes/bem-wrapper.yaml | 5 +- .../recipes/diagrams/implementation-groups.md | 2 +- .../recipes/diagrams/implementation.md | 2 +- src/autoskillit/recipes/diagrams/merge-prs.md | 4 +- .../recipes/diagrams/remediation.md | 2 +- src/autoskillit/recipes/diagrams/research.md | 6 +- .../recipes/implement-findings.json | 6 +- .../recipes/implement-findings.yaml | 5 +- .../recipes/implementation-groups.json | 259 ++++++++++++--- .../recipes/implementation-groups.yaml | 232 +++++++++---- src/autoskillit/recipes/implementation.json | 266 ++++++++++++--- src/autoskillit/recipes/implementation.yaml | 295 ++++++++++------- src/autoskillit/recipes/merge-prs.json | 189 ++++++++--- src/autoskillit/recipes/merge-prs.yaml | 175 ++++++---- src/autoskillit/recipes/planner.json | 32 +- src/autoskillit/recipes/planner.yaml | 35 +- src/autoskillit/recipes/remediation.json | 285 ++++++++++++---- src/autoskillit/recipes/remediation.yaml | 294 +++++++++++------ src/autoskillit/recipes/research-design.json | 18 +- src/autoskillit/recipes/research-design.yaml | 16 +- .../recipes/research-implement.json | 139 ++++++-- .../recipes/research-implement.yaml | 113 +++++-- src/autoskillit/recipes/research-review.json | 24 +- src/autoskillit/recipes/research-review.yaml | 21 +- src/autoskillit/recipes/research.json | 308 ++++++++++++++---- src/autoskillit/recipes/research.yaml | 257 ++++++++++++--- .../apply-review-dimensions/SKILL.md | 31 +- .../skills_extended/diagnose-ci/SKILL.md | 20 +- .../skills_extended/merge-pr/SKILL.md | 17 +- .../resolve-design-review/SKILL.md | 20 +- .../skills_extended/review-approach/SKILL.md | 13 +- .../troubleshoot-experiment/SKILL.md | 10 +- .../test_prepare_compose_pr_contracts.py | 35 +- .../test_review_pr_diff_annotation.py | 14 +- .../test_headless_path_validation.py | 2 + tests/infra/test_pretty_output_recipe.py | 4 +- tests/recipe/test_api.py | 3 + .../test_audit_cycle_lifecycle_integration.py | 91 ++++++ .../test_audit_trail_recipe_contracts.py | 14 +- ...test_bundled_recipes_pipeline_structure.py | 65 ++-- tests/recipe/test_bundled_recipes_research.py | 15 +- .../test_bundled_recipes_research_design.py | 5 +- .../recipe/test_bundled_recipes_review_pr.py | 16 +- .../test_cmd_rpc_verify_plan_artifacts.py | 138 ++++++++ tests/recipe/test_contracts.py | 11 +- tests/recipe/test_implementation.py | 10 +- tests/recipe/test_issue_url_pipeline.py | 6 +- tests/recipe/test_merge_prs.py | 35 +- tests/recipe/test_planner_recipe.py | 20 +- .../test_remediation_depth_ingredient.py | 8 +- tests/recipe/test_remediation_recipe.py | 5 +- tests/recipe/test_research_audit_impl.py | 52 +-- .../test_research_download_data_step.py | 2 +- tests/recipe/test_research_output_mode.py | 10 +- tests/recipe/test_research_recipe_diag.py | 43 ++- tests/recipe/test_research_smoke_fixtures.py | 22 +- tests/skills/test_planner_skill_contracts.py | 14 +- tests/skills/test_skill_output_compliance.py | 2 + 62 files changed, 2881 insertions(+), 1027 deletions(-) create mode 100644 tests/recipe/test_audit_cycle_lifecycle_integration.py diff --git a/src/autoskillit/hooks/formatters/_fmt_recipe.py b/src/autoskillit/hooks/formatters/_fmt_recipe.py index 35551f89f..54f9b0059 100644 --- a/src/autoskillit/hooks/formatters/_fmt_recipe.py +++ b/src/autoskillit/hooks/formatters/_fmt_recipe.py @@ -58,6 +58,7 @@ "post_prune_routing_edges", # internal preflight field; not displayed to agent "dispatch_feasible", # internal admission control signal; surfaced via refusal envelopes "infeasible_steps", # internal admission control detail; surfaced via refusal envelopes + "_compiled_bindings", # internal host-attested invocation carrier } ) diff --git a/src/autoskillit/recipe/_analysis_detectors.py b/src/autoskillit/recipe/_analysis_detectors.py index b82d4896a..92a1ffe3b 100644 --- a/src/autoskillit/recipe/_analysis_detectors.py +++ b/src/autoskillit/recipe/_analysis_detectors.py @@ -17,6 +17,23 @@ # --------------------------------------------------------------------------- +def _context_refs_in_value(value: object) -> set[str]: + if isinstance(value, str): + return set(_CONTEXT_REF_RE.findall(value)) + if isinstance(value, dict): + refs: set[str] = set() + for key, nested in value.items(): + refs.update(_context_refs_in_value(key)) + refs.update(_context_refs_in_value(nested)) + return refs + if isinstance(value, (list, tuple)): + refs = set() + for nested in value: + refs.update(_context_refs_in_value(nested)) + return refs + return set() + + def _detect_ref_invalidations(recipe: Recipe, graph: dict[str, set[str]]) -> list[DataFlowWarning]: """Detect context variables consumed after the step that invalidated the underlying resource. @@ -260,9 +277,7 @@ def _detect_dead_outputs(recipe: Recipe, graph: dict[str, set[str]]) -> list[Dat for reachable_name in reachable: reachable_step = recipe.steps[reachable_name] for arg_val in reachable_step.with_args.values(): - if not isinstance(arg_val, str): - continue - consumed.update(_CONTEXT_REF_RE.findall(arg_val)) + consumed.update(_context_refs_in_value(arg_val)) # message fields are not recipe args; scanner must handle them separately. if reachable_step.message and isinstance(reachable_step.message, str): consumed.update(_CONTEXT_REF_RE.findall(reachable_step.message)) diff --git a/src/autoskillit/recipe/_cmd_rpc_guards.py b/src/autoskillit/recipe/_cmd_rpc_guards.py index e70a808c5..fd8d48a50 100644 --- a/src/autoskillit/recipe/_cmd_rpc_guards.py +++ b/src/autoskillit/recipe/_cmd_rpc_guards.py @@ -10,7 +10,19 @@ import regex as re -from autoskillit.core import atomic_write, get_logger, is_generated_path, run_git +from autoskillit.core import ( + AUDIT_CYCLE_SCHEMA_VERSION, + ArtifactRef, + AuditCycleVerifier, + AuditVerdict, + atomic_write, + compute_canonical_hash, + decode_versioned_json_bytes, + get_logger, + is_generated_path, + read_stable_contained_bytes, + run_git, +) logger = get_logger(__name__) @@ -202,14 +214,118 @@ def _normalize_plan_parts(plan_parts: str) -> list[str] | None: return items -def verify_plan_artifacts(plan_parts: str) -> dict[str, str]: +_PLAN_ASSOCIATION_DOMAIN = "autoskillit:audit-cycle:plan-association:v1:sha256" +_PLAN_ASSOCIATION_KEYS = frozenset( + { + "schema_version", + "plan_ref", + "disposition_ref", + "parent_authority_digest", + "association_digest", + } +) +_MAX_ASSOCIATION_FILES = 256 + + +def _resolve_plan_disposition(*, audit_cycle_path: str, current_plan_path: Path) -> str | None: + authority_path = Path(audit_cycle_path) + if not authority_path.is_absolute() or not current_plan_path.is_absolute(): + return None + cycle_dir = authority_path.parent + try: + authority = AuditCycleVerifier(cycle_dir).load_authority(authority_path) + except (OSError, ValueError): + return None + if authority.verdict is not AuditVerdict.NO_GO: + return None + + try: + plan_ref_candidates: list[ArtifactRef] = [] + associations_dir = cycle_dir / "associations" + candidates = tuple(sorted(associations_dir.glob("*.json"))) + if len(candidates) > _MAX_ASSOCIATION_FILES: + return None + records: list[tuple[Path, dict[str, object]]] = [] + for candidate in candidates: + _, association_bytes = read_stable_contained_bytes( + candidate, + associations_dir, + max_size_bytes=1_000_000, + ) + raw = decode_versioned_json_bytes( + association_bytes, + expected_version=AUDIT_CYCLE_SCHEMA_VERSION, + require_canonical=True, + ) + if raw is None or frozenset(raw) != _PLAN_ASSOCIATION_KEYS: + continue + try: + plan_ref = ArtifactRef.from_dict(raw["plan_ref"]) + except (TypeError, ValueError): + continue + if Path(plan_ref.locator) == current_plan_path: + plan_ref_candidates.append(plan_ref) + records.append((candidate, raw)) + if len(plan_ref_candidates) != 1 or len(records) != 1: + return None + + plan_ref = plan_ref_candidates[0] + association_path, association = records[0] + if association_path.name != f"{plan_ref.content_digest}.json": + return None + if ( + association["schema_version"] != AUDIT_CYCLE_SCHEMA_VERSION + or association["parent_authority_digest"] != authority.authority_digest + ): + return None + association_payload = { + key: value for key, value in association.items() if key != "association_digest" + } + if association["association_digest"] != compute_canonical_hash( + association_payload, + domain=_PLAN_ASSOCIATION_DOMAIN, + ): + return None + + AuditCycleVerifier(current_plan_path.parent).verify_artifact_ref(plan_ref) + disposition_data = association["disposition_ref"] + if not isinstance(disposition_data, dict): + return None + disposition_ref = ArtifactRef.from_dict(disposition_data) + cycle_verifier = AuditCycleVerifier(cycle_dir) + cycle_verifier.verify_artifact_ref(disposition_ref) + report = cycle_verifier.load_report(disposition_ref.locator) + except (KeyError, OSError, TypeError, ValueError): + return None + + identity_matches = ( + report.execution_generation == authority.execution_generation + and report.cycle_id == authority.cycle_id + and report.plan_set_id == authority.plan_set_id + and report.scope_id == authority.scope_id + and report.part_id == authority.part_id + and report.audit_round == authority.audit_round + and report.parent_authority_digest == authority.authority_digest + and report.inventory_digest == authority.inventory_ref.content_digest + and report.findings_digest == authority.findings_digest + and report.current_plan_ref == plan_ref + and Path(report.current_plan_ref.locator) == current_plan_path + ) + return disposition_ref.locator if identity_matches else None + + +def verify_plan_artifacts( + plan_parts: str, + audit_cycle_path: str = "", +) -> dict[str, str]: """Deterministically verify captured plan_parts artifacts for context-limit salvage. Verdict is 'salvaged' iff the normalized plan_parts list is non-empty and every listed path exists as a non-empty regular file; 'unsalvageable' - otherwise. On salvage, echoes the normalized plan_parts (newline-joined) and - the derived plan_path (first part) so downstream context is populated - identically to the plan step's success path. + otherwise. Under an explicit audit cycle, salvage additionally requires exactly + one canonical plan-digest-keyed association matching the current NO GO + authority, recovered plan, and disposition report. No latest-file discovery or + report synthesis is permitted. """ items = _normalize_plan_parts(plan_parts) if not items: @@ -221,11 +337,20 @@ def verify_plan_artifacts(plan_parts: str) -> dict[str, str]: return {"verdict": "unsalvageable"} except OSError: return {"verdict": "unsalvageable"} - return { + result = { "verdict": "salvaged", "plan_parts": "\n".join(items), "plan_path": items[0], } + if audit_cycle_path: + disposition_path = _resolve_plan_disposition( + audit_cycle_path=audit_cycle_path, + current_plan_path=Path(items[0]), + ) + if disposition_path is None: + return {"verdict": "unsalvageable"} + result["plan_disposition_path"] = disposition_path + return result def _count_numstat_net(numstat_output: str) -> int: diff --git a/src/autoskillit/recipes/bem-wrapper.json b/src/autoskillit/recipes/bem-wrapper.json index 4ad8b5f2f..76f35aefc 100644 --- a/src/autoskillit/recipes/bem-wrapper.json +++ b/src/autoskillit/recipes/bem-wrapper.json @@ -51,7 +51,11 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:build-execution-map ${{ context.issue_numbers }} --base-ref ${{ inputs.base_branch }}", + "skill_command": "/autoskillit:build-execution-map", + "skill_inputs": { + "issue_numbers": "${{ context.issue_numbers }}", + "base_ref": "${{ inputs.base_branch }}" + }, "step_name": "run_bem", "output_dir": "." }, diff --git a/src/autoskillit/recipes/bem-wrapper.yaml b/src/autoskillit/recipes/bem-wrapper.yaml index 04ccebbb1..fa4994c5c 100644 --- a/src/autoskillit/recipes/bem-wrapper.yaml +++ b/src/autoskillit/recipes/bem-wrapper.yaml @@ -48,7 +48,10 @@ steps: tool: run_skill model: "" with: - skill_command: "/autoskillit:build-execution-map ${{ context.issue_numbers }} --base-ref ${{ inputs.base_branch }}" + skill_command: "/autoskillit:build-execution-map" + skill_inputs: + issue_numbers: "${{ context.issue_numbers }}" + base_ref: "${{ inputs.base_branch }}" step_name: run_bem output_dir: "." retries: 1 diff --git a/src/autoskillit/recipes/diagrams/implementation-groups.md b/src/autoskillit/recipes/diagrams/implementation-groups.md index 3c9958224..3299580e6 100644 --- a/src/autoskillit/recipes/diagrams/implementation-groups.md +++ b/src/autoskillit/recipes/diagrams/implementation-groups.md @@ -1,4 +1,4 @@ - + ## implementation-groups diff --git a/src/autoskillit/recipes/diagrams/implementation.md b/src/autoskillit/recipes/diagrams/implementation.md index b9d1a0da0..86bd6bdce 100644 --- a/src/autoskillit/recipes/diagrams/implementation.md +++ b/src/autoskillit/recipes/diagrams/implementation.md @@ -1,4 +1,4 @@ - + ## implementation diff --git a/src/autoskillit/recipes/diagrams/merge-prs.md b/src/autoskillit/recipes/diagrams/merge-prs.md index 3211f9489..bd6b00876 100644 --- a/src/autoskillit/recipes/diagrams/merge-prs.md +++ b/src/autoskillit/recipes/diagrams/merge-prs.md @@ -1,4 +1,4 @@ - + ## merge-prs Merge multiple PRs into an integration branch with conflict resolution and CI gates. @@ -15,7 +15,7 @@ fetch_merge_queue_data → analyze_prs → route_by_queue_mode | → resolve ejected conflicts on failure | +-- [integration mode]: -| create_batch_branch → publish +| create_batch_branch → publish → check_pr_merge_loop | | | ┌────┤ FOR EACH PR: | │ merge_pr → plan → verify → implement → test diff --git a/src/autoskillit/recipes/diagrams/remediation.md b/src/autoskillit/recipes/diagrams/remediation.md index 3f936bed9..53247d22e 100644 --- a/src/autoskillit/recipes/diagrams/remediation.md +++ b/src/autoskillit/recipes/diagrams/remediation.md @@ -1,4 +1,4 @@ - + ## remediation diff --git a/src/autoskillit/recipes/diagrams/research.md b/src/autoskillit/recipes/diagrams/research.md index 9a9251050..83c3aaa00 100644 --- a/src/autoskillit/recipes/diagrams/research.md +++ b/src/autoskillit/recipes/diagrams/research.md @@ -1,4 +1,4 @@ - + ## research @@ -44,7 +44,7 @@ decompose_phases | | | plan_phase | | -| implement_phase <-> [x fail -> troubleshoot_implement_failure] +| implement_phase <-> [x fail -> check_implement_fix_loop -> troubleshoot_implement_failure] | | x exhausted [-> run_experiment] | +----+ @@ -60,7 +60,7 @@ check_audit_retry_loop (max 2) pre_remediation_cleanup | run_experiment <-> [adjust_experiment] (optional) -| x fail [-> troubleshoot_run_failure] +| x fail [-> check_run_fix_loop -> troubleshoot_run_failure] | x exhausted [-> ensure_results] | generate_report diff --git a/src/autoskillit/recipes/implement-findings.json b/src/autoskillit/recipes/implement-findings.json index 18f34ed88..551375b87 100644 --- a/src/autoskillit/recipes/implement-findings.json +++ b/src/autoskillit/recipes/implement-findings.json @@ -97,7 +97,11 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:build-execution-map ${{ context.remaining_urls_json }} ${{ inputs.base_branch }}", + "skill_command": "/autoskillit:build-execution-map", + "skill_inputs": { + "issue_numbers": "${{ context.remaining_urls_json }}", + "base_ref": "${{ inputs.base_branch }}" + }, "cwd": "${{ inputs.source_dir }}", "step_name": "run_bem_internally", "output_dir": "{{AUTOSKILLIT_TEMP}}/build-execution-map" diff --git a/src/autoskillit/recipes/implement-findings.yaml b/src/autoskillit/recipes/implement-findings.yaml index 286959214..90d1c2478 100644 --- a/src/autoskillit/recipes/implement-findings.yaml +++ b/src/autoskillit/recipes/implement-findings.yaml @@ -100,7 +100,10 @@ steps: tool: run_skill model: "" with: - skill_command: "/autoskillit:build-execution-map ${{ context.remaining_urls_json }} ${{ inputs.base_branch }}" + skill_command: "/autoskillit:build-execution-map" + skill_inputs: + issue_numbers: "${{ context.remaining_urls_json }}" + base_ref: "${{ inputs.base_branch }}" cwd: "${{ inputs.source_dir }}" step_name: "run_bem_internally" output_dir: "{{AUTOSKILLIT_TEMP}}/build-execution-map" diff --git a/src/autoskillit/recipes/implementation-groups.json b/src/autoskillit/recipes/implementation-groups.json index 83ba7039d..4fe4b624c 100644 --- a/src/autoskillit/recipes/implementation-groups.json +++ b/src/autoskillit/recipes/implementation-groups.json @@ -205,7 +205,10 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:make-groups ${{ inputs.source_doc }}", + "skill_command": "/autoskillit:make-groups", + "skill_inputs": { + "source_doc": "${{ inputs.source_doc }}" + }, "cwd": "${{ context.work_dir }}", "step_name": "group", "output_dir": "{{AUTOSKILLIT_TEMP}}/make-groups" @@ -222,51 +225,59 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:make-plan CURRENT_GROUP_FILE", + "skill_command": "/autoskillit:make-plan", + "skill_inputs": { + "adversarial_review_level": "${{ inputs.adversarial_review_level }}", + "task": "${{ context.group_files }}", + "issue_url": "${{ inputs.issue_url }}", + "audit_cycle_path": "${{ context.audit_cycle_path }}" + }, "cwd": "${{ context.work_dir }}", "step_name": "plan", - "group_files": "${{ context.group_files }}", - "issue_url": "${{ inputs.issue_url }}", - "issue_title": "${{ context.issue_title }}", - "adversarial_review_level": "${{ inputs.adversarial_review_level }}", "output_dir": "." }, "capture": { "plan_path": "${{ result.plan_path }}", "all_plan_paths": "${{ result.plan_path }}", - "verdict": "${{ result.verdict }}" + "verdict": "${{ result.verdict }}", + "plan_disposition_path": "${{ result.plan_disposition_path }}" }, "capture_list": { "plan_parts": "${{ result.plan_parts }}" }, + "optional_context_refs": [ + "audit_cycle_path" + ], "retries": 0, "on_result": [ { "when": "${{ result.verdict }} == plan", "route": "review_approach" - }, - { - "when": "${{ result.verdict }} == false_positive", - "route": "push" } ], "on_failure": "release_issue_failure", "on_rate_limit": "release_issue_failure", "on_context_limit": "salvage_plan", - "note": "Produces plan in {{AUTOSKILLIT_TEMP}}/make-plan/. Glob plan_dir for *_part_*.md or single plan file. plan_parts context key contains the full ordered list of part files (sorted alphabetically). For a single-part plan, plan_parts has one entry equal to plan_path. GROUPS MODE: Replace the skill_command task with the current group's file path from context.group_files extracted from the group step's capture — do NOT read the file; pass the path directly to the skill. REMEDIATION MODE: context.remediation_path is available when the orchestrator re-enters plan from the remediate step; pass it inline in the skill_command rather than as a named with: key. When in remediation mode, also append audit_remediation_mode=true to the skill_command. ACCUMULATION: After each plan step execution in groups mode, the agent MUST append the captured plan_path to context.all_plan_paths using a comma separator instead of letting the capture block overwrite the accumulated value. For multi-group runs, the accumulated value looks like \"/path/groupA_plan.md,/path/groupB_plan.md\". When verdict=false_positive, do NOT overwrite all_plan_paths — preserve the existing accumulated value from prior plan executions. ISSUE CONTEXT: If inputs.issue_url is a non-empty string, pass it to the skill so the make-plan skill can call fetch_github_issue internally to get full issue content: \"/autoskillit:make-plan {group_path}\\n\\nGitHub Issue: {inputs.issue_url}\" ADVERSARIAL REVIEW LEVEL: If inputs.adversarial_review_level is a non-empty string, append it to the skill_command: \"/autoskillit:make-plan {group_path}\\n\\nadversarial_review_level={inputs.adversarial_review_level}\" VERDICT: The skill emits verdict=plan (normal) or verdict=false_positive (remediation finding is a false positive). false_positive routes to push.\n" + "note": "Produces plan in {{AUTOSKILLIT_TEMP}}/make-plan/. Glob plan_dir for *_part_*.md or single plan file. plan_parts context key contains the full ordered list of part files (sorted alphabetically). For a single-part plan, plan_parts has one entry equal to plan_path. GROUPS MODE: Replace the skill_command task with the current group's file path from context.group_files extracted from the group step's capture — do NOT read the file; pass the path directly to the skill. REMEDIATION MODE: on re-entry, pass the exact current audit_cycle_path; make-plan emits the matching plan_disposition_path. ACCUMULATION: After each plan step execution in groups mode, the agent MUST append the captured plan_path to context.all_plan_paths using a comma separator instead of letting the capture block overwrite the accumulated value. For multi-group runs, the accumulated value looks like \"/path/groupA_plan.md,/path/groupB_plan.md\". ISSUE CONTEXT: If inputs.issue_url is a non-empty string, pass it to the skill so the make-plan skill can call fetch_github_issue internally to get full issue content: \"/autoskillit:make-plan {group_path}\\n\\nGitHub Issue: {inputs.issue_url}\" ADVERSARIAL REVIEW LEVEL: If inputs.adversarial_review_level is a non-empty string, append it to the skill_command: \"/autoskillit:make-plan {group_path}\\n\\nadversarial_review_level={inputs.adversarial_review_level}\" verdict=plan is the only successful result.\n" }, "salvage_plan": { "tool": "run_python", "with": { "callable": "autoskillit.recipe._cmd_rpc.verify_plan_artifacts", - "plan_parts": "${{ context.plan_parts }}" + "plan_parts": "${{ context.plan_parts }}", + "audit_cycle_path": "${{ context.audit_cycle_path }}" }, "capture": { - "plan_path": "${{ result.plan_path }}" + "plan_path": "${{ result.plan_path }}", + "plan_disposition_path": "${{ result.plan_disposition_path }}", + "all_plan_paths": "${{ result.plan_path }}" }, "capture_list": { "plan_parts": "${{ result.plan_parts }}" }, + "optional_context_refs": [ + "audit_cycle_path" + ], "retries": 0, "on_result": [ { @@ -284,10 +295,13 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:review-approach ${{ context.plan_path }}", + "skill_command": "/autoskillit:review-approach", + "skill_inputs": { + "plan_path": "${{ context.plan_path }}" + }, "cwd": "${{ context.work_dir }}", "step_name": "review_approach", - "output_dir": "{{AUTOSKILLIT_TEMP}}/review-approach" + "output_dir": "{{AUTOSKILLIT_TEMP}}/review-approach/audit_${{ context.audit_remediation_count }}" }, "capture": { "review_path": "${{ result.review_path }}" @@ -298,24 +312,33 @@ "on_context_limit": "verify", "on_rate_limit": "verify", "skip_when_false": "inputs.review_approach", + "optional_context_refs": [ + "audit_remediation_count" + ], "note": "Only execute this step if review_approach input is 'true'. Otherwise skip directly to verify." }, "verify": { "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:dry-walkthrough ${{ context.plan_path }}", + "skill_command": "/autoskillit:dry-walkthrough", + "skill_inputs": { + "plan_path": "${{ context.plan_path }}", + "issue_url": "${{ inputs.issue_url }}", + "review_path": "${{ context.review_path }}", + "audit_cycle_path": "${{ context.audit_cycle_path }}", + "plan_disposition_path": "${{ context.plan_disposition_path }}" + }, "cwd": "${{ context.work_dir }}", "step_name": "verify", - "review_path": "${{ context.review_path }}", - "issue_url": "${{ inputs.issue_url }}", - "output_dir": "{{AUTOSKILLIT_TEMP}}", - "remediation_path": "${{ context.remediation_path }}" + "output_dir": "{{AUTOSKILLIT_TEMP}}" }, "on_success": "create_impl_worktree", "on_failure": "release_issue_failure", "optional_context_refs": [ - "remediation_path" + "review_path", + "audit_cycle_path", + "plan_disposition_path" ], "note": "SEQUENTIAL EXECUTION: For each plan_part, run the FULL cycle — verify → implement → test → merge — for that part before advancing to the next part. Never run verify for all parts first. If review produced a report, context.review_path is available — pass it as an additional arg to dry-walkthrough." }, @@ -338,7 +361,10 @@ "model": "", "stale_threshold": 2400, "with": { - "skill_command": "/autoskillit:implement-worktree-no-merge ${{ context.plan_path }}", + "skill_command": "/autoskillit:implement-worktree-no-merge", + "skill_inputs": { + "plan_path": "${{ context.plan_path }}" + }, "cwd": "${{ context.worktree_path }}", "step_name": "implement", "output_dir": "." @@ -360,7 +386,11 @@ "model": "", "stale_threshold": 2400, "with": { - "skill_command": "/autoskillit:retry-worktree ${{ context.plan_path }} ${{ context.worktree_path }}", + "skill_command": "/autoskillit:retry-worktree", + "skill_inputs": { + "plan_path": "${{ context.plan_path }}", + "worktree_path": "${{ context.worktree_path }}" + }, "cwd": "${{ context.worktree_path }}", "step_name": "retry_worktree", "output_dir": "." @@ -728,7 +758,14 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:resolve-failures ${{ context.worktree_path }} ${{ context.plan_path }} ${{ inputs.base_branch }} ${{ context.merge_gate_ci_conclusion }} - ${{ context.merge_gate_diagnosis_path }}", + "skill_command": "/autoskillit:resolve-failures", + "skill_inputs": { + "base_branch": "${{ inputs.base_branch }}", + "diagnosis_path": "${{ context.merge_gate_diagnosis_path }}", + "ci_conclusion": "${{ context.merge_gate_ci_conclusion }}", + "plan_path": "${{ context.plan_path }}", + "worktree_path": "${{ context.worktree_path }}" + }, "cwd": "${{ context.worktree_path }}", "step_name": "fix", "output_dir": "." @@ -802,7 +839,14 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:resolve-failures ${{ context.worktree_path }} ${{ context.plan_path }} ${{ inputs.base_branch }} ${{ context.merge_gate_ci_conclusion }} - ${{ context.merge_gate_diagnosis_path }}", + "skill_command": "/autoskillit:resolve-failures", + "skill_inputs": { + "base_branch": "${{ inputs.base_branch }}", + "diagnosis_path": "${{ context.merge_gate_diagnosis_path }}", + "ci_conclusion": "${{ context.merge_gate_ci_conclusion }}", + "plan_path": "${{ context.plan_path }}", + "worktree_path": "${{ context.worktree_path }}" + }, "cwd": "${{ context.worktree_path }}", "step_name": "merge_gate_fix", "output_dir": "." @@ -884,7 +928,12 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:resolve-merge-conflicts ${{ context.worktree_path }} ${{ context.plan_path }} ${{ inputs.base_branch }}", + "skill_command": "/autoskillit:resolve-merge-conflicts", + "skill_inputs": { + "plan_path": "${{ context.plan_path }}", + "base_branch": "${{ inputs.base_branch }}", + "worktree_path": "${{ context.worktree_path }}" + }, "cwd": "${{ context.worktree_path }}", "step_name": "rebase_conflict_fix", "output_dir": "." @@ -967,7 +1016,13 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:audit-impl ${{ context.all_plan_paths }} ${{ context.base_sha }} ${{ inputs.base_branch }}", + "skill_command": "/autoskillit:audit-impl", + "skill_inputs": { + "all_plan_paths": "${{ context.all_plan_paths }}", + "branch_name": "${{ context.base_sha }}", + "base_branch": "${{ inputs.base_branch }}", + "prior_audit_cycle_path": "${{ context.audit_cycle_path }}" + }, "cwd": "${{ context.work_dir }}", "step_name": "audit_impl", "output_dir": "{{AUTOSKILLIT_TEMP}}/audit-impl", @@ -979,7 +1034,8 @@ "closure_target_sha": "" }, "capture": { - "remediation_path": "${{ result.remediation_path }}" + "remediation_path": "${{ result.remediation_path }}", + "audit_cycle_path": "${{ result.audit_cycle_path }}" }, "on_result": [ { @@ -1003,12 +1059,15 @@ "on_rate_limit": "register_clone_failure", "optional": true, "skip_when_false": "inputs.audit_impl", - "note": "Only execute if inputs.audit_impl is 'true'. If false, skip directly to push. Captures verdict and remediation_path. Routes GO → push, NO GO → remediate which re-enters the plan step with the remediation file. All groups and parts have been merged at this point — no worktree exists. Uses context.base_sha (a stable commit SHA captured before any merge began) as implementation_ref. A SHA names a git object and survives git branch -D unconditionally. The diff git diff base_sha..base_branch (two-dot, SHA on the left) covers the full implementation across all plan groups.\n" + "optional_context_refs": [ + "audit_cycle_path" + ], + "note": "Only execute if inputs.audit_impl is 'true'. If false, skip directly to push. Captures audit_cycle_path on both verdicts. NO GO re-enters planning with that exact authority and the successor audit receives it as prior authority. GO replaces the previous cycle before push. All groups and parts have been merged at this point — no worktree exists. Uses context.base_sha (a stable commit SHA captured before any merge began) as implementation_ref. A SHA names a git object and survives git branch -D unconditionally. The diff git diff base_sha..base_branch (two-dot, SHA on the left) covers the full implementation across all plan groups.\n" }, "remediate": { "action": "route", "with": { - "remediation_path": "${{ context.remediation_path }}" + "audit_cycle_path": "${{ context.audit_cycle_path }}" }, "on_success": "plan" }, @@ -1016,7 +1075,14 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:prepare-pr ${{ context.all_plan_paths }} ${{ inputs.run_name }} ${{ inputs.base_branch }} ${{ context.issue_number }} ${{ inputs.arch_lenses }}", + "skill_command": "/autoskillit:prepare-pr", + "skill_inputs": { + "arch_lenses": "${{ inputs.arch_lenses }}", + "plan_paths": "${{ context.all_plan_paths }}", + "closing_issue": "${{ context.issue_number }}", + "base_branch": "${{ inputs.base_branch }}", + "run_name": "${{ inputs.run_name }}" + }, "cwd": "${{ context.work_dir }}", "output_dir": ".", "step_name": "prepare_pr" @@ -1050,7 +1116,10 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:arch-lens-{slug} {context_path}", + "skill_command": "/autoskillit:arch-lens-{slug}", + "skill_inputs": { + "context_path": "{context_path}" + }, "cwd": "${{ context.work_dir }}", "step_name": "run_arch_lenses" }, @@ -1070,11 +1139,16 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:compose-pr ${{ context.prep_path }} \"${{ context.all_diagram_paths }}\" ${{ context.work_dir }} ${{ inputs.base_branch }}", + "skill_command": "/autoskillit:compose-pr", + "skill_inputs": { + "work_dir": "${{ context.work_dir }}", + "all_diagram_paths": "${{ context.all_diagram_paths }}", + "base_branch": "${{ inputs.base_branch }}", + "prep_path": "${{ context.prep_path }}" + }, "cwd": "${{ context.work_dir }}", "output_dir": ".", - "step_name": "compose_pr", - "issue_number": "${{ context.issue_number }}" + "step_name": "compose_pr" }, "capture": { "pr_url": { @@ -1175,7 +1249,17 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:review-pr ${{ context.merge_target }} ${{ inputs.base_branch }} annotated_diff_path=${{ context.annotated_diff_path }} hunk_ranges_path=${{ context.hunk_ranges_path }} valid_lines_path=${{ context.valid_lines_path }} diff_metrics_path=${{ context.diff_metrics_path }} mode=${{ context.review_mode }} pr_head_sha=${{ context.pr_head_sha }}", + "skill_command": "/autoskillit:review-pr", + "skill_inputs": { + "feature_branch": "${{ context.merge_target }}", + "base_branch": "${{ inputs.base_branch }}", + "annotated_diff_path": "${{ context.annotated_diff_path }}", + "hunk_ranges_path": "${{ context.hunk_ranges_path }}", + "valid_lines_path": "${{ context.valid_lines_path }}", + "diff_metrics_path": "${{ context.diff_metrics_path }}", + "mode": "${{ context.review_mode }}", + "pr_head_sha": "${{ context.pr_head_sha }}" + }, "cwd": "${{ context.work_dir }}", "step_name": "review_pr", "output_dir": "{{AUTOSKILLIT_TEMP}}/review-pr/iter_${{ context.review_loop_count }}" @@ -1219,7 +1303,12 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:resolve-review ${{ context.merge_target }} ${{ inputs.base_branch }} mode=${{ context.review_mode }}", + "skill_command": "/autoskillit:resolve-review", + "skill_inputs": { + "base_branch": "${{ inputs.base_branch }}", + "feature_branch": "${{ context.merge_target }}", + "mode": "${{ context.review_mode }}" + }, "cwd": "${{ context.work_dir }}", "output_dir": ".", "step_name": "resolve_review" @@ -1284,7 +1373,12 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:resolve-merge-conflicts ${{ context.work_dir }} ${{ context.plan_path }} ${{ inputs.base_branch }}", + "skill_command": "/autoskillit:resolve-merge-conflicts", + "skill_inputs": { + "plan_path": "${{ context.plan_path }}", + "base_branch": "${{ inputs.base_branch }}", + "worktree_path": "${{ context.work_dir }}" + }, "cwd": "${{ context.work_dir }}", "output_dir": ".", "step_name": "resolve_pre_review_conflicts" @@ -1646,7 +1740,12 @@ "optional": true, "skip_when_false": "inputs.pipeline_health", "with": { - "skill_command": "/autoskillit:analyze-pipeline-health ${{ inputs.kitchen_id }} --dispatch-id=${{ inputs.dispatch_id }} --diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}", + "skill_command": "/autoskillit:analyze-pipeline-health", + "skill_inputs": { + "kitchen_id": "${{ inputs.kitchen_id }}", + "dispatch_id": "--dispatch-id=${{ inputs.dispatch_id }}", + "diagnostics_log_dir": "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" + }, "cwd": "${{ context.work_dir }}", "step_name": "analyze_pipeline_health" }, @@ -1664,11 +1763,15 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:diagnose-ci ${{ context.merge_target }} - - - ${{ context.ci_event }}", + "skill_command": "/autoskillit:diagnose-ci", + "skill_inputs": { + "ci_failed_jobs": "${{ context.ci_failed_jobs }}", + "event": "${{ context.ci_event }}", + "branch": "${{ context.merge_target }}" + }, "cwd": "${{ context.work_dir }}", "step_name": "diagnose_ci", - "ci_failed_jobs": "${{ context.ci_failed_jobs }}", - "output_dir": "{{AUTOSKILLIT_TEMP}}/diagnose-ci" + "output_dir": "{{AUTOSKILLIT_TEMP}}/diagnose-ci/rebase_${{ context.ci_rebase_count }}_flake_${{ context.ci_flake_count }}" }, "capture": { "diagnosis_path": "${{ result.diagnosis_path }}" @@ -1679,7 +1782,9 @@ "on_rate_limit": "resolve_ci", "optional": true, "optional_context_refs": [ - "ci_event" + "ci_event", + "ci_rebase_count", + "ci_flake_count" ], "skip_when_false": "inputs.open_pr", "note": "Runs when ci_watch reports a CI failure. Fetches CI logs via gh API and writes a structured diagnosis to {{AUTOSKILLIT_TEMP}}/diagnose-ci/. Routes to resolve_ci on success OR failure. If diagnosis fails, resolve_ci proceeds without diagnosis context. diagnosis_path is passed to resolve_ci for targeted remediation context.\n" @@ -1688,7 +1793,15 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:resolve-failures ${{ context.work_dir }} ${{ context.plan_path }} ${{ inputs.base_branch }} ${{ context.ci_conclusion }} ${{ context.ci_failed_jobs }} ${{ context.diagnosis_path }}", + "skill_command": "/autoskillit:resolve-failures", + "skill_inputs": { + "base_branch": "${{ inputs.base_branch }}", + "diagnosis_path": "${{ context.diagnosis_path }}", + "ci_failed_jobs": "${{ context.ci_failed_jobs }}", + "ci_conclusion": "${{ context.ci_conclusion }}", + "plan_path": "${{ context.plan_path }}", + "worktree_path": "${{ context.work_dir }}" + }, "cwd": "${{ context.work_dir }}", "output_dir": ".", "step_name": "resolve_ci" @@ -1755,7 +1868,12 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:resolve-merge-conflicts ${{ context.work_dir }} ${{ context.plan_path }} ${{ inputs.base_branch }}", + "skill_command": "/autoskillit:resolve-merge-conflicts", + "skill_inputs": { + "plan_path": "${{ context.plan_path }}", + "base_branch": "${{ inputs.base_branch }}", + "worktree_path": "${{ context.work_dir }}" + }, "cwd": "${{ context.work_dir }}", "output_dir": ".", "step_name": "resolve_pre_resolve_conflicts" @@ -2127,7 +2245,12 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:resolve-merge-conflicts ${{ context.work_dir }} ${{ context.plan_path }} ${{ inputs.base_branch }}", + "skill_command": "/autoskillit:resolve-merge-conflicts", + "skill_inputs": { + "plan_path": "${{ context.plan_path }}", + "base_branch": "${{ inputs.base_branch }}", + "worktree_path": "${{ context.work_dir }}" + }, "cwd": "${{ context.work_dir }}", "output_dir": ".", "step_name": "resolve_queue_merge_conflicts" @@ -2379,7 +2502,12 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:resolve-merge-conflicts ${{ context.work_dir }} ${{ context.plan_path }} ${{ inputs.base_branch }}", + "skill_command": "/autoskillit:resolve-merge-conflicts", + "skill_inputs": { + "plan_path": "${{ context.plan_path }}", + "base_branch": "${{ inputs.base_branch }}", + "worktree_path": "${{ context.work_dir }}" + }, "cwd": "${{ context.work_dir }}", "output_dir": ".", "step_name": "resolve_direct_merge_conflicts" @@ -2491,7 +2619,12 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:resolve-merge-conflicts ${{ context.work_dir }} ${{ context.plan_path }} ${{ inputs.base_branch }}", + "skill_command": "/autoskillit:resolve-merge-conflicts", + "skill_inputs": { + "plan_path": "${{ context.plan_path }}", + "base_branch": "${{ inputs.base_branch }}", + "worktree_path": "${{ context.work_dir }}" + }, "cwd": "${{ context.work_dir }}", "output_dir": ".", "step_name": "resolve_immediate_merge_conflicts" @@ -2560,7 +2693,12 @@ "optional": true, "skip_when_false": "inputs.pipeline_health", "with": { - "skill_command": "/autoskillit:analyze-pipeline-health ${{ inputs.kitchen_id }} --dispatch-id=${{ inputs.dispatch_id }} --diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}", + "skill_command": "/autoskillit:analyze-pipeline-health", + "skill_inputs": { + "kitchen_id": "${{ inputs.kitchen_id }}", + "dispatch_id": "--dispatch-id=${{ inputs.dispatch_id }}", + "diagnostics_log_dir": "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" + }, "cwd": "${{ context.work_dir }}", "step_name": "analyze_pipeline_health" }, @@ -2591,7 +2729,12 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:resolve-merge-conflicts ${{ context.work_dir }} ${{ context.plan_path }} ${{ inputs.base_branch }}", + "skill_command": "/autoskillit:resolve-merge-conflicts", + "skill_inputs": { + "plan_path": "${{ context.plan_path }}", + "base_branch": "${{ inputs.base_branch }}", + "worktree_path": "${{ context.work_dir }}" + }, "cwd": "${{ context.work_dir }}", "output_dir": ".", "step_name": "ci_conflict_fix" @@ -2675,7 +2818,12 @@ "optional": true, "skip_when_false": "inputs.pipeline_health", "with": { - "skill_command": "/autoskillit:analyze-pipeline-health ${{ inputs.kitchen_id }} --dispatch-id=${{ inputs.dispatch_id }} --diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}", + "skill_command": "/autoskillit:analyze-pipeline-health", + "skill_inputs": { + "kitchen_id": "${{ inputs.kitchen_id }}", + "dispatch_id": "--dispatch-id=${{ inputs.dispatch_id }}", + "diagnostics_log_dir": "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" + }, "cwd": "${{ context.work_dir }}", "step_name": "analyze_pipeline_health" }, @@ -2704,7 +2852,12 @@ "optional": true, "skip_when_false": "inputs.pipeline_health", "with": { - "skill_command": "/autoskillit:analyze-pipeline-health ${{ inputs.kitchen_id }} --dispatch-id=${{ inputs.dispatch_id }} --diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}", + "skill_command": "/autoskillit:analyze-pipeline-health", + "skill_inputs": { + "kitchen_id": "${{ inputs.kitchen_id }}", + "dispatch_id": "--dispatch-id=${{ inputs.dispatch_id }}", + "diagnostics_log_dir": "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" + }, "cwd": "${{ context.work_dir }}", "step_name": "analyze_pipeline_health" }, diff --git a/src/autoskillit/recipes/implementation-groups.yaml b/src/autoskillit/recipes/implementation-groups.yaml index 7a339b7c9..e0dc7e000 100644 --- a/src/autoskillit/recipes/implementation-groups.yaml +++ b/src/autoskillit/recipes/implementation-groups.yaml @@ -188,7 +188,9 @@ steps: tool: run_skill model: "" with: - skill_command: "/autoskillit:make-groups ${{ inputs.source_doc }}" + skill_command: "/autoskillit:make-groups" + skill_inputs: + source_doc: "${{ inputs.source_doc }}" cwd: "${{ context.work_dir }}" step_name: "group" output_dir: "{{AUTOSKILLIT_TEMP}}/make-groups" @@ -209,26 +211,28 @@ steps: tool: run_skill model: "" with: - skill_command: "/autoskillit:make-plan CURRENT_GROUP_FILE" + skill_command: "/autoskillit:make-plan" + skill_inputs: + adversarial_review_level: "${{ inputs.adversarial_review_level }}" + task: "${{ context.group_files }}" + issue_url: "${{ inputs.issue_url }}" + audit_cycle_path: "${{ context.audit_cycle_path }}" cwd: "${{ context.work_dir }}" step_name: "plan" - group_files: "${{ context.group_files }}" - issue_url: "${{ inputs.issue_url }}" - issue_title: "${{ context.issue_title }}" - adversarial_review_level: "${{ inputs.adversarial_review_level }}" output_dir: "." capture: plan_path: "${{ result.plan_path }}" all_plan_paths: "${{ result.plan_path }}" verdict: "${{ result.verdict }}" + plan_disposition_path: "${{ result.plan_disposition_path }}" capture_list: plan_parts: "${{ result.plan_parts }}" + optional_context_refs: + - audit_cycle_path retries: 0 on_result: - when: "${{ result.verdict }} == plan" route: review_approach - - when: "${{ result.verdict }} == false_positive" - route: push on_failure: release_issue_failure on_rate_limit: release_issue_failure on_context_limit: salvage_plan @@ -239,34 +243,34 @@ steps: GROUPS MODE: Replace the skill_command task with the current group's file path from context.group_files extracted from the group step's capture — do NOT read the file; pass the path directly to the skill. - REMEDIATION MODE: context.remediation_path is available when the orchestrator - re-enters plan from the remediate step; pass it inline in the skill_command - rather than as a named with: key. When in remediation mode, also append - audit_remediation_mode=true to the skill_command. + REMEDIATION MODE: on re-entry, pass the exact current audit_cycle_path; make-plan + emits the matching plan_disposition_path. ACCUMULATION: After each plan step execution in groups mode, the agent MUST append the captured plan_path to context.all_plan_paths using a comma separator instead of letting the capture block overwrite the accumulated value. For multi-group runs, the accumulated value looks like "/path/groupA_plan.md,/path/groupB_plan.md". - When verdict=false_positive, do NOT overwrite all_plan_paths — preserve the - existing accumulated value from prior plan executions. ISSUE CONTEXT: If inputs.issue_url is a non-empty string, pass it to the skill so the make-plan skill can call fetch_github_issue internally to get full issue content: "/autoskillit:make-plan {group_path}\n\nGitHub Issue: {inputs.issue_url}" ADVERSARIAL REVIEW LEVEL: If inputs.adversarial_review_level is a non-empty string, append it to the skill_command: "/autoskillit:make-plan {group_path}\n\nadversarial_review_level={inputs.adversarial_review_level}" - VERDICT: The skill emits verdict=plan (normal) or verdict=false_positive - (remediation finding is a false positive). false_positive routes to push. + verdict=plan is the only successful result. salvage_plan: tool: run_python with: callable: "autoskillit.recipe._cmd_rpc.verify_plan_artifacts" plan_parts: "${{ context.plan_parts }}" + audit_cycle_path: "${{ context.audit_cycle_path }}" capture: plan_path: "${{ result.plan_path }}" + plan_disposition_path: "${{ result.plan_disposition_path }}" + all_plan_paths: "${{ result.plan_path }}" capture_list: plan_parts: "${{ result.plan_parts }}" + optional_context_refs: + - audit_cycle_path retries: 0 on_result: - when: "${{ result.verdict }} == salvaged" @@ -285,10 +289,12 @@ steps: tool: run_skill model: "" with: - skill_command: "/autoskillit:review-approach ${{ context.plan_path }}" + skill_command: "/autoskillit:review-approach" + skill_inputs: + plan_path: "${{ context.plan_path }}" cwd: "${{ context.work_dir }}" step_name: "review_approach" - output_dir: "{{AUTOSKILLIT_TEMP}}/review-approach" + output_dir: "{{AUTOSKILLIT_TEMP}}/review-approach/audit_${{ context.audit_remediation_count }}" capture: review_path: "${{ result.review_path }}" retries: 1 @@ -297,23 +303,30 @@ steps: on_context_limit: verify on_rate_limit: verify skip_when_false: "inputs.review_approach" + optional_context_refs: + - audit_remediation_count note: "Only execute this step if review_approach input is 'true'. Otherwise skip directly to verify." verify: tool: run_skill model: "" with: - skill_command: "/autoskillit:dry-walkthrough ${{ context.plan_path }}" + skill_command: "/autoskillit:dry-walkthrough" + skill_inputs: + plan_path: "${{ context.plan_path }}" + issue_url: "${{ inputs.issue_url }}" + review_path: "${{ context.review_path }}" + audit_cycle_path: "${{ context.audit_cycle_path }}" + plan_disposition_path: "${{ context.plan_disposition_path }}" cwd: "${{ context.work_dir }}" step_name: "verify" - review_path: "${{ context.review_path }}" - issue_url: "${{ inputs.issue_url }}" output_dir: "{{AUTOSKILLIT_TEMP}}" - remediation_path: "${{ context.remediation_path }}" on_success: create_impl_worktree on_failure: release_issue_failure optional_context_refs: - - remediation_path + - review_path + - audit_cycle_path + - plan_disposition_path note: "SEQUENTIAL EXECUTION: For each plan_part, run the FULL cycle — verify → implement → test → merge — for that part before advancing to the next part. Never run verify for all parts first. If review produced a report, context.review_path is available — pass it as an additional arg to dry-walkthrough." create_impl_worktree: @@ -333,7 +346,9 @@ steps: model: "" stale_threshold: 2400 with: - skill_command: "/autoskillit:implement-worktree-no-merge ${{ context.plan_path }}" + skill_command: "/autoskillit:implement-worktree-no-merge" + skill_inputs: + plan_path: "${{ context.plan_path }}" cwd: "${{ context.worktree_path }}" step_name: "implement" output_dir: "." @@ -353,7 +368,10 @@ steps: model: "" stale_threshold: 2400 with: - skill_command: "/autoskillit:retry-worktree ${{ context.plan_path }} ${{ context.worktree_path }}" + skill_command: "/autoskillit:retry-worktree" + skill_inputs: + plan_path: "${{ context.plan_path }}" + worktree_path: "${{ context.worktree_path }}" cwd: "${{ context.worktree_path }}" step_name: "retry_worktree" output_dir: "." @@ -678,7 +696,13 @@ steps: tool: run_skill model: "" with: - skill_command: "/autoskillit:resolve-failures ${{ context.worktree_path }} ${{ context.plan_path }} ${{ inputs.base_branch }} ${{ context.merge_gate_ci_conclusion }} - ${{ context.merge_gate_diagnosis_path }}" + skill_command: "/autoskillit:resolve-failures" + skill_inputs: + base_branch: "${{ inputs.base_branch }}" + diagnosis_path: "${{ context.merge_gate_diagnosis_path }}" + ci_conclusion: "${{ context.merge_gate_ci_conclusion }}" + plan_path: "${{ context.plan_path }}" + worktree_path: "${{ context.worktree_path }}" cwd: "${{ context.worktree_path }}" step_name: "fix" output_dir: '.' @@ -731,7 +755,13 @@ steps: tool: run_skill model: "" with: - skill_command: "/autoskillit:resolve-failures ${{ context.worktree_path }} ${{ context.plan_path }} ${{ inputs.base_branch }} ${{ context.merge_gate_ci_conclusion }} - ${{ context.merge_gate_diagnosis_path }}" + skill_command: "/autoskillit:resolve-failures" + skill_inputs: + base_branch: "${{ inputs.base_branch }}" + diagnosis_path: "${{ context.merge_gate_diagnosis_path }}" + ci_conclusion: "${{ context.merge_gate_ci_conclusion }}" + plan_path: "${{ context.plan_path }}" + worktree_path: "${{ context.worktree_path }}" cwd: "${{ context.worktree_path }}" step_name: "merge_gate_fix" output_dir: '.' @@ -794,7 +824,11 @@ steps: tool: run_skill model: "" with: - skill_command: "/autoskillit:resolve-merge-conflicts ${{ context.worktree_path }} ${{ context.plan_path }} ${{ inputs.base_branch }}" + skill_command: "/autoskillit:resolve-merge-conflicts" + skill_inputs: + plan_path: "${{ context.plan_path }}" + base_branch: "${{ inputs.base_branch }}" + worktree_path: "${{ context.worktree_path }}" cwd: "${{ context.worktree_path }}" step_name: "rebase_conflict_fix" output_dir: '.' @@ -867,7 +901,12 @@ steps: tool: run_skill model: "" with: - skill_command: "/autoskillit:audit-impl ${{ context.all_plan_paths }} ${{ context.base_sha }} ${{ inputs.base_branch }}" + skill_command: "/autoskillit:audit-impl" + skill_inputs: + all_plan_paths: "${{ context.all_plan_paths }}" + branch_name: "${{ context.base_sha }}" + base_branch: "${{ inputs.base_branch }}" + prior_audit_cycle_path: "${{ context.audit_cycle_path }}" cwd: "${{ context.work_dir }}" step_name: "audit_impl" output_dir: "{{AUTOSKILLIT_TEMP}}/audit-impl" @@ -879,6 +918,7 @@ steps: closure_target_sha: "" capture: remediation_path: "${{ result.remediation_path }}" + audit_cycle_path: "${{ result.audit_cycle_path }}" on_result: - when: "${{ result.verdict }} == GO" route: push @@ -892,10 +932,13 @@ steps: on_rate_limit: register_clone_failure optional: true skip_when_false: "inputs.audit_impl" + optional_context_refs: + - audit_cycle_path note: > Only execute if inputs.audit_impl is 'true'. If false, skip directly to push. - Captures verdict and remediation_path. Routes GO → push, NO GO → remediate - which re-enters the plan step with the remediation file. + Captures audit_cycle_path on both verdicts. NO GO re-enters planning with + that exact authority and the successor audit receives it as prior authority. + GO replaces the previous cycle before push. All groups and parts have been merged at this point — no worktree exists. Uses context.base_sha (a stable commit SHA captured before any merge began) as implementation_ref. A SHA names a git object and survives git branch -D @@ -905,14 +948,20 @@ steps: remediate: action: route with: - remediation_path: "${{ context.remediation_path }}" + audit_cycle_path: "${{ context.audit_cycle_path }}" on_success: plan prepare_pr: tool: run_skill model: "" with: - skill_command: "/autoskillit:prepare-pr ${{ context.all_plan_paths }} ${{ inputs.run_name }} ${{ inputs.base_branch }} ${{ context.issue_number }} ${{ inputs.arch_lenses }}" + skill_command: "/autoskillit:prepare-pr" + skill_inputs: + arch_lenses: "${{ inputs.arch_lenses }}" + plan_paths: "${{ context.all_plan_paths }}" + closing_issue: "${{ context.issue_number }}" + base_branch: "${{ inputs.base_branch }}" + run_name: "${{ inputs.run_name }}" cwd: "${{ context.work_dir }}" output_dir: '.' step_name: "prepare_pr" @@ -942,7 +991,9 @@ steps: tool: run_skill model: "" with: - skill_command: "/autoskillit:arch-lens-{slug} {context_path}" + skill_command: "/autoskillit:arch-lens-{slug}" + skill_inputs: + context_path: "{context_path}" cwd: "${{ context.work_dir }}" step_name: "run_arch_lenses" capture_list: @@ -969,11 +1020,15 @@ steps: tool: run_skill model: "" with: - skill_command: "/autoskillit:compose-pr ${{ context.prep_path }} \"${{ context.all_diagram_paths }}\" ${{ context.work_dir }} ${{ inputs.base_branch }}" + skill_command: "/autoskillit:compose-pr" + skill_inputs: + work_dir: "${{ context.work_dir }}" + all_diagram_paths: "${{ context.all_diagram_paths }}" + base_branch: "${{ inputs.base_branch }}" + prep_path: "${{ context.prep_path }}" cwd: "${{ context.work_dir }}" output_dir: '.' step_name: "compose_pr" - issue_number: "${{ context.issue_number }}" capture: pr_url: from: "${{ result.pr_url }}" @@ -1060,7 +1115,16 @@ steps: tool: run_skill model: "" with: - skill_command: "/autoskillit:review-pr ${{ context.merge_target }} ${{ inputs.base_branch }} annotated_diff_path=${{ context.annotated_diff_path }} hunk_ranges_path=${{ context.hunk_ranges_path }} valid_lines_path=${{ context.valid_lines_path }} diff_metrics_path=${{ context.diff_metrics_path }} mode=${{ context.review_mode }} pr_head_sha=${{ context.pr_head_sha }}" + skill_command: "/autoskillit:review-pr" + skill_inputs: + feature_branch: "${{ context.merge_target }}" + base_branch: "${{ inputs.base_branch }}" + annotated_diff_path: "${{ context.annotated_diff_path }}" + hunk_ranges_path: "${{ context.hunk_ranges_path }}" + valid_lines_path: "${{ context.valid_lines_path }}" + diff_metrics_path: "${{ context.diff_metrics_path }}" + mode: "${{ context.review_mode }}" + pr_head_sha: "${{ context.pr_head_sha }}" cwd: "${{ context.work_dir }}" step_name: "review_pr" output_dir: "{{AUTOSKILLIT_TEMP}}/review-pr/iter_${{ context.review_loop_count }}" @@ -1099,7 +1163,11 @@ steps: tool: run_skill model: "" with: - skill_command: "/autoskillit:resolve-review ${{ context.merge_target }} ${{ inputs.base_branch }} mode=${{ context.review_mode }}" + skill_command: "/autoskillit:resolve-review" + skill_inputs: + base_branch: "${{ inputs.base_branch }}" + feature_branch: "${{ context.merge_target }}" + mode: "${{ context.review_mode }}" cwd: "${{ context.work_dir }}" output_dir: '.' step_name: "resolve_review" @@ -1149,8 +1217,11 @@ steps: tool: run_skill model: '' with: - skill_command: /autoskillit:resolve-merge-conflicts ${{ context.work_dir }} - ${{ context.plan_path }} ${{ inputs.base_branch }} + skill_command: "/autoskillit:resolve-merge-conflicts" + skill_inputs: + plan_path: "${{ context.plan_path }}" + base_branch: "${{ inputs.base_branch }}" + worktree_path: "${{ context.work_dir }}" cwd: ${{ context.work_dir }} output_dir: '.' step_name: resolve_pre_review_conflicts @@ -1477,8 +1548,11 @@ steps: optional: true skip_when_false: inputs.pipeline_health with: - skill_command: /autoskillit:analyze-pipeline-health ${{ inputs.kitchen_id }} - --dispatch-id=${{ inputs.dispatch_id }} --diagnostics-log-dir=${{ inputs.diagnostics_log_dir }} + skill_command: "/autoskillit:analyze-pipeline-health" + skill_inputs: + kitchen_id: "${{ inputs.kitchen_id }}" + dispatch_id: "--dispatch-id=${{ inputs.dispatch_id }}" + diagnostics_log_dir: "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" cwd: ${{ context.work_dir }} step_name: analyze_pipeline_health on_success: escalate_stop_no_ci @@ -1502,11 +1576,14 @@ steps: tool: run_skill model: "" with: - skill_command: "/autoskillit:diagnose-ci ${{ context.merge_target }} - - - ${{ context.ci_event }}" + skill_command: "/autoskillit:diagnose-ci" + skill_inputs: + ci_failed_jobs: "${{ context.ci_failed_jobs }}" + event: "${{ context.ci_event }}" + branch: "${{ context.merge_target }}" cwd: "${{ context.work_dir }}" step_name: "diagnose_ci" - ci_failed_jobs: "${{ context.ci_failed_jobs }}" - output_dir: "{{AUTOSKILLIT_TEMP}}/diagnose-ci" + output_dir: "{{AUTOSKILLIT_TEMP}}/diagnose-ci/rebase_${{ context.ci_rebase_count }}_flake_${{ context.ci_flake_count }}" capture: diagnosis_path: "${{ result.diagnosis_path }}" on_success: resolve_ci @@ -1514,7 +1591,7 @@ steps: on_context_limit: resolve_ci on_rate_limit: resolve_ci optional: true - optional_context_refs: [ci_event] + optional_context_refs: [ci_event, ci_rebase_count, ci_flake_count] skip_when_false: "inputs.open_pr" note: > Runs when ci_watch reports a CI failure. Fetches CI logs via gh API and writes @@ -1526,7 +1603,14 @@ steps: tool: run_skill model: "" with: - skill_command: "/autoskillit:resolve-failures ${{ context.work_dir }} ${{ context.plan_path }} ${{ inputs.base_branch }} ${{ context.ci_conclusion }} ${{ context.ci_failed_jobs }} ${{ context.diagnosis_path }}" + skill_command: "/autoskillit:resolve-failures" + skill_inputs: + base_branch: "${{ inputs.base_branch }}" + diagnosis_path: "${{ context.diagnosis_path }}" + ci_failed_jobs: "${{ context.ci_failed_jobs }}" + ci_conclusion: "${{ context.ci_conclusion }}" + plan_path: "${{ context.plan_path }}" + worktree_path: "${{ context.work_dir }}" cwd: "${{ context.work_dir }}" output_dir: '.' step_name: "resolve_ci" @@ -1551,7 +1635,6 @@ steps: route: release_issue_failure - route: release_issue_failure on_failure: release_issue_failure - skip_when_false: "inputs.backend_supports_git_write" note: > Runs when ci_watch reports a CI failure. Receives ci_failed_jobs from wait_for_ci structured output to avoid redundant CI failure re-investigation. Routes via @@ -1584,8 +1667,11 @@ steps: tool: run_skill model: '' with: - skill_command: /autoskillit:resolve-merge-conflicts ${{ context.work_dir }} - ${{ context.plan_path }} ${{ inputs.base_branch }} + skill_command: "/autoskillit:resolve-merge-conflicts" + skill_inputs: + plan_path: "${{ context.plan_path }}" + base_branch: "${{ inputs.base_branch }}" + worktree_path: "${{ context.work_dir }}" cwd: ${{ context.work_dir }} output_dir: '.' step_name: resolve_pre_resolve_conflicts @@ -1602,7 +1688,6 @@ steps: on_rate_limit: diagnose_ci retries: 1 on_exhausted: diagnose_ci - skip_when_false: "inputs.backend_supports_git_write" check_ci_rebase_loop: tool: run_python with: @@ -1908,7 +1993,11 @@ steps: tool: run_skill model: "" with: - skill_command: "/autoskillit:resolve-merge-conflicts ${{ context.work_dir }} ${{ context.plan_path }} ${{ inputs.base_branch }}" + skill_command: "/autoskillit:resolve-merge-conflicts" + skill_inputs: + plan_path: "${{ context.plan_path }}" + base_branch: "${{ inputs.base_branch }}" + worktree_path: "${{ context.work_dir }}" cwd: "${{ context.work_dir }}" output_dir: '.' step_name: "resolve_queue_merge_conflicts" @@ -2118,7 +2207,11 @@ steps: tool: run_skill model: "" with: - skill_command: "/autoskillit:resolve-merge-conflicts ${{ context.work_dir }} ${{ context.plan_path }} ${{ inputs.base_branch }}" + skill_command: "/autoskillit:resolve-merge-conflicts" + skill_inputs: + plan_path: "${{ context.plan_path }}" + base_branch: "${{ inputs.base_branch }}" + worktree_path: "${{ context.work_dir }}" cwd: "${{ context.work_dir }}" output_dir: '.' step_name: "resolve_direct_merge_conflicts" @@ -2225,7 +2318,11 @@ steps: tool: run_skill model: "" with: - skill_command: "/autoskillit:resolve-merge-conflicts ${{ context.work_dir }} ${{ context.plan_path }} ${{ inputs.base_branch }}" + skill_command: "/autoskillit:resolve-merge-conflicts" + skill_inputs: + plan_path: "${{ context.plan_path }}" + base_branch: "${{ inputs.base_branch }}" + worktree_path: "${{ context.work_dir }}" cwd: "${{ context.work_dir }}" output_dir: '.' step_name: "resolve_immediate_merge_conflicts" @@ -2294,8 +2391,11 @@ steps: optional: true skip_when_false: inputs.pipeline_health with: - skill_command: /autoskillit:analyze-pipeline-health ${{ inputs.kitchen_id }} - --dispatch-id=${{ inputs.dispatch_id }} --diagnostics-log-dir=${{ inputs.diagnostics_log_dir }} + skill_command: "/autoskillit:analyze-pipeline-health" + skill_inputs: + kitchen_id: "${{ inputs.kitchen_id }}" + dispatch_id: "--dispatch-id=${{ inputs.dispatch_id }}" + diagnostics_log_dir: "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" cwd: ${{ context.work_dir }} step_name: analyze_pipeline_health on_success: done_unconfirmed @@ -2342,7 +2442,11 @@ steps: tool: run_skill model: "" with: - skill_command: "/autoskillit:resolve-merge-conflicts ${{ context.work_dir }} ${{ context.plan_path }} ${{ inputs.base_branch }}" + skill_command: "/autoskillit:resolve-merge-conflicts" + skill_inputs: + plan_path: "${{ context.plan_path }}" + base_branch: "${{ inputs.base_branch }}" + worktree_path: "${{ context.work_dir }}" cwd: "${{ context.work_dir }}" output_dir: '.' step_name: "ci_conflict_fix" @@ -2427,8 +2531,11 @@ steps: optional: true skip_when_false: inputs.pipeline_health with: - skill_command: /autoskillit:analyze-pipeline-health ${{ inputs.kitchen_id }} - --dispatch-id=${{ inputs.dispatch_id }} --diagnostics-log-dir=${{ inputs.diagnostics_log_dir }} + skill_command: "/autoskillit:analyze-pipeline-health" + skill_inputs: + kitchen_id: "${{ inputs.kitchen_id }}" + dispatch_id: "--dispatch-id=${{ inputs.dispatch_id }}" + diagnostics_log_dir: "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" cwd: ${{ context.work_dir }} step_name: analyze_pipeline_health on_success: done @@ -2457,8 +2564,11 @@ steps: optional: true skip_when_false: inputs.pipeline_health with: - skill_command: /autoskillit:analyze-pipeline-health ${{ inputs.kitchen_id }} - --dispatch-id=${{ inputs.dispatch_id }} --diagnostics-log-dir=${{ inputs.diagnostics_log_dir }} + skill_command: "/autoskillit:analyze-pipeline-health" + skill_inputs: + kitchen_id: "${{ inputs.kitchen_id }}" + dispatch_id: "--dispatch-id=${{ inputs.dispatch_id }}" + diagnostics_log_dir: "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" cwd: ${{ context.work_dir }} step_name: analyze_pipeline_health on_success: escalate_stop diff --git a/src/autoskillit/recipes/implementation.json b/src/autoskillit/recipes/implementation.json index 27d923136..f3a377345 100644 --- a/src/autoskillit/recipes/implementation.json +++ b/src/autoskillit/recipes/implementation.json @@ -221,18 +221,22 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:make-plan ${{ inputs.task }}", + "skill_command": "/autoskillit:make-plan", + "skill_inputs": { + "adversarial_review_level": "${{ inputs.adversarial_review_level }}", + "task": "${{ inputs.task }}", + "issue_url": "${{ inputs.issue_url }}", + "audit_cycle_path": "${{ context.audit_cycle_path }}" + }, "cwd": "${{ context.work_dir }}", "step_name": "plan", - "issue_url": "${{ inputs.issue_url }}", - "issue_title": "${{ context.issue_title }}", - "adversarial_review_level": "${{ inputs.adversarial_review_level }}", "output_dir": "." }, "capture": { "plan_path": "${{ result.plan_path }}", "all_plan_paths": "${{ result.plan_path }}", - "verdict": "${{ result.verdict }}" + "verdict": "${{ result.verdict }}", + "plan_disposition_path": "${{ result.plan_disposition_path }}" }, "capture_list": { "plan_parts": "${{ result.plan_parts }}" @@ -242,29 +246,34 @@ { "when": "${{ result.verdict }} == plan", "route": "review_approach" - }, - { - "when": "${{ result.verdict }} == false_positive", - "route": "check_has_commits" } ], "on_failure": "release_issue_failure", "on_rate_limit": "release_issue_failure", "on_context_limit": "salvage_plan", - "note": "Produces plan in {{AUTOSKILLIT_TEMP}}/make-plan/. Glob plan_dir for *_part_*.md or single plan file. plan_parts context key contains the full ordered list of part files (sorted alphabetically). For a single-part plan, plan_parts has one entry equal to plan_path. REMEDIATION MODE: context.remediation_path is available when the orchestrator re-enters plan from the remediate step; pass it inline in the skill_command rather than as a named with: key. When in remediation mode, also append audit_remediation_mode=true to the skill_command. ACCUMULATION: all_plan_paths accumulates across any re-entries to the plan step (e.g. remediation). For a single-pass run, all_plan_paths equals plan_path (the capture block handles this automatically). When verdict=false_positive, do NOT overwrite all_plan_paths — preserve the existing accumulated value from prior plan executions. ISSUE CONTEXT: If inputs.issue_url is a non-empty string, append it to the skill_command so the make-plan skill can detect and fetch the issue itself: \"/autoskillit:make-plan {task}\\n\\nGitHub Issue: {inputs.issue_url}\" The skill detects the URL pattern and calls fetch_github_issue internally. ADVERSARIAL REVIEW LEVEL: If inputs.adversarial_review_level is a non-empty string, append it to the skill_command: \"/autoskillit:make-plan {task}\\n\\nadversarial_review_level={inputs.adversarial_review_level}\" VERDICT: The skill emits verdict=plan (normal) or verdict=false_positive (remediation finding is a false positive). false_positive skips implement and routes to check_has_commits.\n" + "optional_context_refs": [ + "audit_cycle_path" + ], + "note": "Produces an ordinary plan when no audit_cycle_path is present. On NO GO re-entry, consumes that exact current authority and emits both plan_path and plan_disposition_path. all_plan_paths appends result.plan_path across re-entries; verdict=plan is the only successful result. Glob plan_dir for *_part_*.md or a single plan file and preserve alphabetical part order." }, "salvage_plan": { "tool": "run_python", "with": { "callable": "autoskillit.recipe._cmd_rpc.verify_plan_artifacts", - "plan_parts": "${{ context.plan_parts }}" + "plan_parts": "${{ context.plan_parts }}", + "audit_cycle_path": "${{ context.audit_cycle_path }}" }, "capture": { - "plan_path": "${{ result.plan_path }}" + "plan_path": "${{ result.plan_path }}", + "plan_disposition_path": "${{ result.plan_disposition_path }}", + "all_plan_paths": "${{ result.plan_path }}" }, "capture_list": { "plan_parts": "${{ result.plan_parts }}" }, + "optional_context_refs": [ + "audit_cycle_path" + ], "retries": 0, "on_result": [ { @@ -282,10 +291,13 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:review-approach ${{ context.plan_path }}", + "skill_command": "/autoskillit:review-approach", + "skill_inputs": { + "plan_path": "${{ context.plan_path }}" + }, "cwd": "${{ context.work_dir }}", "step_name": "review_approach", - "output_dir": "{{AUTOSKILLIT_TEMP}}/review-approach" + "output_dir": "{{AUTOSKILLIT_TEMP}}/review-approach/audit_${{ context.audit_remediation_count }}" }, "capture": { "review_path": "${{ result.review_path }}" @@ -296,24 +308,33 @@ "on_context_limit": "verify", "on_rate_limit": "verify", "skip_when_false": "inputs.review_approach", + "optional_context_refs": [ + "audit_remediation_count" + ], "note": "Only execute this step if review_approach input is 'true'. Otherwise skip directly to verify." }, "verify": { "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:dry-walkthrough ${{ context.plan_path }}", + "skill_command": "/autoskillit:dry-walkthrough", + "skill_inputs": { + "plan_path": "${{ context.plan_path }}", + "issue_url": "${{ inputs.issue_url }}", + "review_path": "${{ context.review_path }}", + "audit_cycle_path": "${{ context.audit_cycle_path }}", + "plan_disposition_path": "${{ context.plan_disposition_path }}" + }, "cwd": "${{ context.work_dir }}", "step_name": "verify", - "review_path": "${{ context.review_path }}", - "issue_url": "${{ inputs.issue_url }}", - "output_dir": "{{AUTOSKILLIT_TEMP}}", - "remediation_path": "${{ context.remediation_path }}" + "output_dir": "{{AUTOSKILLIT_TEMP}}" }, "on_success": "create_impl_worktree", "on_failure": "release_issue_failure", "optional_context_refs": [ - "remediation_path" + "review_path", + "audit_cycle_path", + "plan_disposition_path" ], "note": "SEQUENTIAL EXECUTION: For each plan_part, run the FULL cycle — verify → implement → test → merge — for that part before advancing to the next part. Never run verify for all parts first. If review produced a report, context.review_path is available — pass it as an additional arg to dry-walkthrough." }, @@ -336,7 +357,10 @@ "model": "", "stale_threshold": 2400, "with": { - "skill_command": "/autoskillit:implement-worktree-no-merge ${{ context.plan_path }}", + "skill_command": "/autoskillit:implement-worktree-no-merge", + "skill_inputs": { + "plan_path": "${{ context.plan_path }}" + }, "cwd": "${{ context.worktree_path }}", "step_name": "implement", "output_dir": "." @@ -359,7 +383,11 @@ "model": "", "stale_threshold": 2400, "with": { - "skill_command": "/autoskillit:retry-worktree ${{ context.plan_path }} ${{ context.worktree_path }}", + "skill_command": "/autoskillit:retry-worktree", + "skill_inputs": { + "plan_path": "${{ context.plan_path }}", + "worktree_path": "${{ context.worktree_path }}" + }, "cwd": "${{ context.worktree_path }}", "step_name": "retry_worktree", "output_dir": "." @@ -800,7 +828,14 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:resolve-failures ${{ context.worktree_path }} ${{ context.plan_path }} ${{ inputs.base_branch }} ${{ context.merge_gate_ci_conclusion }} - ${{ context.merge_gate_diagnosis_path }}", + "skill_command": "/autoskillit:resolve-failures", + "skill_inputs": { + "base_branch": "${{ inputs.base_branch }}", + "diagnosis_path": "${{ context.merge_gate_diagnosis_path }}", + "ci_conclusion": "${{ context.merge_gate_ci_conclusion }}", + "plan_path": "${{ context.plan_path }}", + "worktree_path": "${{ context.worktree_path }}" + }, "cwd": "${{ context.worktree_path }}", "step_name": "fix", "output_dir": "." @@ -878,7 +913,14 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:resolve-failures ${{ context.worktree_path }} ${{ context.plan_path }} ${{ inputs.base_branch }} ${{ context.merge_gate_ci_conclusion }} - ${{ context.merge_gate_diagnosis_path }}", + "skill_command": "/autoskillit:resolve-failures", + "skill_inputs": { + "base_branch": "${{ inputs.base_branch }}", + "diagnosis_path": "${{ context.merge_gate_diagnosis_path }}", + "ci_conclusion": "${{ context.merge_gate_ci_conclusion }}", + "plan_path": "${{ context.plan_path }}", + "worktree_path": "${{ context.worktree_path }}" + }, "cwd": "${{ context.worktree_path }}", "step_name": "merge_gate_fix", "output_dir": "." @@ -964,7 +1006,12 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:resolve-merge-conflicts ${{ context.worktree_path }} ${{ context.plan_path }} ${{ inputs.base_branch }}", + "skill_command": "/autoskillit:resolve-merge-conflicts", + "skill_inputs": { + "plan_path": "${{ context.plan_path }}", + "base_branch": "${{ inputs.base_branch }}", + "worktree_path": "${{ context.worktree_path }}" + }, "cwd": "${{ context.worktree_path }}", "step_name": "rebase_conflict_fix", "output_dir": "." @@ -1018,7 +1065,14 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:audit-impl ${{ context.all_plan_paths }} ${{ context.base_sha }} ${{ inputs.base_branch }} deviation_manifest_path=${{ context.deviation_manifest_path }}", + "skill_command": "/autoskillit:audit-impl", + "skill_inputs": { + "all_plan_paths": "${{ context.all_plan_paths }}", + "deviation_manifest_path": "${{ context.deviation_manifest_path }}", + "branch_name": "${{ context.base_sha }}", + "base_branch": "${{ inputs.base_branch }}", + "prior_audit_cycle_path": "${{ context.audit_cycle_path }}" + }, "cwd": "${{ context.work_dir }}", "step_name": "audit_impl", "output_dir": "{{AUTOSKILLIT_TEMP}}/audit-impl", @@ -1030,7 +1084,8 @@ "closure_target_sha": "" }, "capture": { - "remediation_path": "${{ result.remediation_path }}" + "remediation_path": "${{ result.remediation_path }}", + "audit_cycle_path": "${{ result.audit_cycle_path }}" }, "on_result": [ { @@ -1055,14 +1110,15 @@ "optional": true, "skip_when_false": "inputs.audit_impl", "optional_context_refs": [ - "deviation_manifest_path" + "deviation_manifest_path", + "audit_cycle_path" ], - "note": "Only execute if inputs.audit_impl is 'true'. If false, skip directly to check_has_commits. Captures verdict and remediation_path. Routes GO → push, NO GO → remediate which re-enters the plan step with the remediation file. All parts have been merged at this point — no worktree exists. Uses context.base_sha (a stable commit SHA captured before any merge began) as implementation_ref. A SHA names a git object and survives git branch -D unconditionally. HEAD points to merge_target (the feature branch tip when open_pr=true, or base_branch when open_pr=false). The diff git diff base_sha..HEAD covers the full implementation.\n" + "note": "Captures immutable audit_cycle_path on GO and NO GO. NO GO re-enters planning with that exact authority; a successor audit receives it as prior_audit_cycle_path. GO replaces the prior cycle before completion." }, "remediate": { "action": "route", "with": { - "remediation_path": "${{ context.remediation_path }}" + "audit_cycle_path": "${{ context.audit_cycle_path }}" }, "on_success": "plan" }, @@ -1071,7 +1127,14 @@ "model": "", "retries": 1, "with": { - "skill_command": "/autoskillit:prepare-pr ${{ context.all_plan_paths }} ${{ inputs.run_name }} ${{ inputs.base_branch }} ${{ context.issue_number }} ${{ inputs.arch_lenses }}", + "skill_command": "/autoskillit:prepare-pr", + "skill_inputs": { + "arch_lenses": "${{ inputs.arch_lenses }}", + "plan_paths": "${{ context.all_plan_paths }}", + "closing_issue": "${{ context.issue_number }}", + "base_branch": "${{ inputs.base_branch }}", + "run_name": "${{ inputs.run_name }}" + }, "cwd": "${{ context.work_dir }}", "output_dir": ".", "step_name": "prepare_pr" @@ -1106,7 +1169,10 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:arch-lens-{slug} {context_path}", + "skill_command": "/autoskillit:arch-lens-{slug}", + "skill_inputs": { + "context_path": "{context_path}" + }, "cwd": "${{ context.work_dir }}", "step_name": "run_arch_lenses" }, @@ -1126,7 +1192,14 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:compose-pr ${{ context.prep_path }} \"${{ context.all_diagram_paths }}\" ${{ context.work_dir }} ${{ inputs.base_branch }} ${{ context.issue_number }}", + "skill_command": "/autoskillit:compose-pr", + "skill_inputs": { + "work_dir": "${{ context.work_dir }}", + "closing_issue": "${{ context.issue_number }}", + "all_diagram_paths": "${{ context.all_diagram_paths }}", + "base_branch": "${{ inputs.base_branch }}", + "prep_path": "${{ context.prep_path }}" + }, "cwd": "${{ context.work_dir }}", "output_dir": ".", "step_name": "compose_pr" @@ -1230,7 +1303,17 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:review-pr ${{ context.merge_target }} ${{ inputs.base_branch }} annotated_diff_path=${{ context.annotated_diff_path }} hunk_ranges_path=${{ context.hunk_ranges_path }} valid_lines_path=${{ context.valid_lines_path }} diff_metrics_path=${{ context.diff_metrics_path }} mode=${{ context.review_mode }} pr_head_sha=${{ context.pr_head_sha }}", + "skill_command": "/autoskillit:review-pr", + "skill_inputs": { + "feature_branch": "${{ context.merge_target }}", + "base_branch": "${{ inputs.base_branch }}", + "annotated_diff_path": "${{ context.annotated_diff_path }}", + "hunk_ranges_path": "${{ context.hunk_ranges_path }}", + "valid_lines_path": "${{ context.valid_lines_path }}", + "diff_metrics_path": "${{ context.diff_metrics_path }}", + "mode": "${{ context.review_mode }}", + "pr_head_sha": "${{ context.pr_head_sha }}" + }, "cwd": "${{ context.work_dir }}", "step_name": "review_pr", "output_dir": "{{AUTOSKILLIT_TEMP}}/review-pr/iter_${{ context.review_loop_count }}" @@ -1291,7 +1374,12 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:resolve-review ${{ context.merge_target }} ${{ inputs.base_branch }} mode=${{ context.review_mode }}", + "skill_command": "/autoskillit:resolve-review", + "skill_inputs": { + "base_branch": "${{ inputs.base_branch }}", + "feature_branch": "${{ context.merge_target }}", + "mode": "${{ context.review_mode }}" + }, "cwd": "${{ context.work_dir }}", "output_dir": ".", "step_name": "resolve_review" @@ -1355,7 +1443,12 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:resolve-merge-conflicts ${{ context.work_dir }} ${{ context.plan_path }} ${{ inputs.base_branch }}", + "skill_command": "/autoskillit:resolve-merge-conflicts", + "skill_inputs": { + "plan_path": "${{ context.plan_path }}", + "base_branch": "${{ inputs.base_branch }}", + "worktree_path": "${{ context.work_dir }}" + }, "cwd": "${{ context.work_dir }}", "output_dir": ".", "step_name": "resolve_pre_review_conflicts" @@ -1718,7 +1811,12 @@ "optional": true, "skip_when_false": "inputs.pipeline_health", "with": { - "skill_command": "/autoskillit:analyze-pipeline-health ${{ inputs.kitchen_id }} --dispatch-id=${{ inputs.dispatch_id }} --diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}", + "skill_command": "/autoskillit:analyze-pipeline-health", + "skill_inputs": { + "kitchen_id": "${{ inputs.kitchen_id }}", + "dispatch_id": "--dispatch-id=${{ inputs.dispatch_id }}", + "diagnostics_log_dir": "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" + }, "cwd": "${{ context.work_dir }}", "step_name": "analyze_pipeline_health" }, @@ -2017,7 +2115,12 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:resolve-merge-conflicts ${{ context.work_dir }} ${{ context.plan_path }} ${{ inputs.base_branch }}", + "skill_command": "/autoskillit:resolve-merge-conflicts", + "skill_inputs": { + "plan_path": "${{ context.plan_path }}", + "base_branch": "${{ inputs.base_branch }}", + "worktree_path": "${{ context.work_dir }}" + }, "cwd": "${{ context.work_dir }}", "output_dir": ".", "step_name": "resolve_queue_merge_conflicts" @@ -2269,7 +2372,12 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:resolve-merge-conflicts ${{ context.work_dir }} ${{ context.plan_path }} ${{ inputs.base_branch }}", + "skill_command": "/autoskillit:resolve-merge-conflicts", + "skill_inputs": { + "plan_path": "${{ context.plan_path }}", + "base_branch": "${{ inputs.base_branch }}", + "worktree_path": "${{ context.work_dir }}" + }, "cwd": "${{ context.work_dir }}", "output_dir": ".", "step_name": "resolve_direct_merge_conflicts" @@ -2381,7 +2489,12 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:resolve-merge-conflicts ${{ context.work_dir }} ${{ context.plan_path }} ${{ inputs.base_branch }}", + "skill_command": "/autoskillit:resolve-merge-conflicts", + "skill_inputs": { + "plan_path": "${{ context.plan_path }}", + "base_branch": "${{ inputs.base_branch }}", + "worktree_path": "${{ context.work_dir }}" + }, "cwd": "${{ context.work_dir }}", "output_dir": ".", "step_name": "resolve_immediate_merge_conflicts" @@ -2434,11 +2547,15 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:diagnose-ci ${{ context.merge_target }} - - - ${{ context.ci_event }}", + "skill_command": "/autoskillit:diagnose-ci", + "skill_inputs": { + "ci_failed_jobs": "${{ context.ci_failed_jobs }}", + "event": "${{ context.ci_event }}", + "branch": "${{ context.merge_target }}" + }, "cwd": "${{ context.work_dir }}", "step_name": "diagnose_ci", - "ci_failed_jobs": "${{ context.ci_failed_jobs }}", - "output_dir": "{{AUTOSKILLIT_TEMP}}/diagnose-ci" + "output_dir": "{{AUTOSKILLIT_TEMP}}/diagnose-ci/rebase_${{ context.ci_rebase_count }}_flake_${{ context.ci_flake_count }}" }, "capture": { "diagnosis_path": "${{ result.diagnosis_path }}" @@ -2448,6 +2565,10 @@ "on_context_limit": "resolve_ci", "on_rate_limit": "resolve_ci", "optional": true, + "optional_context_refs": [ + "ci_rebase_count", + "ci_flake_count" + ], "skip_when_false": "inputs.open_pr", "note": "Runs when ci_watch reports a CI failure. Fetches CI logs via gh API and writes a structured diagnosis to {{AUTOSKILLIT_TEMP}}/diagnose-ci/. Routes to resolve_ci on success OR failure. If diagnosis fails, resolve_ci proceeds without diagnosis context. diagnosis_path is passed to resolve_ci for targeted remediation context.\n" }, @@ -2455,7 +2576,15 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:resolve-failures ${{ context.work_dir }} ${{ context.plan_path }} ${{ inputs.base_branch }} ${{ context.ci_conclusion }} ${{ context.ci_failed_jobs }} ${{ context.diagnosis_path }}", + "skill_command": "/autoskillit:resolve-failures", + "skill_inputs": { + "base_branch": "${{ inputs.base_branch }}", + "diagnosis_path": "${{ context.diagnosis_path }}", + "ci_failed_jobs": "${{ context.ci_failed_jobs }}", + "ci_conclusion": "${{ context.ci_conclusion }}", + "plan_path": "${{ context.plan_path }}", + "worktree_path": "${{ context.work_dir }}" + }, "cwd": "${{ context.work_dir }}", "output_dir": ".", "step_name": "resolve_ci" @@ -2522,7 +2651,12 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:resolve-merge-conflicts ${{ context.work_dir }} ${{ context.plan_path }} ${{ inputs.base_branch }}", + "skill_command": "/autoskillit:resolve-merge-conflicts", + "skill_inputs": { + "plan_path": "${{ context.plan_path }}", + "base_branch": "${{ inputs.base_branch }}", + "worktree_path": "${{ context.work_dir }}" + }, "cwd": "${{ context.work_dir }}", "output_dir": ".", "step_name": "resolve_pre_resolve_conflicts" @@ -2626,7 +2760,12 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:resolve-merge-conflicts ${{ context.work_dir }} ${{ context.plan_path }} ${{ inputs.base_branch }}", + "skill_command": "/autoskillit:resolve-merge-conflicts", + "skill_inputs": { + "plan_path": "${{ context.plan_path }}", + "base_branch": "${{ inputs.base_branch }}", + "worktree_path": "${{ context.work_dir }}" + }, "cwd": "${{ context.work_dir }}", "output_dir": ".", "step_name": "ci_conflict_fix" @@ -2699,7 +2838,12 @@ "optional": true, "skip_when_false": "inputs.pipeline_health", "with": { - "skill_command": "/autoskillit:analyze-pipeline-health ${{ inputs.kitchen_id }} --dispatch-id=${{ inputs.dispatch_id }} --diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}", + "skill_command": "/autoskillit:analyze-pipeline-health", + "skill_inputs": { + "kitchen_id": "${{ inputs.kitchen_id }}", + "dispatch_id": "--dispatch-id=${{ inputs.dispatch_id }}", + "diagnostics_log_dir": "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" + }, "cwd": "${{ context.work_dir }}", "step_name": "analyze_pipeline_health" }, @@ -2755,7 +2899,12 @@ "optional": true, "skip_when_false": "inputs.pipeline_health", "with": { - "skill_command": "/autoskillit:analyze-pipeline-health ${{ inputs.kitchen_id }} --dispatch-id=${{ inputs.dispatch_id }} --diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}", + "skill_command": "/autoskillit:analyze-pipeline-health", + "skill_inputs": { + "kitchen_id": "${{ inputs.kitchen_id }}", + "dispatch_id": "--dispatch-id=${{ inputs.dispatch_id }}", + "diagnostics_log_dir": "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" + }, "cwd": "${{ context.work_dir }}", "step_name": "analyze_pipeline_health" }, @@ -2773,7 +2922,12 @@ "optional": true, "skip_when_false": "inputs.pipeline_health", "with": { - "skill_command": "/autoskillit:analyze-pipeline-health ${{ inputs.kitchen_id }} --dispatch-id=${{ inputs.dispatch_id }} --diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}", + "skill_command": "/autoskillit:analyze-pipeline-health", + "skill_inputs": { + "kitchen_id": "${{ inputs.kitchen_id }}", + "dispatch_id": "--dispatch-id=${{ inputs.dispatch_id }}", + "diagnostics_log_dir": "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" + }, "cwd": "${{ context.work_dir }}", "step_name": "analyze_pipeline_health" }, @@ -2802,7 +2956,12 @@ "optional": true, "skip_when_false": "inputs.pipeline_health", "with": { - "skill_command": "/autoskillit:analyze-pipeline-health ${{ inputs.kitchen_id }} --dispatch-id=${{ inputs.dispatch_id }} --diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}", + "skill_command": "/autoskillit:analyze-pipeline-health", + "skill_inputs": { + "kitchen_id": "${{ inputs.kitchen_id }}", + "dispatch_id": "--dispatch-id=${{ inputs.dispatch_id }}", + "diagnostics_log_dir": "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" + }, "cwd": "${{ context.work_dir }}", "step_name": "analyze_pipeline_health" }, @@ -2831,7 +2990,12 @@ "optional": true, "skip_when_false": "inputs.pipeline_health", "with": { - "skill_command": "/autoskillit:analyze-pipeline-health ${{ inputs.kitchen_id }} --dispatch-id=${{ inputs.dispatch_id }} --diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}", + "skill_command": "/autoskillit:analyze-pipeline-health", + "skill_inputs": { + "kitchen_id": "${{ inputs.kitchen_id }}", + "dispatch_id": "--dispatch-id=${{ inputs.dispatch_id }}", + "diagnostics_log_dir": "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" + }, "cwd": "${{ context.work_dir }}", "step_name": "analyze_pipeline_health" }, diff --git a/src/autoskillit/recipes/implementation.yaml b/src/autoskillit/recipes/implementation.yaml index 70774adde..c93294981 100644 --- a/src/autoskillit/recipes/implementation.yaml +++ b/src/autoskillit/recipes/implementation.yaml @@ -231,58 +231,50 @@ steps: tool: run_skill model: '' with: - skill_command: /autoskillit:make-plan ${{ inputs.task }} + skill_command: "/autoskillit:make-plan" + skill_inputs: + adversarial_review_level: "${{ inputs.adversarial_review_level }}" + task: "${{ inputs.task }}" + issue_url: "${{ inputs.issue_url }}" + audit_cycle_path: "${{ context.audit_cycle_path }}" cwd: ${{ context.work_dir }} step_name: plan - issue_url: ${{ inputs.issue_url }} - issue_title: ${{ context.issue_title }} - adversarial_review_level: ${{ inputs.adversarial_review_level }} output_dir: '.' capture: plan_path: ${{ result.plan_path }} all_plan_paths: ${{ result.plan_path }} verdict: ${{ result.verdict }} + plan_disposition_path: ${{ result.plan_disposition_path }} capture_list: plan_parts: ${{ result.plan_parts }} retries: 0 on_result: - when: ${{ result.verdict }} == plan route: review_approach - - when: ${{ result.verdict }} == false_positive - route: check_has_commits on_failure: release_issue_failure on_rate_limit: release_issue_failure on_context_limit: salvage_plan - note: 'Produces plan in {{AUTOSKILLIT_TEMP}}/make-plan/. Glob plan_dir for *_part_*.md - or single plan file. plan_parts context key contains the full ordered list of - part files (sorted alphabetically). For a single-part plan, plan_parts has one - entry equal to plan_path. REMEDIATION MODE: context.remediation_path is available - when the orchestrator re-enters plan from the remediate step; pass it inline - in the skill_command rather than as a named with: key. When in remediation mode, - also append audit_remediation_mode=true to the skill_command. ACCUMULATION: all_plan_paths - accumulates across any re-entries to the plan step (e.g. remediation). For a - single-pass run, all_plan_paths equals plan_path (the capture block handles - this automatically). When verdict=false_positive, do NOT overwrite all_plan_paths - — preserve the existing accumulated value from prior plan executions. ISSUE - CONTEXT: If inputs.issue_url is a non-empty string, append it to the skill_command - so the make-plan skill can detect and fetch the issue itself: "/autoskillit:make-plan - {task}\n\nGitHub Issue: {inputs.issue_url}" The skill detects the URL pattern - and calls fetch_github_issue internally. ADVERSARIAL REVIEW LEVEL: If inputs.adversarial_review_level - is a non-empty string, append it to the skill_command: "/autoskillit:make-plan - {task}\n\nadversarial_review_level={inputs.adversarial_review_level}" VERDICT: - The skill emits verdict=plan (normal) or verdict=false_positive (remediation - finding is a false positive). false_positive skips implement and routes to check_has_commits. - - ' + optional_context_refs: + - audit_cycle_path + note: 'Produces an ordinary plan when no audit_cycle_path is present. On NO GO + re-entry, consumes that exact current authority and emits both plan_path and + plan_disposition_path. all_plan_paths appends result.plan_path across re-entries; + verdict=plan is the only successful result. Glob plan_dir for *_part_*.md or + a single plan file and preserve alphabetical part order.' salvage_plan: tool: run_python with: callable: autoskillit.recipe._cmd_rpc.verify_plan_artifacts plan_parts: ${{ context.plan_parts }} + audit_cycle_path: ${{ context.audit_cycle_path }} capture: plan_path: ${{ result.plan_path }} + plan_disposition_path: ${{ result.plan_disposition_path }} + all_plan_paths: ${{ result.plan_path }} capture_list: plan_parts: ${{ result.plan_parts }} + optional_context_refs: + - audit_cycle_path retries: 0 on_result: - when: ${{ result.verdict }} == salvaged @@ -299,10 +291,12 @@ steps: tool: run_skill model: '' with: - skill_command: /autoskillit:review-approach ${{ context.plan_path }} + skill_command: "/autoskillit:review-approach" + skill_inputs: + plan_path: "${{ context.plan_path }}" cwd: ${{ context.work_dir }} step_name: review_approach - output_dir: '{{AUTOSKILLIT_TEMP}}/review-approach' + output_dir: '{{AUTOSKILLIT_TEMP}}/review-approach/audit_${{ context.audit_remediation_count }}' capture: review_path: ${{ result.review_path }} retries: 1 @@ -311,23 +305,30 @@ steps: on_context_limit: verify on_rate_limit: verify skip_when_false: inputs.review_approach + optional_context_refs: + - audit_remediation_count note: Only execute this step if review_approach input is 'true'. Otherwise skip directly to verify. verify: tool: run_skill model: '' with: - skill_command: /autoskillit:dry-walkthrough ${{ context.plan_path }} + skill_command: "/autoskillit:dry-walkthrough" + skill_inputs: + plan_path: "${{ context.plan_path }}" + issue_url: "${{ inputs.issue_url }}" + review_path: "${{ context.review_path }}" + audit_cycle_path: "${{ context.audit_cycle_path }}" + plan_disposition_path: "${{ context.plan_disposition_path }}" cwd: ${{ context.work_dir }} step_name: verify - review_path: ${{ context.review_path }} - issue_url: ${{ inputs.issue_url }} output_dir: '{{AUTOSKILLIT_TEMP}}' - remediation_path: ${{ context.remediation_path }} on_success: create_impl_worktree on_failure: release_issue_failure optional_context_refs: - - remediation_path + - review_path + - audit_cycle_path + - plan_disposition_path note: 'SEQUENTIAL EXECUTION: For each plan_part, run the FULL cycle — verify → implement → test → merge — for that part before advancing to the next part. Never run verify for all parts first. If review produced a report, context.review_path @@ -349,8 +350,9 @@ steps: model: '' stale_threshold: 2400 with: - skill_command: /autoskillit:implement-worktree-no-merge ${{ context.plan_path - }} + skill_command: "/autoskillit:implement-worktree-no-merge" + skill_inputs: + plan_path: "${{ context.plan_path }}" cwd: ${{ context.worktree_path }} step_name: implement output_dir: '.' @@ -370,8 +372,10 @@ steps: model: '' stale_threshold: 2400 with: - skill_command: /autoskillit:retry-worktree ${{ context.plan_path }} ${{ context.worktree_path - }} + skill_command: "/autoskillit:retry-worktree" + skill_inputs: + plan_path: "${{ context.plan_path }}" + worktree_path: "${{ context.worktree_path }}" cwd: ${{ context.worktree_path }} step_name: retry_worktree output_dir: '.' @@ -753,9 +757,13 @@ steps: tool: run_skill model: '' with: - skill_command: /autoskillit:resolve-failures ${{ context.worktree_path }} ${{ - context.plan_path }} ${{ inputs.base_branch }} ${{ context.merge_gate_ci_conclusion - }} - ${{ context.merge_gate_diagnosis_path }} + skill_command: "/autoskillit:resolve-failures" + skill_inputs: + base_branch: "${{ inputs.base_branch }}" + diagnosis_path: "${{ context.merge_gate_diagnosis_path }}" + ci_conclusion: "${{ context.merge_gate_ci_conclusion }}" + plan_path: "${{ context.plan_path }}" + worktree_path: "${{ context.worktree_path }}" cwd: ${{ context.worktree_path }} step_name: fix output_dir: '.' @@ -810,9 +818,13 @@ steps: tool: run_skill model: '' with: - skill_command: /autoskillit:resolve-failures ${{ context.worktree_path }} ${{ - context.plan_path }} ${{ inputs.base_branch }} ${{ context.merge_gate_ci_conclusion - }} - ${{ context.merge_gate_diagnosis_path }} + skill_command: "/autoskillit:resolve-failures" + skill_inputs: + base_branch: "${{ inputs.base_branch }}" + diagnosis_path: "${{ context.merge_gate_diagnosis_path }}" + ci_conclusion: "${{ context.merge_gate_ci_conclusion }}" + plan_path: "${{ context.plan_path }}" + worktree_path: "${{ context.worktree_path }}" cwd: ${{ context.worktree_path }} step_name: merge_gate_fix output_dir: '.' @@ -875,8 +887,11 @@ steps: tool: run_skill model: '' with: - skill_command: /autoskillit:resolve-merge-conflicts ${{ context.worktree_path - }} ${{ context.plan_path }} ${{ inputs.base_branch }} + skill_command: "/autoskillit:resolve-merge-conflicts" + skill_inputs: + plan_path: "${{ context.plan_path }}" + base_branch: "${{ inputs.base_branch }}" + worktree_path: "${{ context.worktree_path }}" cwd: ${{ context.worktree_path }} step_name: rebase_conflict_fix output_dir: '.' @@ -924,9 +939,13 @@ steps: tool: run_skill model: '' with: - skill_command: /autoskillit:audit-impl ${{ context.all_plan_paths }} ${{ context.base_sha - }} ${{ inputs.base_branch }} deviation_manifest_path=${{ context.deviation_manifest_path - }} + skill_command: "/autoskillit:audit-impl" + skill_inputs: + all_plan_paths: "${{ context.all_plan_paths }}" + deviation_manifest_path: "${{ context.deviation_manifest_path }}" + branch_name: "${{ context.base_sha }}" + base_branch: "${{ inputs.base_branch }}" + prior_audit_cycle_path: "${{ context.audit_cycle_path }}" cwd: ${{ context.work_dir }} step_name: audit_impl output_dir: '{{AUTOSKILLIT_TEMP}}/audit-impl' @@ -938,6 +957,7 @@ steps: closure_target_sha: "" capture: remediation_path: ${{ result.remediation_path }} + audit_cycle_path: ${{ result.audit_cycle_path }} on_result: - when: ${{ result.verdict }} == GO route: check_has_commits @@ -953,28 +973,27 @@ steps: skip_when_false: inputs.audit_impl optional_context_refs: - deviation_manifest_path - note: 'Only execute if inputs.audit_impl is ''true''. If false, skip directly to check_has_commits. - Captures verdict and remediation_path. Routes GO → push, NO GO → remediate which - re-enters the plan step with the remediation file. All parts have been merged - at this point — no worktree exists. Uses context.base_sha (a stable commit SHA - captured before any merge began) as implementation_ref. A SHA names a git object - and survives git branch -D unconditionally. HEAD points to merge_target (the - feature branch tip when open_pr=true, or base_branch when open_pr=false). The - diff git diff base_sha..HEAD covers the full implementation. - - ' + - audit_cycle_path + note: 'Captures immutable audit_cycle_path on GO and NO GO. NO GO re-enters + planning with that exact authority; a successor audit receives it as + prior_audit_cycle_path. GO replaces the prior cycle before completion.' remediate: action: route with: - remediation_path: ${{ context.remediation_path }} + audit_cycle_path: ${{ context.audit_cycle_path }} on_success: plan prepare_pr: tool: run_skill model: '' retries: 1 with: - skill_command: /autoskillit:prepare-pr ${{ context.all_plan_paths }} ${{ inputs.run_name - }} ${{ inputs.base_branch }} ${{ context.issue_number }} ${{ inputs.arch_lenses }} + skill_command: "/autoskillit:prepare-pr" + skill_inputs: + arch_lenses: "${{ inputs.arch_lenses }}" + plan_paths: "${{ context.all_plan_paths }}" + closing_issue: "${{ context.issue_number }}" + base_branch: "${{ inputs.base_branch }}" + run_name: "${{ inputs.run_name }}" cwd: ${{ context.work_dir }} output_dir: '.' step_name: prepare_pr @@ -1003,7 +1022,9 @@ steps: tool: run_skill model: '' with: - skill_command: /autoskillit:arch-lens-{slug} {context_path} + skill_command: "/autoskillit:arch-lens-{slug}" + skill_inputs: + context_path: "{context_path}" cwd: ${{ context.work_dir }} step_name: run_arch_lenses capture_list: @@ -1027,9 +1048,13 @@ steps: tool: run_skill model: '' with: - skill_command: /autoskillit:compose-pr ${{ context.prep_path }} "${{ context.all_diagram_paths - }}" ${{ context.work_dir }} ${{ inputs.base_branch }} ${{ context.issue_number - }} + skill_command: "/autoskillit:compose-pr" + skill_inputs: + work_dir: "${{ context.work_dir }}" + closing_issue: "${{ context.issue_number }}" + all_diagram_paths: "${{ context.all_diagram_paths }}" + base_branch: "${{ inputs.base_branch }}" + prep_path: "${{ context.prep_path }}" cwd: ${{ context.work_dir }} output_dir: '.' step_name: compose_pr @@ -1120,11 +1145,16 @@ steps: tool: run_skill model: '' with: - skill_command: /autoskillit:review-pr ${{ context.merge_target }} ${{ inputs.base_branch - }} annotated_diff_path=${{ context.annotated_diff_path }} hunk_ranges_path=${{ - context.hunk_ranges_path }} valid_lines_path=${{ context.valid_lines_path }} - diff_metrics_path=${{ context.diff_metrics_path - }} mode=${{ context.review_mode }} pr_head_sha=${{ context.pr_head_sha }} + skill_command: "/autoskillit:review-pr" + skill_inputs: + feature_branch: "${{ context.merge_target }}" + base_branch: "${{ inputs.base_branch }}" + annotated_diff_path: "${{ context.annotated_diff_path }}" + hunk_ranges_path: "${{ context.hunk_ranges_path }}" + valid_lines_path: "${{ context.valid_lines_path }}" + diff_metrics_path: "${{ context.diff_metrics_path }}" + mode: "${{ context.review_mode }}" + pr_head_sha: "${{ context.pr_head_sha }}" cwd: ${{ context.work_dir }} step_name: review_pr output_dir: '{{AUTOSKILLIT_TEMP}}/review-pr/iter_${{ context.review_loop_count }}' @@ -1181,8 +1211,11 @@ steps: tool: run_skill model: '' with: - skill_command: /autoskillit:resolve-review ${{ context.merge_target }} ${{ inputs.base_branch - }} mode=${{ context.review_mode }} + skill_command: "/autoskillit:resolve-review" + skill_inputs: + base_branch: "${{ inputs.base_branch }}" + feature_branch: "${{ context.merge_target }}" + mode: "${{ context.review_mode }}" cwd: ${{ context.work_dir }} output_dir: '.' step_name: resolve_review @@ -1231,8 +1264,11 @@ steps: tool: run_skill model: '' with: - skill_command: /autoskillit:resolve-merge-conflicts ${{ context.work_dir }} - ${{ context.plan_path }} ${{ inputs.base_branch }} + skill_command: "/autoskillit:resolve-merge-conflicts" + skill_inputs: + plan_path: "${{ context.plan_path }}" + base_branch: "${{ inputs.base_branch }}" + worktree_path: "${{ context.work_dir }}" cwd: ${{ context.work_dir }} output_dir: '.' step_name: resolve_pre_review_conflicts @@ -1558,9 +1594,11 @@ steps: optional: true skip_when_false: inputs.pipeline_health with: - skill_command: /autoskillit:analyze-pipeline-health ${{ inputs.kitchen_id }} - --dispatch-id=${{ inputs.dispatch_id }} --diagnostics-log-dir=${{ inputs.diagnostics_log_dir - }} + skill_command: "/autoskillit:analyze-pipeline-health" + skill_inputs: + kitchen_id: "${{ inputs.kitchen_id }}" + dispatch_id: "--dispatch-id=${{ inputs.dispatch_id }}" + diagnostics_log_dir: "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" cwd: ${{ context.work_dir }} step_name: analyze_pipeline_health on_success: escalate_stop_no_ci @@ -1825,8 +1863,11 @@ steps: tool: run_skill model: '' with: - skill_command: /autoskillit:resolve-merge-conflicts ${{ context.work_dir }} - ${{ context.plan_path }} ${{ inputs.base_branch }} + skill_command: "/autoskillit:resolve-merge-conflicts" + skill_inputs: + plan_path: "${{ context.plan_path }}" + base_branch: "${{ inputs.base_branch }}" + worktree_path: "${{ context.work_dir }}" cwd: ${{ context.work_dir }} output_dir: '.' step_name: resolve_queue_merge_conflicts @@ -2031,8 +2072,11 @@ steps: tool: run_skill model: '' with: - skill_command: /autoskillit:resolve-merge-conflicts ${{ context.work_dir }} - ${{ context.plan_path }} ${{ inputs.base_branch }} + skill_command: "/autoskillit:resolve-merge-conflicts" + skill_inputs: + plan_path: "${{ context.plan_path }}" + base_branch: "${{ inputs.base_branch }}" + worktree_path: "${{ context.work_dir }}" cwd: ${{ context.work_dir }} output_dir: '.' step_name: resolve_direct_merge_conflicts @@ -2137,8 +2181,11 @@ steps: tool: run_skill model: '' with: - skill_command: /autoskillit:resolve-merge-conflicts ${{ context.work_dir }} - ${{ context.plan_path }} ${{ inputs.base_branch }} + skill_command: "/autoskillit:resolve-merge-conflicts" + skill_inputs: + plan_path: "${{ context.plan_path }}" + base_branch: "${{ inputs.base_branch }}" + worktree_path: "${{ context.work_dir }}" cwd: ${{ context.work_dir }} output_dir: '.' step_name: resolve_immediate_merge_conflicts @@ -2187,12 +2234,14 @@ steps: tool: run_skill model: '' with: - skill_command: /autoskillit:diagnose-ci ${{ context.merge_target }} - - - ${{ - context.ci_event }} + skill_command: "/autoskillit:diagnose-ci" + skill_inputs: + ci_failed_jobs: "${{ context.ci_failed_jobs }}" + event: "${{ context.ci_event }}" + branch: "${{ context.merge_target }}" cwd: ${{ context.work_dir }} step_name: diagnose_ci - ci_failed_jobs: ${{ context.ci_failed_jobs }} - output_dir: '{{AUTOSKILLIT_TEMP}}/diagnose-ci' + output_dir: '{{AUTOSKILLIT_TEMP}}/diagnose-ci/rebase_${{ context.ci_rebase_count }}_flake_${{ context.ci_flake_count }}' capture: diagnosis_path: ${{ result.diagnosis_path }} on_success: resolve_ci @@ -2200,6 +2249,9 @@ steps: on_context_limit: resolve_ci on_rate_limit: resolve_ci optional: true + optional_context_refs: + - ci_rebase_count + - ci_flake_count skip_when_false: inputs.open_pr note: 'Runs when ci_watch reports a CI failure. Fetches CI logs via gh API and writes a structured diagnosis to {{AUTOSKILLIT_TEMP}}/diagnose-ci/. Routes to @@ -2212,9 +2264,14 @@ steps: tool: run_skill model: '' with: - skill_command: /autoskillit:resolve-failures ${{ context.work_dir }} ${{ context.plan_path - }} ${{ inputs.base_branch }} ${{ context.ci_conclusion }} ${{ context.ci_failed_jobs - }} ${{ context.diagnosis_path }} + skill_command: "/autoskillit:resolve-failures" + skill_inputs: + base_branch: "${{ inputs.base_branch }}" + diagnosis_path: "${{ context.diagnosis_path }}" + ci_failed_jobs: "${{ context.ci_failed_jobs }}" + ci_conclusion: "${{ context.ci_conclusion }}" + plan_path: "${{ context.plan_path }}" + worktree_path: "${{ context.work_dir }}" cwd: ${{ context.work_dir }} output_dir: '.' step_name: resolve_ci @@ -2239,7 +2296,6 @@ steps: route: release_issue_failure - route: release_issue_failure on_failure: release_issue_failure - skip_when_false: inputs.backend_supports_git_write note: 'Runs when ci_watch reports a CI failure. Receives ci_failed_jobs from wait_for_ci structured output to avoid redundant CI failure re-investigation. Routes via on_result: verdict dispatch — real_fix pushes, already_green rebases and re-diagnoses @@ -2272,8 +2328,11 @@ steps: tool: run_skill model: '' with: - skill_command: /autoskillit:resolve-merge-conflicts ${{ context.work_dir }} - ${{ context.plan_path }} ${{ inputs.base_branch }} + skill_command: "/autoskillit:resolve-merge-conflicts" + skill_inputs: + plan_path: "${{ context.plan_path }}" + base_branch: "${{ inputs.base_branch }}" + worktree_path: "${{ context.work_dir }}" cwd: ${{ context.work_dir }} output_dir: '.' step_name: resolve_pre_resolve_conflicts @@ -2290,7 +2349,6 @@ steps: on_rate_limit: diagnose_ci retries: 1 on_exhausted: diagnose_ci - skip_when_false: inputs.backend_supports_git_write check_ci_rebase_loop: tool: run_python with: @@ -2376,8 +2434,11 @@ steps: tool: run_skill model: '' with: - skill_command: /autoskillit:resolve-merge-conflicts ${{ context.work_dir }} - ${{ context.plan_path }} ${{ inputs.base_branch }} + skill_command: "/autoskillit:resolve-merge-conflicts" + skill_inputs: + plan_path: "${{ context.plan_path }}" + base_branch: "${{ inputs.base_branch }}" + worktree_path: "${{ context.work_dir }}" cwd: ${{ context.work_dir }} output_dir: '.' step_name: ci_conflict_fix @@ -2461,9 +2522,11 @@ steps: optional: true skip_when_false: inputs.pipeline_health with: - skill_command: /autoskillit:analyze-pipeline-health ${{ inputs.kitchen_id }} - --dispatch-id=${{ inputs.dispatch_id }} --diagnostics-log-dir=${{ inputs.diagnostics_log_dir - }} + skill_command: "/autoskillit:analyze-pipeline-health" + skill_inputs: + kitchen_id: "${{ inputs.kitchen_id }}" + dispatch_id: "--dispatch-id=${{ inputs.dispatch_id }}" + diagnostics_log_dir: "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" cwd: ${{ context.work_dir }} step_name: analyze_pipeline_health on_success: done_unconfirmed @@ -2518,9 +2581,11 @@ steps: optional: true skip_when_false: inputs.pipeline_health with: - skill_command: /autoskillit:analyze-pipeline-health ${{ inputs.kitchen_id }} - --dispatch-id=${{ inputs.dispatch_id }} --diagnostics-log-dir=${{ inputs.diagnostics_log_dir - }} + skill_command: "/autoskillit:analyze-pipeline-health" + skill_inputs: + kitchen_id: "${{ inputs.kitchen_id }}" + dispatch_id: "--dispatch-id=${{ inputs.dispatch_id }}" + diagnostics_log_dir: "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" cwd: ${{ context.work_dir }} step_name: analyze_pipeline_health on_success: done @@ -2539,9 +2604,11 @@ steps: optional: true skip_when_false: inputs.pipeline_health with: - skill_command: /autoskillit:analyze-pipeline-health ${{ inputs.kitchen_id }} - --dispatch-id=${{ inputs.dispatch_id }} --diagnostics-log-dir=${{ inputs.diagnostics_log_dir - }} + skill_command: "/autoskillit:analyze-pipeline-health" + skill_inputs: + kitchen_id: "${{ inputs.kitchen_id }}" + dispatch_id: "--dispatch-id=${{ inputs.dispatch_id }}" + diagnostics_log_dir: "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" cwd: ${{ context.work_dir }} step_name: analyze_pipeline_health on_success: escalate_stop @@ -2569,9 +2636,11 @@ steps: optional: true skip_when_false: inputs.pipeline_health with: - skill_command: /autoskillit:analyze-pipeline-health ${{ inputs.kitchen_id }} - --dispatch-id=${{ inputs.dispatch_id }} --diagnostics-log-dir=${{ inputs.diagnostics_log_dir - }} + skill_command: "/autoskillit:analyze-pipeline-health" + skill_inputs: + kitchen_id: "${{ inputs.kitchen_id }}" + dispatch_id: "--dispatch-id=${{ inputs.dispatch_id }}" + diagnostics_log_dir: "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" cwd: ${{ context.work_dir }} step_name: analyze_pipeline_health on_success: done_no_changes @@ -2599,9 +2668,11 @@ steps: optional: true skip_when_false: inputs.pipeline_health with: - skill_command: /autoskillit:analyze-pipeline-health ${{ inputs.kitchen_id }} - --dispatch-id=${{ inputs.dispatch_id }} --diagnostics-log-dir=${{ inputs.diagnostics_log_dir - }} + skill_command: "/autoskillit:analyze-pipeline-health" + skill_inputs: + kitchen_id: "${{ inputs.kitchen_id }}" + dispatch_id: "--dispatch-id=${{ inputs.dispatch_id }}" + diagnostics_log_dir: "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" cwd: ${{ context.work_dir }} step_name: analyze_pipeline_health on_success: done_already_done diff --git a/src/autoskillit/recipes/merge-prs.json b/src/autoskillit/recipes/merge-prs.json index 559c9db1f..0c8b0a792 100644 --- a/src/autoskillit/recipes/merge-prs.json +++ b/src/autoskillit/recipes/merge-prs.json @@ -185,7 +185,11 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:analyze-prs ${{ inputs.base_branch }} merge_queue_data_path=${{ context.merge_queue_data_path }}", + "skill_command": "/autoskillit:analyze-prs", + "skill_inputs": { + "base_branch": "${{ inputs.base_branch }}", + "merge_queue_data_path": "${{ context.merge_queue_data_path }}" + }, "cwd": "${{ context.work_dir }}", "step_name": "analyze_prs", "output_dir": "{{AUTOSKILLIT_TEMP}}/analyze-prs" @@ -448,7 +452,12 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:resolve-merge-conflicts '${{ context.ejected_pr_branch }}' '${{ inputs.base_branch }}'", + "skill_command": "/autoskillit:resolve-merge-conflicts", + "skill_inputs": { + "worktree_path": "${{ context.ejected_pr_branch }}", + "plan_path": "${{ context.pr_plan_path }}", + "base_branch": "${{ inputs.base_branch }}" + }, "cwd": "${{ context.work_dir }}", "output_dir": ".", "step_name": "resolve_ejected_conflicts" @@ -459,6 +468,9 @@ "capture_list": { "all_conflict_report_paths": "${{ result.conflict_report_path }}" }, + "optional_context_refs": [ + "pr_plan_path" + ], "on_result": [ { "when": "${{ result.escalation_required }} == true", @@ -753,7 +765,12 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:resolve-merge-conflicts '${{ context.next_pr_branch }}' '${{ inputs.base_branch }}'", + "skill_command": "/autoskillit:resolve-merge-conflicts", + "skill_inputs": { + "worktree_path": "${{ context.next_pr_branch }}", + "plan_path": "${{ context.pr_plan_path }}", + "base_branch": "${{ inputs.base_branch }}" + }, "cwd": "${{ context.work_dir }}", "output_dir": ".", "step_name": "resolve_proactive_rebase_conflicts" @@ -765,6 +782,9 @@ "capture_list": { "all_conflict_report_paths": "${{ result.conflict_report_path }}" }, + "optional_context_refs": [ + "pr_plan_path" + ], "on_result": [ { "when": "${{ result.escalation_required }} == true", @@ -798,7 +818,11 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:diagnose-ci '${{ context.current_pr_number }}' - - - ${{ context.ci_event }}", + "skill_command": "/autoskillit:diagnose-ci", + "skill_inputs": { + "event": "${{ context.ci_event }}", + "branch": "${{ context.current_pr_number }}" + }, "cwd": "${{ context.work_dir }}", "step_name": "diagnose_queue_ci", "output_dir": "{{AUTOSKILLIT_TEMP}}/diagnose-ci" @@ -866,7 +890,7 @@ "remote_url": "${{ context.remote_url }}", "branch": "${{ context.batch_branch }}" }, - "on_success": "merge_pr", + "on_success": "check_pr_merge_loop", "on_failure": "register_clone_failure", "note": "Publishes the integration branch to origin immediately after creation so that merge_worktree can verify refs/remotes/origin/batch_branch exists before rebasing. The branch is re-pushed at push_batch_branch after all merges complete to update the remote with accumulated merge commits.\n" }, @@ -874,12 +898,18 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:merge-pr {pr_number} {complexity}", + "skill_command": "/autoskillit:merge-pr", + "skill_inputs": { + "complexity": "{complexity}", + "pr_number": "{pr_number}" + }, "cwd": "${{ context.work_dir }}", "step_name": "merge_pr", - "pr_order_file": "${{ context.pr_order_file }}", - "output_dir": "{{AUTOSKILLIT_TEMP}}/merge-pr" + "output_dir": "{{AUTOSKILLIT_TEMP}}/merge-prs/iter_${{ context.pr_merge_loop_count }}" }, + "optional_context_refs": [ + "pr_merge_loop_count" + ], "capture": { "needs_plan": "${{ result.needs_plan }}", "escalation_required": "${{ result.escalation_required }}", @@ -916,14 +946,19 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:make-plan ${{ context.task }}", + "skill_command": "/autoskillit:make-plan", + "skill_inputs": { + "task": "${{ context.task }}", + "audit_cycle_path": "${{ context.audit_cycle_path }}" + }, "cwd": "${{ context.work_dir }}", "step_name": "plan", "output_dir": "." }, "capture": { "pr_plan_path": "${{ result.plan_path }}", - "verdict": "${{ result.verdict }}" + "verdict": "${{ result.verdict }}", + "plan_disposition_path": "${{ result.plan_disposition_path }}" }, "capture_list": { "plan_parts": "${{ result.plan_parts }}", @@ -934,29 +969,34 @@ { "when": "${{ result.verdict }} == plan", "route": "verify" - }, - { - "when": "${{ result.verdict }} == false_positive", - "route": "verify" } ], "on_failure": "register_clone_failure", "on_rate_limit": "register_clone_failure", "on_context_limit": "salvage_plan", - "note": "Glob plan_dir for *_part_*.md or single plan file. Sort alphabetically into plan_parts[]. For a single-part plan, plan_parts has one entry equal to plan_path. PRIMARY PATH: context.task = conflict_report_path from merge_pr. REMEDIATION MODE: context.remediation_path is set when re-entering from the remediate step; pass it inline in the skill_command instead of context.task.\n" + "note": "Glob plan_dir for *_part_*.md or single plan file. Sort alphabetically into plan_parts[]. For a single-part plan, plan_parts has one entry equal to plan_path. PRIMARY PATH: context.task = conflict_report_path from merge_pr. REMEDIATION MODE: consume the exact current audit_cycle_path and emit the matching plan_disposition_path; make-plan cannot close the NO GO lineage.\n", + "optional_context_refs": [ + "audit_cycle_path" + ] }, "salvage_plan": { "tool": "run_python", "with": { "callable": "autoskillit.recipe._cmd_rpc.verify_plan_artifacts", - "plan_parts": "${{ context.plan_parts }}" + "plan_parts": "${{ context.plan_parts }}", + "audit_cycle_path": "${{ context.audit_cycle_path }}" }, "capture": { - "pr_plan_path": "${{ result.plan_path }}" + "pr_plan_path": "${{ result.plan_path }}", + "plan_disposition_path": "${{ result.plan_disposition_path }}" }, "capture_list": { - "plan_parts": "${{ result.plan_parts }}" + "plan_parts": "${{ result.plan_parts }}", + "all_plan_paths": "${{ result.plan_path }}" }, + "optional_context_refs": [ + "audit_cycle_path" + ], "retries": 0, "on_result": [ { @@ -974,11 +1014,15 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:dry-walkthrough ${{ context.pr_plan_path }}", + "skill_command": "/autoskillit:dry-walkthrough", + "skill_inputs": { + "plan_path": "${{ context.pr_plan_path }}", + "audit_cycle_path": "${{ context.audit_cycle_path }}", + "plan_disposition_path": "${{ context.plan_disposition_path }}" + }, "cwd": "${{ context.work_dir }}", "step_name": "verify", - "output_dir": "{{AUTOSKILLIT_TEMP}}", - "remediation_path": "${{ context.remediation_path }}" + "output_dir": "{{AUTOSKILLIT_TEMP}}" }, "on_success": "implement", "on_failure": "register_clone_failure", @@ -987,7 +1031,8 @@ "on_context_limit": "register_clone_failure", "on_rate_limit": "register_clone_failure", "optional_context_refs": [ - "remediation_path" + "audit_cycle_path", + "plan_disposition_path" ], "note": "SEQUENTIAL: For each plan_part, run the full cycle (verify → implement → test → push_worktree_branch → create_conflict_pr → wait_for_conflict_ci → merge_conflict_pr) before advancing.\n" }, @@ -996,7 +1041,10 @@ "model": "", "stale_threshold": 2400, "with": { - "skill_command": "/autoskillit:implement-worktree-no-merge ${{ context.pr_plan_path }}", + "skill_command": "/autoskillit:implement-worktree-no-merge", + "skill_inputs": { + "plan_path": "${{ context.pr_plan_path }}" + }, "cwd": "${{ context.work_dir }}", "step_name": "implement", "output_dir": "." @@ -1018,7 +1066,11 @@ "model": "", "stale_threshold": 2400, "with": { - "skill_command": "/autoskillit:retry-worktree ${{ context.pr_plan_path }} ${{ context.worktree_path }}", + "skill_command": "/autoskillit:retry-worktree", + "skill_inputs": { + "plan_path": "${{ context.pr_plan_path }}", + "worktree_path": "${{ context.worktree_path }}" + }, "cwd": "${{ context.worktree_path }}", "step_name": "retry_worktree", "output_dir": "." @@ -1212,10 +1264,14 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:resolve-failures ${{ context.worktree_path }} ${{ context.pr_plan_path }} ${{ context.batch_branch }}", + "skill_command": "/autoskillit:resolve-failures", + "skill_inputs": { + "plan_path": "${{ context.pr_plan_path }}", + "base_branch": "${{ inputs.base_branch }}", + "worktree_path": "${{ context.worktree_path }}" + }, "cwd": "${{ context.worktree_path }}", "step_name": "fix", - "base_branch": "${{ inputs.base_branch }}", "output_dir": "." }, "capture": { @@ -1351,7 +1407,14 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:audit-impl \"${{ context.all_plan_paths }}\" ${{ context.batch_branch }} ${{ inputs.base_branch }} \"${{ context.all_conflict_report_paths }}\"", + "skill_command": "/autoskillit:audit-impl", + "skill_inputs": { + "all_plan_paths": "${{ context.all_plan_paths }}", + "all_conflict_report_paths": "${{ context.all_conflict_report_paths }}", + "branch_name": "${{ context.batch_branch }}", + "base_branch": "${{ inputs.base_branch }}", + "prior_audit_cycle_path": "${{ context.audit_cycle_path }}" + }, "cwd": "${{ context.work_dir }}", "step_name": "audit_impl", "output_dir": "{{AUTOSKILLIT_TEMP}}/audit-impl", @@ -1364,7 +1427,8 @@ }, "capture": { "verdict": "${{ result.verdict }}", - "remediation_path": "${{ result.remediation_path }}" + "remediation_path": "${{ result.remediation_path }}", + "audit_cycle_path": "${{ result.audit_cycle_path }}" }, "on_result": [ { @@ -1384,12 +1448,15 @@ "on_rate_limit": "register_clone_failure", "optional": true, "skip_when_false": "inputs.audit_impl", - "note": "DEPENDENCY: context.all_plan_paths is only populated if the plan step executed at least once. The check_impl_plans gate ensures this step is only reached when implementation plan files exist (i.e. make-plan was invoked), so all_plan_paths is guaranteed to be set when audit_impl runs. Gated by check_impl_plans — only reached when implementation plan files exist in plans_dir. Only execute if inputs.audit_impl is 'true'; if false, skip directly to open_integration_pr. Passes context.all_plan_paths (comma-separated list of plan file paths accumulated across the PR loop) as plans_input. audit-impl splits on commas and audits each plan file. implementation_ref is context.batch_branch (no worktree remains). GO → open_integration_pr. NO GO → remediate (re-enters plan with remediation file).\n" + "optional_context_refs": [ + "audit_cycle_path" + ], + "note": "DEPENDENCY: context.all_plan_paths is only populated if the plan step executed at least once. The check_impl_plans gate ensures this step is only reached when implementation plan files exist (i.e. make-plan was invoked), so all_plan_paths is guaranteed to be set when audit_impl runs. Gated by check_impl_plans — only reached when implementation plan files exist in plans_dir. Only execute if inputs.audit_impl is 'true'; if false, skip directly to open_integration_pr. Passes context.all_plan_paths (comma-separated list of plan file paths accumulated across the PR loop) as plans_input. audit-impl splits on commas and audits each plan file. implementation_ref is context.batch_branch (no worktree remains). GO → open_integration_pr. NO GO replaces the prior authority before completion. NO GO re-enters plan with the exact current authority and the successor audit consumes it as prior.\n" }, "remediate": { "action": "route", "with": { - "remediation_path": "${{ context.remediation_path }}" + "audit_cycle_path": "${{ context.audit_cycle_path }}" }, "on_success": "plan" }, @@ -1414,7 +1481,15 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:open-integration-pr ${{ context.batch_branch }} ${{ inputs.base_branch }} ${{ context.pr_order_file }} ${{ context.verdict }} \"${{ context.all_conflict_report_paths }}\" domain_partitions_path=${{ context.domain_partitions_path }}", + "skill_command": "/autoskillit:open-integration-pr", + "skill_inputs": { + "audit_verdict": "${{ context.verdict }}", + "batch_branch": "${{ context.batch_branch }}", + "domain_partitions_path": "${{ context.domain_partitions_path }}", + "base_branch": "${{ inputs.base_branch }}", + "conflict_report_paths": "${{ context.all_conflict_report_paths }}", + "pr_order_file": "${{ context.pr_order_file }}" + }, "cwd": "${{ context.work_dir }}", "step_name": "open_integration_pr", "output_dir": "{{AUTOSKILLIT_TEMP}}/open-integration-pr" @@ -1467,7 +1542,12 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:resolve-merge-conflicts ${{ context.batch_branch }} ${{ inputs.base_branch }}", + "skill_command": "/autoskillit:resolve-merge-conflicts", + "skill_inputs": { + "worktree_path": "${{ context.batch_branch }}", + "plan_path": "${{ context.pr_plan_path }}", + "base_branch": "${{ inputs.base_branch }}" + }, "cwd": "${{ context.work_dir }}", "step_name": "resolve_integration_conflicts", "output_dir": "." @@ -1554,7 +1634,16 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:review-pr ${{ context.batch_branch }} ${{ inputs.base_branch }} annotated_diff_path=${{ context.annotated_diff_path }} hunk_ranges_path=${{ context.hunk_ranges_path }} valid_lines_path=${{ context.valid_lines_path }} diff_metrics_path=${{ context.diff_metrics_path }} pr_head_sha=${{ context.pr_head_sha }}", + "skill_command": "/autoskillit:review-pr", + "skill_inputs": { + "feature_branch": "${{ context.batch_branch }}", + "base_branch": "${{ inputs.base_branch }}", + "annotated_diff_path": "${{ context.annotated_diff_path }}", + "hunk_ranges_path": "${{ context.hunk_ranges_path }}", + "valid_lines_path": "${{ context.valid_lines_path }}", + "diff_metrics_path": "${{ context.diff_metrics_path }}", + "pr_head_sha": "${{ context.pr_head_sha }}" + }, "cwd": "${{ context.work_dir }}", "step_name": "review_pr_integration", "output_dir": "{{AUTOSKILLIT_TEMP}}/review-pr" @@ -1616,7 +1705,11 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:resolve-review ${{ context.batch_branch }} ${{ inputs.base_branch }}", + "skill_command": "/autoskillit:resolve-review", + "skill_inputs": { + "base_branch": "${{ inputs.base_branch }}", + "feature_branch": "${{ context.batch_branch }}" + }, "cwd": "${{ context.work_dir }}", "output_dir": ".", "step_name": "resolve_review_integration" @@ -1677,7 +1770,12 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:resolve-merge-conflicts ${{ context.work_dir }} ${{ inputs.base_branch }}", + "skill_command": "/autoskillit:resolve-merge-conflicts", + "skill_inputs": { + "worktree_path": "${{ context.work_dir }}", + "plan_path": "${{ context.pr_plan_path }}", + "base_branch": "${{ inputs.base_branch }}" + }, "cwd": "${{ context.work_dir }}", "output_dir": ".", "step_name": "resolve_pre_review_integration_conflicts" @@ -1736,7 +1834,6 @@ "timeout_seconds": 600, "cwd": "${{ context.work_dir }}", "remote_url": "${{ context.remote_url }}", - "pr_url": "${{ context.pr_url }}", "auto_trigger": true }, "on_result": [ @@ -1784,7 +1881,11 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:diagnose-ci ${{ context.batch_branch }} - - - ${{ context.ci_event }}", + "skill_command": "/autoskillit:diagnose-ci", + "skill_inputs": { + "event": "${{ context.ci_event }}", + "branch": "${{ context.batch_branch }}" + }, "cwd": "${{ context.work_dir }}", "step_name": "diagnose_ci", "output_dir": "{{AUTOSKILLIT_TEMP}}/diagnose-ci" @@ -1840,7 +1941,12 @@ "optional": true, "skip_when_false": "inputs.pipeline_health", "with": { - "skill_command": "/autoskillit:analyze-pipeline-health ${{ inputs.kitchen_id }} --dispatch-id=${{ inputs.dispatch_id }} --diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}", + "skill_command": "/autoskillit:analyze-pipeline-health", + "skill_inputs": { + "kitchen_id": "${{ inputs.kitchen_id }}", + "dispatch_id": "--dispatch-id=${{ inputs.dispatch_id }}", + "diagnostics_log_dir": "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" + }, "cwd": "${{ context.work_dir }}", "step_name": "analyze_pipeline_health" }, @@ -1858,7 +1964,12 @@ "optional": true, "skip_when_false": "inputs.pipeline_health", "with": { - "skill_command": "/autoskillit:analyze-pipeline-health ${{ inputs.kitchen_id }} --dispatch-id=${{ inputs.dispatch_id }} --diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}", + "skill_command": "/autoskillit:analyze-pipeline-health", + "skill_inputs": { + "kitchen_id": "${{ inputs.kitchen_id }}", + "dispatch_id": "--dispatch-id=${{ inputs.dispatch_id }}", + "diagnostics_log_dir": "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" + }, "cwd": "${{ context.work_dir }}", "step_name": "analyze_pipeline_health" }, diff --git a/src/autoskillit/recipes/merge-prs.yaml b/src/autoskillit/recipes/merge-prs.yaml index b65d74f56..83c1e78e0 100644 --- a/src/autoskillit/recipes/merge-prs.yaml +++ b/src/autoskillit/recipes/merge-prs.yaml @@ -237,8 +237,10 @@ steps: tool: run_skill model: '' with: - skill_command: /autoskillit:analyze-prs ${{ inputs.base_branch }} merge_queue_data_path=${{ - context.merge_queue_data_path }} + skill_command: "/autoskillit:analyze-prs" + skill_inputs: + base_branch: "${{ inputs.base_branch }}" + merge_queue_data_path: "${{ context.merge_queue_data_path }}" cwd: ${{ context.work_dir }} step_name: analyze_prs output_dir: '{{AUTOSKILLIT_TEMP}}/analyze-prs' @@ -470,8 +472,11 @@ steps: tool: run_skill model: '' with: - skill_command: /autoskillit:resolve-merge-conflicts '${{ context.ejected_pr_branch - }}' '${{ inputs.base_branch }}' + skill_command: "/autoskillit:resolve-merge-conflicts" + skill_inputs: + worktree_path: "${{ context.ejected_pr_branch }}" + plan_path: "${{ context.pr_plan_path }}" + base_branch: "${{ inputs.base_branch }}" cwd: ${{ context.work_dir }} output_dir: '.' step_name: resolve_ejected_conflicts @@ -479,6 +484,8 @@ steps: conflict_escalation_required: ${{ result.escalation_required }} capture_list: all_conflict_report_paths: ${{ result.conflict_report_path }} + optional_context_refs: + - pr_plan_path on_result: - when: ${{ result.escalation_required }} == true route: register_clone_failure @@ -718,8 +725,11 @@ steps: tool: run_skill model: '' with: - skill_command: /autoskillit:resolve-merge-conflicts '${{ context.next_pr_branch - }}' '${{ inputs.base_branch }}' + skill_command: "/autoskillit:resolve-merge-conflicts" + skill_inputs: + worktree_path: "${{ context.next_pr_branch }}" + plan_path: "${{ context.pr_plan_path }}" + base_branch: "${{ inputs.base_branch }}" cwd: ${{ context.work_dir }} output_dir: '.' step_name: resolve_proactive_rebase_conflicts @@ -728,6 +738,8 @@ steps: conflict_escalation_reason: ${{ result.escalation_reason }} capture_list: all_conflict_report_paths: ${{ result.conflict_report_path }} + optional_context_refs: + - pr_plan_path on_result: - when: ${{ result.escalation_required }} == true route: register_clone_failure @@ -763,8 +775,10 @@ steps: tool: run_skill model: '' with: - skill_command: /autoskillit:diagnose-ci '${{ context.current_pr_number }}' - - - - ${{ context.ci_event }} + skill_command: "/autoskillit:diagnose-ci" + skill_inputs: + event: "${{ context.ci_event }}" + branch: "${{ context.current_pr_number }}" cwd: ${{ context.work_dir }} step_name: diagnose_queue_ci output_dir: '{{AUTOSKILLIT_TEMP}}/diagnose-ci' @@ -832,7 +846,7 @@ steps: clone_path: ${{ context.work_dir }} remote_url: ${{ context.remote_url }} branch: ${{ context.batch_branch }} - on_success: merge_pr + on_success: check_pr_merge_loop on_failure: register_clone_failure note: 'Publishes the integration branch to origin immediately after creation so that merge_worktree can verify refs/remotes/origin/batch_branch exists before @@ -844,11 +858,14 @@ steps: tool: run_skill model: '' with: - skill_command: /autoskillit:merge-pr {pr_number} {complexity} + skill_command: "/autoskillit:merge-pr" + skill_inputs: + complexity: "{complexity}" + pr_number: "{pr_number}" cwd: ${{ context.work_dir }} step_name: merge_pr - pr_order_file: ${{ context.pr_order_file }} - output_dir: '{{AUTOSKILLIT_TEMP}}/merge-pr' + output_dir: '{{AUTOSKILLIT_TEMP}}/merge-prs/iter_${{ context.pr_merge_loop_count }}' + optional_context_refs: [pr_merge_loop_count] capture: needs_plan: ${{ result.needs_plan }} escalation_required: ${{ result.escalation_required }} @@ -880,13 +897,17 @@ steps: tool: run_skill model: '' with: - skill_command: /autoskillit:make-plan ${{ context.task }} + skill_command: "/autoskillit:make-plan" + skill_inputs: + task: "${{ context.task }}" + audit_cycle_path: "${{ context.audit_cycle_path }}" cwd: ${{ context.work_dir }} step_name: plan output_dir: '.' capture: pr_plan_path: ${{ result.plan_path }} verdict: ${{ result.verdict }} + plan_disposition_path: ${{ result.plan_disposition_path }} capture_list: plan_parts: ${{ result.plan_parts }} all_plan_paths: ${{ result.plan_path }} @@ -894,27 +915,32 @@ steps: on_result: - when: ${{ result.verdict }} == plan route: verify - - when: ${{ result.verdict }} == false_positive - route: verify on_failure: register_clone_failure on_rate_limit: register_clone_failure on_context_limit: salvage_plan note: 'Glob plan_dir for *_part_*.md or single plan file. Sort alphabetically into plan_parts[]. For a single-part plan, plan_parts has one entry equal to plan_path. PRIMARY PATH: context.task = conflict_report_path from merge_pr. - REMEDIATION MODE: context.remediation_path is set when re-entering from the - remediate step; pass it inline in the skill_command instead of context.task. + REMEDIATION MODE: consume the exact current audit_cycle_path and emit the + matching plan_disposition_path; make-plan cannot close the NO GO lineage. ' + optional_context_refs: + - audit_cycle_path salvage_plan: tool: run_python with: callable: autoskillit.recipe._cmd_rpc.verify_plan_artifacts plan_parts: ${{ context.plan_parts }} + audit_cycle_path: ${{ context.audit_cycle_path }} capture: pr_plan_path: ${{ result.plan_path }} + plan_disposition_path: ${{ result.plan_disposition_path }} capture_list: plan_parts: ${{ result.plan_parts }} + all_plan_paths: ${{ result.plan_path }} + optional_context_refs: + - audit_cycle_path retries: 0 on_result: - when: ${{ result.verdict }} == salvaged @@ -931,11 +957,14 @@ steps: tool: run_skill model: '' with: - skill_command: /autoskillit:dry-walkthrough ${{ context.pr_plan_path }} + skill_command: "/autoskillit:dry-walkthrough" + skill_inputs: + plan_path: "${{ context.pr_plan_path }}" + audit_cycle_path: "${{ context.audit_cycle_path }}" + plan_disposition_path: "${{ context.plan_disposition_path }}" cwd: ${{ context.work_dir }} step_name: verify output_dir: '{{AUTOSKILLIT_TEMP}}' - remediation_path: ${{ context.remediation_path }} on_success: implement on_failure: register_clone_failure retries: 5 @@ -943,7 +972,8 @@ steps: on_context_limit: register_clone_failure on_rate_limit: register_clone_failure optional_context_refs: - - remediation_path + - audit_cycle_path + - plan_disposition_path note: 'SEQUENTIAL: For each plan_part, run the full cycle (verify → implement → test → push_worktree_branch → create_conflict_pr → wait_for_conflict_ci → merge_conflict_pr) before advancing. @@ -954,8 +984,9 @@ steps: model: '' stale_threshold: 2400 with: - skill_command: /autoskillit:implement-worktree-no-merge ${{ context.pr_plan_path - }} + skill_command: "/autoskillit:implement-worktree-no-merge" + skill_inputs: + plan_path: "${{ context.pr_plan_path }}" cwd: ${{ context.work_dir }} step_name: implement output_dir: '.' @@ -974,8 +1005,10 @@ steps: model: '' stale_threshold: 2400 with: - skill_command: /autoskillit:retry-worktree ${{ context.pr_plan_path }} ${{ context.worktree_path - }} + skill_command: "/autoskillit:retry-worktree" + skill_inputs: + plan_path: "${{ context.pr_plan_path }}" + worktree_path: "${{ context.worktree_path }}" cwd: ${{ context.worktree_path }} step_name: retry_worktree output_dir: '.' @@ -1181,11 +1214,13 @@ steps: tool: run_skill model: '' with: - skill_command: /autoskillit:resolve-failures ${{ context.worktree_path }} ${{ - context.pr_plan_path }} ${{ context.batch_branch }} + skill_command: "/autoskillit:resolve-failures" + skill_inputs: + plan_path: "${{ context.pr_plan_path }}" + base_branch: "${{ inputs.base_branch }}" + worktree_path: "${{ context.worktree_path }}" cwd: ${{ context.worktree_path }} step_name: fix - base_branch: ${{ inputs.base_branch }} output_dir: '.' capture: verdict: ${{ result.verdict }} @@ -1314,8 +1349,13 @@ steps: tool: run_skill model: '' with: - skill_command: /autoskillit:audit-impl "${{ context.all_plan_paths }}" ${{ context.batch_branch - }} ${{ inputs.base_branch }} "${{ context.all_conflict_report_paths }}" + skill_command: "/autoskillit:audit-impl" + skill_inputs: + all_plan_paths: "${{ context.all_plan_paths }}" + all_conflict_report_paths: "${{ context.all_conflict_report_paths }}" + branch_name: "${{ context.batch_branch }}" + base_branch: "${{ inputs.base_branch }}" + prior_audit_cycle_path: "${{ context.audit_cycle_path }}" cwd: ${{ context.work_dir }} step_name: audit_impl output_dir: '{{AUTOSKILLIT_TEMP}}/audit-impl' @@ -1328,6 +1368,7 @@ steps: capture: verdict: ${{ result.verdict }} remediation_path: ${{ result.remediation_path }} + audit_cycle_path: ${{ result.audit_cycle_path }} on_result: - when: ${{ result.verdict }} == GO route: compute_domain_partitions @@ -1339,6 +1380,8 @@ steps: on_rate_limit: register_clone_failure optional: true skip_when_false: inputs.audit_impl + optional_context_refs: + - audit_cycle_path note: 'DEPENDENCY: context.all_plan_paths is only populated if the plan step executed at least once. The check_impl_plans gate ensures this step is only reached when implementation plan files exist (i.e. make-plan was invoked), so all_plan_paths @@ -1348,13 +1391,14 @@ steps: (comma-separated list of plan file paths accumulated across the PR loop) as plans_input. audit-impl splits on commas and audits each plan file. implementation_ref is context.batch_branch (no worktree remains). GO → open_integration_pr. NO - GO → remediate (re-enters plan with remediation file). + GO replaces the prior authority before completion. NO GO re-enters plan with + the exact current authority and the successor audit consumes it as prior. ' remediate: action: route with: - remediation_path: ${{ context.remediation_path }} + audit_cycle_path: ${{ context.audit_cycle_path }} on_success: plan compute_domain_partitions: tool: run_python @@ -1379,10 +1423,14 @@ steps: tool: run_skill model: '' with: - skill_command: /autoskillit:open-integration-pr ${{ context.batch_branch }} - ${{ inputs.base_branch }} ${{ context.pr_order_file }} ${{ context.verdict - }} "${{ context.all_conflict_report_paths }}" domain_partitions_path=${{ context.domain_partitions_path - }} + skill_command: "/autoskillit:open-integration-pr" + skill_inputs: + audit_verdict: "${{ context.verdict }}" + batch_branch: "${{ context.batch_branch }}" + domain_partitions_path: "${{ context.domain_partitions_path }}" + base_branch: "${{ inputs.base_branch }}" + conflict_report_paths: "${{ context.all_conflict_report_paths }}" + pr_order_file: "${{ context.pr_order_file }}" cwd: ${{ context.work_dir }} step_name: open_integration_pr output_dir: '{{AUTOSKILLIT_TEMP}}/open-integration-pr' @@ -1430,8 +1478,11 @@ steps: tool: run_skill model: '' with: - skill_command: /autoskillit:resolve-merge-conflicts ${{ context.batch_branch - }} ${{ inputs.base_branch }} + skill_command: "/autoskillit:resolve-merge-conflicts" + skill_inputs: + worktree_path: "${{ context.batch_branch }}" + plan_path: "${{ context.pr_plan_path }}" + base_branch: "${{ inputs.base_branch }}" cwd: ${{ context.work_dir }} step_name: resolve_integration_conflicts output_dir: '.' @@ -1519,11 +1570,15 @@ steps: tool: run_skill model: '' with: - skill_command: /autoskillit:review-pr ${{ context.batch_branch }} ${{ inputs.base_branch - }} annotated_diff_path=${{ context.annotated_diff_path }} hunk_ranges_path=${{ - context.hunk_ranges_path }} valid_lines_path=${{ context.valid_lines_path }} - diff_metrics_path=${{ context.diff_metrics_path - }} pr_head_sha=${{ context.pr_head_sha }} + skill_command: "/autoskillit:review-pr" + skill_inputs: + feature_branch: "${{ context.batch_branch }}" + base_branch: "${{ inputs.base_branch }}" + annotated_diff_path: "${{ context.annotated_diff_path }}" + hunk_ranges_path: "${{ context.hunk_ranges_path }}" + valid_lines_path: "${{ context.valid_lines_path }}" + diff_metrics_path: "${{ context.diff_metrics_path }}" + pr_head_sha: "${{ context.pr_head_sha }}" cwd: ${{ context.work_dir }} step_name: review_pr_integration output_dir: '{{AUTOSKILLIT_TEMP}}/review-pr' @@ -1570,8 +1625,10 @@ steps: tool: run_skill model: '' with: - skill_command: /autoskillit:resolve-review ${{ context.batch_branch }} ${{ inputs.base_branch - }} + skill_command: "/autoskillit:resolve-review" + skill_inputs: + base_branch: "${{ inputs.base_branch }}" + feature_branch: "${{ context.batch_branch }}" cwd: ${{ context.work_dir }} output_dir: '.' step_name: resolve_review_integration @@ -1617,8 +1674,11 @@ steps: tool: run_skill model: '' with: - skill_command: /autoskillit:resolve-merge-conflicts ${{ context.work_dir }} - ${{ inputs.base_branch }} + skill_command: "/autoskillit:resolve-merge-conflicts" + skill_inputs: + worktree_path: "${{ context.work_dir }}" + plan_path: "${{ context.pr_plan_path }}" + base_branch: "${{ inputs.base_branch }}" cwd: ${{ context.work_dir }} output_dir: '.' step_name: resolve_pre_review_integration_conflicts @@ -1675,7 +1735,6 @@ steps: timeout_seconds: 600 cwd: ${{ context.work_dir }} remote_url: ${{ context.remote_url }} - pr_url: ${{ context.pr_url }} auto_trigger: true on_result: - when: ${{ result.conclusion }} == 'success' @@ -1713,8 +1772,10 @@ steps: tool: run_skill model: '' with: - skill_command: /autoskillit:diagnose-ci ${{ context.batch_branch }} - - - ${{ - context.ci_event }} + skill_command: "/autoskillit:diagnose-ci" + skill_inputs: + event: "${{ context.ci_event }}" + branch: "${{ context.batch_branch }}" cwd: ${{ context.work_dir }} step_name: diagnose_ci output_dir: '{{AUTOSKILLIT_TEMP}}/diagnose-ci' @@ -1770,9 +1831,11 @@ steps: optional: true skip_when_false: inputs.pipeline_health with: - skill_command: /autoskillit:analyze-pipeline-health ${{ inputs.kitchen_id }} - --dispatch-id=${{ inputs.dispatch_id }} --diagnostics-log-dir=${{ inputs.diagnostics_log_dir - }} + skill_command: "/autoskillit:analyze-pipeline-health" + skill_inputs: + kitchen_id: "${{ inputs.kitchen_id }}" + dispatch_id: "--dispatch-id=${{ inputs.dispatch_id }}" + diagnostics_log_dir: "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" cwd: ${{ context.work_dir }} step_name: analyze_pipeline_health on_success: done @@ -1791,9 +1854,11 @@ steps: optional: true skip_when_false: inputs.pipeline_health with: - skill_command: /autoskillit:analyze-pipeline-health ${{ inputs.kitchen_id }} - --dispatch-id=${{ inputs.dispatch_id }} --diagnostics-log-dir=${{ inputs.diagnostics_log_dir - }} + skill_command: "/autoskillit:analyze-pipeline-health" + skill_inputs: + kitchen_id: "${{ inputs.kitchen_id }}" + dispatch_id: "--dispatch-id=${{ inputs.dispatch_id }}" + diagnostics_log_dir: "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" cwd: ${{ context.work_dir }} step_name: analyze_pipeline_health on_success: escalate_stop diff --git a/src/autoskillit/recipes/planner.json b/src/autoskillit/recipes/planner.json index 1357fa534..852ce7754 100644 --- a/src/autoskillit/recipes/planner.json +++ b/src/autoskillit/recipes/planner.json @@ -72,7 +72,10 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:planner-analyze ${{ context.planner_dir }}", + "skill_command": "/autoskillit:planner-analyze", + "skill_inputs": { + "planner_dir": "${{ context.planner_dir }}" + }, "cwd": "${{ inputs.source_dir }}", "output_dir": "${{ context.planner_dir }}" }, @@ -85,7 +88,11 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:planner-extract-domain ${{ context.planner_dir }}/analysis.json \"${{ context.task_file_path }}\"\n", + "skill_command": "/autoskillit:planner-extract-domain", + "skill_inputs": { + "analysis_path": "${{ context.planner_dir }}/analysis.json", + "task_file_path": "${{ context.task_file_path }}" + }, "cwd": "${{ inputs.source_dir }}", "output_dir": "${{ context.planner_dir }}" }, @@ -98,7 +105,12 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:planner-generate-phases ${{ context.planner_dir }}/analysis.json ${{ context.planner_dir }}/domain_knowledge.md \"${{ context.task_file_path }}\"\n", + "skill_command": "/autoskillit:planner-generate-phases", + "skill_inputs": { + "analysis_path": "${{ context.planner_dir }}/analysis.json", + "domain_knowledge_path": "${{ context.planner_dir }}/domain_knowledge.md", + "task_file_path": "${{ context.task_file_path }}" + }, "cwd": "${{ inputs.source_dir }}", "output_dir": "${{ context.planner_dir }}" }, @@ -404,10 +416,12 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:planner-reconcile-deps ${{ context.planner_dir }}", + "skill_command": "/autoskillit:planner-reconcile-deps", + "skill_inputs": { + "planner_dir": "${{ context.planner_dir }}" + }, "cwd": "${{ inputs.source_dir }}", - "output_dir": "${{ context.planner_dir }}", - "consolidated_wps_path": "${{ context.consolidated_wps_path }}" + "output_dir": "${{ context.planner_dir }}" }, "on_success": "planner_assess_review_approach", "on_failure": "escalate_stop", @@ -463,7 +477,11 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:planner-refine ${{ context.planner_dir }}/validation.json ${{ context.planner_dir }}\n", + "skill_command": "/autoskillit:planner-refine", + "skill_inputs": { + "validation_path": "${{ context.planner_dir }}/validation.json", + "planner_dir": "${{ context.planner_dir }}" + }, "cwd": "${{ inputs.source_dir }}", "output_dir": "${{ context.planner_dir }}" }, diff --git a/src/autoskillit/recipes/planner.yaml b/src/autoskillit/recipes/planner.yaml index a7695250e..feb15d8c9 100644 --- a/src/autoskillit/recipes/planner.yaml +++ b/src/autoskillit/recipes/planner.yaml @@ -73,7 +73,9 @@ steps: tool: run_skill model: "" with: - skill_command: "/autoskillit:planner-analyze ${{ context.planner_dir }}" + skill_command: "/autoskillit:planner-analyze" + skill_inputs: + planner_dir: "${{ context.planner_dir }}" cwd: "${{ inputs.source_dir }}" output_dir: "${{ context.planner_dir }}" on_success: extract_domain @@ -85,10 +87,10 @@ steps: tool: run_skill model: "" with: - skill_command: > - /autoskillit:planner-extract-domain - ${{ context.planner_dir }}/analysis.json - "${{ context.task_file_path }}" + skill_command: "/autoskillit:planner-extract-domain" + skill_inputs: + analysis_path: "${{ context.planner_dir }}/analysis.json" + task_file_path: "${{ context.task_file_path }}" cwd: "${{ inputs.source_dir }}" output_dir: "${{ context.planner_dir }}" on_success: generate_phases @@ -100,11 +102,11 @@ steps: tool: run_skill model: "" with: - skill_command: > - /autoskillit:planner-generate-phases - ${{ context.planner_dir }}/analysis.json - ${{ context.planner_dir }}/domain_knowledge.md - "${{ context.task_file_path }}" + skill_command: "/autoskillit:planner-generate-phases" + skill_inputs: + analysis_path: "${{ context.planner_dir }}/analysis.json" + domain_knowledge_path: "${{ context.planner_dir }}/domain_knowledge.md" + task_file_path: "${{ context.task_file_path }}" cwd: "${{ inputs.source_dir }}" output_dir: "${{ context.planner_dir }}" capture: @@ -413,10 +415,11 @@ steps: tool: run_skill model: "" with: - skill_command: "/autoskillit:planner-reconcile-deps ${{ context.planner_dir }}" + skill_command: "/autoskillit:planner-reconcile-deps" + skill_inputs: + planner_dir: "${{ context.planner_dir }}" cwd: "${{ inputs.source_dir }}" output_dir: "${{ context.planner_dir }}" - consolidated_wps_path: "${{ context.consolidated_wps_path }}" on_success: planner_assess_review_approach on_failure: escalate_stop on_context_limit: planner_assess_review_approach @@ -467,10 +470,10 @@ steps: tool: run_skill model: "" with: - skill_command: > - /autoskillit:planner-refine - ${{ context.planner_dir }}/validation.json - ${{ context.planner_dir }} + skill_command: "/autoskillit:planner-refine" + skill_inputs: + validation_path: "${{ context.planner_dir }}/validation.json" + planner_dir: "${{ context.planner_dir }}" cwd: "${{ inputs.source_dir }}" output_dir: "${{ context.planner_dir }}" capture: diff --git a/src/autoskillit/recipes/remediation.json b/src/autoskillit/recipes/remediation.json index 77361800a..952d313d7 100644 --- a/src/autoskillit/recipes/remediation.json +++ b/src/autoskillit/recipes/remediation.json @@ -231,12 +231,13 @@ "skip_when_false": "inputs.investigate", "model": "${{ 'opus[1m]' if inputs.depth == 'deep' else 'sonnet' }}", "with": { - "skill_command": "/autoskillit:investigate ${{ '--depth deep ' if inputs.depth == 'deep' else '' }}${{ inputs.task }}", + "skill_command": "/autoskillit:investigate", + "skill_inputs": { + "topic": "${{ '--depth deep ' if inputs.depth == 'deep' else '' }}${{ inputs.task }} ${{ inputs.issue_url }}" + }, "cwd": "${{ context.work_dir }}", "output_dir": "{{AUTOSKILLIT_TEMP}}/investigate", - "step_name": "investigate", - "issue_url": "${{ inputs.issue_url }}", - "issue_title": "${{ context.issue_title }}" + "step_name": "investigate" }, "capture": { "investigation_path": "${{ result.investigation_path }}" @@ -268,7 +269,10 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:rectify ${{ context.effective_investigation_path }}", + "skill_command": "/autoskillit:rectify", + "skill_inputs": { + "investigation_path": "${{ context.effective_investigation_path }}" + }, "cwd": "${{ context.work_dir }}", "output_dir": "{{AUTOSKILLIT_TEMP}}/rectify", "step_name": "rectify" @@ -285,7 +289,7 @@ "on_failure": "release_issue_failure", "on_rate_limit": "release_issue_failure", "on_context_limit": "salvage_rectify_plan", - "note": "Glob plan_dir for *_part_*.md or single plan file. Sort alphabetically into plan_parts[].\nACCUMULATION: all_plan_paths accumulates across any re-entries to the rectify step. On re-entry, the agent MUST append the captured plan_path to context.all_plan_paths using a comma separator instead of letting the capture block overwrite the accumulated value. When verdict=false_positive, do NOT overwrite all_plan_paths — preserve the existing accumulated value from prior plan executions.\n" + "note": "Glob plan_dir for *_part_*.md or single plan file. Sort alphabetically into plan_parts[].\nACCUMULATION: all_plan_paths accumulates across any re-entries to the rectify step. On re-entry, the agent MUST append the captured plan_path to context.all_plan_paths using a comma separator instead of letting the capture block overwrite the accumulated value.\n" }, "salvage_rectify_plan": { "tool": "run_python", @@ -316,7 +320,10 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:review-approach ${{ context.plan_path }}", + "skill_command": "/autoskillit:review-approach", + "skill_inputs": { + "plan_path": "${{ context.plan_path }}" + }, "cwd": "${{ context.work_dir }}", "step_name": "review_approach", "output_dir": "{{AUTOSKILLIT_TEMP}}/review-approach" @@ -336,12 +343,16 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:dry-walkthrough ${{ context.plan_path }}", + "skill_command": "/autoskillit:dry-walkthrough", + "skill_inputs": { + "plan_path": "${{ context.plan_path }}", + "issue_url": "${{ inputs.issue_url }}", + "review_path": "${{ context.review_path }}", + "audit_cycle_path": "${{ context.audit_cycle_path }}", + "plan_disposition_path": "${{ context.plan_disposition_path }}" + }, "cwd": "${{ context.work_dir }}", "output_dir": "{{AUTOSKILLIT_TEMP}}", - "review_path": "${{ context.review_path }}", - "issue_url": "${{ inputs.issue_url }}", - "remediation_path": "${{ context.remediation_path }}", "step_name": "dry_walkthrough" }, "on_success": "create_impl_worktree", @@ -350,7 +361,9 @@ "on_exhausted": "release_issue_failure", "on_context_limit": "retry_walkthrough", "optional_context_refs": [ - "remediation_path" + "review_path", + "audit_cycle_path", + "plan_disposition_path" ], "note": "Dry walkthrough retries on failure. It never routes back to a previous step." }, @@ -358,12 +371,16 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:dry-walkthrough ${{ context.plan_path }} remediation_path=${{ context.remediation_path }}", + "skill_command": "/autoskillit:dry-walkthrough", + "skill_inputs": { + "plan_path": "${{ context.plan_path }}", + "issue_url": "${{ inputs.issue_url }}", + "review_path": "${{ context.review_path }}", + "audit_cycle_path": "${{ context.audit_cycle_path }}", + "plan_disposition_path": "${{ context.plan_disposition_path }}" + }, "cwd": "${{ context.work_dir }}", "output_dir": "{{AUTOSKILLIT_TEMP}}", - "review_path": "${{ context.review_path }}", - "issue_url": "${{ inputs.issue_url }}", - "remediation_path": "${{ context.remediation_path }}", "step_name": "retry_walkthrough" }, "retries": 0, @@ -372,7 +389,9 @@ "on_context_limit": "release_issue_failure", "on_rate_limit": "release_issue_failure", "optional_context_refs": [ - "remediation_path" + "review_path", + "audit_cycle_path", + "plan_disposition_path" ], "note": "Bounded one-hop re-invoke of dry-walkthrough after a context-limit stumble on the primary dry_walkthrough step. Never routes back to dry_walkthrough — on_failure and on_context_limit both fall through to the original failure destination." }, @@ -395,7 +414,10 @@ "model": "", "stale_threshold": 2400, "with": { - "skill_command": "/autoskillit:implement-worktree-no-merge ${{ context.plan_path }}", + "skill_command": "/autoskillit:implement-worktree-no-merge", + "skill_inputs": { + "plan_path": "${{ context.plan_path }}" + }, "cwd": "${{ context.worktree_path }}", "step_name": "implement", "output_dir": "." @@ -419,7 +441,11 @@ "model": "", "stale_threshold": 2400, "with": { - "skill_command": "/autoskillit:retry-worktree ${{ context.plan_path }} ${{ context.worktree_path }}", + "skill_command": "/autoskillit:retry-worktree", + "skill_inputs": { + "plan_path": "${{ context.plan_path }}", + "worktree_path": "${{ context.worktree_path }}" + }, "cwd": "${{ context.worktree_path }}", "step_name": "retry_worktree", "output_dir": "." @@ -887,7 +913,14 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:resolve-failures ${{ context.worktree_path }} ${{ context.plan_path }} ${{ inputs.base_branch }} ${{ context.merge_gate_ci_conclusion }} - ${{ context.merge_gate_diagnosis_path }}", + "skill_command": "/autoskillit:resolve-failures", + "skill_inputs": { + "base_branch": "${{ inputs.base_branch }}", + "diagnosis_path": "${{ context.merge_gate_diagnosis_path }}", + "ci_conclusion": "${{ context.merge_gate_ci_conclusion }}", + "plan_path": "${{ context.plan_path }}", + "worktree_path": "${{ context.worktree_path }}" + }, "cwd": "${{ context.worktree_path }}", "step_name": "assess", "output_dir": "." @@ -965,7 +998,14 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:resolve-failures ${{ context.worktree_path }} ${{ context.plan_path }} ${{ inputs.base_branch }} ${{ context.merge_gate_ci_conclusion }} - ${{ context.merge_gate_diagnosis_path }}", + "skill_command": "/autoskillit:resolve-failures", + "skill_inputs": { + "base_branch": "${{ inputs.base_branch }}", + "diagnosis_path": "${{ context.merge_gate_diagnosis_path }}", + "ci_conclusion": "${{ context.merge_gate_ci_conclusion }}", + "plan_path": "${{ context.plan_path }}", + "worktree_path": "${{ context.worktree_path }}" + }, "cwd": "${{ context.worktree_path }}", "step_name": "merge_gate_assess", "output_dir": "." @@ -1052,7 +1092,12 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:resolve-merge-conflicts ${{ context.worktree_path }} ${{ context.plan_path }} ${{ inputs.base_branch }}", + "skill_command": "/autoskillit:resolve-merge-conflicts", + "skill_inputs": { + "plan_path": "${{ context.plan_path }}", + "base_branch": "${{ inputs.base_branch }}", + "worktree_path": "${{ context.worktree_path }}" + }, "cwd": "${{ context.worktree_path }}", "step_name": "rebase_conflict_fix", "output_dir": "." @@ -1081,7 +1126,14 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:audit-impl ${{ context.all_plan_paths }} ${{ context.branch_name }} ${{ inputs.base_branch }} deviation_manifest_path=${{ context.deviation_manifest_path }}", + "skill_command": "/autoskillit:audit-impl", + "skill_inputs": { + "all_plan_paths": "${{ context.all_plan_paths }}", + "deviation_manifest_path": "${{ context.deviation_manifest_path }}", + "branch_name": "${{ context.branch_name }}", + "base_branch": "${{ inputs.base_branch }}", + "prior_audit_cycle_path": "${{ context.audit_cycle_path }}" + }, "cwd": "${{ context.work_dir }}", "step_name": "audit_impl", "output_dir": "{{AUTOSKILLIT_TEMP}}/audit-impl", @@ -1093,7 +1145,8 @@ "closure_target_sha": "" }, "capture": { - "remediation_path": "${{ result.remediation_path }}" + "remediation_path": "${{ result.remediation_path }}", + "audit_cycle_path": "${{ result.audit_cycle_path }}" }, "on_result": [ { @@ -1118,14 +1171,15 @@ "optional": true, "skip_when_false": "inputs.audit_impl", "optional_context_refs": [ - "deviation_manifest_path" + "deviation_manifest_path", + "audit_cycle_path" ], "note": "Only run if inputs.audit_impl = true. If inputs.audit_impl = false, skip directly to commit_guard." }, "remediate": { "action": "route", "with": { - "remediation_path": "${{ context.remediation_path }}" + "audit_cycle_path": "${{ context.audit_cycle_path }}" }, "on_success": "make_plan" }, @@ -1133,17 +1187,22 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:make-plan ${{ context.remediation_path }}", - "task": "${{ inputs.task }}", + "skill_command": "/autoskillit:make-plan", + "skill_inputs": { + "adversarial_review_level": "${{ inputs.adversarial_review_level }}", + "task": "${{ inputs.task }}", + "issue_url": "${{ inputs.issue_url }}", + "audit_cycle_path": "${{ context.audit_cycle_path }}" + }, "cwd": "${{ context.work_dir }}", "step_name": "make_plan", - "adversarial_review_level": "${{ inputs.adversarial_review_level }}", "output_dir": "." }, "capture": { "plan_path": "${{ result.plan_path }}", "all_plan_paths": "${{ result.plan_path }}", - "verdict": "${{ result.verdict }}" + "verdict": "${{ result.verdict }}", + "plan_disposition_path": "${{ result.plan_disposition_path }}" }, "capture_list": { "plan_parts": "${{ result.plan_parts }}" @@ -1153,29 +1212,31 @@ { "when": "${{ result.verdict }} == plan", "route": "dry_walkthrough" - }, - { - "when": "${{ result.verdict }} == false_positive", - "route": "check_has_commits" } ], "on_failure": "release_issue_failure", "on_rate_limit": "release_issue_failure", "on_context_limit": "salvage_plan", - "note": "ADVERSARIAL REVIEW LEVEL: If inputs.adversarial_review_level is a non-empty string, append it to the skill_command: \"/autoskillit:make-plan {task}\\n\\nadversarial_review_level={inputs.adversarial_review_level}\" AUDIT REMEDIATION MODE: This step is always in remediation context (invoked from the remediate route step). Always append audit_remediation_mode=true to the skill_command at runtime: \"/autoskillit:make-plan {remediation_path}\\n\\naudit_remediation_mode=true\" VERDICT: The skill emits verdict=plan (produce implementation plan) or verdict=false_positive (audit finding is a false positive, skip implement). false_positive routes to check_has_commits. ACCUMULATION: all_plan_paths accumulates across any re-entries to the make_plan step. On re-entry, the agent MUST append the captured plan_path to context.all_plan_paths using a comma separator instead of letting the capture block overwrite the accumulated value. When verdict=false_positive, do NOT overwrite all_plan_paths — preserve the existing accumulated value from prior plan executions.\n" + "note": "Consumes the exact current NO GO audit_cycle_path and emits verdict=plan, plan_path, and plan_disposition_path. all_plan_paths appends the recovered plan; make-plan cannot close the audit lineage." }, "salvage_plan": { "tool": "run_python", "with": { "callable": "autoskillit.recipe._cmd_rpc.verify_plan_artifacts", - "plan_parts": "${{ context.plan_parts }}" + "plan_parts": "${{ context.plan_parts }}", + "audit_cycle_path": "${{ context.audit_cycle_path }}" }, "capture": { - "plan_path": "${{ result.plan_path }}" + "plan_path": "${{ result.plan_path }}", + "plan_disposition_path": "${{ result.plan_disposition_path }}", + "all_plan_paths": "${{ result.plan_path }}" }, "capture_list": { "plan_parts": "${{ result.plan_parts }}" }, + "optional_context_refs": [ + "audit_cycle_path" + ], "retries": 0, "on_result": [ { @@ -1352,7 +1413,14 @@ "model": "", "retries": 1, "with": { - "skill_command": "/autoskillit:prepare-pr ${{ context.all_plan_paths }} ${{ inputs.run_name }} ${{ inputs.base_branch }} ${{ context.issue_number }} ${{ inputs.arch_lenses }}", + "skill_command": "/autoskillit:prepare-pr", + "skill_inputs": { + "arch_lenses": "${{ inputs.arch_lenses }}", + "plan_paths": "${{ context.all_plan_paths }}", + "closing_issue": "${{ context.issue_number }}", + "base_branch": "${{ inputs.base_branch }}", + "run_name": "${{ inputs.run_name }}" + }, "cwd": "${{ context.work_dir }}", "output_dir": ".", "step_name": "prepare_pr" @@ -1387,7 +1455,10 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:arch-lens-{slug} {context_path}", + "skill_command": "/autoskillit:arch-lens-{slug}", + "skill_inputs": { + "context_path": "{context_path}" + }, "cwd": "${{ context.work_dir }}", "step_name": "run_arch_lenses" }, @@ -1407,7 +1478,14 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:compose-pr ${{ context.prep_path }} \"${{ context.all_diagram_paths }}\" ${{ context.work_dir }} ${{ inputs.base_branch }} ${{ context.issue_number }}", + "skill_command": "/autoskillit:compose-pr", + "skill_inputs": { + "work_dir": "${{ context.work_dir }}", + "closing_issue": "${{ context.issue_number }}", + "all_diagram_paths": "${{ context.all_diagram_paths }}", + "base_branch": "${{ inputs.base_branch }}", + "prep_path": "${{ context.prep_path }}" + }, "cwd": "${{ context.work_dir }}", "output_dir": ".", "step_name": "compose_pr" @@ -1511,7 +1589,17 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:review-pr ${{ context.merge_target }} ${{ inputs.base_branch }} annotated_diff_path=${{ context.annotated_diff_path }} hunk_ranges_path=${{ context.hunk_ranges_path }} valid_lines_path=${{ context.valid_lines_path }} diff_metrics_path=${{ context.diff_metrics_path }} mode=${{ context.review_mode }} pr_head_sha=${{ context.pr_head_sha }}", + "skill_command": "/autoskillit:review-pr", + "skill_inputs": { + "feature_branch": "${{ context.merge_target }}", + "base_branch": "${{ inputs.base_branch }}", + "annotated_diff_path": "${{ context.annotated_diff_path }}", + "hunk_ranges_path": "${{ context.hunk_ranges_path }}", + "valid_lines_path": "${{ context.valid_lines_path }}", + "diff_metrics_path": "${{ context.diff_metrics_path }}", + "mode": "${{ context.review_mode }}", + "pr_head_sha": "${{ context.pr_head_sha }}" + }, "cwd": "${{ context.work_dir }}", "step_name": "review_pr", "output_dir": "{{AUTOSKILLIT_TEMP}}/review-pr/iter_${{ context.review_loop_count }}" @@ -1555,7 +1643,12 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:resolve-review ${{ context.merge_target }} ${{ inputs.base_branch }} mode=${{ context.review_mode }}", + "skill_command": "/autoskillit:resolve-review", + "skill_inputs": { + "base_branch": "${{ inputs.base_branch }}", + "feature_branch": "${{ context.merge_target }}", + "mode": "${{ context.review_mode }}" + }, "cwd": "${{ context.work_dir }}", "output_dir": ".", "step_name": "resolve_review" @@ -1619,7 +1712,12 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:resolve-merge-conflicts ${{ context.work_dir }} ${{ context.plan_path }} ${{ inputs.base_branch }}", + "skill_command": "/autoskillit:resolve-merge-conflicts", + "skill_inputs": { + "plan_path": "${{ context.plan_path }}", + "base_branch": "${{ inputs.base_branch }}", + "worktree_path": "${{ context.work_dir }}" + }, "cwd": "${{ context.work_dir }}", "output_dir": ".", "step_name": "resolve_pre_review_conflicts" @@ -1981,7 +2079,12 @@ "optional": true, "skip_when_false": "inputs.pipeline_health", "with": { - "skill_command": "/autoskillit:analyze-pipeline-health ${{ inputs.kitchen_id }} --dispatch-id=${{ inputs.dispatch_id }} --diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}", + "skill_command": "/autoskillit:analyze-pipeline-health", + "skill_inputs": { + "kitchen_id": "${{ inputs.kitchen_id }}", + "dispatch_id": "--dispatch-id=${{ inputs.dispatch_id }}", + "diagnostics_log_dir": "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" + }, "cwd": "${{ context.work_dir }}", "step_name": "analyze_pipeline_health" }, @@ -2280,7 +2383,12 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:resolve-merge-conflicts ${{ context.work_dir }} ${{ context.plan_path }} ${{ inputs.base_branch }}", + "skill_command": "/autoskillit:resolve-merge-conflicts", + "skill_inputs": { + "plan_path": "${{ context.plan_path }}", + "base_branch": "${{ inputs.base_branch }}", + "worktree_path": "${{ context.work_dir }}" + }, "cwd": "${{ context.work_dir }}", "output_dir": ".", "step_name": "resolve_queue_merge_conflicts" @@ -2532,7 +2640,12 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:resolve-merge-conflicts ${{ context.work_dir }} ${{ context.plan_path }} ${{ inputs.base_branch }}", + "skill_command": "/autoskillit:resolve-merge-conflicts", + "skill_inputs": { + "plan_path": "${{ context.plan_path }}", + "base_branch": "${{ inputs.base_branch }}", + "worktree_path": "${{ context.work_dir }}" + }, "cwd": "${{ context.work_dir }}", "output_dir": ".", "step_name": "resolve_direct_merge_conflicts" @@ -2644,7 +2757,12 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:resolve-merge-conflicts ${{ context.work_dir }} ${{ context.plan_path }} ${{ inputs.base_branch }}", + "skill_command": "/autoskillit:resolve-merge-conflicts", + "skill_inputs": { + "plan_path": "${{ context.plan_path }}", + "base_branch": "${{ inputs.base_branch }}", + "worktree_path": "${{ context.work_dir }}" + }, "cwd": "${{ context.work_dir }}", "output_dir": ".", "step_name": "resolve_immediate_merge_conflicts" @@ -2697,11 +2815,15 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:diagnose-ci ${{ context.merge_target }} - - - ${{ context.ci_event }}", + "skill_command": "/autoskillit:diagnose-ci", + "skill_inputs": { + "ci_failed_jobs": "${{ context.ci_failed_jobs }}", + "event": "${{ context.ci_event }}", + "branch": "${{ context.merge_target }}" + }, "cwd": "${{ context.work_dir }}", "step_name": "diagnose_ci", - "ci_failed_jobs": "${{ context.ci_failed_jobs }}", - "output_dir": "{{AUTOSKILLIT_TEMP}}/diagnose-ci" + "output_dir": "{{AUTOSKILLIT_TEMP}}/diagnose-ci/rebase_${{ context.ci_rebase_count }}_flake_${{ context.ci_flake_count }}" }, "capture": { "diagnosis_path": "${{ result.diagnosis_path }}" @@ -2711,6 +2833,10 @@ "on_context_limit": "resolve_ci", "on_rate_limit": "resolve_ci", "optional": true, + "optional_context_refs": [ + "ci_rebase_count", + "ci_flake_count" + ], "skip_when_false": "inputs.open_pr", "note": "Runs when ci_watch reports a CI failure. Fetches CI logs via gh API and writes a structured diagnosis to {{AUTOSKILLIT_TEMP}}/diagnose-ci/. Routes to resolve_ci on success OR failure. If diagnosis fails, resolve_ci proceeds without diagnosis context. diagnosis_path is passed to resolve_ci for targeted remediation context.\n" }, @@ -2718,7 +2844,15 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:resolve-failures ${{ context.work_dir }} ${{ context.plan_path }} ${{ inputs.base_branch }} ${{ context.ci_conclusion }} ${{ context.ci_failed_jobs }} ${{ context.diagnosis_path }}", + "skill_command": "/autoskillit:resolve-failures", + "skill_inputs": { + "base_branch": "${{ inputs.base_branch }}", + "diagnosis_path": "${{ context.diagnosis_path }}", + "ci_failed_jobs": "${{ context.ci_failed_jobs }}", + "ci_conclusion": "${{ context.ci_conclusion }}", + "plan_path": "${{ context.plan_path }}", + "worktree_path": "${{ context.work_dir }}" + }, "cwd": "${{ context.work_dir }}", "output_dir": ".", "step_name": "resolve_ci" @@ -2785,7 +2919,12 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:resolve-merge-conflicts ${{ context.work_dir }} ${{ context.plan_path }} ${{ inputs.base_branch }}", + "skill_command": "/autoskillit:resolve-merge-conflicts", + "skill_inputs": { + "plan_path": "${{ context.plan_path }}", + "base_branch": "${{ inputs.base_branch }}", + "worktree_path": "${{ context.work_dir }}" + }, "cwd": "${{ context.work_dir }}", "output_dir": ".", "step_name": "resolve_pre_resolve_conflicts" @@ -2889,7 +3028,12 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:resolve-merge-conflicts ${{ context.work_dir }} ${{ context.plan_path }} ${{ inputs.base_branch }}", + "skill_command": "/autoskillit:resolve-merge-conflicts", + "skill_inputs": { + "plan_path": "${{ context.plan_path }}", + "base_branch": "${{ inputs.base_branch }}", + "worktree_path": "${{ context.work_dir }}" + }, "cwd": "${{ context.work_dir }}", "output_dir": ".", "step_name": "ci_conflict_fix" @@ -2934,7 +3078,12 @@ "optional": true, "skip_when_false": "inputs.pipeline_health", "with": { - "skill_command": "/autoskillit:analyze-pipeline-health ${{ inputs.kitchen_id }} --dispatch-id=${{ inputs.dispatch_id }} --diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}", + "skill_command": "/autoskillit:analyze-pipeline-health", + "skill_inputs": { + "kitchen_id": "${{ inputs.kitchen_id }}", + "dispatch_id": "--dispatch-id=${{ inputs.dispatch_id }}", + "diagnostics_log_dir": "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" + }, "cwd": "${{ context.work_dir }}", "step_name": "analyze_pipeline_health" }, @@ -3018,7 +3167,12 @@ "optional": true, "skip_when_false": "inputs.pipeline_health", "with": { - "skill_command": "/autoskillit:analyze-pipeline-health ${{ inputs.kitchen_id }} --dispatch-id=${{ inputs.dispatch_id }} --diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}", + "skill_command": "/autoskillit:analyze-pipeline-health", + "skill_inputs": { + "kitchen_id": "${{ inputs.kitchen_id }}", + "dispatch_id": "--dispatch-id=${{ inputs.dispatch_id }}", + "diagnostics_log_dir": "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" + }, "cwd": "${{ context.work_dir }}", "step_name": "analyze_pipeline_health" }, @@ -3036,7 +3190,12 @@ "optional": true, "skip_when_false": "inputs.pipeline_health", "with": { - "skill_command": "/autoskillit:analyze-pipeline-health ${{ inputs.kitchen_id }} --dispatch-id=${{ inputs.dispatch_id }} --diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}", + "skill_command": "/autoskillit:analyze-pipeline-health", + "skill_inputs": { + "kitchen_id": "${{ inputs.kitchen_id }}", + "dispatch_id": "--dispatch-id=${{ inputs.dispatch_id }}", + "diagnostics_log_dir": "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" + }, "cwd": "${{ context.work_dir }}", "step_name": "analyze_pipeline_health" }, @@ -3065,7 +3224,12 @@ "optional": true, "skip_when_false": "inputs.pipeline_health", "with": { - "skill_command": "/autoskillit:analyze-pipeline-health ${{ inputs.kitchen_id }} --dispatch-id=${{ inputs.dispatch_id }} --diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}", + "skill_command": "/autoskillit:analyze-pipeline-health", + "skill_inputs": { + "kitchen_id": "${{ inputs.kitchen_id }}", + "dispatch_id": "--dispatch-id=${{ inputs.dispatch_id }}", + "diagnostics_log_dir": "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" + }, "cwd": "${{ context.work_dir }}", "step_name": "analyze_pipeline_health" }, @@ -3094,7 +3258,12 @@ "optional": true, "skip_when_false": "inputs.pipeline_health", "with": { - "skill_command": "/autoskillit:analyze-pipeline-health ${{ inputs.kitchen_id }} --dispatch-id=${{ inputs.dispatch_id }} --diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}", + "skill_command": "/autoskillit:analyze-pipeline-health", + "skill_inputs": { + "kitchen_id": "${{ inputs.kitchen_id }}", + "dispatch_id": "--dispatch-id=${{ inputs.dispatch_id }}", + "diagnostics_log_dir": "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" + }, "cwd": "${{ context.work_dir }}", "step_name": "analyze_pipeline_health" }, diff --git a/src/autoskillit/recipes/remediation.yaml b/src/autoskillit/recipes/remediation.yaml index 792bf0c58..f97489c8a 100644 --- a/src/autoskillit/recipes/remediation.yaml +++ b/src/autoskillit/recipes/remediation.yaml @@ -238,13 +238,12 @@ steps: skip_when_false: inputs.investigate model: ${{ 'opus[1m]' if inputs.depth == 'deep' else 'sonnet' }} with: - skill_command: /autoskillit:investigate ${{ '--depth deep ' if inputs.depth - == 'deep' else '' }}${{ inputs.task }} + skill_command: "/autoskillit:investigate" + skill_inputs: + topic: "${{ '--depth deep ' if inputs.depth == 'deep' else '' }}${{ inputs.task }} ${{ inputs.issue_url }}" cwd: ${{ context.work_dir }} output_dir: '{{AUTOSKILLIT_TEMP}}/investigate' step_name: investigate - issue_url: ${{ inputs.issue_url }} - issue_title: ${{ context.issue_title }} capture: investigation_path: ${{ result.investigation_path }} on_success: bridge_investigation @@ -290,7 +289,9 @@ steps: tool: run_skill model: '' with: - skill_command: /autoskillit:rectify ${{ context.effective_investigation_path }} + skill_command: "/autoskillit:rectify" + skill_inputs: + investigation_path: "${{ context.effective_investigation_path }}" cwd: ${{ context.work_dir }} output_dir: '{{AUTOSKILLIT_TEMP}}/rectify' step_name: rectify @@ -310,8 +311,7 @@ steps: ACCUMULATION: all_plan_paths accumulates across any re-entries to the rectify step. On re-entry, the agent MUST append the captured plan_path to context.all_plan_paths using a comma separator instead of letting the capture block overwrite the - accumulated value. When verdict=false_positive, do NOT overwrite all_plan_paths — - preserve the existing accumulated value from prior plan executions. + accumulated value. ' salvage_rectify_plan: @@ -339,7 +339,9 @@ steps: tool: run_skill model: '' with: - skill_command: /autoskillit:review-approach ${{ context.plan_path }} + skill_command: "/autoskillit:review-approach" + skill_inputs: + plan_path: "${{ context.plan_path }}" cwd: ${{ context.work_dir }} step_name: review_approach output_dir: '{{AUTOSKILLIT_TEMP}}/review-approach' @@ -357,12 +359,15 @@ steps: tool: run_skill model: '' with: - skill_command: /autoskillit:dry-walkthrough ${{ context.plan_path }} + skill_command: "/autoskillit:dry-walkthrough" + skill_inputs: + plan_path: "${{ context.plan_path }}" + issue_url: "${{ inputs.issue_url }}" + review_path: "${{ context.review_path }}" + audit_cycle_path: "${{ context.audit_cycle_path }}" + plan_disposition_path: "${{ context.plan_disposition_path }}" cwd: ${{ context.work_dir }} output_dir: '{{AUTOSKILLIT_TEMP}}' - review_path: ${{ context.review_path }} - issue_url: ${{ inputs.issue_url }} - remediation_path: ${{ context.remediation_path }} step_name: dry_walkthrough on_success: create_impl_worktree on_failure: release_issue_failure @@ -370,19 +375,23 @@ steps: on_exhausted: release_issue_failure on_context_limit: retry_walkthrough optional_context_refs: - - remediation_path + - review_path + - audit_cycle_path + - plan_disposition_path note: Dry walkthrough retries on failure. It never routes back to a previous step. retry_walkthrough: tool: run_skill model: '' with: - skill_command: /autoskillit:dry-walkthrough ${{ context.plan_path }} remediation_path=${{ - context.remediation_path }} + skill_command: "/autoskillit:dry-walkthrough" + skill_inputs: + plan_path: "${{ context.plan_path }}" + issue_url: "${{ inputs.issue_url }}" + review_path: "${{ context.review_path }}" + audit_cycle_path: "${{ context.audit_cycle_path }}" + plan_disposition_path: "${{ context.plan_disposition_path }}" cwd: ${{ context.work_dir }} output_dir: '{{AUTOSKILLIT_TEMP}}' - review_path: ${{ context.review_path }} - issue_url: ${{ inputs.issue_url }} - remediation_path: ${{ context.remediation_path }} step_name: retry_walkthrough retries: 0 on_success: create_impl_worktree @@ -390,7 +399,9 @@ steps: on_context_limit: release_issue_failure on_rate_limit: release_issue_failure optional_context_refs: - - remediation_path + - review_path + - audit_cycle_path + - plan_disposition_path note: Bounded one-hop re-invoke of dry-walkthrough after a context-limit stumble on the primary dry_walkthrough step. Never routes back to dry_walkthrough — on_failure and on_context_limit both fall through to the original failure @@ -412,8 +423,9 @@ steps: model: '' stale_threshold: 2400 with: - skill_command: /autoskillit:implement-worktree-no-merge ${{ context.plan_path - }} + skill_command: "/autoskillit:implement-worktree-no-merge" + skill_inputs: + plan_path: "${{ context.plan_path }}" cwd: ${{ context.worktree_path }} step_name: implement output_dir: '.' @@ -434,8 +446,10 @@ steps: model: '' stale_threshold: 2400 with: - skill_command: /autoskillit:retry-worktree ${{ context.plan_path }} ${{ context.worktree_path - }} + skill_command: "/autoskillit:retry-worktree" + skill_inputs: + plan_path: "${{ context.plan_path }}" + worktree_path: "${{ context.worktree_path }}" cwd: ${{ context.worktree_path }} step_name: retry_worktree output_dir: '.' @@ -850,9 +864,13 @@ steps: tool: run_skill model: '' with: - skill_command: /autoskillit:resolve-failures ${{ context.worktree_path }} ${{ - context.plan_path }} ${{ inputs.base_branch }} ${{ context.merge_gate_ci_conclusion - }} - ${{ context.merge_gate_diagnosis_path }} + skill_command: "/autoskillit:resolve-failures" + skill_inputs: + base_branch: "${{ inputs.base_branch }}" + diagnosis_path: "${{ context.merge_gate_diagnosis_path }}" + ci_conclusion: "${{ context.merge_gate_ci_conclusion }}" + plan_path: "${{ context.plan_path }}" + worktree_path: "${{ context.worktree_path }}" cwd: ${{ context.worktree_path }} step_name: assess output_dir: '.' @@ -906,9 +924,13 @@ steps: tool: run_skill model: '' with: - skill_command: /autoskillit:resolve-failures ${{ context.worktree_path }} ${{ - context.plan_path }} ${{ inputs.base_branch }} ${{ context.merge_gate_ci_conclusion - }} - ${{ context.merge_gate_diagnosis_path }} + skill_command: "/autoskillit:resolve-failures" + skill_inputs: + base_branch: "${{ inputs.base_branch }}" + diagnosis_path: "${{ context.merge_gate_diagnosis_path }}" + ci_conclusion: "${{ context.merge_gate_ci_conclusion }}" + plan_path: "${{ context.plan_path }}" + worktree_path: "${{ context.worktree_path }}" cwd: ${{ context.worktree_path }} step_name: merge_gate_assess output_dir: '.' @@ -972,8 +994,11 @@ steps: tool: run_skill model: '' with: - skill_command: /autoskillit:resolve-merge-conflicts ${{ context.worktree_path - }} ${{ context.plan_path }} ${{ inputs.base_branch }} + skill_command: "/autoskillit:resolve-merge-conflicts" + skill_inputs: + plan_path: "${{ context.plan_path }}" + base_branch: "${{ inputs.base_branch }}" + worktree_path: "${{ context.worktree_path }}" cwd: ${{ context.worktree_path }} step_name: rebase_conflict_fix output_dir: '.' @@ -994,9 +1019,13 @@ steps: tool: run_skill model: '' with: - skill_command: /autoskillit:audit-impl ${{ context.all_plan_paths }} ${{ context.branch_name - }} ${{ inputs.base_branch }} deviation_manifest_path=${{ context.deviation_manifest_path - }} + skill_command: "/autoskillit:audit-impl" + skill_inputs: + all_plan_paths: "${{ context.all_plan_paths }}" + deviation_manifest_path: "${{ context.deviation_manifest_path }}" + branch_name: "${{ context.branch_name }}" + base_branch: "${{ inputs.base_branch }}" + prior_audit_cycle_path: "${{ context.audit_cycle_path }}" cwd: ${{ context.work_dir }} step_name: audit_impl output_dir: '{{AUTOSKILLIT_TEMP}}/audit-impl' @@ -1008,6 +1037,7 @@ steps: closure_target_sha: "" capture: remediation_path: ${{ result.remediation_path }} + audit_cycle_path: ${{ result.audit_cycle_path }} on_result: - when: ${{ result.verdict }} == GO route: commit_guard @@ -1023,61 +1053,58 @@ steps: skip_when_false: inputs.audit_impl optional_context_refs: - deviation_manifest_path + - audit_cycle_path note: Only run if inputs.audit_impl = true. If inputs.audit_impl = false, skip directly to commit_guard. remediate: action: route with: - remediation_path: ${{ context.remediation_path }} + audit_cycle_path: ${{ context.audit_cycle_path }} on_success: make_plan make_plan: tool: run_skill model: '' with: - skill_command: /autoskillit:make-plan ${{ context.remediation_path }} - task: ${{ inputs.task }} + skill_command: "/autoskillit:make-plan" + skill_inputs: + adversarial_review_level: "${{ inputs.adversarial_review_level }}" + task: "${{ inputs.task }}" + issue_url: "${{ inputs.issue_url }}" + audit_cycle_path: "${{ context.audit_cycle_path }}" cwd: ${{ context.work_dir }} step_name: make_plan - adversarial_review_level: ${{ inputs.adversarial_review_level }} output_dir: '.' capture: plan_path: ${{ result.plan_path }} all_plan_paths: ${{ result.plan_path }} verdict: ${{ result.verdict }} + plan_disposition_path: ${{ result.plan_disposition_path }} capture_list: plan_parts: ${{ result.plan_parts }} retries: 0 on_result: - when: ${{ result.verdict }} == plan route: dry_walkthrough - - when: ${{ result.verdict }} == false_positive - route: check_has_commits on_failure: release_issue_failure on_rate_limit: release_issue_failure on_context_limit: salvage_plan - note: 'ADVERSARIAL REVIEW LEVEL: If inputs.adversarial_review_level is a non-empty - string, append it to the skill_command: "/autoskillit:make-plan {task}\n\nadversarial_review_level={inputs.adversarial_review_level}" - AUDIT REMEDIATION MODE: This step is always in remediation context (invoked - from the remediate route step). Always append audit_remediation_mode=true to - the skill_command at runtime: "/autoskillit:make-plan {remediation_path}\n\naudit_remediation_mode=true" - VERDICT: The skill emits verdict=plan (produce implementation plan) or verdict=false_positive - (audit finding is a false positive, skip implement). false_positive routes to - check_has_commits. ACCUMULATION: all_plan_paths accumulates across any re-entries - to the make_plan step. On re-entry, the agent MUST append the captured plan_path - to context.all_plan_paths using a comma separator instead of letting the capture - block overwrite the accumulated value. When verdict=false_positive, do NOT overwrite - all_plan_paths — preserve the existing accumulated value from prior plan executions. - - ' + note: 'Consumes the exact current NO GO audit_cycle_path and emits verdict=plan, + plan_path, and plan_disposition_path. all_plan_paths appends the recovered plan; + make-plan cannot close the audit lineage.' salvage_plan: tool: run_python with: callable: autoskillit.recipe._cmd_rpc.verify_plan_artifacts plan_parts: ${{ context.plan_parts }} + audit_cycle_path: ${{ context.audit_cycle_path }} capture: plan_path: ${{ result.plan_path }} + plan_disposition_path: ${{ result.plan_disposition_path }} + all_plan_paths: ${{ result.plan_path }} capture_list: plan_parts: ${{ result.plan_parts }} + optional_context_refs: + - audit_cycle_path retries: 0 on_result: - when: ${{ result.verdict }} == salvaged @@ -1237,8 +1264,13 @@ steps: model: '' retries: 1 with: - skill_command: /autoskillit:prepare-pr ${{ context.all_plan_paths }} ${{ inputs.run_name - }} ${{ inputs.base_branch }} ${{ context.issue_number }} ${{ inputs.arch_lenses }} + skill_command: "/autoskillit:prepare-pr" + skill_inputs: + arch_lenses: "${{ inputs.arch_lenses }}" + plan_paths: "${{ context.all_plan_paths }}" + closing_issue: "${{ context.issue_number }}" + base_branch: "${{ inputs.base_branch }}" + run_name: "${{ inputs.run_name }}" cwd: ${{ context.work_dir }} output_dir: '.' step_name: prepare_pr @@ -1267,7 +1299,9 @@ steps: tool: run_skill model: '' with: - skill_command: /autoskillit:arch-lens-{slug} {context_path} + skill_command: "/autoskillit:arch-lens-{slug}" + skill_inputs: + context_path: "{context_path}" cwd: ${{ context.work_dir }} step_name: run_arch_lenses capture_list: @@ -1291,9 +1325,13 @@ steps: tool: run_skill model: '' with: - skill_command: /autoskillit:compose-pr ${{ context.prep_path }} "${{ context.all_diagram_paths - }}" ${{ context.work_dir }} ${{ inputs.base_branch }} ${{ context.issue_number - }} + skill_command: "/autoskillit:compose-pr" + skill_inputs: + work_dir: "${{ context.work_dir }}" + closing_issue: "${{ context.issue_number }}" + all_diagram_paths: "${{ context.all_diagram_paths }}" + base_branch: "${{ inputs.base_branch }}" + prep_path: "${{ context.prep_path }}" cwd: ${{ context.work_dir }} output_dir: '.' step_name: compose_pr @@ -1382,11 +1420,16 @@ steps: tool: run_skill model: '' with: - skill_command: /autoskillit:review-pr ${{ context.merge_target }} ${{ inputs.base_branch - }} annotated_diff_path=${{ context.annotated_diff_path }} hunk_ranges_path=${{ - context.hunk_ranges_path }} valid_lines_path=${{ context.valid_lines_path }} - diff_metrics_path=${{ context.diff_metrics_path - }} mode=${{ context.review_mode }} pr_head_sha=${{ context.pr_head_sha }} + skill_command: "/autoskillit:review-pr" + skill_inputs: + feature_branch: "${{ context.merge_target }}" + base_branch: "${{ inputs.base_branch }}" + annotated_diff_path: "${{ context.annotated_diff_path }}" + hunk_ranges_path: "${{ context.hunk_ranges_path }}" + valid_lines_path: "${{ context.valid_lines_path }}" + diff_metrics_path: "${{ context.diff_metrics_path }}" + mode: "${{ context.review_mode }}" + pr_head_sha: "${{ context.pr_head_sha }}" cwd: ${{ context.work_dir }} step_name: review_pr output_dir: '{{AUTOSKILLIT_TEMP}}/review-pr/iter_${{ context.review_loop_count }}' @@ -1425,8 +1468,11 @@ steps: tool: run_skill model: '' with: - skill_command: /autoskillit:resolve-review ${{ context.merge_target }} ${{ inputs.base_branch - }} mode=${{ context.review_mode }} + skill_command: "/autoskillit:resolve-review" + skill_inputs: + base_branch: "${{ inputs.base_branch }}" + feature_branch: "${{ context.merge_target }}" + mode: "${{ context.review_mode }}" cwd: ${{ context.work_dir }} output_dir: '.' step_name: resolve_review @@ -1473,8 +1519,11 @@ steps: tool: run_skill model: '' with: - skill_command: /autoskillit:resolve-merge-conflicts ${{ context.work_dir }} - ${{ context.plan_path }} ${{ inputs.base_branch }} + skill_command: "/autoskillit:resolve-merge-conflicts" + skill_inputs: + plan_path: "${{ context.plan_path }}" + base_branch: "${{ inputs.base_branch }}" + worktree_path: "${{ context.work_dir }}" cwd: ${{ context.work_dir }} output_dir: '.' step_name: resolve_pre_review_conflicts @@ -1799,9 +1848,11 @@ steps: optional: true skip_when_false: inputs.pipeline_health with: - skill_command: /autoskillit:analyze-pipeline-health ${{ inputs.kitchen_id }} - --dispatch-id=${{ inputs.dispatch_id }} --diagnostics-log-dir=${{ inputs.diagnostics_log_dir - }} + skill_command: "/autoskillit:analyze-pipeline-health" + skill_inputs: + kitchen_id: "${{ inputs.kitchen_id }}" + dispatch_id: "--dispatch-id=${{ inputs.dispatch_id }}" + diagnostics_log_dir: "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" cwd: ${{ context.work_dir }} step_name: analyze_pipeline_health on_success: escalate_stop_no_ci @@ -2068,8 +2119,11 @@ steps: tool: run_skill model: '' with: - skill_command: /autoskillit:resolve-merge-conflicts ${{ context.work_dir }} - ${{ context.plan_path }} ${{ inputs.base_branch }} + skill_command: "/autoskillit:resolve-merge-conflicts" + skill_inputs: + plan_path: "${{ context.plan_path }}" + base_branch: "${{ inputs.base_branch }}" + worktree_path: "${{ context.work_dir }}" cwd: ${{ context.work_dir }} output_dir: '.' step_name: resolve_queue_merge_conflicts @@ -2274,8 +2328,11 @@ steps: tool: run_skill model: '' with: - skill_command: /autoskillit:resolve-merge-conflicts ${{ context.work_dir }} - ${{ context.plan_path }} ${{ inputs.base_branch }} + skill_command: "/autoskillit:resolve-merge-conflicts" + skill_inputs: + plan_path: "${{ context.plan_path }}" + base_branch: "${{ inputs.base_branch }}" + worktree_path: "${{ context.work_dir }}" cwd: ${{ context.work_dir }} output_dir: '.' step_name: resolve_direct_merge_conflicts @@ -2380,8 +2437,11 @@ steps: tool: run_skill model: '' with: - skill_command: /autoskillit:resolve-merge-conflicts ${{ context.work_dir }} - ${{ context.plan_path }} ${{ inputs.base_branch }} + skill_command: "/autoskillit:resolve-merge-conflicts" + skill_inputs: + plan_path: "${{ context.plan_path }}" + base_branch: "${{ inputs.base_branch }}" + worktree_path: "${{ context.work_dir }}" cwd: ${{ context.work_dir }} output_dir: '.' step_name: resolve_immediate_merge_conflicts @@ -2430,12 +2490,14 @@ steps: tool: run_skill model: '' with: - skill_command: /autoskillit:diagnose-ci ${{ context.merge_target }} - - - ${{ - context.ci_event }} + skill_command: "/autoskillit:diagnose-ci" + skill_inputs: + ci_failed_jobs: "${{ context.ci_failed_jobs }}" + event: "${{ context.ci_event }}" + branch: "${{ context.merge_target }}" cwd: ${{ context.work_dir }} step_name: diagnose_ci - ci_failed_jobs: ${{ context.ci_failed_jobs }} - output_dir: '{{AUTOSKILLIT_TEMP}}/diagnose-ci' + output_dir: '{{AUTOSKILLIT_TEMP}}/diagnose-ci/rebase_${{ context.ci_rebase_count }}_flake_${{ context.ci_flake_count }}' capture: diagnosis_path: ${{ result.diagnosis_path }} on_success: resolve_ci @@ -2443,6 +2505,9 @@ steps: on_context_limit: resolve_ci on_rate_limit: resolve_ci optional: true + optional_context_refs: + - ci_rebase_count + - ci_flake_count skip_when_false: inputs.open_pr note: 'Runs when ci_watch reports a CI failure. Fetches CI logs via gh API and writes a structured diagnosis to {{AUTOSKILLIT_TEMP}}/diagnose-ci/. Routes to @@ -2455,9 +2520,14 @@ steps: tool: run_skill model: '' with: - skill_command: /autoskillit:resolve-failures ${{ context.work_dir }} ${{ context.plan_path - }} ${{ inputs.base_branch }} ${{ context.ci_conclusion }} ${{ context.ci_failed_jobs - }} ${{ context.diagnosis_path }} + skill_command: "/autoskillit:resolve-failures" + skill_inputs: + base_branch: "${{ inputs.base_branch }}" + diagnosis_path: "${{ context.diagnosis_path }}" + ci_failed_jobs: "${{ context.ci_failed_jobs }}" + ci_conclusion: "${{ context.ci_conclusion }}" + plan_path: "${{ context.plan_path }}" + worktree_path: "${{ context.work_dir }}" cwd: ${{ context.work_dir }} output_dir: '.' step_name: resolve_ci @@ -2514,8 +2584,11 @@ steps: tool: run_skill model: '' with: - skill_command: /autoskillit:resolve-merge-conflicts ${{ context.work_dir }} - ${{ context.plan_path }} ${{ inputs.base_branch }} + skill_command: "/autoskillit:resolve-merge-conflicts" + skill_inputs: + plan_path: "${{ context.plan_path }}" + base_branch: "${{ inputs.base_branch }}" + worktree_path: "${{ context.work_dir }}" cwd: ${{ context.work_dir }} output_dir: '.' step_name: resolve_pre_resolve_conflicts @@ -2617,8 +2690,11 @@ steps: tool: run_skill model: '' with: - skill_command: /autoskillit:resolve-merge-conflicts ${{ context.work_dir }} - ${{ context.plan_path }} ${{ inputs.base_branch }} + skill_command: "/autoskillit:resolve-merge-conflicts" + skill_inputs: + plan_path: "${{ context.plan_path }}" + base_branch: "${{ inputs.base_branch }}" + worktree_path: "${{ context.work_dir }}" cwd: ${{ context.work_dir }} output_dir: '.' step_name: ci_conflict_fix @@ -2668,9 +2744,11 @@ steps: optional: true skip_when_false: inputs.pipeline_health with: - skill_command: /autoskillit:analyze-pipeline-health ${{ inputs.kitchen_id }} - --dispatch-id=${{ inputs.dispatch_id }} --diagnostics-log-dir=${{ inputs.diagnostics_log_dir - }} + skill_command: "/autoskillit:analyze-pipeline-health" + skill_inputs: + kitchen_id: "${{ inputs.kitchen_id }}" + dispatch_id: "--dispatch-id=${{ inputs.dispatch_id }}" + diagnostics_log_dir: "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" cwd: ${{ context.work_dir }} step_name: analyze_pipeline_health on_success: done_unconfirmed @@ -2757,9 +2835,11 @@ steps: optional: true skip_when_false: inputs.pipeline_health with: - skill_command: /autoskillit:analyze-pipeline-health ${{ inputs.kitchen_id }} - --dispatch-id=${{ inputs.dispatch_id }} --diagnostics-log-dir=${{ inputs.diagnostics_log_dir - }} + skill_command: "/autoskillit:analyze-pipeline-health" + skill_inputs: + kitchen_id: "${{ inputs.kitchen_id }}" + dispatch_id: "--dispatch-id=${{ inputs.dispatch_id }}" + diagnostics_log_dir: "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" cwd: ${{ context.work_dir }} step_name: analyze_pipeline_health on_success: done @@ -2778,9 +2858,11 @@ steps: optional: true skip_when_false: inputs.pipeline_health with: - skill_command: /autoskillit:analyze-pipeline-health ${{ inputs.kitchen_id }} - --dispatch-id=${{ inputs.dispatch_id }} --diagnostics-log-dir=${{ inputs.diagnostics_log_dir - }} + skill_command: "/autoskillit:analyze-pipeline-health" + skill_inputs: + kitchen_id: "${{ inputs.kitchen_id }}" + dispatch_id: "--dispatch-id=${{ inputs.dispatch_id }}" + diagnostics_log_dir: "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" cwd: ${{ context.work_dir }} step_name: analyze_pipeline_health on_success: escalate_stop @@ -2808,9 +2890,11 @@ steps: optional: true skip_when_false: inputs.pipeline_health with: - skill_command: /autoskillit:analyze-pipeline-health ${{ inputs.kitchen_id }} - --dispatch-id=${{ inputs.dispatch_id }} --diagnostics-log-dir=${{ inputs.diagnostics_log_dir - }} + skill_command: "/autoskillit:analyze-pipeline-health" + skill_inputs: + kitchen_id: "${{ inputs.kitchen_id }}" + dispatch_id: "--dispatch-id=${{ inputs.dispatch_id }}" + diagnostics_log_dir: "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" cwd: ${{ context.work_dir }} step_name: analyze_pipeline_health on_success: done_no_changes @@ -2838,9 +2922,11 @@ steps: optional: true skip_when_false: inputs.pipeline_health with: - skill_command: /autoskillit:analyze-pipeline-health ${{ inputs.kitchen_id }} - --dispatch-id=${{ inputs.dispatch_id }} --diagnostics-log-dir=${{ inputs.diagnostics_log_dir - }} + skill_command: "/autoskillit:analyze-pipeline-health" + skill_inputs: + kitchen_id: "${{ inputs.kitchen_id }}" + dispatch_id: "--dispatch-id=${{ inputs.dispatch_id }}" + diagnostics_log_dir: "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" cwd: ${{ context.work_dir }} step_name: analyze_pipeline_health on_success: done_already_done diff --git a/src/autoskillit/recipes/research-design.json b/src/autoskillit/recipes/research-design.json index 88620fff2..a7729c1aa 100644 --- a/src/autoskillit/recipes/research-design.json +++ b/src/autoskillit/recipes/research-design.json @@ -133,7 +133,7 @@ "selected_lenses": "${{ result.selected_lenses }}", "dimensions_manifest_path": "${{ result.dimensions_manifest_path }}" }, - "on_success": "apply", + "on_success": "check_design_review_loop", "on_failure": "escalate_stop" }, "apply": { @@ -141,10 +141,17 @@ "model": "", "phoropter_family": "review-design", "with": { - "skill_command": "/autoskillit:apply-review-dimensions ${{ context.experiment_plan }} ${{ context.scope_report }} ${{ context.dimensions_manifest_path }}", + "skill_command": "/autoskillit:apply-review-dimensions", + "skill_inputs": { + "dimensions_manifest_path": "${{ context.dimensions_manifest_path }}", + "scope_report_path": "${{ context.scope_report }}", + "experiment_plan_path": "${{ context.experiment_plan }}", + "experiment_type": "${{ context.experiment_type }}", + "classification_timestamp": "${{ context.classification_timestamp }}" + }, "cwd": "${{ inputs.source_dir }}", "step_name": "apply", - "output_dir": "{{AUTOSKILLIT_TEMP}}/apply-review-dimensions" + "output_dir": "{{AUTOSKILLIT_TEMP}}/apply-review-dimensions/iter_${{ context.design_review_count }}" }, "capture": { "findings_manifest_path": "${{ result.findings_manifest_path }}", @@ -152,7 +159,8 @@ }, "optional_context_refs": [ "is_silent_type", - "classification_timestamp" + "classification_timestamp", + "design_review_count" ], "skip_when_true": "context.is_silent_type", "retries": 2, @@ -298,7 +306,7 @@ "skill_command": "/autoskillit:resolve-design-review ${{ context.evaluation_dashboard }} ${{ context.experiment_plan }} ${{ context.revision_guidance }}", "cwd": "${{ inputs.source_dir }}", "step_name": "resolve_design_review", - "output_dir": "{{AUTOSKILLIT_TEMP}}/resolve-design-review" + "output_dir": "{{AUTOSKILLIT_TEMP}}/resolve-design-review/iter_${{ context.design_review_count }}" }, "optional_context_refs": [ "evaluation_dashboard", diff --git a/src/autoskillit/recipes/research-design.yaml b/src/autoskillit/recipes/research-design.yaml index 580f42feb..596c93d2a 100644 --- a/src/autoskillit/recipes/research-design.yaml +++ b/src/autoskillit/recipes/research-design.yaml @@ -127,7 +127,7 @@ steps: capture: selected_lenses: "${{ result.selected_lenses }}" dimensions_manifest_path: "${{ result.dimensions_manifest_path }}" - on_success: apply + on_success: check_design_review_loop on_failure: escalate_stop apply: @@ -135,14 +135,20 @@ steps: model: "" phoropter_family: review-design with: - skill_command: "/autoskillit:apply-review-dimensions ${{ context.experiment_plan }} ${{ context.scope_report }} ${{ context.dimensions_manifest_path }}" + skill_command: "/autoskillit:apply-review-dimensions" + skill_inputs: + dimensions_manifest_path: "${{ context.dimensions_manifest_path }}" + scope_report_path: "${{ context.scope_report }}" + experiment_plan_path: "${{ context.experiment_plan }}" + experiment_type: "${{ context.experiment_type }}" + classification_timestamp: "${{ context.classification_timestamp }}" cwd: "${{ inputs.source_dir }}" step_name: apply - output_dir: "{{AUTOSKILLIT_TEMP}}/apply-review-dimensions" + output_dir: "{{AUTOSKILLIT_TEMP}}/apply-review-dimensions/iter_${{ context.design_review_count }}" capture: findings_manifest_path: "${{ result.findings_manifest_path }}" evaluation_dashboard: "${{ result.evaluation_dashboard_path }}" - optional_context_refs: [is_silent_type, classification_timestamp] + optional_context_refs: [is_silent_type, classification_timestamp, design_review_count] skip_when_true: context.is_silent_type retries: 2 on_exhausted: synthesize @@ -257,7 +263,7 @@ steps: skill_command: "/autoskillit:resolve-design-review ${{ context.evaluation_dashboard }} ${{ context.experiment_plan }} ${{ context.revision_guidance }}" cwd: "${{ inputs.source_dir }}" step_name: resolve_design_review - output_dir: "{{AUTOSKILLIT_TEMP}}/resolve-design-review" + output_dir: "{{AUTOSKILLIT_TEMP}}/resolve-design-review/iter_${{ context.design_review_count }}" optional_context_refs: [evaluation_dashboard, experiment_plan, revision_guidance, findings_manifest_path] capture: revision_guidance: "${{ result.revision_guidance }}" diff --git a/src/autoskillit/recipes/research-implement.json b/src/autoskillit/recipes/research-implement.json index f93620c49..738fa8098 100644 --- a/src/autoskillit/recipes/research-implement.json +++ b/src/autoskillit/recipes/research-implement.json @@ -91,7 +91,10 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:stage-data ${{ inputs.experiment_plan }}", + "skill_command": "/autoskillit:stage-data", + "skill_inputs": { + "experiment_plan": "${{ inputs.experiment_plan }}" + }, "cwd": "${{ inputs.worktree_path }}", "step_name": "stage_data", "output_dir": "{{AUTOSKILLIT_TEMP}}/stage-data" @@ -119,7 +122,10 @@ "retries": 1, "on_exhausted": "escalate_stop", "with": { - "skill_command": "/autoskillit:download-data ${{ inputs.experiment_plan }}", + "skill_command": "/autoskillit:download-data", + "skill_inputs": { + "experiment_plan": "${{ inputs.experiment_plan }}" + }, "cwd": "${{ inputs.worktree_path }}", "step_name": "download_data", "output_dir": "{{AUTOSKILLIT_TEMP}}/download-data" @@ -143,7 +149,10 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:make-groups ${{ inputs.experiment_plan }}", + "skill_command": "/autoskillit:make-groups", + "skill_inputs": { + "source_doc": "${{ inputs.experiment_plan }}" + }, "cwd": "${{ inputs.worktree_path }}", "step_name": "decompose", "output_dir": "{{AUTOSKILLIT_TEMP}}/make-groups" @@ -173,15 +182,19 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:make-plan ${{ context.group_files }}", + "skill_command": "/autoskillit:make-plan", + "skill_inputs": { + "task": "${{ context.group_files }}", + "audit_cycle_path": "${{ context.audit_cycle_path }}" + }, "cwd": "${{ inputs.worktree_path }}", "step_name": "plan_phase", - "output_dir": ".", - "group_files": "${{ context.group_files }}" + "output_dir": "." }, "capture": { "phase_plan_path": "${{ result.plan_path }}", - "verdict": "${{ result.verdict }}" + "verdict": "${{ result.verdict }}", + "plan_disposition_path": "${{ result.plan_disposition_path }}" }, "capture_list": { "plan_parts": "${{ result.plan_parts }}" @@ -191,28 +204,32 @@ { "when": "${{ result.verdict }} == plan", "route": "implement_phase" - }, - { - "when": "${{ result.verdict }} == false_positive", - "route": "implement_phase" } ], "on_failure": "escalate_stop", "on_context_limit": "salvage_plan_phase", - "note": "Plans the current setup phase. Replace ${{ context.group_files }} in the skill_command with the FIRST REMAINING phase file path from the list — do NOT pass the entire list. Pass the path directly to the skill (do not read it). Glob plan_dir for *_part_*.md or single plan file. ITERATION: After each implement_phase cycle, advance to the next entry in context.group_files for the following plan_phase iteration. REMEDIATION MODE: context.remediation_path is available when the orchestrator re-enters plan_phase from the remediate step via check_audit_retry_loop; pass it inline in the skill_command rather than as a named with: key.\n" + "note": "Plans the current setup phase. Replace ${{ context.group_files }} in the skill_command with the FIRST REMAINING phase file path from the list — do NOT pass the entire list. Pass the path directly to the skill (do not read it). Glob plan_dir for *_part_*.md or single plan file. ITERATION: After each implement_phase cycle, advance to the next entry in context.group_files for the following plan_phase iteration. REMEDIATION MODE: consume the exact audit_cycle_path from the NO GO producer and emit its plan_disposition_path. verdict=plan is the only success.\n", + "optional_context_refs": [ + "audit_cycle_path" + ] }, "salvage_plan_phase": { "tool": "run_python", "with": { "callable": "autoskillit.recipe._cmd_rpc.verify_plan_artifacts", - "plan_parts": "${{ context.plan_parts }}" + "plan_parts": "${{ context.plan_parts }}", + "audit_cycle_path": "${{ context.audit_cycle_path }}" }, "capture": { - "phase_plan_path": "${{ result.plan_path }}" + "phase_plan_path": "${{ result.plan_path }}", + "plan_disposition_path": "${{ result.plan_disposition_path }}" }, "capture_list": { "plan_parts": "${{ result.plan_parts }}" }, + "optional_context_refs": [ + "audit_cycle_path" + ], "retries": 0, "on_result": [ { @@ -232,7 +249,10 @@ "stale_threshold": 2400, "idle_output_timeout": 0, "with": { - "skill_command": "/autoskillit:implement-experiment ${{ context.phase_plan_path }}", + "skill_command": "/autoskillit:implement-experiment", + "skill_inputs": { + "plan_path": "${{ context.phase_plan_path }}" + }, "cwd": "${{ inputs.worktree_path }}", "step_name": "implement_phase", "output_dir": "." @@ -242,7 +262,7 @@ }, "retries": 0, "on_success": "next_phase_or_experiment", - "on_failure": "troubleshoot_implement_failure", + "on_failure": "check_implement_fix_loop", "on_exhausted": "audit_impl", "on_context_limit": "audit_impl", "skip_when_false": "inputs.backend_supports_git_write", @@ -254,11 +274,18 @@ "stale_threshold": 2400, "idle_output_timeout": 0, "with": { - "skill_command": "/autoskillit:troubleshoot-experiment ${{ context.worktree_path }} implement_phase", + "skill_command": "/autoskillit:troubleshoot-experiment", + "skill_inputs": { + "worktree_path": "${{ context.worktree_path }}", + "step_name": "implement_phase" + }, "cwd": "${{ inputs.source_dir }}", "step_name": "troubleshoot_implement_failure", - "output_dir": "{{AUTOSKILLIT_TEMP}}/troubleshoot-experiment" + "output_dir": "{{AUTOSKILLIT_TEMP}}/troubleshoot-experiment/implement_${{ context.implement_fix_count }}" }, + "optional_context_refs": [ + "implement_fix_count" + ], "capture": { "is_fixable": "${{ result.is_fixable }}", "retry_delay": "${{ result.retry_delay }}" @@ -271,7 +298,7 @@ "on_result": [ { "when": "${{ context.is_fixable }} == true", - "route": "check_implement_fix_loop" + "route": "route_implement_retry_delay" }, { "route": "escalate_stop" @@ -294,7 +321,7 @@ "route": "audit_impl" }, { - "route": "route_implement_retry_delay" + "route": "troubleshoot_implement_failure" } ], "on_failure": "audit_impl", @@ -382,7 +409,13 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:audit-impl \"${{ context.group_files }}\" ${{ context.impl_base_sha }} ${{ inputs.base_branch }}", + "skill_command": "/autoskillit:audit-impl", + "skill_inputs": { + "all_plan_paths": "${{ context.group_files }}", + "branch_name": "${{ context.impl_base_sha }}", + "base_branch": "${{ inputs.base_branch }}", + "prior_audit_cycle_path": "${{ context.audit_cycle_path }}" + }, "cwd": "${{ context.worktree_path }}", "step_name": "audit_impl", "output_dir": "{{AUTOSKILLIT_TEMP}}/audit-impl", @@ -394,7 +427,8 @@ "closure_target_sha": "" }, "capture": { - "remediation_path": "${{ result.remediation_path }}" + "remediation_path": "${{ result.remediation_path }}", + "audit_cycle_path": "${{ result.audit_cycle_path }}" }, "on_result": [ { @@ -416,15 +450,18 @@ "on_failure": "escalate_stop", "on_context_limit": "escalate_stop", "skip_when_false": "inputs.audit_impl", + "optional_context_refs": [ + "audit_cycle_path" + ], "note": "Diffs impl_base_sha..HEAD (two-dot SHA-mode) and verifies all decomposed phases from group_files have corresponding implementation commits. impl_base_sha is captured by capture_impl_base before phase implementation begins. Catches silent group abandonment when on_context_limit fires mid-iteration — reports exactly which phases were completed vs. skipped. On GO, proceeds to run_experiment. On error, escalates immediately. On NO GO, routes to remediate which re-enters plan_phase via check_audit_retry_loop (max 2 cycles). If false (inputs.audit_impl=false), skip directly to run_experiment.\n" }, "remediate": { "action": "route", "with": { - "remediation_path": "${{ context.remediation_path }}" + "audit_cycle_path": "${{ context.audit_cycle_path }}" }, "on_success": "check_audit_retry_loop", - "note": "Pure routing step carrying the remediation file path from audit_impl's capture into the retry cycle. The remediation_path contains the list of missing phases identified by audit-impl.\n" + "note": "Carries the exact current NO GO authority into the bounded retry. The successor audit receives it as prior_audit_cycle_path; GO replaces it.\n" }, "check_audit_retry_loop": { "tool": "run_python", @@ -467,7 +504,10 @@ "stale_threshold": 2400, "idle_output_timeout": 0, "with": { - "skill_command": "/autoskillit:run-experiment ${{ context.worktree_path }}", + "skill_command": "/autoskillit:run-experiment", + "skill_inputs": { + "worktree_path": "${{ context.worktree_path }}" + }, "cwd": "${{ context.worktree_path }}", "step_name": "run_experiment", "output_dir": "." @@ -492,7 +532,7 @@ "route": "ensure_results" } ], - "on_failure": "troubleshoot_run_failure", + "on_failure": "check_run_fix_loop", "on_exhausted": "ensure_results", "on_context_limit": "escalate_stop" }, @@ -502,11 +542,18 @@ "stale_threshold": 2400, "idle_output_timeout": 0, "with": { - "skill_command": "/autoskillit:troubleshoot-experiment ${{ context.worktree_path }} run_experiment", + "skill_command": "/autoskillit:troubleshoot-experiment", + "skill_inputs": { + "worktree_path": "${{ context.worktree_path }}", + "step_name": "run_experiment" + }, "cwd": "${{ inputs.source_dir }}", "step_name": "troubleshoot_run_failure", - "output_dir": "{{AUTOSKILLIT_TEMP}}/troubleshoot-experiment" + "output_dir": "{{AUTOSKILLIT_TEMP}}/troubleshoot-experiment/run_${{ context.run_fix_count }}" }, + "optional_context_refs": [ + "run_fix_count" + ], "capture": { "run_is_fixable": "${{ result.is_fixable }}", "run_retry_delay": "${{ result.retry_delay }}" @@ -532,7 +579,11 @@ "stale_threshold": 2400, "idle_output_timeout": 0, "with": { - "skill_command": "/autoskillit:run-experiment ${{ context.worktree_path }} --adjust", + "skill_command": "/autoskillit:run-experiment", + "skill_inputs": { + "worktree_path": "${{ context.worktree_path }}", + "adjust": true + }, "cwd": "${{ context.worktree_path }}", "step_name": "adjust", "output_dir": "." @@ -545,11 +596,11 @@ "on_result": [ { "when": "${{ result.verdict }} == CONCLUSIVE", - "route": "check_run_fix_loop" + "route": "route_run_retry_delay" }, { "when": "${{ result.verdict }} == INCONCLUSIVE", - "route": "check_run_fix_loop" + "route": "route_run_retry_delay" }, { "when": "${{ result.verdict }} == BLOCKED", @@ -575,7 +626,7 @@ "route": "ensure_results" }, { - "route": "route_run_retry_delay" + "route": "troubleshoot_run_failure" } ], "on_failure": "ensure_results", @@ -630,7 +681,13 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:generate-report ${{ context.worktree_path }} ${{ context.experiment_results }} --output-mode ${{ inputs.output_mode }} --issue-url ${{ inputs.issue_url }}", + "skill_command": "/autoskillit:generate-report", + "skill_inputs": { + "worktree_path": "${{ context.worktree_path }}", + "results_path": "${{ context.experiment_results }}", + "output_mode": "${{ inputs.output_mode }}", + "issue_url": "${{ inputs.issue_url }}" + }, "cwd": "${{ context.worktree_path }}", "step_name": "generate_report", "output_dir": "{{AUTOSKILLIT_TEMP}}/generate-report" @@ -648,7 +705,14 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:generate-report ${{ context.worktree_path }} ${{ context.experiment_results }} --inconclusive --output-mode ${{ inputs.output_mode }} --issue-url ${{ inputs.issue_url }}", + "skill_command": "/autoskillit:generate-report", + "skill_inputs": { + "worktree_path": "${{ context.worktree_path }}", + "results_path": "${{ context.experiment_results }}", + "inconclusive": true, + "output_mode": "${{ inputs.output_mode }}", + "issue_url": "${{ inputs.issue_url }}" + }, "cwd": "${{ context.worktree_path }}", "step_name": "generate_report_inconclusive", "output_dir": "{{AUTOSKILLIT_TEMP}}/generate-report" @@ -675,7 +739,12 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:resolve-failures ${{ context.worktree_path }} ${{ inputs.experiment_plan }} ${{ inputs.base_branch }}", + "skill_command": "/autoskillit:resolve-failures", + "skill_inputs": { + "plan_path": "${{ inputs.experiment_plan }}", + "base_branch": "${{ inputs.base_branch }}", + "worktree_path": "${{ context.worktree_path }}" + }, "cwd": "${{ inputs.source_dir }}", "step_name": "fix", "output_dir": "." diff --git a/src/autoskillit/recipes/research-implement.yaml b/src/autoskillit/recipes/research-implement.yaml index 790bd53cc..9ca0c1c39 100644 --- a/src/autoskillit/recipes/research-implement.yaml +++ b/src/autoskillit/recipes/research-implement.yaml @@ -99,7 +99,9 @@ steps: tool: run_skill model: "" with: - skill_command: "/autoskillit:stage-data ${{ inputs.experiment_plan }}" + skill_command: "/autoskillit:stage-data" + skill_inputs: + experiment_plan: "${{ inputs.experiment_plan }}" cwd: "${{ inputs.worktree_path }}" step_name: stage_data output_dir: "{{AUTOSKILLIT_TEMP}}/stage-data" @@ -129,7 +131,9 @@ steps: retries: 1 on_exhausted: escalate_stop with: - skill_command: "/autoskillit:download-data ${{ inputs.experiment_plan }}" + skill_command: "/autoskillit:download-data" + skill_inputs: + experiment_plan: "${{ inputs.experiment_plan }}" cwd: "${{ inputs.worktree_path }}" step_name: download_data output_dir: "{{AUTOSKILLIT_TEMP}}/download-data" @@ -155,7 +159,9 @@ steps: tool: run_skill model: "" with: - skill_command: "/autoskillit:make-groups ${{ inputs.experiment_plan }}" + skill_command: "/autoskillit:make-groups" + skill_inputs: + source_doc: "${{ inputs.experiment_plan }}" cwd: "${{ inputs.worktree_path }}" step_name: decompose output_dir: "{{AUTOSKILLIT_TEMP}}/make-groups" @@ -188,22 +194,23 @@ steps: tool: run_skill model: "" with: - skill_command: "/autoskillit:make-plan ${{ context.group_files }}" + skill_command: "/autoskillit:make-plan" + skill_inputs: + task: "${{ context.group_files }}" + audit_cycle_path: "${{ context.audit_cycle_path }}" cwd: "${{ inputs.worktree_path }}" step_name: plan_phase output_dir: '.' - group_files: "${{ context.group_files }}" capture: phase_plan_path: "${{ result.plan_path }}" verdict: "${{ result.verdict }}" + plan_disposition_path: "${{ result.plan_disposition_path }}" capture_list: plan_parts: "${{ result.plan_parts }}" retries: 0 on_result: - when: "${{ result.verdict }} == plan" route: implement_phase - - when: "${{ result.verdict }} == false_positive" - route: implement_phase on_failure: escalate_stop on_context_limit: salvage_plan_phase note: > @@ -213,19 +220,24 @@ steps: Glob plan_dir for *_part_*.md or single plan file. ITERATION: After each implement_phase cycle, advance to the next entry in context.group_files for the following plan_phase iteration. - REMEDIATION MODE: context.remediation_path is available when the orchestrator - re-enters plan_phase from the remediate step via check_audit_retry_loop; - pass it inline in the skill_command rather than as a named with: key. + REMEDIATION MODE: consume the exact audit_cycle_path from the NO GO producer + and emit its plan_disposition_path. verdict=plan is the only success. + optional_context_refs: + - audit_cycle_path salvage_plan_phase: tool: run_python with: callable: "autoskillit.recipe._cmd_rpc.verify_plan_artifacts" plan_parts: "${{ context.plan_parts }}" + audit_cycle_path: "${{ context.audit_cycle_path }}" capture: phase_plan_path: "${{ result.plan_path }}" + plan_disposition_path: "${{ result.plan_disposition_path }}" capture_list: plan_parts: "${{ result.plan_parts }}" + optional_context_refs: + - audit_cycle_path retries: 0 on_result: - when: "${{ result.verdict }} == salvaged" @@ -247,7 +259,9 @@ steps: stale_threshold: 2400 idle_output_timeout: 0 with: - skill_command: "/autoskillit:implement-experiment ${{ context.phase_plan_path }}" + skill_command: "/autoskillit:implement-experiment" + skill_inputs: + plan_path: "${{ context.phase_plan_path }}" cwd: "${{ inputs.worktree_path }}" step_name: implement_phase output_dir: '.' @@ -255,7 +269,7 @@ steps: worktree_path: "${{ result.worktree_path }}" retries: 0 on_success: next_phase_or_experiment - on_failure: troubleshoot_implement_failure + on_failure: check_implement_fix_loop on_exhausted: audit_impl on_context_limit: audit_impl skip_when_false: "inputs.backend_supports_git_write" @@ -271,10 +285,14 @@ steps: stale_threshold: 2400 idle_output_timeout: 0 with: - skill_command: "/autoskillit:troubleshoot-experiment ${{ context.worktree_path }} implement_phase" + skill_command: "/autoskillit:troubleshoot-experiment" + skill_inputs: + worktree_path: "${{ context.worktree_path }}" + step_name: "implement_phase" cwd: "${{ inputs.source_dir }}" step_name: troubleshoot_implement_failure - output_dir: "{{AUTOSKILLIT_TEMP}}/troubleshoot-experiment" + output_dir: "{{AUTOSKILLIT_TEMP}}/troubleshoot-experiment/implement_${{ context.implement_fix_count }}" + optional_context_refs: [implement_fix_count] capture: is_fixable: "${{ result.is_fixable }}" retry_delay: "${{ result.retry_delay }}" @@ -285,7 +303,7 @@ steps: action: route on_result: - when: "${{ context.is_fixable }} == true" - route: check_implement_fix_loop + route: route_implement_retry_delay - route: escalate_stop check_implement_fix_loop: @@ -299,7 +317,7 @@ steps: on_result: - when: "${{ result.max_exceeded }} == true" route: audit_impl - - route: route_implement_retry_delay + - route: troubleshoot_implement_failure on_failure: audit_impl optional_context_refs: - implement_fix_count @@ -378,7 +396,12 @@ steps: tool: run_skill model: "" with: - skill_command: "/autoskillit:audit-impl \"${{ context.group_files }}\" ${{ context.impl_base_sha }} ${{ inputs.base_branch }}" + skill_command: "/autoskillit:audit-impl" + skill_inputs: + all_plan_paths: "${{ context.group_files }}" + branch_name: "${{ context.impl_base_sha }}" + base_branch: "${{ inputs.base_branch }}" + prior_audit_cycle_path: "${{ context.audit_cycle_path }}" cwd: "${{ context.worktree_path }}" step_name: audit_impl output_dir: "{{AUTOSKILLIT_TEMP}}/audit-impl" @@ -390,6 +413,7 @@ steps: closure_target_sha: "" capture: remediation_path: "${{ result.remediation_path }}" + audit_cycle_path: "${{ result.audit_cycle_path }}" on_result: - when: "${{ result.verdict }} == GO" route: run_experiment @@ -401,6 +425,8 @@ steps: on_failure: escalate_stop on_context_limit: escalate_stop skip_when_false: "inputs.audit_impl" + optional_context_refs: + - audit_cycle_path note: > Diffs impl_base_sha..HEAD (two-dot SHA-mode) and verifies all decomposed phases from group_files have corresponding implementation commits. impl_base_sha is @@ -414,12 +440,11 @@ steps: remediate: action: route with: - remediation_path: "${{ context.remediation_path }}" + audit_cycle_path: "${{ context.audit_cycle_path }}" on_success: check_audit_retry_loop note: > - Pure routing step carrying the remediation file path from audit_impl's - capture into the retry cycle. The remediation_path contains the list of - missing phases identified by audit-impl. + Carries the exact current NO GO authority into the bounded retry. The + successor audit receives it as prior_audit_cycle_path; GO replaces it. check_audit_retry_loop: tool: run_python @@ -461,7 +486,9 @@ steps: stale_threshold: 2400 idle_output_timeout: 0 with: - skill_command: "/autoskillit:run-experiment ${{ context.worktree_path }}" + skill_command: "/autoskillit:run-experiment" + skill_inputs: + worktree_path: "${{ context.worktree_path }}" cwd: "${{ context.worktree_path }}" step_name: run_experiment output_dir: '.' @@ -477,7 +504,7 @@ steps: route: escalate_stop - when: "${{ result.verdict }} == INCONCLUSIVE" route: ensure_results - on_failure: troubleshoot_run_failure + on_failure: check_run_fix_loop on_exhausted: ensure_results on_context_limit: escalate_stop @@ -487,10 +514,14 @@ steps: stale_threshold: 2400 idle_output_timeout: 0 with: - skill_command: "/autoskillit:troubleshoot-experiment ${{ context.worktree_path }} run_experiment" + skill_command: "/autoskillit:troubleshoot-experiment" + skill_inputs: + worktree_path: "${{ context.worktree_path }}" + step_name: "run_experiment" cwd: "${{ inputs.source_dir }}" step_name: troubleshoot_run_failure - output_dir: "{{AUTOSKILLIT_TEMP}}/troubleshoot-experiment" + output_dir: "{{AUTOSKILLIT_TEMP}}/troubleshoot-experiment/run_${{ context.run_fix_count }}" + optional_context_refs: [run_fix_count] capture: run_is_fixable: "${{ result.is_fixable }}" run_retry_delay: "${{ result.retry_delay }}" @@ -510,7 +541,10 @@ steps: stale_threshold: 2400 idle_output_timeout: 0 with: - skill_command: "/autoskillit:run-experiment ${{ context.worktree_path }} --adjust" + skill_command: "/autoskillit:run-experiment" + skill_inputs: + worktree_path: "${{ context.worktree_path }}" + adjust: true cwd: "${{ context.worktree_path }}" step_name: adjust output_dir: '.' @@ -520,9 +554,9 @@ steps: blocked_hypotheses: "${{ result.blocked_hypotheses }}" on_result: - when: "${{ result.verdict }} == CONCLUSIVE" - route: check_run_fix_loop + route: route_run_retry_delay - when: "${{ result.verdict }} == INCONCLUSIVE" - route: check_run_fix_loop + route: route_run_retry_delay - when: "${{ result.verdict }} == BLOCKED" route: escalate_stop on_failure: ensure_results @@ -539,7 +573,7 @@ steps: on_result: - when: "${{ result.max_exceeded }} == true" route: ensure_results - - route: route_run_retry_delay + - route: troubleshoot_run_failure on_failure: ensure_results optional_context_refs: - run_fix_count @@ -591,7 +625,12 @@ steps: tool: run_skill model: "" with: - skill_command: "/autoskillit:generate-report ${{ context.worktree_path }} ${{ context.experiment_results }} --output-mode ${{ inputs.output_mode }} --issue-url ${{ inputs.issue_url }}" + skill_command: "/autoskillit:generate-report" + skill_inputs: + worktree_path: "${{ context.worktree_path }}" + results_path: "${{ context.experiment_results }}" + output_mode: "${{ inputs.output_mode }}" + issue_url: "${{ inputs.issue_url }}" cwd: "${{ context.worktree_path }}" step_name: generate_report output_dir: "{{AUTOSKILLIT_TEMP}}/generate-report" @@ -607,7 +646,13 @@ steps: tool: run_skill model: "" with: - skill_command: "/autoskillit:generate-report ${{ context.worktree_path }} ${{ context.experiment_results }} --inconclusive --output-mode ${{ inputs.output_mode }} --issue-url ${{ inputs.issue_url }}" + skill_command: "/autoskillit:generate-report" + skill_inputs: + worktree_path: "${{ context.worktree_path }}" + results_path: "${{ context.experiment_results }}" + inconclusive: true + output_mode: "${{ inputs.output_mode }}" + issue_url: "${{ inputs.issue_url }}" cwd: "${{ context.worktree_path }}" step_name: generate_report_inconclusive output_dir: "{{AUTOSKILLIT_TEMP}}/generate-report" @@ -634,7 +679,11 @@ steps: tool: run_skill model: "" with: - skill_command: "/autoskillit:resolve-failures ${{ context.worktree_path }} ${{ inputs.experiment_plan }} ${{ inputs.base_branch }}" + skill_command: "/autoskillit:resolve-failures" + skill_inputs: + plan_path: "${{ inputs.experiment_plan }}" + base_branch: "${{ inputs.base_branch }}" + worktree_path: "${{ context.worktree_path }}" cwd: "${{ inputs.source_dir }}" step_name: fix output_dir: '.' diff --git a/src/autoskillit/recipes/research-review.json b/src/autoskillit/recipes/research-review.json index 06ec2a7fc..05edacf32 100644 --- a/src/autoskillit/recipes/research-review.json +++ b/src/autoskillit/recipes/research-review.json @@ -250,7 +250,15 @@ "worktree_path" ], "with": { - "skill_command": "/autoskillit:review-research-pr ${{ context.worktree_path }} ${{ inputs.base_branch }} ${{ context.pr_url }} annotated_diff_path=${{ context.annotated_diff_path }} hunk_ranges_path=${{ context.hunk_ranges_path }} valid_lines_path=${{ context.valid_lines_path }}", + "skill_command": "/autoskillit:review-research-pr", + "skill_inputs": { + "worktree_path": "${{ context.worktree_path }}", + "base_branch": "${{ inputs.base_branch }}", + "pr_url": "${{ context.pr_url }}", + "annotated_diff_path": "${{ context.annotated_diff_path }}", + "hunk_ranges_path": "${{ context.hunk_ranges_path }}", + "valid_lines_path": "${{ context.valid_lines_path }}" + }, "cwd": "${{ context.worktree_path }}", "step_name": "review_research_pr", "output_dir": "{{AUTOSKILLIT_TEMP}}/review-research-pr" @@ -395,7 +403,11 @@ "worktree_path" ], "with": { - "skill_command": "/autoskillit:run-experiment ${{ context.worktree_path }} --adjust", + "skill_command": "/autoskillit:run-experiment", + "skill_inputs": { + "worktree_path": "${{ context.worktree_path }}", + "adjust": true + }, "cwd": "${{ context.worktree_path }}", "step_name": "re_run_experiment", "output_dir": "." @@ -431,7 +443,13 @@ "experiment_results" ], "with": { - "skill_command": "/autoskillit:generate-report ${{ context.worktree_path }} ${{ context.experiment_results }} --output-mode ${{ inputs.output_mode }} --issue-url ${{ inputs.issue_url }}", + "skill_command": "/autoskillit:generate-report", + "skill_inputs": { + "worktree_path": "${{ context.worktree_path }}", + "results_path": "${{ context.experiment_results }}", + "output_mode": "${{ inputs.output_mode }}", + "issue_url": "${{ inputs.issue_url }}" + }, "cwd": "${{ context.worktree_path }}", "step_name": "re_generate_report", "output_dir": "{{AUTOSKILLIT_TEMP}}/generate-report" diff --git a/src/autoskillit/recipes/research-review.yaml b/src/autoskillit/recipes/research-review.yaml index 99c08eae2..2069e996c 100644 --- a/src/autoskillit/recipes/research-review.yaml +++ b/src/autoskillit/recipes/research-review.yaml @@ -231,7 +231,14 @@ steps: model: "" optional_context_refs: [worktree_path] with: - skill_command: "/autoskillit:review-research-pr ${{ context.worktree_path }} ${{ inputs.base_branch }} ${{ context.pr_url }} annotated_diff_path=${{ context.annotated_diff_path }} hunk_ranges_path=${{ context.hunk_ranges_path }} valid_lines_path=${{ context.valid_lines_path }}" + skill_command: "/autoskillit:review-research-pr" + skill_inputs: + worktree_path: "${{ context.worktree_path }}" + base_branch: "${{ inputs.base_branch }}" + pr_url: "${{ context.pr_url }}" + annotated_diff_path: "${{ context.annotated_diff_path }}" + hunk_ranges_path: "${{ context.hunk_ranges_path }}" + valid_lines_path: "${{ context.valid_lines_path }}" cwd: "${{ context.worktree_path }}" step_name: review_research_pr output_dir: "{{AUTOSKILLIT_TEMP}}/review-research-pr" @@ -366,7 +373,10 @@ steps: idle_output_timeout: 0 optional_context_refs: [worktree_path] with: - skill_command: "/autoskillit:run-experiment ${{ context.worktree_path }} --adjust" + skill_command: "/autoskillit:run-experiment" + skill_inputs: + worktree_path: "${{ context.worktree_path }}" + adjust: true cwd: "${{ context.worktree_path }}" step_name: re_run_experiment output_dir: '.' @@ -397,7 +407,12 @@ steps: model: "" optional_context_refs: [worktree_path, experiment_results] with: - skill_command: "/autoskillit:generate-report ${{ context.worktree_path }} ${{ context.experiment_results }} --output-mode ${{ inputs.output_mode }} --issue-url ${{ inputs.issue_url }}" + skill_command: "/autoskillit:generate-report" + skill_inputs: + worktree_path: "${{ context.worktree_path }}" + results_path: "${{ context.experiment_results }}" + output_mode: "${{ inputs.output_mode }}" + issue_url: "${{ inputs.issue_url }}" cwd: "${{ context.worktree_path }}" step_name: re_generate_report output_dir: "{{AUTOSKILLIT_TEMP}}/generate-report" diff --git a/src/autoskillit/recipes/research.json b/src/autoskillit/recipes/research.json index 16d2a041d..1008ed539 100644 --- a/src/autoskillit/recipes/research.json +++ b/src/autoskillit/recipes/research.json @@ -89,7 +89,10 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:scope ${{ inputs.task }}", + "skill_command": "/autoskillit:scope", + "skill_inputs": { + "research_question": "${{ inputs.task }}" + }, "cwd": "${{ inputs.source_dir }}", "step_name": "scope", "output_dir": "{{AUTOSKILLIT_TEMP}}/scope" @@ -105,7 +108,12 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:select-directions ${{ context.scope_directions }} ${{ context.scope_report }} ${{ inputs.min_breadth }}", + "skill_command": "/autoskillit:select-directions", + "skill_inputs": { + "min_breadth": "${{ inputs.min_breadth }}", + "scope_directions_path": "${{ context.scope_directions }}", + "scope_report_path": "${{ context.scope_report }}" + }, "cwd": "${{ inputs.source_dir }}", "step_name": "select_directions", "output_dir": "{{AUTOSKILLIT_TEMP}}/select-directions" @@ -120,7 +128,12 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:plan-experiment ${{ context.scope_report }} ${{ context.selected_directions }} ${{ context.revision_guidance }}", + "skill_command": "/autoskillit:plan-experiment", + "skill_inputs": { + "scope_directions_path": "${{ context.selected_directions }}", + "revision_guidance": "${{ context.revision_guidance }}", + "scope_report_path": "${{ context.scope_report }}" + }, "cwd": "${{ inputs.source_dir }}", "step_name": "plan_experiment", "output_dir": "{{AUTOSKILLIT_TEMP}}/plan-experiment" @@ -140,7 +153,11 @@ "model": "", "phoropter_family": "review-design", "with": { - "skill_command": "/autoskillit:classify-experiment-type ${{ context.experiment_plan }} ${{ context.scope_report }}", + "skill_command": "/autoskillit:classify-experiment-type", + "skill_inputs": { + "scope_report_path": "${{ context.scope_report }}", + "experiment_plan_path": "${{ context.experiment_plan }}" + }, "cwd": "${{ inputs.source_dir }}", "step_name": "dial", "output_dir": "{{AUTOSKILLIT_TEMP}}/classify-experiment-type" @@ -173,7 +190,7 @@ }, { "when": "${{ context.is_silent_type }} == false", - "route": "apply" + "route": "check_design_review_loop" } ] }, @@ -182,11 +199,21 @@ "model": "", "phoropter_family": "review-design", "with": { - "skill_command": "/autoskillit:apply-review-dimensions ${{ context.dimensions_manifest_path }} ${{ context.scope_report }} ${{ context.experiment_plan }} ${{ context.experiment_type }} ${{ context.classification_timestamp }}", + "skill_command": "/autoskillit:apply-review-dimensions", + "skill_inputs": { + "scope_report_path": "${{ context.scope_report }}", + "dimensions_manifest_path": "${{ context.dimensions_manifest_path }}", + "experiment_type": "${{ context.experiment_type }}", + "experiment_plan_path": "${{ context.experiment_plan }}", + "classification_timestamp": "${{ context.classification_timestamp }}" + }, "cwd": "${{ inputs.source_dir }}", "step_name": "apply", - "output_dir": "{{AUTOSKILLIT_TEMP}}/apply-review-dimensions" + "output_dir": "{{AUTOSKILLIT_TEMP}}/apply-review-dimensions/iter_${{ context.design_review_count }}" }, + "optional_context_refs": [ + "design_review_count" + ], "capture": { "findings_manifest_path": "${{ result.findings_manifest_path }}", "evaluation_dashboard": "${{ result.evaluation_dashboard_path }}" @@ -244,7 +271,12 @@ "model": "", "phoropter_family": "vis-lens", "with": { - "skill_command": "/autoskillit:select-vis-lenses ${{ inputs.source_dir }} ${{ context.experiment_plan }} ${{ context.scope_report }}", + "skill_command": "/autoskillit:select-vis-lenses", + "skill_inputs": { + "source_dir": "${{ inputs.source_dir }}", + "scope_report_path": "${{ context.scope_report }}", + "experiment_plan_path": "${{ context.experiment_plan }}" + }, "cwd": "${{ inputs.source_dir }}", "step_name": "vis_dial", "output_dir": "{{AUTOSKILLIT_TEMP}}/select-vis-lenses" @@ -264,7 +296,10 @@ "model": "", "phoropter_family": "vis-lens", "with": { - "skill_command": "/autoskillit:vis-lens-{slug} {context_path}", + "skill_command": "/autoskillit:vis-lens-{slug}", + "skill_inputs": { + "context_path": "{context_path}" + }, "cwd": "${{ inputs.source_dir }}", "step_name": "vis_apply", "output_dir": "{{AUTOSKILLIT_TEMP}}/run-vis-lenses" @@ -282,7 +317,15 @@ "model": "", "phoropter_family": "vis-lens", "with": { - "skill_command": "/autoskillit:synthesize-vis-plan ${{ inputs.source_dir }} ${{ context.experiment_plan }} {{AUTOSKILLIT_TEMP}}/run-vis-lenses --tier-c-lens=${{ context.tier_c_lens }} --methodology-tradition=${{ context.methodology_tradition }} --disambiguation-rule-applied=${{ context.disambiguation_rule_applied }}", + "skill_command": "/autoskillit:synthesize-vis-plan", + "skill_inputs": { + "methodology_tradition": "--methodology-tradition=${{ context.methodology_tradition }}", + "disambiguation_rule_applied": "--disambiguation-rule-applied=${{ context.disambiguation_rule_applied }}", + "experiment_plan_path": "${{ context.experiment_plan }}", + "tier_c_lens": "--tier-c-lens=${{ context.tier_c_lens }}", + "source_dir": "${{ inputs.source_dir }}", + "capture_dir": "{{AUTOSKILLIT_TEMP}}/run-vis-lenses" + }, "cwd": "${{ inputs.source_dir }}", "step_name": "vis_synthesize", "output_dir": "{{AUTOSKILLIT_TEMP}}/synthesize-vis-plan" @@ -332,10 +375,15 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:resolve-design-review ${{ context.evaluation_dashboard }} ${{ context.experiment_plan }} ${{ context.revision_guidance }}", + "skill_command": "/autoskillit:resolve-design-review", + "skill_inputs": { + "revision_guidance": "${{ context.revision_guidance }}", + "evaluation_dashboard": "${{ context.evaluation_dashboard }}", + "experiment_plan": "${{ context.experiment_plan }}" + }, "cwd": "${{ inputs.source_dir }}", "step_name": "resolve_design_review", - "output_dir": "{{AUTOSKILLIT_TEMP}}/resolve-design-review" + "output_dir": "{{AUTOSKILLIT_TEMP}}/resolve-design-review/iter_${{ context.design_review_count }}" }, "optional_context_refs": [ "evaluation_dashboard", @@ -386,7 +434,10 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:stage-data ${{ context.experiment_plan }}", + "skill_command": "/autoskillit:stage-data", + "skill_inputs": { + "experiment_plan": "${{ context.experiment_plan }}" + }, "cwd": "${{ context.worktree_path }}", "step_name": "stage_data", "output_dir": "{{AUTOSKILLIT_TEMP}}/stage-data" @@ -414,7 +465,10 @@ "retries": 1, "on_exhausted": "escalate_stop", "with": { - "skill_command": "/autoskillit:download-data ${{ context.experiment_plan }}", + "skill_command": "/autoskillit:download-data", + "skill_inputs": { + "experiment_plan": "${{ context.experiment_plan }}" + }, "cwd": "${{ context.worktree_path }}", "step_name": "download_data", "output_dir": "{{AUTOSKILLIT_TEMP}}/download-data" @@ -438,7 +492,11 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:setup-environment ${{ context.worktree_path }} ${{ context.experiment_plan }}", + "skill_command": "/autoskillit:setup-environment", + "skill_inputs": { + "worktree_path": "${{ context.worktree_path }}", + "experiment_plan": "${{ context.experiment_plan }}" + }, "cwd": "${{ context.worktree_path }}", "step_name": "setup_environment", "output_dir": "{{AUTOSKILLIT_TEMP}}/setup-environment" @@ -464,7 +522,10 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:make-groups ${{ context.experiment_plan }}", + "skill_command": "/autoskillit:make-groups", + "skill_inputs": { + "source_doc": "${{ context.experiment_plan }}" + }, "cwd": "${{ context.worktree_path }}", "step_name": "decompose", "output_dir": "{{AUTOSKILLIT_TEMP}}/make-groups" @@ -494,15 +555,19 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:make-plan ${{ context.group_files }}", + "skill_command": "/autoskillit:make-plan", + "skill_inputs": { + "task": "${{ context.group_files }}", + "audit_cycle_path": "${{ context.audit_cycle_path }}" + }, "cwd": "${{ context.worktree_path }}", "step_name": "plan_phase", - "output_dir": ".", - "group_files": "${{ context.group_files }}" + "output_dir": "." }, "capture": { "phase_plan_path": "${{ result.plan_path }}", - "verdict": "${{ result.verdict }}" + "verdict": "${{ result.verdict }}", + "plan_disposition_path": "${{ result.plan_disposition_path }}" }, "capture_list": { "plan_parts": "${{ result.plan_parts }}" @@ -512,28 +577,32 @@ { "when": "${{ result.verdict }} == plan", "route": "implement_phase" - }, - { - "when": "${{ result.verdict }} == false_positive", - "route": "implement_phase" } ], "on_failure": "escalate_stop", "on_context_limit": "salvage_plan_phase", - "note": "Plans the current setup phase. Replace ${{ context.group_files }} in the skill_command with the FIRST REMAINING phase file path from the list — do NOT pass the entire list. Pass the path directly to the skill (do not read it). Glob plan_dir for *_part_*.md or single plan file. ITERATION: After each implement_phase cycle, advance to the next entry in context.group_files for the following plan_phase iteration.\n" + "note": "Plans the current setup phase. Replace ${{ context.group_files }} in the skill_command with the FIRST REMAINING phase file path from the list — do NOT pass the entire list. Pass the path directly to the skill (do not read it). Glob plan_dir for *_part_*.md or single plan file. ITERATION: After each implement_phase cycle, advance to the next entry in context.group_files for the following plan_phase iteration. On audit remediation re-entry, consume the exact current audit_cycle_path and emit its plan_disposition_path; verdict=plan is the only success.\n", + "optional_context_refs": [ + "audit_cycle_path" + ] }, "salvage_plan_phase": { "tool": "run_python", "with": { "callable": "autoskillit.recipe._cmd_rpc.verify_plan_artifacts", - "plan_parts": "${{ context.plan_parts }}" + "plan_parts": "${{ context.plan_parts }}", + "audit_cycle_path": "${{ context.audit_cycle_path }}" }, "capture": { - "phase_plan_path": "${{ result.plan_path }}" + "phase_plan_path": "${{ result.plan_path }}", + "plan_disposition_path": "${{ result.plan_disposition_path }}" }, "capture_list": { "plan_parts": "${{ result.plan_parts }}" }, + "optional_context_refs": [ + "audit_cycle_path" + ], "retries": 0, "on_result": [ { @@ -553,7 +622,10 @@ "stale_threshold": 2400, "idle_output_timeout": 0, "with": { - "skill_command": "/autoskillit:implement-experiment ${{ context.phase_plan_path }}", + "skill_command": "/autoskillit:implement-experiment", + "skill_inputs": { + "plan_path": "${{ context.phase_plan_path }}" + }, "cwd": "${{ context.worktree_path }}", "step_name": "implement_phase", "output_dir": "." @@ -563,7 +635,7 @@ }, "retries": 0, "on_success": "next_phase_or_experiment", - "on_failure": "troubleshoot_implement_failure", + "on_failure": "check_implement_fix_loop", "on_exhausted": "audit_impl", "on_context_limit": "audit_impl", "skip_when_false": "inputs.backend_supports_git_write", @@ -575,11 +647,18 @@ "stale_threshold": 2400, "idle_output_timeout": 0, "with": { - "skill_command": "/autoskillit:troubleshoot-experiment ${{ context.worktree_path }} implement_phase", + "skill_command": "/autoskillit:troubleshoot-experiment", + "skill_inputs": { + "worktree_path": "${{ context.worktree_path }}", + "step_name": "implement_phase" + }, "cwd": "${{ inputs.source_dir }}", "step_name": "troubleshoot_implement_failure", - "output_dir": "{{AUTOSKILLIT_TEMP}}/troubleshoot-experiment" + "output_dir": "{{AUTOSKILLIT_TEMP}}/troubleshoot-experiment/implement_${{ context.implement_fix_count }}" }, + "optional_context_refs": [ + "implement_fix_count" + ], "capture": { "is_fixable": "${{ result.is_fixable }}", "retry_delay": "${{ result.retry_delay }}" @@ -592,7 +671,7 @@ "on_result": [ { "when": "${{ context.is_fixable }} == true", - "route": "check_implement_fix_loop" + "route": "route_implement_retry_delay" }, { "route": "escalate_stop" @@ -615,7 +694,7 @@ "route": "audit_impl" }, { - "route": "route_implement_retry_delay" + "route": "troubleshoot_implement_failure" } ], "on_failure": "audit_impl", @@ -703,7 +782,13 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:audit-impl \"${{ context.group_files }}\" ${{ context.impl_base_sha }} ${{ inputs.base_branch }}", + "skill_command": "/autoskillit:audit-impl", + "skill_inputs": { + "all_plan_paths": "${{ context.group_files }}", + "branch_name": "${{ context.impl_base_sha }}", + "base_branch": "${{ inputs.base_branch }}", + "prior_audit_cycle_path": "${{ context.audit_cycle_path }}" + }, "cwd": "${{ context.worktree_path }}", "step_name": "audit_impl", "output_dir": "{{AUTOSKILLIT_TEMP}}/audit-impl", @@ -715,7 +800,8 @@ "closure_target_sha": "" }, "capture": { - "remediation_path": "${{ result.remediation_path }}" + "remediation_path": "${{ result.remediation_path }}", + "audit_cycle_path": "${{ result.audit_cycle_path }}" }, "on_result": [ { @@ -737,15 +823,18 @@ "on_failure": "escalate_stop", "on_context_limit": "escalate_stop", "skip_when_false": "inputs.audit_impl", + "optional_context_refs": [ + "audit_cycle_path" + ], "note": "Post-implementation quality gate. Diffs impl_base_sha..HEAD and verifies all decomposed phases from group_files have corresponding implementation commits. Catches silent group abandonment when on_context_limit fires mid-iteration — reports exactly which phases were completed vs. skipped. On GO, proceeds to run_experiment. On error, escalates immediately. On NO GO, routes to remediate which re-enters plan_phase via check_audit_retry_loop (max 2 cycles). If false (inputs.audit_impl=false), skip directly to run_experiment.\n" }, "remediate": { "action": "route", "with": { - "remediation_path": "${{ context.remediation_path }}" + "audit_cycle_path": "${{ context.audit_cycle_path }}" }, "on_success": "check_audit_retry_loop", - "note": "Pure routing step carrying the remediation file path from audit_impl's capture into the retry cycle. The remediation_path contains the list of missing phases identified by audit-impl.\n" + "note": "Carries the exact current NO GO authority into the retry. The successor audit consumes it as prior_audit_cycle_path and GO replaces it.\n" }, "check_audit_retry_loop": { "tool": "run_python", @@ -788,7 +877,10 @@ "stale_threshold": 2400, "idle_output_timeout": 0, "with": { - "skill_command": "/autoskillit:run-experiment ${{ context.worktree_path }}", + "skill_command": "/autoskillit:run-experiment", + "skill_inputs": { + "worktree_path": "${{ context.worktree_path }}" + }, "cwd": "${{ context.worktree_path }}", "step_name": "run_experiment", "output_dir": "." @@ -814,7 +906,7 @@ "route": "ensure_results" } ], - "on_failure": "troubleshoot_run_failure", + "on_failure": "check_run_fix_loop", "on_exhausted": "ensure_results" }, "troubleshoot_run_failure": { @@ -823,11 +915,18 @@ "stale_threshold": 2400, "idle_output_timeout": 0, "with": { - "skill_command": "/autoskillit:troubleshoot-experiment ${{ context.worktree_path }} run_experiment", + "skill_command": "/autoskillit:troubleshoot-experiment", + "skill_inputs": { + "worktree_path": "${{ context.worktree_path }}", + "step_name": "run_experiment" + }, "cwd": "${{ inputs.source_dir }}", "step_name": "troubleshoot_run_failure", - "output_dir": "{{AUTOSKILLIT_TEMP}}/troubleshoot-experiment" + "output_dir": "{{AUTOSKILLIT_TEMP}}/troubleshoot-experiment/run_${{ context.run_fix_count }}" }, + "optional_context_refs": [ + "run_fix_count" + ], "capture": { "run_is_fixable": "${{ result.is_fixable }}", "run_retry_delay": "${{ result.retry_delay }}" @@ -853,7 +952,11 @@ "stale_threshold": 2400, "idle_output_timeout": 0, "with": { - "skill_command": "/autoskillit:run-experiment ${{ context.worktree_path }} --adjust", + "skill_command": "/autoskillit:run-experiment", + "skill_inputs": { + "worktree_path": "${{ context.worktree_path }}", + "adjust": true + }, "cwd": "${{ context.worktree_path }}", "step_name": "adjust", "output_dir": "." @@ -867,11 +970,11 @@ "on_result": [ { "when": "${{ result.verdict }} == CONCLUSIVE", - "route": "check_run_fix_loop" + "route": "route_run_retry_delay" }, { "when": "${{ result.verdict }} == INCONCLUSIVE", - "route": "check_run_fix_loop" + "route": "route_run_retry_delay" }, { "when": "${{ result.verdict }} == BLOCKED", @@ -897,7 +1000,7 @@ "route": "ensure_results" }, { - "route": "route_run_retry_delay" + "route": "troubleshoot_run_failure" } ], "on_failure": "ensure_results", @@ -952,7 +1055,20 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:generate-report ${{ context.worktree_path }} ${{ context.experiment_results }} --output-mode ${{ inputs.output_mode }} --issue-url ${{ inputs.issue_url }} --experiment-type '${{ context.experiment_type }}' --methodology-traditions '${{ context.methodology_tradition }}' --design-review-verdict '${{ context.verdict }}' --disambiguation-rule-applied '${{ context.disambiguation_rule_applied }}' --tier-c-lens '${{ context.tier_c_lens }}' --classification-timestamp '${{ context.classification_timestamp }}' --group-manifest '${{ context.group_manifest }}'", + "skill_command": "/autoskillit:generate-report", + "skill_inputs": { + "worktree_path": "${{ context.worktree_path }}", + "results_path": "${{ context.experiment_results }}", + "output_mode": "${{ inputs.output_mode }}", + "issue_url": "${{ inputs.issue_url }}", + "experiment_type": "${{ context.experiment_type }}", + "methodology_traditions": "${{ context.methodology_tradition }}", + "design_review_verdict": "${{ context.verdict }}", + "disambiguation_rule_applied": "${{ context.disambiguation_rule_applied }}", + "tier_c_lens": "${{ context.tier_c_lens }}", + "classification_timestamp": "${{ context.classification_timestamp }}", + "group_manifest": "${{ context.group_manifest }}" + }, "cwd": "${{ context.worktree_path }}", "step_name": "generate_report", "output_dir": "{{AUTOSKILLIT_TEMP}}/generate-report" @@ -970,7 +1086,21 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:generate-report ${{ context.worktree_path }} ${{ context.experiment_results }} --inconclusive --output-mode ${{ inputs.output_mode }} --issue-url ${{ inputs.issue_url }} --experiment-type '${{ context.experiment_type }}' --methodology-traditions '${{ context.methodology_tradition }}' --design-review-verdict '${{ context.verdict }}' --disambiguation-rule-applied '${{ context.disambiguation_rule_applied }}' --tier-c-lens '${{ context.tier_c_lens }}' --classification-timestamp '${{ context.classification_timestamp }}' --group-manifest '${{ context.group_manifest }}'", + "skill_command": "/autoskillit:generate-report", + "skill_inputs": { + "worktree_path": "${{ context.worktree_path }}", + "results_path": "${{ context.experiment_results }}", + "inconclusive": true, + "output_mode": "${{ inputs.output_mode }}", + "issue_url": "${{ inputs.issue_url }}", + "experiment_type": "${{ context.experiment_type }}", + "methodology_traditions": "${{ context.methodology_tradition }}", + "design_review_verdict": "${{ context.verdict }}", + "disambiguation_rule_applied": "${{ context.disambiguation_rule_applied }}", + "tier_c_lens": "${{ context.tier_c_lens }}", + "classification_timestamp": "${{ context.classification_timestamp }}", + "group_manifest": "${{ context.group_manifest }}" + }, "cwd": "${{ context.worktree_path }}", "step_name": "generate_report", "output_dir": "{{AUTOSKILLIT_TEMP}}/generate-report" @@ -997,7 +1127,12 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:resolve-failures ${{ context.worktree_path }} ${{ context.experiment_plan }} ${{ inputs.base_branch }}", + "skill_command": "/autoskillit:resolve-failures", + "skill_inputs": { + "plan_path": "${{ context.experiment_plan }}", + "base_branch": "${{ inputs.base_branch }}", + "worktree_path": "${{ context.worktree_path }}" + }, "cwd": "${{ inputs.source_dir }}", "step_name": "fix", "output_dir": "." @@ -1057,7 +1192,13 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:prepare-research-pr ${{ context.report_path }} ${{ context.experiment_plan }} ${{ context.worktree_path }} ${{ inputs.base_branch }}", + "skill_command": "/autoskillit:prepare-research-pr", + "skill_inputs": { + "base_branch": "${{ inputs.base_branch }}", + "worktree_path": "${{ context.worktree_path }}", + "report_path": "${{ context.report_path }}", + "experiment_plan_path": "${{ context.experiment_plan }}" + }, "cwd": "${{ context.worktree_path }}", "step_name": "prepare_research_pr", "output_dir": "." @@ -1074,7 +1215,11 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:exp-lens-{slug} {context_path} ${{ context.experiment_plan }}", + "skill_command": "/autoskillit:exp-lens-{slug}", + "skill_inputs": { + "context_path": "{context_path}", + "experiment_plan_path": "${{ context.experiment_plan }}" + }, "cwd": "${{ context.worktree_path }}", "step_name": "run_experiment_lenses" }, @@ -1114,7 +1259,13 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:compose-research-pr ${{ context.prep_path }} \"${{ context.all_diagram_paths }}\" ${{ context.worktree_path }} ${{ inputs.base_branch }}", + "skill_command": "/autoskillit:compose-research-pr", + "skill_inputs": { + "all_diagram_paths": "${{ context.all_diagram_paths }}", + "base_branch": "${{ inputs.base_branch }}", + "worktree_path": "${{ context.worktree_path }}", + "prep_path": "${{ context.prep_path }}" + }, "cwd": "${{ context.worktree_path }}", "step_name": "compose_research_pr", "output_dir": "." @@ -1145,7 +1296,12 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:review-research-pr ${{ context.worktree_path }} ${{ inputs.base_branch }} ${{ context.pr_url }}", + "skill_command": "/autoskillit:review-research-pr", + "skill_inputs": { + "base_branch": "${{ inputs.base_branch }}", + "worktree_path": "${{ context.worktree_path }}", + "pr_url": "${{ context.pr_url }}" + }, "cwd": "${{ context.worktree_path }}", "step_name": "review_research_pr", "output_dir": "{{AUTOSKILLIT_TEMP}}/review-research-pr" @@ -1170,7 +1326,12 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:audit-claims ${{ context.worktree_path }} ${{ inputs.base_branch }} ${{ context.pr_url }}", + "skill_command": "/autoskillit:audit-claims", + "skill_inputs": { + "base_branch": "${{ inputs.base_branch }}", + "worktree_path": "${{ context.worktree_path }}", + "pr_url": "${{ context.pr_url }}" + }, "cwd": "${{ context.worktree_path }}", "step_name": "audit_claims", "output_dir": "{{AUTOSKILLIT_TEMP}}/audit-claims" @@ -1210,7 +1371,11 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:resolve-research-review ${{ context.worktree_path }} ${{ inputs.base_branch }}", + "skill_command": "/autoskillit:resolve-research-review", + "skill_inputs": { + "base_branch": "${{ inputs.base_branch }}", + "worktree_path": "${{ context.worktree_path }}" + }, "cwd": "${{ context.worktree_path }}", "step_name": "resolve_research_review", "output_dir": "." @@ -1242,7 +1407,11 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:resolve-claims-review ${{ context.worktree_path }} ${{ inputs.base_branch }}", + "skill_command": "/autoskillit:resolve-claims-review", + "skill_inputs": { + "base_branch": "${{ inputs.base_branch }}", + "worktree_path": "${{ context.worktree_path }}" + }, "cwd": "${{ context.worktree_path }}", "step_name": "resolve_claims_review", "output_dir": "." @@ -1280,7 +1449,11 @@ "stale_threshold": 2400, "idle_output_timeout": 0, "with": { - "skill_command": "/autoskillit:run-experiment ${{ context.worktree_path }} --adjust", + "skill_command": "/autoskillit:run-experiment", + "skill_inputs": { + "worktree_path": "${{ context.worktree_path }}", + "adjust": true + }, "cwd": "${{ context.worktree_path }}", "step_name": "re_run_experiment", "output_dir": "." @@ -1312,7 +1485,20 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:generate-report ${{ context.worktree_path }} ${{ context.experiment_results }} --output-mode ${{ inputs.output_mode }} --issue-url ${{ inputs.issue_url }} --experiment-type '${{ context.experiment_type }}' --methodology-traditions '${{ context.methodology_tradition }}' --design-review-verdict '${{ context.verdict }}' --disambiguation-rule-applied '${{ context.disambiguation_rule_applied }}' --tier-c-lens '${{ context.tier_c_lens }}' --classification-timestamp '${{ context.classification_timestamp }}' --group-manifest '${{ context.group_manifest }}'", + "skill_command": "/autoskillit:generate-report", + "skill_inputs": { + "worktree_path": "${{ context.worktree_path }}", + "results_path": "${{ context.experiment_results }}", + "output_mode": "${{ inputs.output_mode }}", + "issue_url": "${{ inputs.issue_url }}", + "experiment_type": "${{ context.experiment_type }}", + "methodology_traditions": "${{ context.methodology_tradition }}", + "design_review_verdict": "${{ context.verdict }}", + "disambiguation_rule_applied": "${{ context.disambiguation_rule_applied }}", + "tier_c_lens": "${{ context.tier_c_lens }}", + "classification_timestamp": "${{ context.classification_timestamp }}", + "group_manifest": "${{ context.group_manifest }}" + }, "cwd": "${{ context.worktree_path }}", "step_name": "re_generate_report", "output_dir": "{{AUTOSKILLIT_TEMP}}/generate-report" @@ -1374,7 +1560,13 @@ "tool": "run_skill", "model": "", "with": { - "skill_command": "/autoskillit:bundle-local-report ${{ context.research_dir }} ${{ context.report_path_after_finalize }} ${{ context.all_diagram_paths }} ${{ context.visualization_plan_path }}", + "skill_command": "/autoskillit:bundle-local-report", + "skill_inputs": { + "research_dir": "${{ context.research_dir }}", + "all_diagram_paths": "${{ context.all_diagram_paths }}", + "report_path": "${{ context.report_path_after_finalize }}", + "visualization_plan_path": "${{ context.visualization_plan_path }}" + }, "cwd": "${{ context.worktree_path }}", "step_name": "finalize_bundle_render", "output_dir": "." diff --git a/src/autoskillit/recipes/research.yaml b/src/autoskillit/recipes/research.yaml index e73bce1c2..db55d2e43 100644 --- a/src/autoskillit/recipes/research.yaml +++ b/src/autoskillit/recipes/research.yaml @@ -127,7 +127,9 @@ steps: tool: run_skill model: "" with: - skill_command: "/autoskillit:scope ${{ inputs.task }}" + skill_command: "/autoskillit:scope" + skill_inputs: + research_question: "${{ inputs.task }}" cwd: "${{ inputs.source_dir }}" step_name: scope output_dir: "{{AUTOSKILLIT_TEMP}}/scope" @@ -141,7 +143,11 @@ steps: tool: run_skill model: "" with: - skill_command: "/autoskillit:select-directions ${{ context.scope_directions }} ${{ context.scope_report }} ${{ inputs.min_breadth }}" + skill_command: "/autoskillit:select-directions" + skill_inputs: + min_breadth: "${{ inputs.min_breadth }}" + scope_directions_path: "${{ context.scope_directions }}" + scope_report_path: "${{ context.scope_report }}" cwd: "${{ inputs.source_dir }}" step_name: select_directions output_dir: "{{AUTOSKILLIT_TEMP}}/select-directions" @@ -154,7 +160,11 @@ steps: tool: run_skill model: "" with: - skill_command: "/autoskillit:plan-experiment ${{ context.scope_report }} ${{ context.selected_directions }} ${{ context.revision_guidance }}" + skill_command: "/autoskillit:plan-experiment" + skill_inputs: + scope_directions_path: "${{ context.selected_directions }}" + revision_guidance: "${{ context.revision_guidance }}" + scope_report_path: "${{ context.scope_report }}" cwd: "${{ inputs.source_dir }}" step_name: plan_experiment output_dir: "{{AUTOSKILLIT_TEMP}}/plan-experiment" @@ -169,7 +179,10 @@ steps: model: "" phoropter_family: review-design with: - skill_command: "/autoskillit:classify-experiment-type ${{ context.experiment_plan }} ${{ context.scope_report }}" + skill_command: "/autoskillit:classify-experiment-type" + skill_inputs: + scope_report_path: "${{ context.scope_report }}" + experiment_plan_path: "${{ context.experiment_plan }}" cwd: "${{ inputs.source_dir }}" step_name: dial output_dir: "{{AUTOSKILLIT_TEMP}}/classify-experiment-type" @@ -193,17 +206,24 @@ steps: - when: "${{ context.is_silent_type }} == true" route: synthesize - when: "${{ context.is_silent_type }} == false" - route: apply + route: check_design_review_loop apply: tool: run_skill model: "" phoropter_family: review-design with: - skill_command: "/autoskillit:apply-review-dimensions ${{ context.dimensions_manifest_path }} ${{ context.scope_report }} ${{ context.experiment_plan }} ${{ context.experiment_type }} ${{ context.classification_timestamp }}" + skill_command: "/autoskillit:apply-review-dimensions" + skill_inputs: + scope_report_path: "${{ context.scope_report }}" + dimensions_manifest_path: "${{ context.dimensions_manifest_path }}" + experiment_type: "${{ context.experiment_type }}" + experiment_plan_path: "${{ context.experiment_plan }}" + classification_timestamp: "${{ context.classification_timestamp }}" cwd: "${{ inputs.source_dir }}" step_name: apply - output_dir: "{{AUTOSKILLIT_TEMP}}/apply-review-dimensions" + output_dir: "{{AUTOSKILLIT_TEMP}}/apply-review-dimensions/iter_${{ context.design_review_count }}" + optional_context_refs: [design_review_count] capture: findings_manifest_path: "${{ result.findings_manifest_path }}" evaluation_dashboard: "${{ result.evaluation_dashboard_path }}" @@ -244,7 +264,11 @@ steps: model: "" phoropter_family: vis-lens with: - skill_command: "/autoskillit:select-vis-lenses ${{ inputs.source_dir }} ${{ context.experiment_plan }} ${{ context.scope_report }}" + skill_command: "/autoskillit:select-vis-lenses" + skill_inputs: + source_dir: "${{ inputs.source_dir }}" + scope_report_path: "${{ context.scope_report }}" + experiment_plan_path: "${{ context.experiment_plan }}" cwd: "${{ inputs.source_dir }}" step_name: vis_dial output_dir: "{{AUTOSKILLIT_TEMP}}/select-vis-lenses" @@ -262,7 +286,9 @@ steps: model: "" phoropter_family: vis-lens with: - skill_command: "/autoskillit:vis-lens-{slug} {context_path}" + skill_command: "/autoskillit:vis-lens-{slug}" + skill_inputs: + context_path: "{context_path}" cwd: "${{ inputs.source_dir }}" step_name: vis_apply output_dir: "{{AUTOSKILLIT_TEMP}}/run-vis-lenses" @@ -286,7 +312,14 @@ steps: model: "" phoropter_family: vis-lens with: - skill_command: "/autoskillit:synthesize-vis-plan ${{ inputs.source_dir }} ${{ context.experiment_plan }} {{AUTOSKILLIT_TEMP}}/run-vis-lenses --tier-c-lens=${{ context.tier_c_lens }} --methodology-tradition=${{ context.methodology_tradition }} --disambiguation-rule-applied=${{ context.disambiguation_rule_applied }}" + skill_command: "/autoskillit:synthesize-vis-plan" + skill_inputs: + methodology_tradition: "--methodology-tradition=${{ context.methodology_tradition }}" + disambiguation_rule_applied: "--disambiguation-rule-applied=${{ context.disambiguation_rule_applied }}" + experiment_plan_path: "${{ context.experiment_plan }}" + tier_c_lens: "--tier-c-lens=${{ context.tier_c_lens }}" + source_dir: "${{ inputs.source_dir }}" + capture_dir: "{{AUTOSKILLIT_TEMP}}/run-vis-lenses" cwd: "${{ inputs.source_dir }}" step_name: vis_synthesize output_dir: "{{AUTOSKILLIT_TEMP}}/synthesize-vis-plan" @@ -328,10 +361,14 @@ steps: tool: run_skill model: "" with: - skill_command: "/autoskillit:resolve-design-review ${{ context.evaluation_dashboard }} ${{ context.experiment_plan }} ${{ context.revision_guidance }}" + skill_command: "/autoskillit:resolve-design-review" + skill_inputs: + revision_guidance: "${{ context.revision_guidance }}" + evaluation_dashboard: "${{ context.evaluation_dashboard }}" + experiment_plan: "${{ context.experiment_plan }}" cwd: "${{ inputs.source_dir }}" step_name: resolve_design_review - output_dir: "{{AUTOSKILLIT_TEMP}}/resolve-design-review" + output_dir: "{{AUTOSKILLIT_TEMP}}/resolve-design-review/iter_${{ context.design_review_count }}" optional_context_refs: [evaluation_dashboard, experiment_plan, revision_guidance] capture: revision_guidance: "${{ result.revision_guidance }}" @@ -381,7 +418,9 @@ steps: tool: run_skill model: "" with: - skill_command: "/autoskillit:stage-data ${{ context.experiment_plan }}" + skill_command: "/autoskillit:stage-data" + skill_inputs: + experiment_plan: "${{ context.experiment_plan }}" cwd: "${{ context.worktree_path }}" step_name: stage_data output_dir: "{{AUTOSKILLIT_TEMP}}/stage-data" @@ -411,7 +450,9 @@ steps: retries: 1 on_exhausted: escalate_stop with: - skill_command: "/autoskillit:download-data ${{ context.experiment_plan }}" + skill_command: "/autoskillit:download-data" + skill_inputs: + experiment_plan: "${{ context.experiment_plan }}" cwd: "${{ context.worktree_path }}" step_name: download_data output_dir: "{{AUTOSKILLIT_TEMP}}/download-data" @@ -437,7 +478,10 @@ steps: tool: run_skill model: "" with: - skill_command: "/autoskillit:setup-environment ${{ context.worktree_path }} ${{ context.experiment_plan }}" + skill_command: "/autoskillit:setup-environment" + skill_inputs: + worktree_path: "${{ context.worktree_path }}" + experiment_plan: "${{ context.experiment_plan }}" cwd: "${{ context.worktree_path }}" step_name: setup_environment output_dir: "{{AUTOSKILLIT_TEMP}}/setup-environment" @@ -465,7 +509,9 @@ steps: tool: run_skill model: "" with: - skill_command: "/autoskillit:make-groups ${{ context.experiment_plan }}" + skill_command: "/autoskillit:make-groups" + skill_inputs: + source_doc: "${{ context.experiment_plan }}" cwd: "${{ context.worktree_path }}" step_name: decompose output_dir: "{{AUTOSKILLIT_TEMP}}/make-groups" @@ -500,22 +546,23 @@ steps: tool: run_skill model: "" with: - skill_command: "/autoskillit:make-plan ${{ context.group_files }}" + skill_command: "/autoskillit:make-plan" + skill_inputs: + task: "${{ context.group_files }}" + audit_cycle_path: "${{ context.audit_cycle_path }}" cwd: "${{ context.worktree_path }}" step_name: plan_phase output_dir: '.' - group_files: "${{ context.group_files }}" capture: phase_plan_path: "${{ result.plan_path }}" verdict: "${{ result.verdict }}" + plan_disposition_path: "${{ result.plan_disposition_path }}" capture_list: plan_parts: "${{ result.plan_parts }}" retries: 0 on_result: - when: "${{ result.verdict }} == plan" route: implement_phase - - when: "${{ result.verdict }} == false_positive" - route: implement_phase on_failure: escalate_stop on_context_limit: salvage_plan_phase note: > @@ -525,16 +572,24 @@ steps: Glob plan_dir for *_part_*.md or single plan file. ITERATION: After each implement_phase cycle, advance to the next entry in context.group_files for the following plan_phase iteration. + On audit remediation re-entry, consume the exact current audit_cycle_path and + emit its plan_disposition_path; verdict=plan is the only success. + optional_context_refs: + - audit_cycle_path salvage_plan_phase: tool: run_python with: callable: "autoskillit.recipe._cmd_rpc.verify_plan_artifacts" plan_parts: "${{ context.plan_parts }}" + audit_cycle_path: "${{ context.audit_cycle_path }}" capture: phase_plan_path: "${{ result.plan_path }}" + plan_disposition_path: "${{ result.plan_disposition_path }}" capture_list: plan_parts: "${{ result.plan_parts }}" + optional_context_refs: + - audit_cycle_path retries: 0 on_result: - when: "${{ result.verdict }} == salvaged" @@ -556,7 +611,9 @@ steps: stale_threshold: 2400 idle_output_timeout: 0 with: - skill_command: "/autoskillit:implement-experiment ${{ context.phase_plan_path }}" + skill_command: "/autoskillit:implement-experiment" + skill_inputs: + plan_path: "${{ context.phase_plan_path }}" cwd: "${{ context.worktree_path }}" step_name: implement_phase output_dir: '.' @@ -564,7 +621,7 @@ steps: worktree_path: "${{ result.worktree_path }}" retries: 0 on_success: next_phase_or_experiment - on_failure: troubleshoot_implement_failure + on_failure: check_implement_fix_loop on_exhausted: audit_impl on_context_limit: audit_impl skip_when_false: "inputs.backend_supports_git_write" @@ -580,10 +637,14 @@ steps: stale_threshold: 2400 idle_output_timeout: 0 with: - skill_command: "/autoskillit:troubleshoot-experiment ${{ context.worktree_path }} implement_phase" + skill_command: "/autoskillit:troubleshoot-experiment" + skill_inputs: + worktree_path: "${{ context.worktree_path }}" + step_name: "implement_phase" cwd: "${{ inputs.source_dir }}" step_name: troubleshoot_implement_failure - output_dir: "{{AUTOSKILLIT_TEMP}}/troubleshoot-experiment" + output_dir: "{{AUTOSKILLIT_TEMP}}/troubleshoot-experiment/implement_${{ context.implement_fix_count }}" + optional_context_refs: [implement_fix_count] capture: is_fixable: "${{ result.is_fixable }}" retry_delay: "${{ result.retry_delay }}" @@ -594,7 +655,7 @@ steps: action: route on_result: - when: "${{ context.is_fixable }} == true" - route: check_implement_fix_loop + route: route_implement_retry_delay - route: escalate_stop check_implement_fix_loop: @@ -608,7 +669,7 @@ steps: on_result: - when: "${{ result.max_exceeded }} == true" route: audit_impl - - route: route_implement_retry_delay + - route: troubleshoot_implement_failure on_failure: audit_impl optional_context_refs: - implement_fix_count @@ -687,7 +748,12 @@ steps: tool: run_skill model: "" with: - skill_command: "/autoskillit:audit-impl \"${{ context.group_files }}\" ${{ context.impl_base_sha }} ${{ inputs.base_branch }}" + skill_command: "/autoskillit:audit-impl" + skill_inputs: + all_plan_paths: "${{ context.group_files }}" + branch_name: "${{ context.impl_base_sha }}" + base_branch: "${{ inputs.base_branch }}" + prior_audit_cycle_path: "${{ context.audit_cycle_path }}" cwd: "${{ context.worktree_path }}" step_name: audit_impl output_dir: "{{AUTOSKILLIT_TEMP}}/audit-impl" @@ -699,6 +765,7 @@ steps: closure_target_sha: "" capture: remediation_path: "${{ result.remediation_path }}" + audit_cycle_path: "${{ result.audit_cycle_path }}" on_result: - when: "${{ result.verdict }} == GO" route: run_experiment @@ -710,6 +777,8 @@ steps: on_failure: escalate_stop on_context_limit: escalate_stop skip_when_false: "inputs.audit_impl" + optional_context_refs: + - audit_cycle_path note: > Post-implementation quality gate. Diffs impl_base_sha..HEAD and verifies all decomposed phases from group_files have corresponding implementation @@ -723,12 +792,11 @@ steps: remediate: action: route with: - remediation_path: "${{ context.remediation_path }}" + audit_cycle_path: "${{ context.audit_cycle_path }}" on_success: check_audit_retry_loop note: > - Pure routing step carrying the remediation file path from audit_impl's - capture into the retry cycle. The remediation_path contains the list of - missing phases identified by audit-impl. + Carries the exact current NO GO authority into the retry. The successor + audit consumes it as prior_audit_cycle_path and GO replaces it. check_audit_retry_loop: tool: run_python @@ -770,7 +838,9 @@ steps: stale_threshold: 2400 idle_output_timeout: 0 with: - skill_command: "/autoskillit:run-experiment ${{ context.worktree_path }}" + skill_command: "/autoskillit:run-experiment" + skill_inputs: + worktree_path: "${{ context.worktree_path }}" cwd: "${{ context.worktree_path }}" step_name: run_experiment output_dir: '.' @@ -787,7 +857,7 @@ steps: route: escalate_stop - when: "${{ result.verdict }} == INCONCLUSIVE" route: ensure_results - on_failure: troubleshoot_run_failure + on_failure: check_run_fix_loop on_exhausted: ensure_results troubleshoot_run_failure: @@ -796,10 +866,14 @@ steps: stale_threshold: 2400 idle_output_timeout: 0 with: - skill_command: "/autoskillit:troubleshoot-experiment ${{ context.worktree_path }} run_experiment" + skill_command: "/autoskillit:troubleshoot-experiment" + skill_inputs: + worktree_path: "${{ context.worktree_path }}" + step_name: "run_experiment" cwd: "${{ inputs.source_dir }}" step_name: troubleshoot_run_failure - output_dir: "{{AUTOSKILLIT_TEMP}}/troubleshoot-experiment" + output_dir: "{{AUTOSKILLIT_TEMP}}/troubleshoot-experiment/run_${{ context.run_fix_count }}" + optional_context_refs: [run_fix_count] capture: run_is_fixable: "${{ result.is_fixable }}" run_retry_delay: "${{ result.retry_delay }}" @@ -819,7 +893,10 @@ steps: stale_threshold: 2400 idle_output_timeout: 0 with: - skill_command: "/autoskillit:run-experiment ${{ context.worktree_path }} --adjust" + skill_command: "/autoskillit:run-experiment" + skill_inputs: + worktree_path: "${{ context.worktree_path }}" + adjust: true cwd: "${{ context.worktree_path }}" step_name: adjust output_dir: '.' @@ -830,9 +907,9 @@ steps: group_manifest: "${{ result.group_manifest }}" on_result: - when: "${{ result.verdict }} == CONCLUSIVE" - route: check_run_fix_loop + route: route_run_retry_delay - when: "${{ result.verdict }} == INCONCLUSIVE" - route: check_run_fix_loop + route: route_run_retry_delay - when: "${{ result.verdict }} == BLOCKED" route: escalate_stop on_failure: ensure_results @@ -849,7 +926,7 @@ steps: on_result: - when: "${{ result.max_exceeded }} == true" route: ensure_results - - route: route_run_retry_delay + - route: troubleshoot_run_failure on_failure: ensure_results optional_context_refs: - run_fix_count @@ -900,7 +977,19 @@ steps: tool: run_skill model: "" with: - skill_command: "/autoskillit:generate-report ${{ context.worktree_path }} ${{ context.experiment_results }} --output-mode ${{ inputs.output_mode }} --issue-url ${{ inputs.issue_url }} --experiment-type '${{ context.experiment_type }}' --methodology-traditions '${{ context.methodology_tradition }}' --design-review-verdict '${{ context.verdict }}' --disambiguation-rule-applied '${{ context.disambiguation_rule_applied }}' --tier-c-lens '${{ context.tier_c_lens }}' --classification-timestamp '${{ context.classification_timestamp }}' --group-manifest '${{ context.group_manifest }}'" + skill_command: "/autoskillit:generate-report" + skill_inputs: + worktree_path: "${{ context.worktree_path }}" + results_path: "${{ context.experiment_results }}" + output_mode: "${{ inputs.output_mode }}" + issue_url: "${{ inputs.issue_url }}" + experiment_type: "${{ context.experiment_type }}" + methodology_traditions: "${{ context.methodology_tradition }}" + design_review_verdict: "${{ context.verdict }}" + disambiguation_rule_applied: "${{ context.disambiguation_rule_applied }}" + tier_c_lens: "${{ context.tier_c_lens }}" + classification_timestamp: "${{ context.classification_timestamp }}" + group_manifest: "${{ context.group_manifest }}" cwd: "${{ context.worktree_path }}" step_name: generate_report output_dir: "{{AUTOSKILLIT_TEMP}}/generate-report" @@ -916,7 +1005,20 @@ steps: tool: run_skill model: "" with: - skill_command: "/autoskillit:generate-report ${{ context.worktree_path }} ${{ context.experiment_results }} --inconclusive --output-mode ${{ inputs.output_mode }} --issue-url ${{ inputs.issue_url }} --experiment-type '${{ context.experiment_type }}' --methodology-traditions '${{ context.methodology_tradition }}' --design-review-verdict '${{ context.verdict }}' --disambiguation-rule-applied '${{ context.disambiguation_rule_applied }}' --tier-c-lens '${{ context.tier_c_lens }}' --classification-timestamp '${{ context.classification_timestamp }}' --group-manifest '${{ context.group_manifest }}'" + skill_command: "/autoskillit:generate-report" + skill_inputs: + worktree_path: "${{ context.worktree_path }}" + results_path: "${{ context.experiment_results }}" + inconclusive: true + output_mode: "${{ inputs.output_mode }}" + issue_url: "${{ inputs.issue_url }}" + experiment_type: "${{ context.experiment_type }}" + methodology_traditions: "${{ context.methodology_tradition }}" + design_review_verdict: "${{ context.verdict }}" + disambiguation_rule_applied: "${{ context.disambiguation_rule_applied }}" + tier_c_lens: "${{ context.tier_c_lens }}" + classification_timestamp: "${{ context.classification_timestamp }}" + group_manifest: "${{ context.group_manifest }}" cwd: "${{ context.worktree_path }}" step_name: generate_report output_dir: "{{AUTOSKILLIT_TEMP}}/generate-report" @@ -942,7 +1044,11 @@ steps: tool: run_skill model: "" with: - skill_command: "/autoskillit:resolve-failures ${{ context.worktree_path }} ${{ context.experiment_plan }} ${{ inputs.base_branch }}" + skill_command: "/autoskillit:resolve-failures" + skill_inputs: + plan_path: "${{ context.experiment_plan }}" + base_branch: "${{ inputs.base_branch }}" + worktree_path: "${{ context.worktree_path }}" cwd: "${{ inputs.source_dir }}" step_name: fix output_dir: '.' @@ -986,7 +1092,12 @@ steps: tool: run_skill model: "" with: - skill_command: "/autoskillit:prepare-research-pr ${{ context.report_path }} ${{ context.experiment_plan }} ${{ context.worktree_path }} ${{ inputs.base_branch }}" + skill_command: "/autoskillit:prepare-research-pr" + skill_inputs: + base_branch: "${{ inputs.base_branch }}" + worktree_path: "${{ context.worktree_path }}" + report_path: "${{ context.report_path }}" + experiment_plan_path: "${{ context.experiment_plan }}" cwd: "${{ context.worktree_path }}" step_name: prepare_research_pr output_dir: '.' @@ -1001,7 +1112,10 @@ steps: tool: run_skill model: "" with: - skill_command: "/autoskillit:exp-lens-{slug} {context_path} ${{ context.experiment_plan }}" + skill_command: "/autoskillit:exp-lens-{slug}" + skill_inputs: + context_path: "{context_path}" + experiment_plan_path: "${{ context.experiment_plan }}" cwd: "${{ context.worktree_path }}" step_name: run_experiment_lenses capture_list: @@ -1050,7 +1164,12 @@ steps: tool: run_skill model: "" with: - skill_command: "/autoskillit:compose-research-pr ${{ context.prep_path }} \"${{ context.all_diagram_paths }}\" ${{ context.worktree_path }} ${{ inputs.base_branch }}" + skill_command: "/autoskillit:compose-research-pr" + skill_inputs: + all_diagram_paths: "${{ context.all_diagram_paths }}" + base_branch: "${{ inputs.base_branch }}" + worktree_path: "${{ context.worktree_path }}" + prep_path: "${{ context.prep_path }}" cwd: "${{ context.worktree_path }}" step_name: compose_research_pr output_dir: '.' @@ -1073,7 +1192,11 @@ steps: tool: run_skill model: "" with: - skill_command: "/autoskillit:review-research-pr ${{ context.worktree_path }} ${{ inputs.base_branch }} ${{ context.pr_url }}" + skill_command: "/autoskillit:review-research-pr" + skill_inputs: + base_branch: "${{ inputs.base_branch }}" + worktree_path: "${{ context.worktree_path }}" + pr_url: "${{ context.pr_url }}" cwd: "${{ context.worktree_path }}" step_name: review_research_pr output_dir: "{{AUTOSKILLIT_TEMP}}/review-research-pr" @@ -1099,7 +1222,11 @@ steps: tool: run_skill model: "" with: - skill_command: "/autoskillit:audit-claims ${{ context.worktree_path }} ${{ inputs.base_branch }} ${{ context.pr_url }}" + skill_command: "/autoskillit:audit-claims" + skill_inputs: + base_branch: "${{ inputs.base_branch }}" + worktree_path: "${{ context.worktree_path }}" + pr_url: "${{ context.pr_url }}" cwd: "${{ context.worktree_path }}" step_name: audit_claims output_dir: "{{AUTOSKILLIT_TEMP}}/audit-claims" @@ -1134,7 +1261,10 @@ steps: tool: run_skill model: "" with: - skill_command: "/autoskillit:resolve-research-review ${{ context.worktree_path }} ${{ inputs.base_branch }}" + skill_command: "/autoskillit:resolve-research-review" + skill_inputs: + base_branch: "${{ inputs.base_branch }}" + worktree_path: "${{ context.worktree_path }}" cwd: "${{ context.worktree_path }}" step_name: resolve_research_review output_dir: '.' @@ -1165,7 +1295,10 @@ steps: tool: run_skill model: "" with: - skill_command: "/autoskillit:resolve-claims-review ${{ context.worktree_path }} ${{ inputs.base_branch }}" + skill_command: "/autoskillit:resolve-claims-review" + skill_inputs: + base_branch: "${{ inputs.base_branch }}" + worktree_path: "${{ context.worktree_path }}" cwd: "${{ context.worktree_path }}" step_name: resolve_claims_review output_dir: '.' @@ -1204,7 +1337,10 @@ steps: stale_threshold: 2400 idle_output_timeout: 0 with: - skill_command: "/autoskillit:run-experiment ${{ context.worktree_path }} --adjust" + skill_command: "/autoskillit:run-experiment" + skill_inputs: + worktree_path: "${{ context.worktree_path }}" + adjust: true cwd: "${{ context.worktree_path }}" step_name: re_run_experiment output_dir: '.' @@ -1232,7 +1368,19 @@ steps: tool: run_skill model: "" with: - skill_command: "/autoskillit:generate-report ${{ context.worktree_path }} ${{ context.experiment_results }} --output-mode ${{ inputs.output_mode }} --issue-url ${{ inputs.issue_url }} --experiment-type '${{ context.experiment_type }}' --methodology-traditions '${{ context.methodology_tradition }}' --design-review-verdict '${{ context.verdict }}' --disambiguation-rule-applied '${{ context.disambiguation_rule_applied }}' --tier-c-lens '${{ context.tier_c_lens }}' --classification-timestamp '${{ context.classification_timestamp }}' --group-manifest '${{ context.group_manifest }}'" + skill_command: "/autoskillit:generate-report" + skill_inputs: + worktree_path: "${{ context.worktree_path }}" + results_path: "${{ context.experiment_results }}" + output_mode: "${{ inputs.output_mode }}" + issue_url: "${{ inputs.issue_url }}" + experiment_type: "${{ context.experiment_type }}" + methodology_traditions: "${{ context.methodology_tradition }}" + design_review_verdict: "${{ context.verdict }}" + disambiguation_rule_applied: "${{ context.disambiguation_rule_applied }}" + tier_c_lens: "${{ context.tier_c_lens }}" + classification_timestamp: "${{ context.classification_timestamp }}" + group_manifest: "${{ context.group_manifest }}" cwd: "${{ context.worktree_path }}" step_name: re_generate_report output_dir: "{{AUTOSKILLIT_TEMP}}/generate-report" @@ -1303,7 +1451,12 @@ steps: tool: run_skill model: "" with: - skill_command: "/autoskillit:bundle-local-report ${{ context.research_dir }} ${{ context.report_path_after_finalize }} ${{ context.all_diagram_paths }} ${{ context.visualization_plan_path }}" + skill_command: "/autoskillit:bundle-local-report" + skill_inputs: + research_dir: "${{ context.research_dir }}" + all_diagram_paths: "${{ context.all_diagram_paths }}" + report_path: "${{ context.report_path_after_finalize }}" + visualization_plan_path: "${{ context.visualization_plan_path }}" cwd: "${{ context.worktree_path }}" step_name: finalize_bundle_render output_dir: '.' diff --git a/src/autoskillit/skills_extended/apply-review-dimensions/SKILL.md b/src/autoskillit/skills_extended/apply-review-dimensions/SKILL.md index 55208ddeb..faf23815a 100644 --- a/src/autoskillit/skills_extended/apply-review-dimensions/SKILL.md +++ b/src/autoskillit/skills_extended/apply-review-dimensions/SKILL.md @@ -49,7 +49,7 @@ invoked standalone — the recipe step named `apply` with - Fabricate, invent, or embellish information not supported by the available evidence or code. - Emit verdict (verdict is computed by `aggregate_review_verdict` in the synthesize step) - Spawn background subagents (`run_in_background: true` is prohibited) -- Write outside `{{AUTOSKILLIT_TEMP}}/apply-review-dimensions/` +- Write outside `${AUTOSKILLIT_ALLOWED_WRITE_PREFIX:-{{AUTOSKILLIT_TEMP}}/apply-review-dimensions}` - Proceed past L1 when any STRUCTURAL critical finding is present - Issue subagent Task calls sequentially — ALL must be in a single parallel message @@ -57,21 +57,26 @@ invoked standalone — the recipe step named `apply` with - Use `Agent(model="sonnet")` for all subagents - Write `findings_manifest` and `evaluation_dashboard` before emitting tokens - Emit both output tokens as absolute paths -- Write output to `{{AUTOSKILLIT_TEMP}}/apply-review-dimensions/` +- Write output to `${AUTOSKILLIT_ALLOWED_WRITE_PREFIX:-{{AUTOSKILLIT_TEMP}}/apply-review-dimensions}` - Issue all subagent calls in a single message to maximize parallel execution ## Workflow ### Step 0: Setup -1. Create `{{AUTOSKILLIT_TEMP}}/apply-review-dimensions/` if absent. +```bash +APPLY_OUTPUT_DIR="${AUTOSKILLIT_ALLOWED_WRITE_PREFIX:-{{AUTOSKILLIT_TEMP}}/apply-review-dimensions}" +mkdir -p "${APPLY_OUTPUT_DIR}" +``` + +1. Use `APPLY_OUTPUT_DIR` for every output path. 2. Load dimensions_manifest JSON from `dimensions_manifest_path`. 3. **Empty / all-silent detection:** If all dimension weights are `S` (SILENT) or the dimension list is empty: - Write an empty `findings_manifest` JSON (empty array) to - `{{AUTOSKILLIT_TEMP}}/apply-review-dimensions/findings_manifest_{slug}_{YYYY-MM-DD_HHMMSS}.json` + `${APPLY_OUTPUT_DIR}/findings_manifest_{slug}_{YYYY-MM-DD_HHMMSS}.json` - Write a "Scope Advisory" `evaluation_dashboard` to - `{{AUTOSKILLIT_TEMP}}/apply-review-dimensions/evaluation_dashboard_{slug}_{YYYY-MM-DD_HHMMSS}.md` + `${APPLY_OUTPUT_DIR}/evaluation_dashboard_{slug}_{YYYY-MM-DD_HHMMSS}.md` - Emit output tokens and return without spawning any subagents. ### Step 1: L1 Fail-Fast Gate (Level 1) @@ -194,7 +199,7 @@ Merge all red-team findings into the finding pool with ### Step 6: Write Findings Manifest Write -`{{AUTOSKILLIT_TEMP}}/apply-review-dimensions/findings_manifest_{slug}_{YYYY-MM-DD_HHMMSS}.json`. +`${APPLY_OUTPUT_DIR}/findings_manifest_{slug}_{YYYY-MM-DD_HHMMSS}.json`. **Explicit JSON schema (machine contract):** @@ -237,7 +242,7 @@ sub-array. ### Step 7: Write Evaluation Dashboard Write -`{{AUTOSKILLIT_TEMP}}/apply-review-dimensions/evaluation_dashboard_{slug}_{YYYY-MM-DD_HHMMSS}.md` +`${APPLY_OUTPUT_DIR}/evaluation_dashboard_{slug}_{YYYY-MM-DD_HHMMSS}.md` always (full-analysis path or STRUCTURAL halt path). **Full-analysis path contents:** @@ -278,8 +283,8 @@ NOTE: NO `verdict` field in this YAML block — verdict is computed by ### Step 8: Emit Structured Output Tokens ``` -findings_manifest_path = {{AUTOSKILLIT_TEMP}}/apply-review-dimensions/findings_manifest_{slug}_{YYYY-MM-DD_HHMMSS}.json -evaluation_dashboard_path = {{AUTOSKILLIT_TEMP}}/apply-review-dimensions/evaluation_dashboard_{slug}_{YYYY-MM-DD_HHMMSS}.md +findings_manifest_path = ${APPLY_OUTPUT_DIR}/findings_manifest_{slug}_{YYYY-MM-DD_HHMMSS}.json +evaluation_dashboard_path = ${APPLY_OUTPUT_DIR}/evaluation_dashboard_{slug}_{YYYY-MM-DD_HHMMSS}.md ``` > **IMPORTANT:** Emit the structured output tokens as **literal plain text with no @@ -294,11 +299,11 @@ No `verdict` token. No `revision_guidance` token. Output tokens (relative to the current working directory): -- `findings_manifest_path` — `{{AUTOSKILLIT_TEMP}}/apply-review-dimensions/findings_manifest_{slug}_{YYYY-MM-DD_HHMMSS}.json` -- `evaluation_dashboard_path` — `{{AUTOSKILLIT_TEMP}}/apply-review-dimensions/evaluation_dashboard_{slug}_{YYYY-MM-DD_HHMMSS}.md` +- `findings_manifest_path` — `${APPLY_OUTPUT_DIR}/findings_manifest_{slug}_{YYYY-MM-DD_HHMMSS}.json` +- `evaluation_dashboard_path` — `${APPLY_OUTPUT_DIR}/evaluation_dashboard_{slug}_{YYYY-MM-DD_HHMMSS}.md` ``` -{{AUTOSKILLIT_TEMP}}/apply-review-dimensions/ +${APPLY_OUTPUT_DIR}/ ├── findings_manifest_{slug}_{YYYY-MM-DD_HHMMSS}.json (always) └── evaluation_dashboard_{slug}_{YYYY-MM-DD_HHMMSS}.md (always) ``` @@ -315,7 +320,7 @@ Output tokens (relative to the current working directory): ## Context Limit Behavior When context is exhausted mid-execution, the `findings_manifest_path` and -`evaluation_dashboard_path` in `{{AUTOSKILLIT_TEMP}}/apply-review-dimensions/` +`evaluation_dashboard_path` in `${APPLY_OUTPUT_DIR}/` may be partially written but missing the deeper L3/L4 review findings. The recipe's `on_context_limit` route triggers `create_worktree`, preserving whatever findings were captured so the downstream synthesis step can still diff --git a/src/autoskillit/skills_extended/diagnose-ci/SKILL.md b/src/autoskillit/skills_extended/diagnose-ci/SKILL.md index e95a5e69c..0388b8b1d 100644 --- a/src/autoskillit/skills_extended/diagnose-ci/SKILL.md +++ b/src/autoskillit/skills_extended/diagnose-ci/SKILL.md @@ -14,7 +14,8 @@ hooks: # diagnose-ci Skill Fetch CI logs for a failing branch, classify the failure type, and write a structured -diagnosis report to `{{AUTOSKILLIT_TEMP}}/diagnose-ci/`. Called by the orchestrator on `ci_watch` failure +diagnosis report under `${AUTOSKILLIT_ALLOWED_WRITE_PREFIX:-{{AUTOSKILLIT_TEMP}}/diagnose-ci}`. +Called by the orchestrator on `ci_watch` failure before routing to `resolve-failures`. ## Invocation @@ -37,7 +38,7 @@ before routing to `resolve-failures`. - Modify any source code files - Run the test suite -- Write files outside `{{AUTOSKILLIT_TEMP}}/diagnose-ci/` +- Write files outside `${AUTOSKILLIT_ALLOWED_WRITE_PREFIX:-{{AUTOSKILLIT_TEMP}}/diagnose-ci}` - Block on missing `gh` CLI — write a minimal `failure_type=unknown` diagnosis instead **ALWAYS:** @@ -147,7 +148,14 @@ Determine `is_fixable`: ### Step 5: Write Diagnosis Report -Create directory `{{AUTOSKILLIT_TEMP}}/diagnose-ci/` if it doesn't exist. Write the diagnosis file: +Set the recipe-scoped output directory and create it if it does not exist: + +```bash +DIAGNOSIS_OUTPUT_DIR="${AUTOSKILLIT_ALLOWED_WRITE_PREFIX:-{{AUTOSKILLIT_TEMP}}/diagnose-ci}" +mkdir -p "${DIAGNOSIS_OUTPUT_DIR}" +``` + +Write the diagnosis file: ```markdown # CI Diagnosis: {branch} @@ -175,7 +183,7 @@ failure_subtype = {failure_subtype} **Suggested Starting Verdict:** {suggested starting verdict from the subtype table above} ``` -Save to `{{AUTOSKILLIT_TEMP}}/diagnose-ci/diagnosis_{timestamp}.md`. (relative to the current working directory) +Save to `${DIAGNOSIS_OUTPUT_DIR}/diagnosis_{timestamp}.md`. ### Step 6: Emit Output Tokens @@ -188,7 +196,7 @@ Emit these tokens on their own lines at the end of your response: > code fences cause match failure. ``` -diagnosis_path = /absolute/path/to/{{AUTOSKILLIT_TEMP}}/diagnose-ci/diagnosis_{timestamp}.md +diagnosis_path = /absolute/path/to/${DIAGNOSIS_OUTPUT_DIR}/diagnosis_{timestamp}.md failure_type = test|lint|build|type_check|env|unknown|no_failure failure_subtype = flaky|timing_race|deterministic|fixture|import|env|unknown|no_failure is_fixable = true|false @@ -202,4 +210,4 @@ When `gh` is not accessible at any step, write a minimal diagnosis: - `is_fixable = false` - Diagnosis body: "gh CLI unavailable — logs could not be fetched. Manual inspection required." -Then emit the output tokens and exit. \ No newline at end of file +Then emit the output tokens and exit. diff --git a/src/autoskillit/skills_extended/merge-pr/SKILL.md b/src/autoskillit/skills_extended/merge-pr/SKILL.md index 8332ada9f..241b97f15 100644 --- a/src/autoskillit/skills_extended/merge-pr/SKILL.md +++ b/src/autoskillit/skills_extended/merge-pr/SKILL.md @@ -41,7 +41,7 @@ conflicts from earlier merges in the queue. - Use `git merge` to merge the PR into the integration branch — always use `gh pr merge --squash --auto` (when `autoMergeAllowed=true`) or `gh pr merge --squash` (when `autoMergeAllowed=false`) - Close or comment on the PR - Leave the git working tree in a dirty state -- Create files outside `{{AUTOSKILLIT_TEMP}}/merge-prs/` directory +- Create files outside `${AUTOSKILLIT_ALLOWED_WRITE_PREFIX:-{{AUTOSKILLIT_TEMP}}/merge-prs}` - Run subagents in the background (`run_in_background: true` is prohibited) - Issue subagent Task calls sequentially — ALL must be in a single parallel message @@ -298,7 +298,14 @@ Extract the `## Requirements` section if present — set `requirements_section = Compute timestamp: `YYYY-MM-DD_HHMMSS`. -Write `{{AUTOSKILLIT_TEMP}}/merge-prs/conflict_pr{pr_number}_plan_{ts}.md`: +Set the recipe-scoped output directory: + +```bash +MERGE_PR_OUTPUT_DIR="${AUTOSKILLIT_ALLOWED_WRITE_PREFIX:-{{AUTOSKILLIT_TEMP}}/merge-prs}" +mkdir -p "${MERGE_PR_OUTPUT_DIR}" +``` + +Write `${MERGE_PR_OUTPUT_DIR}/conflict_pr{pr_number}_plan_{ts}.md`: ```markdown # Conflict Resolution Plan: PR #{pr_number} — "{pr_title}" @@ -420,7 +427,7 @@ Print a JSON result block to stdout for recipe capture: "pr_number": 47, "pr_branch": "feature/db-refactor", "pr_title": "Refactor database layer", - "conflict_report_path": "{{AUTOSKILLIT_TEMP}}/merge-prs/conflict_pr47_plan_YYYY-MM-DD_HHMMSS.md" + "conflict_report_path": "${MERGE_PR_OUTPUT_DIR}/conflict_pr47_plan_YYYY-MM-DD_HHMMSS.md" } ``` @@ -439,7 +446,7 @@ with `conflict_report_path` set. The pipeline then routes to make-plan → imple "pr_number": 47, "pr_branch": "feature/stale-branch", "pr_title": "Feature from stale branch", - "conflict_report_path": "{{AUTOSKILLIT_TEMP}}/merge-prs/conflict_pr47_plan_YYYY-MM-DD_HHMMSS.md" + "conflict_report_path": "${MERGE_PR_OUTPUT_DIR}/conflict_pr47_plan_YYYY-MM-DD_HHMMSS.md" } ``` @@ -569,7 +576,7 @@ written. Omit the line entirely on a successful direct merge or when `escalation ## Output Location ``` -{{AUTOSKILLIT_TEMP}}/merge-prs/ +${MERGE_PR_OUTPUT_DIR}/ └── conflict_pr{N}_plan_{ts}.md (written only when needs_plan=true) ``` diff --git a/src/autoskillit/skills_extended/resolve-design-review/SKILL.md b/src/autoskillit/skills_extended/resolve-design-review/SKILL.md index 2e725d0b1..dd60dfdd9 100644 --- a/src/autoskillit/skills_extended/resolve-design-review/SKILL.md +++ b/src/autoskillit/skills_extended/resolve-design-review/SKILL.md @@ -28,6 +28,10 @@ loop or halt. `/autoskillit:resolve-design-review [prior_revision_guidance_path]` +`evaluation_dashboard` and `experiment_plan` each identify one Markdown file; read and +parse those files as immutable inputs. `prior_revision_guidance_path`, when present, +likewise identifies one file. + ## When to Use Called by the research recipe via run_skill when review_design emits verdict=STOP. @@ -38,7 +42,7 @@ MCP-only — not user-invocable directly. **NEVER:** - Fabricate, invent, or embellish information not supported by the available evidence or code. -- Create files outside `{{AUTOSKILLIT_TEMP}}/resolve-design-review/` +- Create files outside `${AUTOSKILLIT_ALLOWED_WRITE_PREFIX:-{{AUTOSKILLIT_TEMP}}/resolve-design-review}` - Modify the evaluation dashboard, experiment plan, or any source file - Apply fixes — this skill triages fixability only - Run subagents in the background (`run_in_background: true` is prohibited) @@ -64,7 +68,7 @@ abandoning the partial triage. ### Step 0: Validate Arguments and Parse Dashboard -1. Create `{{AUTOSKILLIT_TEMP}}/resolve-design-review/` if absent +1. Set `RESOLVE_DESIGN_REVIEW_OUTPUT_DIR="${AUTOSKILLIT_ALLOWED_WRITE_PREFIX:-{{AUTOSKILLIT_TEMP}}/resolve-design-review}"` and create it if absent 2. Parse two positional path arguments: `evaluation_dashboard_path`, `experiment_plan_path` - If missing: print `"Error: missing required argument(s) — expected "`, then emit `resolution=failed`, and return - If file not found: print `"Error: file not found — {missing_path}"`, then emit `resolution=failed`, and return @@ -106,7 +110,7 @@ Each subagent returns: Fallback: failed/timed-out subagent → classify finding as DISCUSS (safe, routes to revision). -Write analysis report to `{{AUTOSKILLIT_TEMP}}/resolve-design-review/analysis_{slug}_{ts}.md` +Write analysis report to `${RESOLVE_DESIGN_REVIEW_OUTPUT_DIR}/analysis_{slug}_{ts}.md` BEFORE any guidance is generated. Report must include summary banner: ``` Triage complete (BEFORE any guidance written) @@ -151,7 +155,7 @@ resolution = "failed" only when ALL findings are STRUCTURAL ### Step 3: Write Revision Guidance (only when resolution = revised) -Write `revision_guidance_{slug}_{ts}.md` to `{{AUTOSKILLIT_TEMP}}/resolve-design-review/` +Write `revision_guidance_{slug}_{ts}.md` to `${RESOLVE_DESIGN_REVIEW_OUTPUT_DIR}/` Sections: 1. **Required Fixes** — ADDRESSABLE findings with fix_sketch from subagent @@ -180,7 +184,7 @@ When resolution = revised, emit as your final output: ``` resolution = revised -revision_guidance = /absolute/path/{{AUTOSKILLIT_TEMP}}/resolve-design-review/revision_guidance_{slug}_{ts}.md +revision_guidance = /absolute/path/${RESOLVE_DESIGN_REVIEW_OUTPUT_DIR}/revision_guidance_{slug}_{ts}.md ``` When resolution = failed, emit as your final output: @@ -193,11 +197,11 @@ resolution = failed ## Output -All output files are written to `{{AUTOSKILLIT_TEMP}}/resolve-design-review/` relative to -the current working directory. +All output files are written to `${RESOLVE_DESIGN_REVIEW_OUTPUT_DIR}/`, which defaults to +`{{AUTOSKILLIT_TEMP}}/resolve-design-review/` relative to the current working directory. ``` -{{AUTOSKILLIT_TEMP}}/resolve-design-review/ +${RESOLVE_DESIGN_REVIEW_OUTPUT_DIR}/ ├── analysis_{slug}_{ts}.md (always written — before any guidance) └── revision_guidance_{slug}_{ts}.md (revised path only) ``` diff --git a/src/autoskillit/skills_extended/review-approach/SKILL.md b/src/autoskillit/skills_extended/review-approach/SKILL.md index bf8cbc3c5..6d220a914 100644 --- a/src/autoskillit/skills_extended/review-approach/SKILL.md +++ b/src/autoskillit/skills_extended/review-approach/SKILL.md @@ -38,7 +38,7 @@ failure, not something to work around. - Fabricate, invent, or embellish information not supported by the available evidence or code. - Modify any source code files -- Create files outside `{{AUTOSKILLIT_TEMP}}/review-approach/` directory +- Create files outside `${AUTOSKILLIT_ALLOWED_WRITE_PREFIX:-{{AUTOSKILLIT_TEMP}}/review-approach}` - Run subagents in the background (`run_in_background: true` is prohibited) - Issue subagent Task calls sequentially — ALL must be in a single parallel message @@ -92,7 +92,14 @@ Drop anything that doesn't meaningfully inform the decision. ### Step 4: Write Review -Save to: `{{AUTOSKILLIT_TEMP}}/review-approach/review_approach_{topic}_{YYYY-MM-DD_HHMMSS}.md` (relative to the current working directory) +Set the recipe-scoped output directory: + +```bash +REVIEW_OUTPUT_DIR="${AUTOSKILLIT_ALLOWED_WRITE_PREFIX:-{{AUTOSKILLIT_TEMP}}/review-approach}" +mkdir -p "${REVIEW_OUTPUT_DIR}" +``` + +Save to: `${REVIEW_OUTPUT_DIR}/review_approach_{topic}_{YYYY-MM-DD_HHMMSS}.md`. ```markdown # Approach Review: {Topic} @@ -132,4 +139,4 @@ of your text output: ``` review_path = {absolute_path_to_review_file} -``` \ No newline at end of file +``` diff --git a/src/autoskillit/skills_extended/troubleshoot-experiment/SKILL.md b/src/autoskillit/skills_extended/troubleshoot-experiment/SKILL.md index e21dd5a44..0989ac604 100644 --- a/src/autoskillit/skills_extended/troubleshoot-experiment/SKILL.md +++ b/src/autoskillit/skills_extended/troubleshoot-experiment/SKILL.md @@ -35,7 +35,7 @@ Called by the `research` recipe on `implement_phase` failure before routing to **NEVER:** - Modify any source code files - Run tests -- Write files outside `{{AUTOSKILLIT_TEMP}}/troubleshoot-experiment/` +- Write files outside `${AUTOSKILLIT_ALLOWED_WRITE_PREFIX:-{{AUTOSKILLIT_TEMP}}/troubleshoot-experiment}` - Fabricate or invent causes for failures when log data is insufficient to determine a root cause — state "cause undetermined from available logs" rather than inferring probable causes - Abort when session data is missing — emit `failure_type=unknown`, `is_fixable=false` and exit cleanly @@ -96,10 +96,14 @@ When `failure_type=transient_api`, the output MUST include `retry_delay = 120`. ### Step 5: Write Diagnosis Report -Create directory `{{AUTOSKILLIT_TEMP}}/troubleshoot-experiment/` if it does not exist. +```bash +TROUBLESHOOT_OUTPUT_DIR="${AUTOSKILLIT_ALLOWED_WRITE_PREFIX:-{{AUTOSKILLIT_TEMP}}/troubleshoot-experiment}" +mkdir -p "${TROUBLESHOOT_OUTPUT_DIR}" +``` + Write the diagnosis file to: -`{{AUTOSKILLIT_TEMP}}/troubleshoot-experiment/diagnosis_{YYYY-MM-DD_HHMMSS}.md` (relative to the current working directory) +`${TROUBLESHOOT_OUTPUT_DIR}/diagnosis_{YYYY-MM-DD_HHMMSS}.md` ```markdown # Experiment Failure Diagnosis diff --git a/tests/contracts/test_prepare_compose_pr_contracts.py b/tests/contracts/test_prepare_compose_pr_contracts.py index 6de9cb70f..fb21364c9 100644 --- a/tests/contracts/test_prepare_compose_pr_contracts.py +++ b/tests/contracts/test_prepare_compose_pr_contracts.py @@ -82,29 +82,21 @@ def test_compose_pr_gh_degrades_gracefully(): def test_compose_pr_skill_command_includes_issue_number() -> None: - """compose_pr skill_command must structurally include context.issue_number.""" + """compose_pr must structurally bind context.issue_number.""" from autoskillit.core.io import load_yaml data = load_yaml(RECIPES_DIR / "implementation.yaml") - skill_command = data["steps"]["compose_pr"]["with"]["skill_command"] - assert "${{ context.issue_number }}" in skill_command, ( - f"compose_pr skill_command must include" - f" ${{{{ context.issue_number }}}} as a positional arg, " - f"not rely on with: metadata. Got: {skill_command!r}" - ) + skill_inputs = data["steps"]["compose_pr"]["with"]["skill_inputs"] + assert skill_inputs["closing_issue"] == "${{ context.issue_number }}" def test_prepare_pr_skill_command_includes_issue_number() -> None: - """prepare_pr skill_command must structurally include context.issue_number.""" + """prepare_pr must structurally bind context.issue_number.""" from autoskillit.core.io import load_yaml data = load_yaml(RECIPES_DIR / "implementation.yaml") - skill_command = data["steps"]["prepare_pr"]["with"]["skill_command"] - assert "${{ context.issue_number }}" in skill_command, ( - f"prepare_pr skill_command must include" - f" ${{{{ context.issue_number }}}} as a positional arg, " - f"not rely on with: metadata. Got: {skill_command!r}" - ) + skill_inputs = data["steps"]["prepare_pr"]["with"]["skill_inputs"] + assert skill_inputs["closing_issue"] == "${{ context.issue_number }}" def test_prepare_pr_title_source_attribution(): @@ -235,20 +227,13 @@ def test_prepare_pr_skips_lens_work_when_arch_lenses_false(): ], ) def test_recipe_prepare_pr_passes_arch_lenses(recipe_name: str) -> None: - """Every recipe's prepare_pr step must pass arch_lenses and issue_number in skill_command.""" + """Every recipe's prepare_pr step binds arch_lenses and issue_number.""" from autoskillit.core.io import load_yaml data = load_yaml(RECIPES_DIR / recipe_name) - skill_command = data["steps"]["prepare_pr"]["with"]["skill_command"] - assert "${{ inputs.arch_lenses }}" in skill_command, ( - f"{recipe_name}: prepare_pr skill_command must include " - "${{ inputs.arch_lenses }} as a positional argument" - ) - assert "${{ context.issue_number }}" in skill_command, ( - f"{recipe_name}: prepare_pr skill_command must include " - "${{ context.issue_number }} as a positional argument, " - "not in a separate with: key (ADR-0003)" - ) + skill_inputs = data["steps"]["prepare_pr"]["with"]["skill_inputs"] + assert skill_inputs["arch_lenses"] == "${{ inputs.arch_lenses }}" + assert skill_inputs["closing_issue"] == "${{ context.issue_number }}" def test_prepare_pr_contract_includes_arch_lenses_input() -> None: diff --git a/tests/contracts/test_review_pr_diff_annotation.py b/tests/contracts/test_review_pr_diff_annotation.py index 341055439..038b0620c 100644 --- a/tests/contracts/test_review_pr_diff_annotation.py +++ b/tests/contracts/test_review_pr_diff_annotation.py @@ -231,7 +231,7 @@ def test_audit_claims_blockquote_uses_path_not_content_var() -> None: def test_review_skill_command_passes_diff_annotation_paths( skill_name: str, recipe_name: str ) -> None: - """Skill command must forward hunk_ranges_path= and valid_lines_path= inline.""" + """Skill invocation must bind hunk and valid-line artifacts.""" recipe = load_recipe(builtin_recipes_dir() / f"{recipe_name}.yaml") review_steps = [ (name, step) @@ -240,12 +240,10 @@ def test_review_skill_command_passes_diff_annotation_paths( ] assert review_steps, f"No {skill_name} step in {recipe_name}.yaml" for step_name, step in review_steps: - cmd = step.with_args.get("skill_command", "") - assert "hunk_ranges_path=" in cmd, ( - f"{recipe_name}.yaml step '{step_name}' calls {skill_name} " - f"without hunk_ranges_path= in skill_command" + skill_inputs = step.with_args["skill_inputs"] + assert "hunk_ranges_path" in skill_inputs, ( + f"{recipe_name}.yaml step '{step_name}' calls {skill_name} without hunk_ranges_path" ) - assert "valid_lines_path=" in cmd, ( - f"{recipe_name}.yaml step '{step_name}' calls {skill_name} " - f"without valid_lines_path= in skill_command" + assert "valid_lines_path" in skill_inputs, ( + f"{recipe_name}.yaml step '{step_name}' calls {skill_name} without valid_lines_path" ) diff --git a/tests/execution/test_headless_path_validation.py b/tests/execution/test_headless_path_validation.py index 5daa627c1..b0bd96ac8 100644 --- a/tests/execution/test_headless_path_validation.py +++ b/tests/execution/test_headless_path_validation.py @@ -534,6 +534,7 @@ class TestOutputPathTokensDerivedFromContracts: _EXPECTED_OUTPUT_PATH_TOKENS = frozenset( { "analysis_file", + "audit_cycle_path", "campaign_path", "conflict_report_path", "diagnosis_path", @@ -546,6 +547,7 @@ class TestOutputPathTokensDerivedFromContracts: "manifest_path", "plan_parts", "plan_path", + "plan_disposition_path", "pr_order_file", "prep_path", "recipe_path", diff --git a/tests/infra/test_pretty_output_recipe.py b/tests/infra/test_pretty_output_recipe.py index f92b4209a..64cc49766 100644 --- a/tests/infra/test_pretty_output_recipe.py +++ b/tests/infra/test_pretty_output_recipe.py @@ -1091,8 +1091,8 @@ def test_canonical_recipe_responses_fit_independent_registry_ceilings(tmp_path, for ingredients_only in (False, True) } assert maxima == { - "load_recipe": (193_203, "remediation", "all_truthy"), - "open_kitchen": (193_256, "remediation", "all_truthy"), + "load_recipe": (168_998, "remediation", "all_truthy"), + "open_kitchen": (169_051, "remediation", "all_truthy"), } diff --git a/tests/recipe/test_api.py b/tests/recipe/test_api.py index 66edc8306..103c29423 100644 --- a/tests/recipe/test_api.py +++ b/tests/recipe/test_api.py @@ -400,6 +400,7 @@ def test_load_and_validate_cache_key_includes_all_result_affecting_params(tmp_pa "backend_name": 10, "effective_backend_map": 11, "backend_capabilities_map": 12, + "include_compiled_bindings": 13, } missing_params: list[str] = [] @@ -520,6 +521,7 @@ def capturing_fn( backend_name=None, effective_backend_map=None, backend_origin_map=None, + include_compiled_bindings=False, ): captured["recipe_info"] = recipe_info captured["backend_capabilities_map"] = locals().get("backend_capabilities_map") @@ -539,6 +541,7 @@ def capturing_fn( effective_backend_map=effective_backend_map, backend_capabilities_map=backend_capabilities_map, backend_origin_map=backend_origin_map, + include_compiled_bindings=include_compiled_bindings, ) monkeypatch.setattr(api_mod, "load_and_validate", capturing_fn) diff --git a/tests/recipe/test_audit_cycle_lifecycle_integration.py b/tests/recipe/test_audit_cycle_lifecycle_integration.py new file mode 100644 index 000000000..f97ddefff --- /dev/null +++ b/tests/recipe/test_audit_cycle_lifecycle_integration.py @@ -0,0 +1,91 @@ +"""Loaded-recipe audit authority and disposition-delivery integration.""" + +from __future__ import annotations + +import pytest + +from autoskillit.recipe._binding import bind_recipe +from autoskillit.recipe.io import builtin_recipes_dir, load_recipe + +pytestmark = [pytest.mark.layer("recipe"), pytest.mark.small] + +_AUDIT_CONSUMERS = ( + "implementation", + "implementation-groups", + "remediation", + "merge-prs", + "research-implement", + "research", +) +_DRY_CONSUMERS = ( + "implementation", + "implementation-groups", + "remediation", + "merge-prs", +) + + +@pytest.mark.parametrize("recipe_name", _AUDIT_CONSUMERS) +def test_loaded_audit_remediation_chain_delivers_one_bound_authority( + recipe_name: str, +) -> None: + recipe = load_recipe(builtin_recipes_dir() / f"{recipe_name}.yaml") + projection = bind_recipe(recipe) + audit_steps = [ + (step_name, invocation) + for step_name, invocation in projection.invocations.items() + if invocation.skill_name == "audit-impl" + ] + make_plan_steps = [ + (step_name, invocation) + for step_name, invocation in projection.invocations.items() + if invocation.skill_name == "make-plan" + ] + + assert audit_steps + assert make_plan_steps + for step_name, invocation in audit_steps: + assert "audit_cycle_path" in recipe.steps[step_name].capture + prior = invocation.skill_input("prior_audit_cycle_path") + assert prior is not None and prior.is_present + assert prior.context_dependencies == ("audit_cycle_path",) + + for step_name, invocation in make_plan_steps: + authority = invocation.skill_input("audit_cycle_path") + assert authority is not None and authority.is_present + assert authority.context_dependencies == ("audit_cycle_path",) + assert "plan_disposition_path" in recipe.steps[step_name].capture + + assert not any( + "false_positive" in str(condition.when) + for step in recipe.steps.values() + if step.on_result is not None + for condition in step.on_result.conditions + ) + + +@pytest.mark.parametrize("recipe_name", _DRY_CONSUMERS) +def test_every_loaded_dry_child_receives_the_complete_current_tuple( + recipe_name: str, +) -> None: + recipe = load_recipe(builtin_recipes_dir() / f"{recipe_name}.yaml") + projection = bind_recipe(recipe) + dry_invocations = [ + invocation + for invocation in projection.invocations.values() + if invocation.skill_name == "dry-walkthrough" + ] + + assert dry_invocations + for invocation in dry_invocations: + inputs = {value.name: value for value in invocation.skill_inputs} + assert inputs.keys() >= { + "plan_path", + "audit_cycle_path", + "plan_disposition_path", + } + assert inputs["plan_path"].is_present + assert inputs["audit_cycle_path"].is_present + assert inputs["plan_disposition_path"].is_present + assert inputs["audit_cycle_path"].context_dependencies == ("audit_cycle_path",) + assert inputs["plan_disposition_path"].context_dependencies == ("plan_disposition_path",) diff --git a/tests/recipe/test_audit_trail_recipe_contracts.py b/tests/recipe/test_audit_trail_recipe_contracts.py index f1bddf678..cb89880c6 100644 --- a/tests/recipe/test_audit_trail_recipe_contracts.py +++ b/tests/recipe/test_audit_trail_recipe_contracts.py @@ -29,14 +29,14 @@ def test_dial_captures_classification_timestamp(): def test_generate_report_receives_audit_fields(): - """generate_report step receives disambiguation, verdict, and timestamp flags.""" + """generate_report receives disambiguation, verdict, and timestamp inputs.""" recipe = load_yaml(RECIPE_PATH) gr_step = recipe["steps"]["generate_report"] - skill_command = gr_step.get("with", {}).get("skill_command", "") + skill_inputs = gr_step["with"]["skill_inputs"] for field in [ - "design-review-verdict", - "disambiguation-rule-applied", - "tier-c-lens", - "classification-timestamp", + "design_review_verdict", + "disambiguation_rule_applied", + "tier_c_lens", + "classification_timestamp", ]: - assert field in skill_command, f"generate_report skill_command must contain --{field}" + assert field in skill_inputs diff --git a/tests/recipe/test_bundled_recipes_pipeline_structure.py b/tests/recipe/test_bundled_recipes_pipeline_structure.py index 218d419d0..6b599b722 100644 --- a/tests/recipe/test_bundled_recipes_pipeline_structure.py +++ b/tests/recipe/test_bundled_recipes_pipeline_structure.py @@ -17,6 +17,12 @@ pytestmark = [pytest.mark.layer("recipe"), pytest.mark.small] +def _skill_input(step, name: str) -> object: + values = step.with_args.get("skill_inputs", {}) + assert isinstance(values, dict) + return values.get(name, "") + + def _assert_ci_conflict_fix_on_context_limit(recipe) -> None: """Shared assertion: ci_conflict_fix routes to diagnose_ci on context limit for CI log capture.""" @@ -58,8 +64,8 @@ def _assert_ci_steps(recipe) -> None: assert "resolve-failures" in skill_cmd assert resolve_ci.retries == 2 assert resolve_ci.on_exhausted == "release_issue_failure" - assert "context.work_dir" in skill_cmd - assert "context.plan_path" in skill_cmd + assert "context.work_dir" in str(_skill_input(resolve_ci, "worktree_path")) + assert "context.plan_path" in str(_skill_input(resolve_ci, "plan_path")) # re_push step (T_CI6) assert "re_push" in recipe.steps @@ -385,12 +391,12 @@ def test_ip_audit_impl_uses_base_sha_as_ref(self, recipe) -> None: names a git object and survives unconditionally. """ step = recipe.steps["audit_impl"] - skill_cmd = step.with_args.get("skill_command", "") - assert "context.base_sha" in skill_cmd, ( + implementation_ref = str(_skill_input(step, "branch_name")) + assert "context.base_sha" in implementation_ref, ( "audit_impl must use context.base_sha as implementation_ref — " "context.branch_name is deleted by merge_worktree before audit_impl runs" ) - assert "context.branch_name" not in skill_cmd, ( + assert "context.branch_name" not in implementation_ref, ( "audit_impl must NOT use context.branch_name (deleted by merge_worktree)" ) @@ -581,23 +587,23 @@ def test_ip_plan_step_captures_all_plan_paths(self, recipe) -> None: def test_ip_prepare_pr_references_all_plan_paths(self, recipe) -> None: """prepare_pr must pass all accumulated plan paths, not just the last.""" - cmd = recipe.steps["prepare_pr"].with_args.get("skill_command", "") - assert "context.all_plan_paths" in cmd - assert "context.plan_path" not in cmd + plan_paths = str(_skill_input(recipe.steps["prepare_pr"], "plan_paths")) + assert "context.all_plan_paths" in plan_paths + assert "context.plan_path" not in plan_paths def test_ip_plan_step_note_contains_accumulation_instruction(self, recipe) -> None: """plan step note must instruct agent to accumulate plan paths across groups.""" note = recipe.steps["plan"].note or "" - assert "ACCUMULATION" in note + assert "appends" in note assert "all_plan_paths" in note def test_audit_impl_uses_all_plan_paths(self, recipe) -> None: """audit_impl must reference context.all_plan_paths so remediation re-entry accumulates.""" - cmd = recipe.steps["audit_impl"].with_args.get("skill_command", "") - assert "context.all_plan_paths" in cmd, ( + plan_paths = str(_skill_input(recipe.steps["audit_impl"], "all_plan_paths")) + assert "context.all_plan_paths" in plan_paths, ( f"{recipe.name} audit_impl must reference context.all_plan_paths" ) - assert "context.plan_path" not in cmd, ( + assert "context.plan_path" not in plan_paths, ( f"{recipe.name} audit_impl must not reference context.plan_path" ) @@ -727,9 +733,9 @@ def test_ig_push_merge_target_routes_to_group(self, recipe) -> None: def test_ig_audit_impl_uses_base_sha_as_ref(self, recipe) -> None: """audit_impl must use context.base_sha as implementation_ref.""" step = recipe.steps["audit_impl"] - skill_cmd = step.with_args.get("skill_command", "") - assert "context.base_sha" in skill_cmd - assert "context.branch_name" not in skill_cmd + implementation_ref = str(_skill_input(step, "branch_name")) + assert "context.base_sha" in implementation_ref + assert "context.branch_name" not in implementation_ref def test_ig_fix_step_routes_via_on_result_to_test(self, recipe) -> None: """fix step must route via verdict-gated on_result to test (verify before guard).""" @@ -858,23 +864,20 @@ def test_if2_remediate_step_routes_to_make_plan(self, recipe) -> None: assert recipe.steps["remediate"].on_success == "make_plan" def test_if5_make_plan_step_has_correct_structure(self, recipe) -> None: - """T_IF5: make_plan step calls make-plan with remediation_path and captures outputs.""" + """T_IF5: make_plan receives explicit audit authority and captures both artifacts.""" assert "make_plan" in recipe.steps step = recipe.steps["make_plan"] assert step.tool == "run_skill" skill_cmd = step.with_args.get("skill_command", "") assert "/autoskillit:make-plan" in skill_cmd - assert "context.remediation_path" in skill_cmd + assert "context.audit_cycle_path" in str(_skill_input(step, "audit_cycle_path")) assert "plan_path" in step.capture + assert "plan_disposition_path" in step.capture assert "plan_parts" in step.capture_list assert "verdict" in step.capture assert step.on_success is None assert step.on_result is not None - plan_routes = [ - c - for c in step.on_result.conditions - if c.when and "plan" in c.when and "false_positive" not in c.when - ] + plan_routes = [c for c in step.on_result.conditions if c.when and "plan" in c.when] assert len(plan_routes) == 1 assert plan_routes[0].route == "dry_walkthrough" assert step.on_failure == "release_issue_failure" @@ -906,8 +909,8 @@ def test_if_b1_implement_captures_branch_name(self, recipe) -> None: def test_if_b2_audit_impl_uses_branch_name_as_ref(self, recipe) -> None: """T_IF_B2: audit_impl with: must reference context.branch_name as implementation_ref.""" step = recipe.steps["audit_impl"] - skill_cmd = step.with_args.get("skill_command", "") - assert "context.branch_name" in skill_cmd, ( + implementation_ref = str(_skill_input(step, "branch_name")) + assert "context.branch_name" in implementation_ref, ( "audit_impl must pass context.branch_name as implementation_ref — not " "context.implementation_ref or context.worktree_path (stale after merge)" ) @@ -969,11 +972,11 @@ def test_remediation_investigate_captures_investigation_path(self, recipe) -> No assert step.capture["investigation_path"].from_ == "${{ result.investigation_path }}" def test_remediation_rectify_uses_context_investigation_path(self, recipe) -> None: - """1d: rectify step must pass an investigation path context variable in skill_command.""" + """1d: rectify receives the bridged investigation path by name.""" step = recipe.steps["rectify"] - skill_cmd = step.with_args.get("skill_command", "") - assert "${{ context.effective_investigation_path }}" in skill_cmd, ( - "rectify step skill_command must include ${{ context.effective_investigation_path }} " + investigation_path = str(_skill_input(step, "investigation_path")) + assert "${{ context.effective_investigation_path }}" in investigation_path, ( + "rectify step must bind ${{ context.effective_investigation_path }} " "to pass the resolved path from the bridge_investigation step" ) @@ -997,10 +1000,10 @@ def test_if_resolve_review_uses_resolve_review_skill(self, recipe) -> None: ) def test_if_resolve_review_passes_merge_target(self, recipe) -> None: - """T_IF_RR2: resolve_review skill_command must pass context.merge_target.""" + """T_IF_RR2: resolve_review binds context.merge_target as feature_branch.""" step = recipe.steps["resolve_review"] - skill_cmd = step.with_args.get("skill_command", "") - assert "context.merge_target" in skill_cmd, ( + feature_branch = str(_skill_input(step, "feature_branch")) + assert "context.merge_target" in feature_branch, ( "resolve-review requires feature_branch as first arg; " "context.merge_target holds the feature branch name" ) diff --git a/tests/recipe/test_bundled_recipes_research.py b/tests/recipe/test_bundled_recipes_research.py index a8b3b03c7..5fa7604fa 100644 --- a/tests/recipe/test_bundled_recipes_research.py +++ b/tests/recipe/test_bundled_recipes_research.py @@ -82,8 +82,9 @@ def test_dial_step_retries_and_exhausted(self, recipe) -> None: def test_dial_receives_scope_report(self, recipe) -> None: """dial step must pass scope_report as a second argument.""" step = recipe.steps["dial"] - cmd = step.with_args["skill_command"] - assert "${{ context.scope_report }}" in cmd + assert step.with_args["skill_inputs"]["scope_report_path"] == ( + "${{ context.scope_report }}" + ) def test_plan_experiment_routes_to_dial(self, recipe) -> None: step = recipe.steps["plan_experiment"] @@ -202,7 +203,7 @@ def test_re_run_experiment_step(self, recipe) -> None: assert "re_run_experiment" in recipe.steps step = recipe.steps["re_run_experiment"] assert step.tool == "run_skill" - assert "--adjust" in step.with_args.get("skill_command", "") + assert step.with_args["skill_inputs"]["adjust"] is True assert step.on_result is not None, ( "re_run_experiment must use on_result for verdict routing" ) @@ -526,12 +527,12 @@ def test_select_directions_on_failure_routes_to_escalate(self, recipe) -> None: assert recipe.steps["select_directions"].on_failure == "escalate_stop" def test_plan_experiment_skill_command_uses_selected_directions(self, recipe) -> None: - cmd = recipe.steps["plan_experiment"].with_args.get("skill_command", "") - assert "selected_directions" in cmd + skill_inputs = recipe.steps["plan_experiment"].with_args["skill_inputs"] + assert skill_inputs["scope_directions_path"] == "${{ context.selected_directions }}" def test_plan_experiment_skill_command_no_longer_uses_scope_directions(self, recipe) -> None: - cmd = recipe.steps["plan_experiment"].with_args.get("skill_command", "") - assert "scope_directions" not in cmd + skill_inputs = recipe.steps["plan_experiment"].with_args["skill_inputs"] + assert "${{ context.scope_directions }}" not in skill_inputs.values() def test_research_has_min_breadth_ingredient(self, recipe) -> None: assert "min_breadth" in recipe.ingredients diff --git a/tests/recipe/test_bundled_recipes_research_design.py b/tests/recipe/test_bundled_recipes_research_design.py index 3ed1f96b2..372ccc691 100644 --- a/tests/recipe/test_bundled_recipes_research_design.py +++ b/tests/recipe/test_bundled_recipes_research_design.py @@ -158,8 +158,9 @@ def test_apply_on_failure(self, recipe) -> None: def test_apply_receives_scope_report(self, recipe) -> None: """apply step must pass scope_report as a second argument.""" step = recipe.steps["apply"] - cmd = step.with_args["skill_command"] - assert "${{ context.scope_report }}" in cmd + assert step.with_args["skill_inputs"]["scope_report_path"] == ( + "${{ context.scope_report }}" + ) def test_apply_captures(self, recipe) -> None: capture = recipe.steps["apply"].capture diff --git a/tests/recipe/test_bundled_recipes_review_pr.py b/tests/recipe/test_bundled_recipes_review_pr.py index 141410ce5..2beafc681 100644 --- a/tests/recipe/test_bundled_recipes_review_pr.py +++ b/tests/recipe/test_bundled_recipes_review_pr.py @@ -182,8 +182,9 @@ def test_annotate_step_captures_diff_metrics_path(self, recipe: object) -> None: def test_review_pr_command_includes_diff_metrics_path(self, recipe: object) -> None: step = recipe.steps["review_pr"] # type: ignore[attr-defined] - cmd = step.with_args.get("skill_command", "") - assert "diff_metrics_path=" in cmd + assert step.with_args["skill_inputs"]["diff_metrics_path"] == ( + "${{ context.diff_metrics_path }}" + ) def test_resolve_review_step_uses_correct_skill(self, recipe: object) -> None: """resolve_review step must invoke /autoskillit:resolve-review in all recipes.""" @@ -206,8 +207,9 @@ def test_implementation_groups_has_ci_watch() -> None: def test_merge_prs_review_pr_integration_includes_diff_metrics_path() -> None: recipe = load_recipe(builtin_recipes_dir() / "merge-prs.yaml") step = recipe.steps["review_pr_integration"] - cmd = step.with_args.get("skill_command", "") - assert "diff_metrics_path=" in cmd + assert step.with_args["skill_inputs"]["diff_metrics_path"] == ( + "${{ context.diff_metrics_path }}" + ) def test_merge_prs_annotate_step_captures_diff_metrics_path() -> None: @@ -291,14 +293,12 @@ def test_annotate_step_passes_base_branch(self, recipe: object) -> None: def test_review_pr_command_includes_mode(self, recipe: object) -> None: """T4.5: review_pr skill_command includes mode=${{ context.review_mode }}.""" step = recipe.steps["review_pr"] - cmd = step.with_args.get("skill_command", "") - assert "mode=${{ context.review_mode }}" in cmd + assert step.with_args["skill_inputs"]["mode"] == "${{ context.review_mode }}" def test_resolve_review_command_includes_mode(self, recipe: object) -> None: """T4.6: resolve_review skill_command includes mode=${{ context.review_mode }}.""" step = recipe.steps["resolve_review"] - cmd = step.with_args.get("skill_command", "") - assert "mode=${{ context.review_mode }}" in cmd + assert step.with_args["skill_inputs"]["mode"] == "${{ context.review_mode }}" def test_local_review_rounds_ingredient_exists(self, recipe: object) -> None: """T4.7: local_review_rounds is in recipe.ingredients.""" diff --git a/tests/recipe/test_cmd_rpc_verify_plan_artifacts.py b/tests/recipe/test_cmd_rpc_verify_plan_artifacts.py index 05eb11a8d..2294e19ad 100644 --- a/tests/recipe/test_cmd_rpc_verify_plan_artifacts.py +++ b/tests/recipe/test_cmd_rpc_verify_plan_artifacts.py @@ -5,6 +5,21 @@ import pytest +from autoskillit.core.closure_hashing import ( + canonical_json_bytes, + compute_bytes_hash, + compute_canonical_hash, +) +from autoskillit.core.types import ( + AUDIT_CYCLE_SCHEMA_VERSION, + ArtifactRef, + AuditAssessment, + AuditAssessmentRow, + AuditCycleAuthority, + AuditVerdict, + PlanDispositionReport, + PlanDispositionRow, +) from autoskillit.recipe._cmd_rpc import verify_plan_artifacts pytestmark = [pytest.mark.layer("recipe"), pytest.mark.small] @@ -16,6 +31,105 @@ def _write(tmp_path, name, content="plan content"): return str(path) +def _ref(path, media_type: str) -> ArtifactRef: + data = path.read_bytes() + return ArtifactRef( + locator=str(path), + media_type=media_type, + schema_version=AUDIT_CYCLE_SCHEMA_VERSION, + byte_size=len(data), + content_digest=compute_bytes_hash(data), + ) + + +def _write_audit_tuple(tmp_path): + cycle = tmp_path / "cycle" + cycle.mkdir() + plan = tmp_path / "plan.md" + plan.write_text( + "## Implementation Steps\n\n### Step 1: Fix\n\n" + "## Requirements Map\n\n" + "| Requirement ID | Disposition | Implementation Step |\n" + "|---|---|---|\n" + "| REQ-001 | carried@step | Step 1 |\n" + ) + inventory = cycle / "inventory.json" + inventory.write_bytes( + canonical_json_bytes( + { + "schema_version": 1, + "requirement_ids": ["REQ-001"], + "requirements": [{"id": "REQ-001"}], + } + ) + ) + remediation = cycle / "remediation.md" + remediation.write_text("REQ-001 remains") + plan_ref = _ref(plan, "text/markdown") + inventory_ref = _ref(inventory, "application/json") + assessment = AuditAssessmentRow.create( + requirement_id="REQ-001", + requirement_text="Fix the issue", + assessment=AuditAssessment.MISSING, + evidence_summary="not present", + ) + authority = AuditCycleAuthority.create( + execution_generation="generation-1", + cycle_id="cycle-1", + plan_set_id="plans-1", + scope_id="scope-1", + part_id="part-a", + audit_round=1, + parent_authority_digest=None, + audited_plan_refs=(plan_ref,), + inventory_ref=inventory_ref, + assessments=(assessment,), + verdict=AuditVerdict.NO_GO, + remediation_ref=_ref(remediation, "text/markdown"), + generated_at="2026-07-23T00:00:00Z", + ) + authority_path = cycle / "authority.json" + authority_path.write_bytes(authority.canonical_bytes) + disposition = PlanDispositionReport.create( + execution_generation=authority.execution_generation, + cycle_id=authority.cycle_id, + plan_set_id=authority.plan_set_id, + scope_id=authority.scope_id, + part_id=authority.part_id, + audit_round=authority.audit_round, + parent_authority_digest=authority.authority_digest, + inventory_digest=authority.inventory_ref.content_digest, + findings_digest=authority.findings_digest, + current_plan_ref=plan_ref, + dispositions=( + PlanDispositionRow.create( + requirement_id="REQ-001", + disposition="carried@step", + implementation_step="Step 1", + ), + ), + generated_at="2026-07-23T00:00:01Z", + ) + disposition_path = cycle / "disposition.json" + disposition_path.write_bytes(disposition.canonical_bytes) + association_payload = { + "schema_version": 1, + "plan_ref": plan_ref.to_dict(), + "disposition_ref": _ref(disposition_path, "application/json").to_dict(), + "parent_authority_digest": authority.authority_digest, + } + association_payload["association_digest"] = compute_canonical_hash( + association_payload, + domain="autoskillit:audit-cycle:plan-association:v1:sha256", + ) + associations = cycle / "associations" + associations.mkdir() + (associations / f"{plan_ref.content_digest}.json").write_bytes( + canonical_json_bytes(association_payload) + ) + return plan, authority_path, disposition_path + + def test_single_absolute_path_salvaged(tmp_path): p = _write(tmp_path, "plan.md") result = verify_plan_artifacts(plan_parts=p) @@ -76,3 +190,27 @@ def test_existing_empty_file_unsalvageable(tmp_path): empty.write_text("") result = verify_plan_artifacts(plan_parts=str(empty)) assert result == {"verdict": "unsalvageable"} + + +def test_active_no_go_salvage_restores_exact_disposition(tmp_path): + plan, authority, disposition = _write_audit_tuple(tmp_path) + result = verify_plan_artifacts( + plan_parts=str(plan), + audit_cycle_path=str(authority), + ) + assert result == { + "verdict": "salvaged", + "plan_parts": str(plan), + "plan_path": str(plan), + "plan_disposition_path": str(disposition), + } + + +def test_active_no_go_salvage_rejects_missing_association(tmp_path): + plan, authority, _ = _write_audit_tuple(tmp_path) + association = next((authority.parent / "associations").iterdir()) + association.rename(association.with_name("wrong-key.json")) + assert verify_plan_artifacts( + plan_parts=str(plan), + audit_cycle_path=str(authority), + ) == {"verdict": "unsalvageable"} diff --git a/tests/recipe/test_contracts.py b/tests/recipe/test_contracts.py index 417ce66f2..52e162569 100644 --- a/tests/recipe/test_contracts.py +++ b/tests/recipe/test_contracts.py @@ -748,14 +748,13 @@ def test_generate_recipe_card_includes_output_patterns(tmp_path: Path) -> None: # --------------------------------------------------------------------------- -def test_write_behavior_conditional_make_plan_loaded() -> None: - """make-plan contract declares write_behavior='conditional' gated on verdict.""" +def test_write_behavior_always_make_plan_loaded() -> None: + """make-plan cannot succeed without publishing a plan.""" manifest = load_bundled_manifest() contract = get_skill_contract("make-plan", manifest) assert contract is not None - assert contract.write_behavior == "conditional" - assert len(contract.write_expected_when) > 0 - assert any("verdict" in p for p in contract.write_expected_when) + assert contract.write_behavior == "always" + assert contract.completion_required is True def test_write_behavior_conditional_loaded() -> None: @@ -799,6 +798,7 @@ def test_investigate_declares_report_write_contract() -> None: "investigate", "make-campaign", "make-groups", + "make-plan", "phoropter-null-synthesis", "phoropter-priority-synthesis", "plan-experiment", @@ -862,7 +862,6 @@ def test_always_write_skills_matches_yaml() -> None: CONDITIONAL_WRITE_SKILLS: dict[str, str] = { # skill_name → substring that must appear in write_expected_when patterns "compose-pr": "pr_url", - "make-plan": "verdict", "promote-to-main": "verdict", "resolve-failures": "verdict", "resolve-merge-conflicts": "conflict_report_path", diff --git a/tests/recipe/test_implementation.py b/tests/recipe/test_implementation.py index 74f2f4c2b..02ab7702d 100644 --- a/tests/recipe/test_implementation.py +++ b/tests/recipe/test_implementation.py @@ -325,8 +325,9 @@ def test_retry_worktree_captures_deviation_manifest_path(recipe) -> None: def test_audit_impl_forwards_deviation_manifest_path(recipe) -> None: """audit_impl must forward deviation_manifest_path kwarg and declare it optional.""" step = recipe.steps["audit_impl"] - cmd = step.with_args.get("skill_command", "") - assert "deviation_manifest_path=" in cmd + assert step.with_args["skill_inputs"]["deviation_manifest_path"] == ( + "${{ context.deviation_manifest_path }}" + ) assert "deviation_manifest_path" in (step.optional_context_refs or []) @@ -345,6 +346,7 @@ def test_audit_impl_deviation_manifest_path_kwarg_consistency(recipe_name: str) """Both recipes must forward deviation_manifest_path identically to audit_impl.""" recipe = load_recipe(builtin_recipes_dir() / f"{recipe_name}.yaml") step = recipe.steps["audit_impl"] - cmd = step.with_args.get("skill_command", "") - assert "deviation_manifest_path=${{ context.deviation_manifest_path }}" in cmd + assert step.with_args["skill_inputs"]["deviation_manifest_path"] == ( + "${{ context.deviation_manifest_path }}" + ) assert "deviation_manifest_path" in (step.optional_context_refs or []) diff --git a/tests/recipe/test_issue_url_pipeline.py b/tests/recipe/test_issue_url_pipeline.py index 982233435..aa935de97 100644 --- a/tests/recipe/test_issue_url_pipeline.py +++ b/tests/recipe/test_issue_url_pipeline.py @@ -157,9 +157,9 @@ def test_create_branch_uses_callable(self): def test_issue_url_referenced_in_downstream_skill_step(self): """investigate step must reference inputs.issue_url, not issue_content.""" data = load_yaml(_recipe_path("remediation")) - skill_step_with = data["steps"]["investigate"].get("with", {}) - assert any("issue_url" in str(v) for v in skill_step_with.values()) - assert not any("issue_content" in str(v) for v in skill_step_with.values()) + topic = data["steps"]["investigate"]["with"]["skill_inputs"]["topic"] + assert "inputs.issue_url" in topic + assert "issue_content" not in topic def test_issue_number_referenced_in_prepare_pr_step(self): data = load_yaml(_recipe_path("remediation")) diff --git a/tests/recipe/test_merge_prs.py b/tests/recipe/test_merge_prs.py index 87ee0000e..9b694ffdf 100644 --- a/tests/recipe/test_merge_prs.py +++ b/tests/recipe/test_merge_prs.py @@ -108,13 +108,13 @@ def test_pmp_plan_step_captures_all_plan_paths(recipe) -> None: def test_pmp_audit_impl_uses_all_plan_paths(recipe) -> None: - """audit_impl skill_command must reference context.all_plan_paths, not inputs.plans_dir.""" + """audit_impl binds context.all_plan_paths by name, not inputs.plans_dir.""" step = recipe.steps["audit_impl"] - cmd = step.with_args["skill_command"] - assert "${{ context.all_plan_paths }}" in cmd, ( - "audit_impl skill_command must reference context.all_plan_paths" + skill_inputs = step.with_args["skill_inputs"] + assert "${{ context.all_plan_paths }}" in skill_inputs["all_plan_paths"], ( + "audit_impl all_plan_paths input must reference context.all_plan_paths" ) - assert "inputs.plans_dir" not in cmd, ( + assert all("inputs.plans_dir" not in str(value) for value in skill_inputs.values()), ( "audit_impl must not pass inputs.plans_dir — directory discovery is fragile " "and inconsistent with how every other recipe invokes audit-impl" ) @@ -167,16 +167,13 @@ def test_pmp_open_integration_pr_captures_pr_url(recipe) -> None: def test_pmp_open_integration_pr_passes_four_args(recipe) -> None: - """skill_command must supply batch_branch, base_branch, pr_order_file, verdict.""" + """Structured inputs supply batch_branch, base_branch, pr_order_file, and verdict.""" step = recipe.steps["open_integration_pr"] - cmd = step.with_args.get("skill_command", "") - for arg in [ - "context.batch_branch", - "inputs.base_branch", - "context.pr_order_file", - "context.verdict", - ]: - assert arg in cmd, f"open_integration_pr skill_command must include {arg}" + skill_inputs = step.with_args["skill_inputs"] + assert "context.batch_branch" in skill_inputs["batch_branch"] + assert "inputs.base_branch" in skill_inputs["base_branch"] + assert "context.pr_order_file" in skill_inputs["pr_order_file"] + assert "context.verdict" in skill_inputs["audit_verdict"] def test_pmp_base_branch_auto_detects(recipe) -> None: @@ -261,9 +258,9 @@ def test_pmp_has_create_persistent_integration_step(recipe) -> None: def test_pmp_create_persistent_integration_passes_required_args(recipe) -> None: """create_persistent_integration must pass work_dir and base_branch.""" - nested_args = recipe.steps["create_persistent_integration"].with_args.get("args", {}) - assert "work_dir" in nested_args - assert "base_branch" in nested_args + with_args = recipe.steps["create_persistent_integration"].with_args + assert "work_dir" in with_args + assert "base_branch" in with_args def test_pmp_create_persistent_integration_routes_to_analyze_prs(recipe) -> None: @@ -504,9 +501,9 @@ def test_pmp_has_review_pr_integration_step(recipe) -> None: def test_pmp_review_pr_integration_uses_batch_branch(recipe) -> None: - """B14: review_pr_integration skill_command must reference context.batch_branch.""" + """B14: review_pr_integration binds context.batch_branch as feature_branch.""" step = recipe.steps["review_pr_integration"] - assert "context.batch_branch" in step.with_args.get("skill_command", "") + assert "context.batch_branch" in step.with_args["skill_inputs"]["feature_branch"] def test_pmp_review_pr_integration_routes_changes_requested_to_resolve_review(recipe) -> None: diff --git a/tests/recipe/test_planner_recipe.py b/tests/recipe/test_planner_recipe.py index 84735daed..203a05413 100644 --- a/tests/recipe/test_planner_recipe.py +++ b/tests/recipe/test_planner_recipe.py @@ -111,11 +111,11 @@ def test_planner_recipe_validation_has_no_errors(planner_recipe): assert_no_rule_errors(findings, context="planner recipe") -def test_planner_recipe_extract_domain_uses_positional_args(planner_recipe): +def test_planner_recipe_extract_domain_uses_structured_inputs(planner_recipe): step = planner_recipe.steps["extract_domain"] - skill_cmd = step.with_args.get("skill_command", "") - assert "analysis.json" in skill_cmd, "Must pass analysis.json as positional arg" - assert "context.task_file_path" in skill_cmd, "Must pass task_file_path as positional arg" + skill_inputs = step.with_args.get("skill_inputs", {}) + assert skill_inputs["analysis_path"].endswith("/analysis.json") + assert "context.task_file_path" in skill_inputs["task_file_path"] assert "env" not in step.with_args, "Must not use env: block (ADR-0003)" @@ -303,17 +303,17 @@ def test_planner_resolve_task_routes_to_analyze(planner_recipe): assert step.on_success == "analyze" -def test_extract_domain_passes_task_file_path_positionally(planner_recipe): +def test_extract_domain_passes_task_file_path_by_name(planner_recipe): step = planner_recipe.steps["extract_domain"] - skill_cmd = step.with_args.get("skill_command", "") - assert "context.task_file_path" in skill_cmd + skill_inputs = step.with_args.get("skill_inputs", {}) + assert "context.task_file_path" in skill_inputs["task_file_path"] assert "env" not in step.with_args, "Must not use env: block (ADR-0003)" -def test_generate_phases_passes_task_file_path_positionally(planner_recipe): +def test_generate_phases_passes_task_file_path_by_name(planner_recipe): step = planner_recipe.steps["generate_phases"] - skill_cmd = step.with_args.get("skill_command", "") - assert "context.task_file_path" in skill_cmd + skill_inputs = step.with_args.get("skill_inputs", {}) + assert "context.task_file_path" in skill_inputs["task_file_path"] assert "env" not in step.with_args, "Must not use env: block (ADR-0003)" diff --git a/tests/recipe/test_remediation_depth_ingredient.py b/tests/recipe/test_remediation_depth_ingredient.py index 24dc5ccf1..42f8f59f2 100644 --- a/tests/recipe/test_remediation_depth_ingredient.py +++ b/tests/recipe/test_remediation_depth_ingredient.py @@ -50,12 +50,12 @@ def test_remediation_depth_ingredient_is_string_type(remediation_recipe: Recipe) def test_remediation_investigate_step_has_depth_conditional(remediation_recipe: Recipe) -> None: - """The investigate step skill_command must include the --depth deep conditional.""" + """The investigate step topic must include the --depth deep conditional.""" step = remediation_recipe.steps.get("investigate") assert step is not None, "remediation.yaml must contain an 'investigate' step" - skill_command = step.with_args.get("skill_command", "") - assert "--depth deep" in skill_command, ( - "remediation.yaml investigate step skill_command must contain '--depth deep' " + topic = step.with_args["skill_inputs"]["topic"] + assert "--depth deep" in topic, ( + "remediation.yaml investigate step topic must contain '--depth deep' " "conditional expression for when inputs.depth == 'deep'" ) diff --git a/tests/recipe/test_remediation_recipe.py b/tests/recipe/test_remediation_recipe.py index e4bab32e5..78f417460 100644 --- a/tests/recipe/test_remediation_recipe.py +++ b/tests/recipe/test_remediation_recipe.py @@ -353,8 +353,9 @@ def test_retry_worktree_captures_deviation_manifest_path(recipe) -> None: def test_audit_impl_forwards_deviation_manifest_path(recipe) -> None: """audit_impl must forward deviation_manifest_path kwarg and declare it optional.""" step = recipe.steps["audit_impl"] - cmd = step.with_args.get("skill_command", "") - assert "deviation_manifest_path=" in cmd + assert step.with_args["skill_inputs"]["deviation_manifest_path"] == ( + "${{ context.deviation_manifest_path }}" + ) assert "deviation_manifest_path" in (step.optional_context_refs or []) diff --git a/tests/recipe/test_research_audit_impl.py b/tests/recipe/test_research_audit_impl.py index dcd15aca5..61e345033 100644 --- a/tests/recipe/test_research_audit_impl.py +++ b/tests/recipe/test_research_audit_impl.py @@ -48,58 +48,41 @@ def test_audit_impl_cwd_uses_context_worktree_path(self, recipe) -> None: assert step.with_args.get("cwd") == "${{ context.worktree_path }}" def test_research_audit_impl_uses_impl_base_sha(self, research_recipe) -> None: - """research.yaml audit_impl skill_command must reference context.impl_base_sha.""" + """research.yaml audit_impl must bind context.impl_base_sha.""" step = research_recipe.steps["audit_impl"] - skill_cmd = step.with_args.get("skill_command", "") - assert "context.impl_base_sha" in skill_cmd + assert step.with_args["skill_inputs"]["branch_name"] == "${{ context.impl_base_sha }}" def test_research_implement_audit_impl_has_impl_base_sha( self, research_implement_recipe ) -> None: - """research-implement.yaml audit_impl must reference context.impl_base_sha.""" + """research-implement.yaml audit_impl must bind context.impl_base_sha.""" step = research_implement_recipe.steps["audit_impl"] - skill_cmd = step.with_args.get("skill_command", "") - assert "context.impl_base_sha" in skill_cmd + assert step.with_args["skill_inputs"]["branch_name"] == "${{ context.impl_base_sha }}" def test_research_implement_audit_impl_passes_three_positional_args( self, research_implement_recipe ) -> None: - """research-implement.yaml audit_impl must pass at least 3 positional args. - - Arg order: plans_input, implementation_ref, base_branch. Passing only 2 args - mis-binds base_branch to the implementation_ref parameter. - """ + """research-implement.yaml audit_impl must bind its three required inputs.""" step = research_implement_recipe.steps["audit_impl"] - skill_cmd = step.with_args.get("skill_command", "") - parts = skill_cmd.split() - assert len(parts) >= 4, ( - "audit-impl skill_command must have at least 3 positional arguments " - "(plans_input, implementation_ref, base_branch) plus the skill name itself. " - f"Got {len(parts) - 1} args in: {skill_cmd!r}" - ) + skill_inputs = step.with_args["skill_inputs"] + assert {"all_plan_paths", "branch_name", "base_branch"} <= skill_inputs.keys() def test_research_implement_audit_impl_uses_base_branch( self, research_implement_recipe ) -> None: - """research-implement.yaml audit_impl skill_command must reference inputs.base_branch.""" + """research-implement.yaml audit_impl must bind inputs.base_branch.""" step = research_implement_recipe.steps["audit_impl"] - skill_cmd = step.with_args.get("skill_command", "") - assert "inputs.base_branch" in skill_cmd + assert step.with_args["skill_inputs"]["base_branch"] == "${{ inputs.base_branch }}" def test_audit_impl_uses_group_files(self, recipe) -> None: - """audit_impl skill_command must reference context.group_files.""" + """audit_impl must bind context.group_files.""" step = recipe.steps["audit_impl"] - skill_cmd = step.with_args.get("skill_command", "") - assert "context.group_files" in skill_cmd + assert step.with_args["skill_inputs"]["all_plan_paths"] == "${{ context.group_files }}" def test_audit_impl_group_files_is_quoted(self, recipe) -> None: - """context.group_files interpolation must be wrapped in double quotes. - - Newline-separated values must remain one logical argument after shlex tokenization. - """ + """Structured binding keeps newline-separated group files one logical value.""" step = recipe.steps["audit_impl"] - skill_cmd = step.with_args.get("skill_command", "") - assert '"${{ context.group_files }}"' in skill_cmd + assert step.with_args["skill_inputs"]["all_plan_paths"] == "${{ context.group_files }}" def test_audit_impl_go_routes_to_run_experiment(self, recipe) -> None: """audit_impl on_result GO verdict must route to run_experiment.""" @@ -203,10 +186,9 @@ def test_has_remediate_step(self, recipe) -> None: assert "remediate" in recipe.steps assert recipe.steps["remediate"].action == "route" - def test_remediate_carries_remediation_path(self, recipe) -> None: + def test_remediate_carries_audit_cycle_path(self, recipe) -> None: step = recipe.steps["remediate"] - assert "remediation_path" in step.with_args - assert "context.remediation_path" in step.with_args["remediation_path"] + assert step.with_args["audit_cycle_path"] == "${{ context.audit_cycle_path }}" def test_remediate_routes_to_check_audit_retry_loop(self, recipe) -> None: step = recipe.steps["remediate"] @@ -273,9 +255,9 @@ def test_has_remediate_step(self, recipe) -> None: assert "remediate" in recipe.steps assert recipe.steps["remediate"].action == "route" - def test_remediate_carries_remediation_path(self, recipe) -> None: + def test_remediate_carries_audit_cycle_path(self, recipe) -> None: step = recipe.steps["remediate"] - assert "context.remediation_path" in step.with_args["remediation_path"] + assert step.with_args["audit_cycle_path"] == "${{ context.audit_cycle_path }}" def test_remediate_routes_to_check_audit_retry_loop(self, recipe) -> None: step = recipe.steps["remediate"] diff --git a/tests/recipe/test_research_download_data_step.py b/tests/recipe/test_research_download_data_step.py index 910198295..ba55c384d 100644 --- a/tests/recipe/test_research_download_data_step.py +++ b/tests/recipe/test_research_download_data_step.py @@ -37,7 +37,7 @@ def test_download_data_skill_command_references_download_data_skill(recipe: Reci def test_download_data_skill_command_passes_experiment_plan(recipe: Recipe) -> None: """download_data skill_command must reference experiment_plan.""" step = recipe.steps["download_data"] - assert "experiment_plan" in step.with_args["skill_command"] + assert step.with_args["skill_inputs"]["experiment_plan"] == "${{ context.experiment_plan }}" def test_download_data_cwd_is_worktree_path(recipe: Recipe) -> None: diff --git a/tests/recipe/test_research_output_mode.py b/tests/recipe/test_research_output_mode.py index ef98e2c59..5d0f47ac7 100644 --- a/tests/recipe/test_research_output_mode.py +++ b/tests/recipe/test_research_output_mode.py @@ -260,16 +260,14 @@ def test_research_output_mode_enum_rule_clean_for_valid_values(): def test_generate_report_steps_pass_output_mode(recipe): - """All generate_report steps must pass --output-mode flag in skill_command.""" + """All generate_report steps must bind output_mode through structured inputs.""" for step_name in ("generate_report", "generate_report_inconclusive", "re_generate_report"): step = recipe.steps[step_name] - cmd = step.with_args.get("skill_command", "") - assert "--output-mode" in cmd, f"{step_name} skill_command must include --output-mode flag" + assert step.with_args["skill_inputs"]["output_mode"] == "${{ inputs.output_mode }}" def test_generate_report_steps_pass_issue_url(recipe): - """generate_report, generate_report_inconclusive, re_generate_report must pass --issue-url.""" + """All generate_report steps must bind issue_url through structured inputs.""" for step_name in ("generate_report", "generate_report_inconclusive", "re_generate_report"): step = recipe.steps[step_name] - cmd = step.with_args.get("skill_command", "") - assert "--issue-url" in cmd, f"{step_name} skill_command must include --issue-url flag" + assert step.with_args["skill_inputs"]["issue_url"] == "${{ inputs.issue_url }}" diff --git a/tests/recipe/test_research_recipe_diag.py b/tests/recipe/test_research_recipe_diag.py index 22b9061c7..867d8687a 100644 --- a/tests/recipe/test_research_recipe_diag.py +++ b/tests/recipe/test_research_recipe_diag.py @@ -26,10 +26,10 @@ def test_research_recipe_has_troubleshoot_step(recipe): assert "route_implement_failure" in step_names -def test_implement_phase_failure_routes_to_troubleshoot(recipe): - """implement_phase on_failure must route to troubleshoot_implement_failure.""" +def test_implement_phase_failure_routes_through_loop_guard(recipe): + """implement_phase failures increment the loop counter before troubleshooting.""" step = recipe.steps["implement_phase"] - assert step.on_failure == "troubleshoot_implement_failure" + assert step.on_failure == "check_implement_fix_loop" def test_implement_phase_exhausted_routes_to_audit_impl(recipe): @@ -101,9 +101,9 @@ def test_prepare_research_pr_captures_prep_path(recipe): def test_prepare_research_pr_uses_context_experiment_plan(recipe): """prepare_research_pr must pass ${{ context.experiment_plan }}, not a hardcoded path.""" step = recipe.steps["prepare_research_pr"] - skill_cmd = step.with_args.get("skill_command", "") - assert "context.experiment_plan" in skill_cmd - assert ".autoskillit/temp/experiment-plan.md" not in skill_cmd + plan_input = step.with_args["skill_inputs"]["experiment_plan_path"] + assert plan_input == "${{ context.experiment_plan }}" + assert ".autoskillit/temp/experiment-plan.md" not in plan_input def test_run_experiment_lenses_has_capture_list_for_diagram_paths(recipe): @@ -117,10 +117,10 @@ def test_stage_bundle_exists(recipe): assert "stage_bundle" in recipe.steps, "research.yaml must have a stage_bundle step" -def test_run_experiment_failure_routes_to_troubleshoot(recipe): - """run_experiment on_failure must route to troubleshoot_run_failure.""" +def test_run_experiment_failure_routes_through_loop_guard(recipe): + """run_experiment failures increment the loop counter before troubleshooting.""" step = recipe.steps["run_experiment"] - assert step.on_failure == "troubleshoot_run_failure" + assert step.on_failure == "check_run_fix_loop" def test_research_recipe_has_troubleshoot_run_steps(recipe): @@ -138,12 +138,11 @@ def test_troubleshoot_run_captures_required_tokens(recipe): def test_troubleshoot_run_uses_run_experiment_step_name(recipe): - """troubleshoot_run_failure skill_command must pass run_experiment, not implement_phase.""" + """troubleshoot_run_failure must bind run_experiment, not implement_phase.""" step = recipe.steps["troubleshoot_run_failure"] skill_cmd = step.with_args.get("skill_command", "") assert "troubleshoot-experiment" in skill_cmd - assert "run_experiment" in skill_cmd - assert "implement_phase" not in skill_cmd + assert step.with_args["skill_inputs"]["step_name"] == "run_experiment" def test_route_run_failure_routes_fixable_to_adjust(recipe): @@ -163,18 +162,18 @@ def test_route_run_failure_default_escalates(recipe): assert default_cond.route == "escalate_stop" -def test_adjust_experiment_routing_unchanged(recipe): - """adjust_experiment uses on_result for all verdict values (not on_success).""" +def test_adjust_experiment_routes_completed_adjustments_to_delay_gate(recipe): + """Completed adjustments proceed through the retry-delay gate.""" step = recipe.steps["adjust_experiment"] assert step.on_result is not None, "adjust_experiment must use on_result for verdict routing" assert step.on_success is None, "adjust_experiment must not use on_success" conditions = step.on_result.conditions conclusive = next((c for c in conditions if c.when and "CONCLUSIVE" in c.when), None) - assert conclusive is not None and conclusive.route == "check_run_fix_loop" + assert conclusive is not None and conclusive.route == "route_run_retry_delay" blocked = next((c for c in conditions if c.when and "BLOCKED" in c.when), None) assert blocked is not None and blocked.route == "escalate_stop" inconclusive = next((c for c in conditions if c.when and "INCONCLUSIVE" in c.when), None) - assert inconclusive is not None and inconclusive.route == "check_run_fix_loop" + assert inconclusive is not None and inconclusive.route == "route_run_retry_delay" assert step.on_failure == "ensure_results" @@ -228,24 +227,24 @@ def test_research_recipe_has_route_run_retry_delay(recipe): assert step.action == "route" -def test_check_implement_fix_loop_routes_to_delay_gate(recipe): - """Loop guard must route through delay gate, not directly to plan_phase.""" +def test_check_implement_fix_loop_routes_to_troubleshoot(recipe): + """Loop guard increments the counter before launching troubleshoot.""" step = recipe.steps["check_implement_fix_loop"] assert step.on_result is not None, "check_implement_fix_loop must have on_result" conditions = step.on_result.conditions assert conditions, "check_implement_fix_loop on_result must have non-empty conditions" non_exhausted = [c for c in conditions if c.when is None or "max_exceeded" not in c.when] - assert any(c.route == "route_implement_retry_delay" for c in non_exhausted) + assert any(c.route == "troubleshoot_implement_failure" for c in non_exhausted) -def test_check_run_fix_loop_routes_to_delay_gate(recipe): - """Run loop guard must route through delay gate, not directly to run_experiment.""" +def test_check_run_fix_loop_routes_to_troubleshoot(recipe): + """Run loop guard increments the counter before launching troubleshoot.""" step = recipe.steps["check_run_fix_loop"] assert step.on_result is not None, "check_run_fix_loop must have on_result" conditions = step.on_result.conditions assert conditions, "check_run_fix_loop on_result must have non-empty conditions" non_exhausted = [c for c in conditions if c.when is None or "max_exceeded" not in c.when] - assert any(c.route == "route_run_retry_delay" for c in non_exhausted) + assert any(c.route == "troubleshoot_run_failure" for c in non_exhausted) def test_run_experiment_uses_verdict_routing(recipe): diff --git a/tests/recipe/test_research_smoke_fixtures.py b/tests/recipe/test_research_smoke_fixtures.py index c3f990d07..32c8bb2eb 100644 --- a/tests/recipe/test_research_smoke_fixtures.py +++ b/tests/recipe/test_research_smoke_fixtures.py @@ -105,24 +105,22 @@ def test_dial_captures_methodology_tradition(research_recipe): def test_generate_report_receives_experiment_type_arg(research_recipe): - skill_cmd = research_recipe.steps["generate_report"].with_args.get("skill_command", "") - assert "--experiment-type" in skill_cmd + inputs = research_recipe.steps["generate_report"].with_args["skill_inputs"] + assert inputs["experiment_type"] == "${{ context.experiment_type }}" def test_generate_report_receives_methodology_traditions_arg(research_recipe): - skill_cmd = research_recipe.steps["generate_report"].with_args.get("skill_command", "") - assert "--methodology-traditions" in skill_cmd + inputs = research_recipe.steps["generate_report"].with_args["skill_inputs"] + assert inputs["methodology_traditions"] == "${{ context.methodology_tradition }}" def test_generate_report_inconclusive_receives_same_args(research_recipe): - skill_cmd = research_recipe.steps["generate_report_inconclusive"].with_args.get( - "skill_command", "" - ) - assert "--experiment-type" in skill_cmd - assert "--methodology-traditions" in skill_cmd + inputs = research_recipe.steps["generate_report_inconclusive"].with_args["skill_inputs"] + assert inputs["experiment_type"] == "${{ context.experiment_type }}" + assert inputs["methodology_traditions"] == "${{ context.methodology_tradition }}" def test_re_generate_report_receives_same_args(research_recipe): - skill_cmd = research_recipe.steps["re_generate_report"].with_args.get("skill_command", "") - assert "--experiment-type" in skill_cmd - assert "--methodology-traditions" in skill_cmd + inputs = research_recipe.steps["re_generate_report"].with_args["skill_inputs"] + assert inputs["experiment_type"] == "${{ context.experiment_type }}" + assert inputs["methodology_traditions"] == "${{ context.methodology_tradition }}" diff --git a/tests/skills/test_planner_skill_contracts.py b/tests/skills/test_planner_skill_contracts.py index 98108d379..1b25efe3b 100644 --- a/tests/skills/test_planner_skill_contracts.py +++ b/tests/skills/test_planner_skill_contracts.py @@ -147,8 +147,8 @@ def test_all_planner_recipe_skills_registered_in_contract_card() -> None: ) -def test_planner_contract_card_records_positional_args_for_generate_phases() -> None: - """T1b: the generate_phases dataflow entry must record positional_args > 0.""" +def test_planner_contract_card_records_structured_inputs_for_generate_phases() -> None: + """The generate_phases dataflow entry records its bound named inputs.""" card_path = RECIPES_ROOT / "contracts" / "planner.yaml" assert card_path.is_file(), "planner contract card not found — run generate_recipe_card" card = load_yaml(card_path) @@ -160,9 +160,13 @@ def test_planner_contract_card_records_positional_args_for_generate_phases() -> assert generate_phases_entry is not None, ( "generate_phases step not found in planner contract card dataflow" ) - assert generate_phases_entry.get("positional_args", 0) > 0, ( - "generate_phases step should record positional_args > 0 in the contract card" - ) + assert generate_phases_entry.get("structured_inputs") == [ + "analysis_path", + "domain_knowledge_path", + "task_file_path", + ] + assert "positional_args" not in generate_phases_entry + assert generate_phases_entry.get("required") == [] @pytest.mark.parametrize("skill_name", ALL_PLANNER_SKILLS) diff --git a/tests/skills/test_skill_output_compliance.py b/tests/skills/test_skill_output_compliance.py index 065cba29b..d2081695a 100644 --- a/tests/skills/test_skill_output_compliance.py +++ b/tests/skills/test_skill_output_compliance.py @@ -212,7 +212,9 @@ def test_output_path_tokens_synchronized() -> None: # Update this set when adding new path-bearing structured output tokens. expected_path_tokens = frozenset( { + "audit_cycle_path", "plan_path", + "plan_disposition_path", "plan_parts", "investigation_path", "diagnosis_path", From 30333d63a9271b7f8866c498bc7fab512f7c51f1 Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 24 Jul 2026 10:52:48 -0700 Subject: [PATCH 06/89] feat(recipe): validate resolved bound artifact flows --- src/autoskillit/recipe/_io_loading.py | 128 +++++++++ src/autoskillit/recipe/io.py | 154 ++--------- src/autoskillit/recipe/rules/AGENTS.md | 4 +- .../rules/dataflow/rules_dataflow_handoff.py | 26 +- .../recipe/rules/rules_flake_loop.py | 20 +- .../rules/rules_inventory_gate_bilateral.py | 248 +++++++++++++----- .../rules/rules_issue_scope_threading.py | 14 +- .../recipe/rules/rules_loop_artifact_scope.py | 45 +++- .../recipe/rules/rules_merge_context.py | 21 +- src/autoskillit/recipe/rules/rules_tools.py | 4 +- src/autoskillit/recipe/validator.py | 45 ++-- tests/arch/test_run_python_path_resolution.py | 9 +- tests/infra/test_schema_version_convention.py | 8 +- tests/recipe/test_io_json_precompile.py | 17 +- tests/recipe/test_recipe_temp_substitution.py | 4 + .../test_review_loop_artifact_isolation.py | 40 +-- tests/recipe/test_rules_dataflow_handoff.py | 6 +- .../test_rules_inventory_gate_bilateral.py | 199 ++++++++------ .../recipe/test_rules_loop_artifact_scope.py | 126 ++++++++- tests/recipe/test_rules_tools.py | 22 +- .../test_rules_work_dir_misplacement.py | 13 + 21 files changed, 773 insertions(+), 380 deletions(-) create mode 100644 src/autoskillit/recipe/_io_loading.py diff --git a/src/autoskillit/recipe/_io_loading.py b/src/autoskillit/recipe/_io_loading.py new file mode 100644 index 000000000..26645232a --- /dev/null +++ b/src/autoskillit/recipe/_io_loading.py @@ -0,0 +1,128 @@ +"""Declaration-preserving recipe document loading and placeholder substitution.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from autoskillit.core import fast_loads, get_logger, load_yaml, pkg_root +from autoskillit.recipe._contracts_types import INPUT_REF_RE + +logger = get_logger(__name__) + +_TEMP_PLACEHOLDER = "{{AUTOSKILLIT_TEMP}}" +_SCRIPTS_PLACEHOLDER = "{{AUTOSKILLIT_SCRIPTS}}" + + +def substitute_temp_placeholder(text: str, temp_dir_relpath: str) -> str: + """Replace the temp placeholder after rejecting YAML-unsafe path text.""" + if "\n" in temp_dir_relpath or ": " in temp_dir_relpath: + raise ValueError(f"temp_dir_relpath is YAML-unsafe: {temp_dir_relpath!r}") + return text.replace(_TEMP_PLACEHOLDER, temp_dir_relpath) + + +def substitute_scripts_placeholder(text: str) -> str: + """Replace the scripts placeholder with the bundled recipe scripts path.""" + scripts_dir = pkg_root() / "recipes" / "scripts" + return text.replace(_SCRIPTS_PLACEHOLDER, str(scripts_dir)) + + +def assert_no_raw_placeholders( + text: str, + *, + context: str = "", + hidden_ingredient_names: frozenset[str] | None = None, +) -> None: + """Reject unresolved host or hidden-ingredient placeholders at delivery.""" + for placeholder in (_TEMP_PLACEHOLDER, _SCRIPTS_PLACEHOLDER): + if placeholder in text: + raise ValueError( + f"Unresolved {placeholder} in recipe content" + + (f" ({context})" if context else "") + ) + if hidden_ingredient_names: + for match in INPUT_REF_RE.finditer(text): + name = match.group(1) + if name in hidden_ingredient_names: + raise ValueError( + f"Unresolved hidden ingredient template ${{{{ inputs.{name} }}}} " + "in recipe content" + (f" ({context})" if context else "") + ) + + +def load_recipe_dict( + yaml_path: Path, + *, + raw_text: str | None = None, + temp_dir_relpath: str | None = None, +) -> dict[str, Any]: + """Load an effective recipe mapping, preferring a fresh compiled sibling.""" + effective, _declared = load_recipe_dict_with_declarations( + yaml_path, + raw_text=raw_text, + temp_dir_relpath=temp_dir_relpath, + ) + return effective + + +def _substitute_recipe_values( + value: Any, + *, + temp_dir_relpath: str | None, +) -> Any: + if isinstance(value, str): + resolved = ( + substitute_temp_placeholder(value, temp_dir_relpath) + if temp_dir_relpath is not None + else value + ) + return substitute_scripts_placeholder(resolved) + if isinstance(value, dict): + return { + key: _substitute_recipe_values(item, temp_dir_relpath=temp_dir_relpath) + for key, item in value.items() + } + if isinstance(value, list): + return [ + _substitute_recipe_values(item, temp_dir_relpath=temp_dir_relpath) for item in value + ] + return value + + +def load_recipe_dict_with_declarations( + yaml_path: Path, + *, + raw_text: str | None = None, + temp_dir_relpath: str | None = None, +) -> tuple[dict[str, Any], dict[str, Any]]: + """Load aligned effective and declared mappings from JSON or YAML.""" + json_path = yaml_path.with_suffix(".json") + try: + if json_path.stat().st_mtime_ns >= yaml_path.stat().st_mtime_ns: + text = json_path.read_text(encoding="utf-8") + data = fast_loads(text) + if isinstance(data, dict): + return ( + _substitute_recipe_values( + data, + temp_dir_relpath=temp_dir_relpath, + ), + data, + ) + logger.warning( + "Pre-compiled JSON is not a mapping, falling back to YAML: %s", json_path + ) + except json.JSONDecodeError: + logger.warning("Pre-compiled JSON is corrupt, falling back to YAML: %s", json_path) + except (FileNotFoundError, OSError): + pass + if raw_text is None: + raw_text = yaml_path.read_text(encoding="utf-8") + data = load_yaml(raw_text) + if not isinstance(data, dict): + raise ValueError(f"Recipe file must contain a YAML mapping: {yaml_path}") + return ( + _substitute_recipe_values(data, temp_dir_relpath=temp_dir_relpath), + data, + ) diff --git a/src/autoskillit/recipe/io.py b/src/autoskillit/recipe/io.py index 1b8ab9f27..a0bd6cd03 100644 --- a/src/autoskillit/recipe/io.py +++ b/src/autoskillit/recipe/io.py @@ -2,7 +2,6 @@ from __future__ import annotations -import json as _json from collections.abc import Iterator from pathlib import Path from typing import Any, cast @@ -17,12 +16,25 @@ LoadResult, RecipeSource, get_logger, - load_yaml, mapping_entry_byte_ranges_from_yaml, pkg_root, ) -from autoskillit.core import fast_loads as _fast_loads -from autoskillit.recipe._contracts_types import INPUT_REF_RE +from autoskillit.recipe._io_loading import ( + _SCRIPTS_PLACEHOLDER as _SCRIPTS_PLACEHOLDER, +) +from autoskillit.recipe._io_loading import assert_no_raw_placeholders +from autoskillit.recipe._io_loading import ( + load_recipe_dict as _load_recipe_dict, +) +from autoskillit.recipe._io_loading import ( + load_recipe_dict_with_declarations as _load_recipe_dict_with_declarations, +) +from autoskillit.recipe._io_loading import ( + substitute_scripts_placeholder as substitute_scripts_placeholder, +) +from autoskillit.recipe._io_loading import ( + substitute_temp_placeholder as substitute_temp_placeholder, +) from autoskillit.recipe.order import BUNDLED_RECIPE_ORDER from autoskillit.recipe.schema import ( AUTOSKILLIT_VERSION_KEY, @@ -38,6 +50,7 @@ ) logger = get_logger(__name__) +_assert_no_raw_placeholders = assert_no_raw_placeholders def step_byte_ranges_from_yaml(content: str) -> dict[str, tuple[int, int]]: @@ -64,139 +77,6 @@ def step_byte_ranges_from_yaml(content: str) -> dict[str, tuple[int, int]]: ) -_TEMP_PLACEHOLDER = "{{AUTOSKILLIT_TEMP}}" -_SCRIPTS_PLACEHOLDER = "{{AUTOSKILLIT_SCRIPTS}}" - - -def substitute_temp_placeholder(text: str, temp_dir_relpath: str) -> str: - """Replace ``{{AUTOSKILLIT_TEMP}}`` in raw recipe/skill text. - - Validates that ``temp_dir_relpath`` is YAML-safe (no newlines or - ``": "`` sequences); raises ``ValueError`` otherwise. Filesystem paths - should never contain these characters, but the guard makes the failure - loud and free. - """ - if "\n" in temp_dir_relpath or ": " in temp_dir_relpath: - raise ValueError(f"temp_dir_relpath is YAML-unsafe: {temp_dir_relpath!r}") - return text.replace(_TEMP_PLACEHOLDER, temp_dir_relpath) - - -def substitute_scripts_placeholder(text: str) -> str: - """Replace ``{{AUTOSKILLIT_SCRIPTS}}`` with the absolute path to bundled scripts.""" - return text.replace(_SCRIPTS_PLACEHOLDER, str(builtin_scripts_dir())) - - -def _assert_no_raw_placeholders( - text: str, - *, - context: str = "", - hidden_ingredient_names: frozenset[str] | None = None, -) -> None: - # Content-delivery boundary guard: raises if placeholder substitution was skipped. - for placeholder in (_TEMP_PLACEHOLDER, _SCRIPTS_PLACEHOLDER): - if placeholder in text: - raise ValueError( - f"Unresolved {placeholder} in recipe content" - + (f" ({context})" if context else "") - ) - if hidden_ingredient_names: - for _m in INPUT_REF_RE.finditer(text): - _name = _m.group(1) - if _name in hidden_ingredient_names: - raise ValueError( - f"Unresolved hidden ingredient template ${{{{ inputs.{_name} }}}} " - "in recipe content" + (f" ({context})" if context else "") - ) - - -def _load_recipe_dict( - yaml_path: Path, - *, - raw_text: str | None = None, - temp_dir_relpath: str | None = None, -) -> dict[str, Any]: - """Load a recipe dict, preferring a pre-compiled JSON sibling when fresh. - - Args: - yaml_path: Path to the .yaml recipe file. - raw_text: Already-read YAML text (avoids redundant I/O on fallback). - temp_dir_relpath: When set, ``{{AUTOSKILLIT_TEMP}}`` is replaced in the - text before parsing (applies to both JSON and YAML paths). - """ - effective, _declared = _load_recipe_dict_with_declarations( - yaml_path, - raw_text=raw_text, - temp_dir_relpath=temp_dir_relpath, - ) - return effective - - -def _substitute_recipe_values( - value: Any, - *, - temp_dir_relpath: str | None, -) -> Any: - if isinstance(value, str): - resolved = ( - substitute_temp_placeholder(value, temp_dir_relpath) - if temp_dir_relpath is not None - else value - ) - return substitute_scripts_placeholder(resolved) - if isinstance(value, dict): - return { - key: _substitute_recipe_values(item, temp_dir_relpath=temp_dir_relpath) - for key, item in value.items() - } - if isinstance(value, list): - return [ - _substitute_recipe_values(item, temp_dir_relpath=temp_dir_relpath) for item in value - ] - return value - - -def _load_recipe_dict_with_declarations( - yaml_path: Path, - *, - raw_text: str | None = None, - temp_dir_relpath: str | None = None, -) -> tuple[dict[str, Any], dict[str, Any]]: - """Load aligned effective and declared mappings. - - Placeholder replacement is applied to parsed values, not source text, so - the declaration remains available for binding provenance. - """ - json_path = yaml_path.with_suffix(".json") - try: - if json_path.stat().st_mtime_ns >= yaml_path.stat().st_mtime_ns: - text = json_path.read_text(encoding="utf-8") - data = _fast_loads(text) - if isinstance(data, dict): - return ( - _substitute_recipe_values( - data, - temp_dir_relpath=temp_dir_relpath, - ), - data, - ) - logger.warning( - "Pre-compiled JSON is not a mapping, falling back to YAML: %s", json_path - ) - except _json.JSONDecodeError: - logger.warning("Pre-compiled JSON is corrupt, falling back to YAML: %s", json_path) - except (FileNotFoundError, OSError): - pass - if raw_text is None: - raw_text = yaml_path.read_text(encoding="utf-8") - data = load_yaml(raw_text) - if not isinstance(data, dict): - raise ValueError(f"Recipe file must contain a YAML mapping: {yaml_path}") - return ( - _substitute_recipe_values(data, temp_dir_relpath=temp_dir_relpath), - data, - ) - - def load_recipe(path: Path, temp_dir_relpath: str = ".autoskillit/temp") -> Recipe: """Parse a YAML recipe file into a Recipe dataclass. diff --git a/src/autoskillit/recipe/rules/AGENTS.md b/src/autoskillit/recipe/rules/AGENTS.md index 8b3dbcc85..fcc7ddb41 100644 --- a/src/autoskillit/recipe/rules/AGENTS.md +++ b/src/autoskillit/recipe/rules/AGENTS.md @@ -38,9 +38,9 @@ See each subdirectory's AGENTS.md for details. | `rules_ingredient_step_name.py` | 1:1 gating ingredient ↔ step name asymmetry detection | | `rules_inline_script.py` | Detects inline shell scripts in `run_cmd` cmd fields | | `rules_inputs.py` | Input/ingredient validation; version compatibility checks | -| `rules_inventory_gate_bilateral.py` | inventory-gate-not-bilateral semantic rule: dry-walkthrough must receive remediation_path in recipes with audit-impl remediation loops | +| `rules_inventory_gate_bilateral.py` | inventory-gate-not-bilateral semantic rule: audit authority and disposition producers must connect to compiled dry-walkthrough inputs | | `rules_isolation.py` | Workspace isolation rules (prevents operating on source repo) | -| `rules_issue_scope_threading.py` | issue-scope-not-threaded-to-walkthrough semantic rule: ensures dry-walkthrough receives issue_url in recipes with issue_url ingredients | +| `rules_issue_scope_threading.py` | issue-scope-not-threaded-to-walkthrough semantic rule: ensures dry-walkthrough receives issue context through its compiled child inputs | | `rules_merge.py` | `merge_worktree` routing completeness | | `rules_model.py` | Model field adequacy: flags context-intensive steps with empty model (dispatch_items users) | | `rules_merge_context.py` | Merge gate test output context forwarding enforcement | diff --git a/src/autoskillit/recipe/rules/dataflow/rules_dataflow_handoff.py b/src/autoskillit/recipe/rules/dataflow/rules_dataflow_handoff.py index d62f9fea8..dfa504f1c 100644 --- a/src/autoskillit/recipe/rules/dataflow/rules_dataflow_handoff.py +++ b/src/autoskillit/recipe/rules/dataflow/rules_dataflow_handoff.py @@ -154,7 +154,12 @@ def _check_uncaptured_handoff_consumer(ctx: ValidationContext) -> list[RuleFindi if step.tool not in SKILL_TOOLS: continue - producer_skill = resolve_skill_name(step.with_args.get("skill_command", "")) + producer_invocation = ctx.binding_projection.for_step(step_name) + producer_skill = ( + producer_invocation.skill_name + if producer_invocation is not None + else resolve_skill_name(step.with_args.get("skill_command", "")) + ) if not producer_skill: continue @@ -170,7 +175,12 @@ def _check_uncaptured_handoff_consumer(ctx: ValidationContext) -> list[RuleFindi if successor_step is None or successor_step.tool not in SKILL_TOOLS: continue - consumer_skill = resolve_skill_name(successor_step.with_args.get("skill_command", "")) + consumer_invocation = ctx.binding_projection.for_step(successor_name) + consumer_skill = ( + consumer_invocation.skill_name + if consumer_invocation is not None + else resolve_skill_name(successor_step.with_args.get("skill_command", "")) + ) if not consumer_skill: continue @@ -187,7 +197,17 @@ def _check_uncaptured_handoff_consumer(ctx: ValidationContext) -> list[RuleFindi continue skill_cmd = successor_step.with_args.get("skill_command", "") - unwired = [inp for inp in file_path_inputs if f"context.{inp.name}" not in skill_cmd] + unwired = [ + inp + for inp in file_path_inputs + if not ( + consumer_invocation is not None + and (value := consumer_invocation.skill_input(inp.name)) is not None + and value.is_present + and value.context_dependencies + ) + and f"context.{inp.name}" not in skill_cmd + ] if not unwired: continue # all file-path inputs wired via context refs diff --git a/src/autoskillit/recipe/rules/rules_flake_loop.py b/src/autoskillit/recipe/rules/rules_flake_loop.py index bb282df06..ac8c59049 100644 --- a/src/autoskillit/recipe/rules/rules_flake_loop.py +++ b/src/autoskillit/recipe/rules/rules_flake_loop.py @@ -10,6 +10,16 @@ logger = get_logger(__name__) +def _has_failure_context(ctx: ValidationContext, step_name: str, skill_command: str) -> bool: + invocation = ctx.binding_projection.for_step(step_name) + if invocation is not None and invocation.skill_inputs: + return all( + (value := invocation.skill_input(name)) is not None and value.is_present + for name in ("ci_conclusion", "diagnosis_path") + ) + return count_skill_args(skill_command) > 3 + + @semantic_rule( name="flake-suspected-unwinnable-loop", description=( @@ -55,21 +65,21 @@ def _check_flake_suspected_unwinnable_loop(ctx: ValidationContext) -> list[RuleF if not merge_in_cycle: continue - if count_skill_args(cmd) <= 3: + if not _has_failure_context(ctx, step_name, cmd): findings.append( make_finding( rule_name="flake-suspected-unwinnable-loop", step_name=step_name, message=( - f"Step '{step_name}' invokes resolve-failures with only " - f"{count_skill_args(cmd)} positional arg(s) and routes " + f"Step '{step_name}' invokes resolve-failures without bound " + f"ci_conclusion and diagnosis_path inputs and routes " f"flake_suspected → '{flake_target}', which leads back through " f"a merge_worktree step. Without failure context (ci_conclusion + " f"diagnosis_path), resolve-failures cannot diagnose the specific " f"failing tests, will always produce flake_suspected on a local-pass " f"flake, and the loop is unwinnable. Add a diagnose_merge_gate step " - f"before this step and expand skill_command to 6 args including " - f"merge_gate_ci_conclusion and merge_gate_diagnosis_path." + f"before this step and bind merge_gate_ci_conclusion and " + f"merge_gate_diagnosis_path." ), ) ) diff --git a/src/autoskillit/recipe/rules/rules_inventory_gate_bilateral.py b/src/autoskillit/recipe/rules/rules_inventory_gate_bilateral.py index 57dd3e77a..05363a465 100644 --- a/src/autoskillit/recipe/rules/rules_inventory_gate_bilateral.py +++ b/src/autoskillit/recipe/rules/rules_inventory_gate_bilateral.py @@ -1,106 +1,214 @@ -"""inventory-gate-not-bilateral: dry-walkthrough must receive -remediation_path in audit-impl remediation loops.""" +"""Validate executable audit-cycle producer/consumer bindings.""" from __future__ import annotations from autoskillit.core import Severity from autoskillit.recipe._analysis import ValidationContext -from autoskillit.recipe._analysis_bfs import bfs_reachable +from autoskillit.recipe._analysis_bfs import all_paths_cross, bfs_reachable from autoskillit.recipe.contracts import resolve_skill_name from autoskillit.recipe.registry import RuleFinding, make_finding, semantic_rule +from autoskillit.recipe.schema import RecipeStep -def _has_audit_impl_remediation_step(ctx: ValidationContext) -> bool: - """Return True if any step invokes audit-impl and captures remediation_path.""" - for step in ctx.recipe.steps.values(): - if step.tool != "run_skill": - continue - skill = resolve_skill_name(step.with_args.get("skill_command", "")) - if skill != "audit-impl": - continue - capture = step.capture or {} - if "remediation_path" in capture: - return True - return False +def _skill_name(ctx: ValidationContext, step_name: str) -> str | None: + invocation = ctx.binding_projection.for_step(step_name) + if invocation is not None and invocation.skill_name is not None: + return invocation.skill_name + step = ctx.recipe.steps[step_name] + return resolve_skill_name(str(step.with_args.get("skill_command", ""))) -def _dry_walkthrough_reachable_from_audit( - ctx: ValidationContext, audit_step_name: str -) -> str | None: - """Return the first dry-walkthrough step reachable from audit_impl via non-GO routes. +def _has_bound_input(ctx: ValidationContext, step_name: str, name: str) -> bool: + invocation = ctx.binding_projection.for_step(step_name) + if invocation is None: + return False + value = invocation.skill_input(name) + return value is not None and value.is_present - Uses GO-filtered adjacency so that the GO verdict edge from audit-impl is - excluded — only the remediation branch is traced. - """ - from autoskillit.recipe.rules.rules_audit_impl_plan_scope import ( - _build_go_filtered_adjacency, - ) - - adjacency = _build_go_filtered_adjacency(ctx, audit_step_name) - reachable = bfs_reachable(adjacency, audit_step_name) - for step_name in reachable: - step = ctx.recipe.steps.get(step_name) - if step is None or step.tool != "run_skill": - continue - skill = resolve_skill_name(step.with_args.get("skill_command", "")) - if skill == "dry-walkthrough": - return step_name - return None - -def _threads_remediation_path(with_args: dict[str, str]) -> bool: - return "remediation_path" in with_args +def _no_go_routes(step: RecipeStep) -> tuple[str, ...]: + if step.on_result is None: + return () + explicit = tuple( + condition.route + for condition in step.on_result.conditions + if condition.when is not None and "NO GO" in str(condition.when) + ) + if explicit: + return explicit + has_go_route = any( + condition.when is not None and "GO" in str(condition.when) + for condition in step.on_result.conditions + ) + if not has_go_route: + return () + return tuple( + condition.route for condition in step.on_result.conditions if condition.when is None + ) @semantic_rule( name="inventory-gate-not-bilateral", description=( - "A recipe with an audit-impl step that captures remediation_path and a " - "dry-walkthrough step reachable from audit-impl via non-GO routes must " - "thread remediation_path to dry-walkthrough via its with: block. Without " - "this, dry-walkthrough Step 4.7 cannot distinguish satisfied from unmapped " - "requirements in the two-disposition plan-vs-inventory gate." + "Audit remediation recipes must capture immutable audit authority and deliver " + "the exact authority/disposition tuple through compiled dry-walkthrough inputs." ), severity=Severity.ERROR, ) def _check_inventory_gate_bilateral(ctx: ValidationContext) -> list[RuleFinding]: - if not _has_audit_impl_remediation_step(ctx): + audit_steps = [name for name in ctx.recipe.steps if _skill_name(ctx, name) == "audit-impl"] + if not audit_steps: return [] findings: list[RuleFinding] = [] - for step_name, step in ctx.recipe.steps.items(): - if step.tool != "run_skill": - continue - skill = resolve_skill_name(step.with_args.get("skill_command", "")) - if skill != "audit-impl": - continue - capture = step.capture or {} - if "remediation_path" not in capture: - continue + for audit_step_name in audit_steps: + if "audit_cycle_path" not in ctx.recipe.steps[audit_step_name].capture: + findings.append( + make_finding( + rule_name="inventory-gate-not-bilateral", + step_name=audit_step_name, + message=( + f"Step '{audit_step_name}' invokes audit-impl but does not " + "capture audit_cycle_path for both verdicts." + ), + ) + ) - dw_step_name = _dry_walkthrough_reachable_from_audit(ctx, step_name) - if dw_step_name is None: + make_plan_steps = {name for name in ctx.recipe.steps if _skill_name(ctx, name) == "make-plan"} + disposition_produced = any( + "plan_disposition_path" in ctx.recipe.steps[name].capture for name in make_plan_steps + ) + for step_name in ctx.recipe.steps: + if _skill_name(ctx, step_name) != "dry-walkthrough": continue - - dw_step = ctx.recipe.steps[dw_step_name] - if _threads_remediation_path(dw_step.with_args): + missing = [ + name + for name in ("audit_cycle_path", "plan_disposition_path") + if (name == "audit_cycle_path" or disposition_produced) + and not _has_bound_input(ctx, step_name, name) + ] + if not missing: continue - findings.append( make_finding( rule_name="inventory-gate-not-bilateral", - step_name=dw_step_name, + step_name=step_name, message=( - f"Step '{dw_step_name}' invokes dry-walkthrough but does not " - f"thread remediation_path via its with: block. The recipe " - f"has an audit-impl step that captures remediation_path " - f"(step '{step_name}'), and dry-walkthrough is reachable from " - f"audit-impl via non-GO routes. Without remediation_path, " - f"dry-walkthrough Step 4.7 cannot distinguish satisfied from " - f"unmapped requirements in the two-disposition gate. Add " - f"'remediation_path: ${{{{ context.remediation_path }}}}' to " - f"the step's with: block." + f"Step '{step_name}' invokes dry-walkthrough without compiled " + f"inputs {missing!r}; audit-cycle admission cannot verify the " + "producer authority and disposition tuple." ), ) ) + + reported: set[tuple[str, str]] = set() + for audit_step_name in audit_steps: + for no_go_start in _no_go_routes(ctx.recipe.steps[audit_step_name]): + reachable = bfs_reachable(ctx.step_graph, no_go_start) | {no_go_start} + reachable_planners = sorted(make_plan_steps & reachable) + if not reachable_planners: + key = (audit_step_name, "producer") + if key not in reported: + reported.add(key) + findings.append( + make_finding( + rule_name="inventory-gate-not-bilateral", + step_name=audit_step_name, + message=( + f"NO GO route from '{audit_step_name}' reaches no " + "make-plan producer for a plan disposition report." + ), + ) + ) + continue + for planner_name in reachable_planners: + missing_planner = [ + name + for name in ("audit_cycle_path",) + if not _has_bound_input(ctx, planner_name, name) + ] + if "plan_disposition_path" not in ctx.recipe.steps[planner_name].capture: + missing_planner.append("capture.plan_disposition_path") + if missing_planner: + key = (planner_name, "producer") + if key not in reported: + reported.add(key) + findings.append( + make_finding( + rule_name="inventory-gate-not-bilateral", + step_name=planner_name, + message=( + f"Step '{planner_name}' is on an audit NO GO route " + f"but lacks executable producer bindings {missing_planner!r}." + ), + ) + ) + for dry_name in sorted( + name + for name in reachable + if name in ctx.recipe.steps and _skill_name(ctx, name) == "dry-walkthrough" + ): + if any( + all_paths_cross( + ctx.step_graph, + no_go_start, + planner_name, + dry_name, + ) + for planner_name in reachable_planners + ): + continue + key = (dry_name, "dominance") + if key in reported: + continue + reported.add(key) + findings.append( + make_finding( + rule_name="inventory-gate-not-bilateral", + step_name=dry_name, + message=( + f"Dry step '{dry_name}' is reachable from audit NO GO " + "without crossing a make-plan disposition producer." + ), + ) + ) + successor_audits = (set(audit_steps) & reachable) - {audit_step_name} + if audit_step_name in reachable and audit_step_name != no_go_start: + successor_audits.add(audit_step_name) + if not successor_audits: + key = (audit_step_name, "successor") + if key not in reported: + reported.add(key) + findings.append( + make_finding( + rule_name="inventory-gate-not-bilateral", + step_name=audit_step_name, + message=( + f"NO GO route from '{audit_step_name}' cannot reach a " + "successor audit-impl verdict; remediation could not " + "close the active authority." + ), + ) + ) + for successor_name in sorted(successor_audits): + if _has_bound_input( + ctx, + successor_name, + "prior_audit_cycle_path", + ): + continue + key = (successor_name, "successor-input") + if key in reported: + continue + reported.add(key) + findings.append( + make_finding( + rule_name="inventory-gate-not-bilateral", + step_name=successor_name, + message=( + f"Successor audit step '{successor_name}' does not consume " + "the bound prior_audit_cycle_path." + ), + ) + ) return findings diff --git a/src/autoskillit/recipe/rules/rules_issue_scope_threading.py b/src/autoskillit/recipe/rules/rules_issue_scope_threading.py index 2c02d8063..bdd53c375 100644 --- a/src/autoskillit/recipe/rules/rules_issue_scope_threading.py +++ b/src/autoskillit/recipe/rules/rules_issue_scope_threading.py @@ -12,7 +12,17 @@ def _has_issue_ingredient(ctx: ValidationContext) -> bool: return "issue_url" in ctx.recipe.ingredients or "issue_number" in ctx.recipe.ingredients -def _threads_issue_context(with_args: dict[str, str]) -> bool: +def _threads_issue_context(ctx: ValidationContext, step_name: str) -> bool: + invocation = ctx.binding_projection.for_step(step_name) + if invocation is not None: + for name in ("issue_url", "issue_number"): + value = invocation.skill_input(name) + if value is not None and value.is_present: + return True + with_args = ctx.recipe.steps[step_name].with_args + structured = with_args.get("skill_inputs") + if isinstance(structured, dict): + return "issue_url" in structured or "issue_number" in structured return "issue_url" in with_args or "issue_number" in with_args @@ -37,7 +47,7 @@ def _check_issue_scope_threading(ctx: ValidationContext) -> list[RuleFinding]: skill = resolve_skill_name(step.with_args.get("skill_command", "")) if skill != "dry-walkthrough": continue - if _threads_issue_context(step.with_args): + if _threads_issue_context(ctx, step_name): continue findings.append( make_finding( diff --git a/src/autoskillit/recipe/rules/rules_loop_artifact_scope.py b/src/autoskillit/recipe/rules/rules_loop_artifact_scope.py index f88138fe2..7b69bb4f0 100644 --- a/src/autoskillit/recipe/rules/rules_loop_artifact_scope.py +++ b/src/autoskillit/recipe/rules/rules_loop_artifact_scope.py @@ -4,9 +4,12 @@ from autoskillit.core import SKILL_TOOLS, Severity from autoskillit.recipe._analysis import ValidationContext +from autoskillit.recipe._contracts_manifest import get_skill_contract, load_bundled_manifest from autoskillit.recipe._rule_helpers import _find_cycle_members from autoskillit.recipe.registry import RuleFinding, make_finding, semantic_rule +_ARTIFACT_OUTPUT_TYPES = frozenset({"directory_path", "file_path", "file_path_list"}) + @semantic_rule( name="loop-iterated-step-requires-iteration-scoped-output", @@ -22,20 +25,52 @@ def _check_loop_artifact_scope(ctx: ValidationContext) -> list[RuleFinding]: recipe_steps = ctx.recipe.steps cycle_sets = _find_cycle_members(ctx.step_graph, recipe_steps) + manifest = load_bundled_manifest() + examined_artifact_steps: set[str] = set() for cycle_set in cycle_sets: for step_name in cycle_set: + if step_name in examined_artifact_steps: + continue step = recipe_steps.get(step_name) if step is None: continue if step.tool not in SKILL_TOOLS: continue - output_dir = step.with_args.get("output_dir", "") - if not output_dir: + invocation = ctx.binding_projection.for_step(step_name) + if invocation is None or invocation.skill_name is None: + continue + contract = get_skill_contract(invocation.skill_name, manifest) + if contract is None: + continue + artifact_outputs = tuple( + output for output in contract.outputs if output.type in _ARTIFACT_OUTPUT_TYPES + ) + if not artifact_outputs: + # Some skills mutate a declared input in place. Their output_dir + # is a write boundary, not an artifact destination. + continue + if any(output.name == "audit_cycle_path" for output in artifact_outputs): + # Audit-cycle outputs are already content-addressed beneath an + # immutable cycle identity rooted at output_dir. + continue + examined_artifact_steps.add(step_name) + output_dir = next( + ( + value + for value in invocation.mcp_kwargs + if value.name == "output_dir" and value.is_present + ), + None, + ) + if output_dir is None: + continue + if "AUTOSKILLIT_TEMP" not in output_dir.template_dependencies: continue - if "{{AUTOSKILLIT_TEMP}}" not in output_dir: + effective_output_dir = output_dir.effective_value + if not isinstance(effective_output_dir, str): continue - if "${{ context." in output_dir: + if output_dir.context_dependencies: continue cycle_list = sorted(cycle_set) findings.append( @@ -44,7 +79,7 @@ def _check_loop_artifact_scope(ctx: ValidationContext) -> list[RuleFinding]: step_name=step_name, message=( f"Step '{step_name}' is in a loop cycle but uses a static " - f"output_dir '{output_dir}'. Loop-iterated run_skill steps must " + f"output_dir '{effective_output_dir}'. Loop-iterated run_skill steps must " f"include an iteration-scoped context variable in output_dir " f"(e.g., '${{{{ context.review_loop_count }}}}') to prevent " f"artifact collision. Cycle: [{'→'.join(cycle_list)}]" diff --git a/src/autoskillit/recipe/rules/rules_merge_context.py b/src/autoskillit/recipe/rules/rules_merge_context.py index 9b2525850..f4df2e080 100644 --- a/src/autoskillit/recipe/rules/rules_merge_context.py +++ b/src/autoskillit/recipe/rules/rules_merge_context.py @@ -136,20 +136,29 @@ def _check_merge_test_gate_context_not_forwarded( ) cmd = rf_step.with_args.get("skill_command", "") - if count_skill_args(cmd) <= 3: + invocation = ctx.binding_projection.for_step(rf_step_name) + has_failure_context = ( + invocation is not None + and bool(invocation.skill_inputs) + and all( + (value := invocation.skill_input(name)) is not None and value.is_present + for name in ("ci_conclusion", "diagnosis_path") + ) + ) or count_skill_args(cmd) > 3 + if not has_failure_context: findings.append( make_finding( rule_name="merge-test-gate-context-not-forwarded", step_name=rf_step_name, message=( - f"Step '{rf_step_name}' invokes resolve-failures with only " - f"{count_skill_args(cmd)} positional arg(s) (worktree, plan, " - f"branch), but is reachable from merge_worktree step " + f"Step '{rf_step_name}' invokes resolve-failures without " + f"bound ci_conclusion and diagnosis_path inputs, but is " + f"reachable from merge_worktree step " f"'{step_name}' via test_gate/post_rebase_test_gate. " f"Without failure context (ci_conclusion + diagnosis_path), " f"resolve-failures cannot investigate the specific failure. " - f"Expand skill_command to 6 args including merge_gate_ci_conclusion " - f"and merge_gate_diagnosis_path." + f"Bind merge_gate_ci_conclusion and " + f"merge_gate_diagnosis_path." ), ) ) diff --git a/src/autoskillit/recipe/rules/rules_tools.py b/src/autoskillit/recipe/rules/rules_tools.py index 4bc62ce83..497c4c705 100644 --- a/src/autoskillit/recipe/rules/rules_tools.py +++ b/src/autoskillit/recipe/rules/rules_tools.py @@ -7,10 +7,10 @@ from autoskillit.core import ( SKILL_TOOLS, + TOOL_REGISTRY, TOOL_SUBSET_TAGS, BindingFailureCode, Severity, - all_tool_names, get_logger, get_tool_def, ) @@ -25,7 +25,7 @@ logger = get_logger(__name__) -_ALL_TOOLS: frozenset[str] = all_tool_names() +_ALL_TOOLS: frozenset[str] = frozenset(TOOL_REGISTRY) _RUN_PYTHON_PATH_LIKE_ARGS: frozenset[str] = frozenset( {"output_dir", "workspace", "diagnostics_log_dir"} diff --git a/src/autoskillit/recipe/validator.py b/src/autoskillit/recipe/validator.py index dbadb922a..9c8198756 100644 --- a/src/autoskillit/recipe/validator.py +++ b/src/autoskillit/recipe/validator.py @@ -63,6 +63,22 @@ _SKILL_HINT = " (Use /autoskillit:write-recipe for schema guidance)" +def _iter_string_leaves(value: object, path: str) -> list[tuple[str, str]]: + if isinstance(value, str): + return [(path, value)] + if isinstance(value, dict): + leaves: list[tuple[str, str]] = [] + for key, nested in value.items(): + leaves.extend(_iter_string_leaves(nested, f"{path}.{key}")) + return leaves + if isinstance(value, (list, tuple)): + leaves = [] + for index, nested in enumerate(value): + leaves.extend(_iter_string_leaves(nested, f"{path}[{index}]")) + return leaves + return [] + + def validate_recipe_structure(recipe: Recipe) -> list[str]: """Return structural validation errors (empty if valid). @@ -242,21 +258,20 @@ def validate_recipe_structure(recipe: Recipe) -> list[str]: if step.sub_recipe is not None: continue for arg_key, arg_val in step.with_args.items(): - if not isinstance(arg_val, str): - continue - for ref in INPUT_REF_RE.findall(arg_val): - if ref not in ingredient_names: - errors.append( - f"Step '{step_name}'.with.{arg_key} references undeclared input '{ref}'." - + _SKILL_HINT - ) - for ref in _CONTEXT_REF_RE.findall(arg_val): - if ref not in available_context and ref not in step.optional_context_refs: - errors.append( - f"Step '{step_name}'.with.{arg_key} references " - f"context variable '{ref}' which has not been " - f"captured by a preceding step." - ) + for arg_path, string_value in _iter_string_leaves(arg_val, arg_key): + for ref in INPUT_REF_RE.findall(string_value): + if ref not in ingredient_names: + errors.append( + f"Step '{step_name}'.with.{arg_path} references undeclared " + f"input '{ref}'." + _SKILL_HINT + ) + for ref in _CONTEXT_REF_RE.findall(string_value): + if ref not in available_context and ref not in step.optional_context_refs: + errors.append( + f"Step '{step_name}'.with.{arg_path} references " + f"context variable '{ref}' which has not been " + f"captured by a preceding step." + ) if not recipe.kitchen_rules: errors.append( diff --git a/tests/arch/test_run_python_path_resolution.py b/tests/arch/test_run_python_path_resolution.py index c6a697ee5..7f2947dd6 100644 --- a/tests/arch/test_run_python_path_resolution.py +++ b/tests/arch/test_run_python_path_resolution.py @@ -158,12 +158,13 @@ def test_sentinel_keys_do_not_collide_with_callable_params() -> None: def test_sentinel_keys_subset_of_tool_params() -> None: """Sentinel keys must be a subset of run_python's tool-level params.""" - from autoskillit.core import RUN_PYTHON_SENTINEL_KEYS - from autoskillit.recipe.rules.rules_tools import _TOOL_PARAMS + from autoskillit.core import RUN_PYTHON_SENTINEL_KEYS, get_tool_def - tool_params = _TOOL_PARAMS["run_python"] + tool_def = get_tool_def("run_python") + assert tool_def is not None + tool_params = tool_def.param_set assert RUN_PYTHON_SENTINEL_KEYS < tool_params, ( - f"Sentinel keys {RUN_PYTHON_SENTINEL_KEYS - tool_params} are not in _TOOL_PARAMS" + f"Sentinel keys {RUN_PYTHON_SENTINEL_KEYS - tool_params} are not in TOOL_REGISTRY" ) non_sentinel_tool_params = tool_params - RUN_PYTHON_SENTINEL_KEYS - {"args"} assert non_sentinel_tool_params == set(), ( diff --git a/tests/infra/test_schema_version_convention.py b/tests/infra/test_schema_version_convention.py index 2bb3293cf..66115c9d0 100644 --- a/tests/infra/test_schema_version_convention.py +++ b/tests/infra/test_schema_version_convention.py @@ -119,10 +119,10 @@ def _is_yaml_dump(node: ast.expr) -> bool: # _lifespan.py — hooks.json self-heal on startup drift (co-owned with Claude plugin system) ("src/autoskillit/server/_lifespan.py", 90), # tools_kitchen.py — hook config, quota guard, git_ops_policy, ingredient locks overlay - ("src/autoskillit/server/tools/tools_kitchen.py", 283), - ("src/autoskillit/server/tools/tools_kitchen.py", 302), - ("src/autoskillit/server/tools/tools_kitchen.py", 336), - ("src/autoskillit/server/tools/tools_kitchen.py", 1441), + ("src/autoskillit/server/tools/tools_kitchen.py", 285), + ("src/autoskillit/server/tools/tools_kitchen.py", 304), + ("src/autoskillit/server/tools/tools_kitchen.py", 338), + ("src/autoskillit/server/tools/tools_kitchen.py", 1454), # tools_pipeline_tracker.py — tracker_data dict (init) and mark_step_complete write # (same tracker file schema as init — not a new format, grandfathered alongside it) ("src/autoskillit/server/tools/tools_pipeline_tracker.py", 256), diff --git a/tests/recipe/test_io_json_precompile.py b/tests/recipe/test_io_json_precompile.py index 3be57bc04..de8a7b5f0 100644 --- a/tests/recipe/test_io_json_precompile.py +++ b/tests/recipe/test_io_json_precompile.py @@ -14,6 +14,7 @@ from autoskillit.recipe.io import ( _collect_recipes, _load_recipe_dict, + _load_recipe_dict_with_declarations, builtin_recipes_dir, ) @@ -50,7 +51,7 @@ def test_load_recipe_dict_prefers_json_when_fresh(tmp_path, monkeypatch): load_yaml_calls = [] monkeypatch.setattr( - "autoskillit.recipe.io.load_yaml", + "autoskillit.recipe._io_loading.load_yaml", lambda *a, **kw: load_yaml_calls.append(1) or _MINIMAL_RECIPE, ) @@ -66,7 +67,7 @@ def test_load_recipe_dict_falls_back_when_json_missing(tmp_path, monkeypatch): load_yaml_calls = [] monkeypatch.setattr( - "autoskillit.recipe.io.load_yaml", + "autoskillit.recipe._io_loading.load_yaml", lambda *a, **kw: load_yaml_calls.append(1) or _MINIMAL_RECIPE, ) @@ -88,7 +89,7 @@ def test_load_recipe_dict_falls_back_when_json_stale(tmp_path, monkeypatch): load_yaml_calls = [] monkeypatch.setattr( - "autoskillit.recipe.io.load_yaml", + "autoskillit.recipe._io_loading.load_yaml", lambda *a, **kw: load_yaml_calls.append(1) or _MINIMAL_RECIPE, ) @@ -116,9 +117,13 @@ def test_load_recipe_dict_applies_substitution_on_json(tmp_path): future_mtime_ns = yaml_path.stat().st_mtime_ns + 10_000_000_000 os.utime(json_path, ns=(future_mtime_ns, future_mtime_ns)) - result = _load_recipe_dict(yaml_path, temp_dir_relpath="custom/temp") + result, declared = _load_recipe_dict_with_declarations( + yaml_path, + temp_dir_relpath="custom/temp", + ) assert result["steps"]["run"]["with"]["worktree_path"] == "custom/temp" + assert declared["steps"]["run"]["with"]["worktree_path"] == "{{AUTOSKILLIT_TEMP}}" def test_load_recipe_dict_handles_json_decode_error(tmp_path, monkeypatch): @@ -132,7 +137,7 @@ def test_load_recipe_dict_handles_json_decode_error(tmp_path, monkeypatch): load_yaml_calls = [] monkeypatch.setattr( - "autoskillit.recipe.io.load_yaml", + "autoskillit.recipe._io_loading.load_yaml", lambda *a, **kw: load_yaml_calls.append(1) or _MINIMAL_RECIPE, ) @@ -153,7 +158,7 @@ def test_load_recipe_dict_falls_back_when_json_is_not_mapping(tmp_path, monkeypa load_yaml_calls = [] monkeypatch.setattr( - "autoskillit.recipe.io.load_yaml", + "autoskillit.recipe._io_loading.load_yaml", lambda *a, **kw: load_yaml_calls.append(1) or _MINIMAL_RECIPE, ) diff --git a/tests/recipe/test_recipe_temp_substitution.py b/tests/recipe/test_recipe_temp_substitution.py index c158b5db3..9f58d8aff 100644 --- a/tests/recipe/test_recipe_temp_substitution.py +++ b/tests/recipe/test_recipe_temp_substitution.py @@ -58,6 +58,10 @@ def test_load_recipe_custom_temp_dir_substituted(tmp_path: Path) -> None: recipe = load_recipe(path, temp_dir_relpath="custom/x") cmd = recipe.steps["setup"].with_args["cmd"] assert cmd == 'mkdir -p "custom/x/worktrees"' + assert ( + recipe.steps["setup"].declared_with_args["cmd"] + == 'mkdir -p "{{AUTOSKILLIT_TEMP}}/worktrees"' + ) def test_load_recipe_rejects_yaml_unsafe_temp_dir_relpath(tmp_path: Path) -> None: diff --git a/tests/recipe/test_review_loop_artifact_isolation.py b/tests/recipe/test_review_loop_artifact_isolation.py index 0e584c779..1096189cb 100644 --- a/tests/recipe/test_review_loop_artifact_isolation.py +++ b/tests/recipe/test_review_loop_artifact_isolation.py @@ -4,10 +4,8 @@ import pytest -from autoskillit.core import SKILL_TOOLS -from autoskillit.recipe._analysis import make_validation_context -from autoskillit.recipe._rule_helpers import _find_cycle_members from autoskillit.recipe.io import builtin_recipes_dir, load_recipe +from autoskillit.recipe.validator import run_semantic_rules pytestmark = [pytest.mark.layer("recipe"), pytest.mark.medium] @@ -20,36 +18,16 @@ def bundled_recipe(request): def test_loop_iterated_run_skill_steps_use_iter_scoped_output_dir(bundled_recipe) -> None: - """run_skill steps in review loop cycles must use iteration-scoped output_dir. - - Iteration-scoped means output_dir includes a ${{ context. variable reference - (e.g., ${{ context.review_loop_count }}) so each iteration writes to a distinct - directory and cannot collide with artifacts from a prior iteration. - """ - ctx = make_validation_context(bundled_recipe) - cycle_sets = _find_cycle_members(ctx.step_graph, bundled_recipe.steps) - cycle_members: set[str] = set() - for cs in cycle_sets: - cycle_members |= cs - - violations: list[tuple[str, str]] = [] - for step_name in cycle_members: - step = bundled_recipe.steps.get(step_name) - if step is None or step.tool not in SKILL_TOOLS: - continue - output_dir = step.with_args.get("output_dir", "") - if not output_dir: - continue - if "{{AUTOSKILLIT_TEMP}}" not in output_dir: - continue - if "${{ context." not in output_dir: - violations.append((step_name, output_dir)) + """Bundled loop artifact producers pass the production provenance rule.""" + violations = [ + finding + for finding in run_semantic_rules(bundled_recipe) + if finding.rule == "loop-iterated-step-requires-iteration-scoped-output" + ] assert not violations, ( - f"run_skill steps in loop cycle have static output_dir (not iteration-scoped): " - f"{[(name, d) for name, d in violations]}. " - f"Loop-iterated run_skill steps must include a ${{{{ context. }}}} reference " - f"in output_dir to prevent artifact collision between iterations." + "loop artifact producers have static resolved output directories: " + f"{[(finding.step_name, finding.message) for finding in violations]}" ) diff --git a/tests/recipe/test_rules_dataflow_handoff.py b/tests/recipe/test_rules_dataflow_handoff.py index 0d72bfe6b..a86dec066 100644 --- a/tests/recipe/test_rules_dataflow_handoff.py +++ b/tests/recipe/test_rules_dataflow_handoff.py @@ -77,8 +77,8 @@ def test_uncaptured_handoff_consumer_silent_when_context_ref_present(self) -> No def test_uncaptured_handoff_consumer_silent_when_no_file_path_inputs(self) -> None: """1h: rule is silent when consumer has no file-path or directory-path inputs. - Uses audit-friction (outputs: []) as producer and make-plan (single task: string - input — no file-path inputs) as consumer. The rule must be silent. + Uses audit-friction (outputs: []) as producer and mermaid (no declared + inputs) as consumer. The rule must be silent. """ steps = { "friction": { @@ -88,7 +88,7 @@ def test_uncaptured_handoff_consumer_silent_when_no_file_path_inputs(self) -> No }, "plan": { "tool": "run_skill", - "with": {"skill_command": "/autoskillit:make-plan some task"}, + "with": {"skill_command": "/autoskillit:mermaid"}, "on_success": "done", }, "done": {"action": "stop", "message": "Done."}, diff --git a/tests/recipe/test_rules_inventory_gate_bilateral.py b/tests/recipe/test_rules_inventory_gate_bilateral.py index 9f03741fa..100fb4b58 100644 --- a/tests/recipe/test_rules_inventory_gate_bilateral.py +++ b/tests/recipe/test_rules_inventory_gate_bilateral.py @@ -1,9 +1,4 @@ -"""Tests for inventory-gate-not-bilateral semantic validation rule. - -Verifies that dry-walkthrough steps in recipes with an audit-impl step that -captures remediation_path receive remediation_path via their with: block. -Recipes without audit-impl remediation loops are unaffected. -""" +"""Tests for executable audit-cycle producer/consumer binding validation.""" from __future__ import annotations @@ -11,21 +6,13 @@ from autoskillit.core.types import CaptureEntrySpec, Severity from autoskillit.recipe.io import builtin_recipes_dir, load_recipe -from autoskillit.recipe.schema import ( - Recipe, - RecipeStep, - StepResultCondition, - StepResultRoute, -) +from autoskillit.recipe.schema import Recipe, RecipeStep, StepResultCondition, StepResultRoute from autoskillit.recipe.validator import run_semantic_rules pytestmark = [pytest.mark.layer("recipe"), pytest.mark.small] _RULE_NAME = "inventory-gate-not-bilateral" -_AUDIT_IMPL_CMD = "/autoskillit:audit-impl ${{ context.plan_path }}" -_DW_CMD = "/autoskillit:dry-walkthrough ${{ context.plan_path }}" - def _cap(from_: str) -> CaptureEntrySpec: return CaptureEntrySpec(from_=from_, value_type="string") @@ -35,99 +22,160 @@ def _make_recipe(steps: dict[str, RecipeStep]) -> Recipe: return Recipe(name="test", description="test", steps=steps, kitchen_rules=["test"]) -def _audit_impl_with_remediation_capture_steps( - dw_with_args: dict[str, str] | None = None, +def _audit_cycle_steps( + *, + capture_authority: bool = True, + dry_inputs: dict[str, str] | None = None, ) -> dict[str, RecipeStep]: - """audit_impl capturing remediation_path, remediation loop routes to verify.""" - dw_with = dw_with_args if dw_with_args is not None else {} - return { - "audit_impl": RecipeStep( - tool="run_skill", - with_args={"skill_command": _AUDIT_IMPL_CMD}, - capture={"remediation_path": _cap("${{ result.remediation_path }}")}, - on_result=StepResultRoute( - conditions=[ - StepResultCondition(route="done", when="${{ result.verdict }} == GO"), - StepResultCondition(route="remediate", when=None), - ] - ), - ), - "remediate": RecipeStep(action="route", on_success="verify"), - "verify": RecipeStep( - tool="run_skill", - with_args={"skill_command": _DW_CMD, **dw_with}, - ), - "done": RecipeStep(action="stop"), - } - - -def _audit_impl_without_remediation_capture_steps() -> dict[str, RecipeStep]: - """audit_impl without remediation_path capture, remediation loop routes to verify.""" + audit_capture = ( + {"audit_cycle_path": _cap("${{ result.audit_cycle_path }}")} if capture_authority else {} + ) return { "audit_impl": RecipeStep( tool="run_skill", - with_args={"skill_command": _AUDIT_IMPL_CMD}, + with_args={ + "skill_command": "/autoskillit:audit-impl", + "skill_inputs": { + "prior_audit_cycle_path": "/tmp/cycle.json", + }, + }, + capture=audit_capture, on_result=StepResultRoute( conditions=[ StepResultCondition(route="done", when="${{ result.verdict }} == GO"), - StepResultCondition(route="remediate", when=None), + StepResultCondition(route="plan", when=None), ] ), ), - "remediate": RecipeStep(action="route", on_success="verify"), - "verify": RecipeStep( + "plan": RecipeStep( tool="run_skill", - with_args={"skill_command": _DW_CMD}, + with_args={ + "skill_command": "/autoskillit:make-plan", + "skill_inputs": { + "task": "task", + "audit_cycle_path": "/tmp/cycle.json", + }, + }, + capture={"plan_disposition_path": _cap("${{ result.plan_disposition_path }}")}, + on_success="verify", ), - "done": RecipeStep(action="stop"), - } - - -def _no_audit_impl_steps() -> dict[str, RecipeStep]: - """Recipe without audit-impl step at all.""" - return { "verify": RecipeStep( tool="run_skill", - with_args={"skill_command": _DW_CMD}, + with_args={ + "skill_command": "/autoskillit:dry-walkthrough", + "skill_inputs": dry_inputs or {"plan_path": "/tmp/plan.md"}, + }, + on_success="audit_impl", ), "done": RecipeStep(action="stop"), } def _findings(steps: dict[str, RecipeStep]) -> list: - recipe = _make_recipe(steps) - return [f for f in run_semantic_rules(recipe) if f.rule == _RULE_NAME] + return [ + finding + for finding in run_semantic_rules(_make_recipe(steps)) + if finding.rule == _RULE_NAME + ] -def test_inventory_gate_bilateral_fires_when_audit_impl_present() -> None: - """dry-walkthrough missing remediation_path with audit-impl remediation loop must fire.""" - findings = _findings(_audit_impl_with_remediation_capture_steps()) +def test_inventory_gate_bilateral_fires_for_missing_dry_tuple() -> None: + findings = _findings(_audit_cycle_steps()) assert len(findings) == 1 assert findings[0].severity == Severity.ERROR assert findings[0].step_name == "verify" - assert "remediation_path" in findings[0].message + assert "audit_cycle_path" in findings[0].message + assert "plan_disposition_path" in findings[0].message -def test_inventory_gate_bilateral_silent_when_no_audit_impl() -> None: - """Recipes without audit-impl must not fire.""" - findings = _findings(_no_audit_impl_steps()) +def test_inventory_gate_bilateral_silent_without_audit_impl() -> None: + findings = _findings( + { + "verify": RecipeStep( + tool="run_skill", + with_args={"skill_command": "/autoskillit:dry-walkthrough plan.md"}, + ), + "done": RecipeStep(action="stop"), + } + ) assert findings == [] -def test_inventory_gate_bilateral_silent_when_properly_wired() -> None: - """dry-walkthrough receiving remediation_path must not fire.""" +def test_inventory_gate_bilateral_silent_for_compiled_tuple() -> None: findings = _findings( - _audit_impl_with_remediation_capture_steps( - dw_with_args={"remediation_path": "${{ context.remediation_path }}"} + _audit_cycle_steps( + dry_inputs={ + "plan_path": "/tmp/plan.md", + "audit_cycle_path": "/tmp/cycle.json", + "plan_disposition_path": "/tmp/disposition.json", + } ) ) assert findings == [] -def test_inventory_gate_bilateral_silent_when_audit_impl_no_remediation_capture() -> None: - """audit_impl without remediation_path capture must not fire.""" - findings = _findings(_audit_impl_without_remediation_capture_steps()) - assert findings == [] +def test_inventory_gate_bilateral_rejects_missing_authority_capture() -> None: + findings = _findings( + _audit_cycle_steps( + capture_authority=False, + dry_inputs={ + "plan_path": "/tmp/plan.md", + "audit_cycle_path": "/tmp/cycle.json", + "plan_disposition_path": "/tmp/disposition.json", + }, + ) + ) + assert len(findings) == 1 + assert findings[0].step_name == "audit_impl" + assert "capture audit_cycle_path" in findings[0].message + + +def test_inventory_gate_bilateral_rejects_non_dominating_disposition_producer() -> None: + steps = _audit_cycle_steps( + dry_inputs={ + "plan_path": "/tmp/plan.md", + "audit_cycle_path": "/tmp/cycle.json", + "plan_disposition_path": "/tmp/disposition.json", + } + ) + steps["fork"] = RecipeStep( + tool="run_cmd", + with_args={"cmd": "echo fork"}, + on_result=StepResultRoute( + conditions=[ + StepResultCondition(route="plan", when="result.use_plan"), + StepResultCondition(route="verify", when=None), + ] + ), + ) + audit_routes = steps["audit_impl"].on_result + assert audit_routes is not None + audit_routes.conditions[-1] = StepResultCondition(route="fork", when=None) + + findings = _findings(steps) + + assert any( + finding.step_name == "verify" and "without crossing" in finding.message + for finding in findings + ) + + +def test_inventory_gate_bilateral_rejects_nogo_without_successor_audit() -> None: + steps = _audit_cycle_steps( + dry_inputs={ + "plan_path": "/tmp/plan.md", + "audit_cycle_path": "/tmp/cycle.json", + "plan_disposition_path": "/tmp/disposition.json", + } + ) + steps["verify"].on_success = "done" + + findings = _findings(steps) + + assert any( + finding.step_name == "audit_impl" and "successor audit-impl" in finding.message + for finding in findings + ) @pytest.mark.parametrize( @@ -137,13 +185,14 @@ def test_inventory_gate_bilateral_silent_when_audit_impl_no_remediation_capture( "implementation.yaml", "implementation-groups.yaml", "merge-prs.yaml", + "research-implement.yaml", + "research.yaml", ], ) def test_bundled_recipes_pass_inventory_gate_bilateral_rule(recipe_name: str) -> None: - """Bundled recipes must not trigger inventory-gate-not-bilateral.""" recipe = load_recipe(builtin_recipes_dir() / recipe_name) - findings = [f for f in run_semantic_rules(recipe) if f.rule == _RULE_NAME] + findings = [finding for finding in run_semantic_rules(recipe) if finding.rule == _RULE_NAME] assert findings == [], ( f"{recipe_name} must not trigger {_RULE_NAME}. " - f"Findings: {[(f.step_name, f.message) for f in findings]}" + f"Findings: {[(finding.step_name, finding.message) for finding in findings]}" ) diff --git a/tests/recipe/test_rules_loop_artifact_scope.py b/tests/recipe/test_rules_loop_artifact_scope.py index 25c3d7f67..7670f3bbe 100644 --- a/tests/recipe/test_rules_loop_artifact_scope.py +++ b/tests/recipe/test_rules_loop_artifact_scope.py @@ -2,9 +2,14 @@ from __future__ import annotations +import json +import os +from pathlib import Path + import pytest from autoskillit.core import Severity +from autoskillit.recipe.io import load_recipe from autoskillit.recipe.registry import run_semantic_rules from autoskillit.recipe.schema import Recipe, RecipeStep @@ -21,6 +26,77 @@ def _make_recipe(steps: dict[str, RecipeStep]) -> Recipe: ) +def _load_resolved_loop_recipe( + tmp_path: Path, + *, + output_dir: str, + temp_root: str, + prefer_json: bool, +) -> Recipe: + yaml_path = tmp_path / "loop.yaml" + parsed = { + "name": "resolved-loop-artifact-scope", + "description": "Resolved loop artifact scope fixture.", + "version": "0.2.0", + "kitchen_rules": ["test"], + "steps": { + "start": { + "tool": "run_cmd", + "with": {"cmd": "echo start"}, + "on_success": "review", + }, + "review": { + "tool": "run_skill", + "with": { + "skill_command": "/autoskillit:review-approach branch", + "output_dir": output_dir, + }, + "capture": {"verdict": "${{ result.verdict }}"}, + "on_success": "check_loop", + }, + "check_loop": { + "tool": "run_cmd", + "with": {"cmd": "echo loop"}, + "on_success": "review", + }, + }, + } + yaml_path.write_text( + f""" +name: resolved-loop-artifact-scope +description: Resolved loop artifact scope fixture. +version: 0.2.0 +kitchen_rules: [test] +steps: + start: + tool: run_cmd + with: + cmd: echo start + on_success: review + review: + tool: run_skill + with: + skill_command: /autoskillit:review-approach branch + output_dir: "{output_dir}" + capture: + verdict: "${{{{ result.verdict }}}}" + on_success: check_loop + check_loop: + tool: run_cmd + with: + cmd: echo loop + on_success: review +""", + encoding="utf-8", + ) + if prefer_json: + json_path = yaml_path.with_suffix(".json") + json_path.write_text(json.dumps(parsed), encoding="utf-8") + newer = yaml_path.stat().st_mtime_ns + 1_000_000 + os.utime(json_path, ns=(newer, newer)) + return load_recipe(yaml_path, temp_dir_relpath=temp_root) + + def test_rule_fires_when_run_skill_in_cycle_has_static_output_dir() -> None: """Rule fires ERROR when run_skill step in a cycle has a static output_dir.""" recipe = _make_recipe( @@ -33,7 +109,7 @@ def test_rule_fires_when_run_skill_in_cycle_has_static_output_dir() -> None: "review": RecipeStep( tool="run_skill", with_args={ - "skill_command": "/autoskillit:review-pr branch", + "skill_command": "/autoskillit:review-approach branch", "output_dir": "{{AUTOSKILLIT_TEMP}}/review-pr", }, capture={"verdict": "${{ result.verdict }}"}, @@ -67,7 +143,7 @@ def test_rule_does_not_fire_when_output_dir_is_iter_scoped() -> None: "review": RecipeStep( tool="run_skill", with_args={ - "skill_command": "/autoskillit:review-pr branch", + "skill_command": "/autoskillit:review-approach branch", "output_dir": "{{AUTOSKILLIT_TEMP}}/review-pr/iter_${{ context.loop_count }}", }, capture={"verdict": "${{ result.verdict }}"}, @@ -87,6 +163,52 @@ def test_rule_does_not_fire_when_output_dir_is_iter_scoped() -> None: assert len(loop_findings) == 0 +@pytest.mark.parametrize("prefer_json", [False, True], ids=["yaml", "json"]) +@pytest.mark.parametrize("temp_root", [".autoskillit/temp", "/custom temp root"]) +def test_rule_uses_bound_origin_after_temp_root_resolution( + tmp_path: Path, + *, + prefer_json: bool, + temp_root: str, +) -> None: + recipe = _load_resolved_loop_recipe( + tmp_path, + output_dir="{{AUTOSKILLIT_TEMP}}/review-pr", + temp_root=temp_root, + prefer_json=prefer_json, + ) + + finding = next( + finding + for finding in run_semantic_rules(recipe) + if finding.rule == "loop-iterated-step-requires-iteration-scoped-output" + ) + + assert f"{temp_root}/review-pr" in finding.message + assert "{{AUTOSKILLIT_TEMP}}" not in finding.message + + +@pytest.mark.parametrize("prefer_json", [False, True], ids=["yaml", "json"]) +def test_resolved_iteration_dependency_survives_yaml_and_json_loading( + tmp_path: Path, + *, + prefer_json: bool, +) -> None: + recipe = _load_resolved_loop_recipe( + tmp_path, + output_dir=("{{AUTOSKILLIT_TEMP}}/review-pr/iter_${{ context.review_loop_count }}"), + temp_root="/custom temp root", + prefer_json=prefer_json, + ) + + findings = run_semantic_rules(recipe) + + assert not any( + finding.rule == "loop-iterated-step-requires-iteration-scoped-output" + for finding in findings + ) + + def test_rule_does_not_fire_for_run_python_in_cycle_with_static_output_dir() -> None: """Rule does NOT fire for run_python steps (run_python is not in SKILL_TOOLS).""" recipe = _make_recipe( diff --git a/tests/recipe/test_rules_tools.py b/tests/recipe/test_rules_tools.py index d5170607b..b83fe5811 100644 --- a/tests/recipe/test_rules_tools.py +++ b/tests/recipe/test_rules_tools.py @@ -266,9 +266,11 @@ def test_wait_for_ci_rejects_poll_interval() -> None: def test_rules_tools_batch_cleanup_clones_accepts_all_owners_param() -> None: """T20 — batch_cleanup_clones with all_owners param must not trigger dead-with-param.""" - from autoskillit.recipe.rules.rules_tools import _TOOL_PARAMS + from autoskillit.core import get_tool_def - assert "all_owners" in _TOOL_PARAMS["batch_cleanup_clones"] + tool_def = get_tool_def("batch_cleanup_clones") + assert tool_def is not None + assert "all_owners" in tool_def.param_set recipe = _make_recipe_with_args( "batch_cleanup_clones", @@ -332,7 +334,7 @@ def test_rebase_then_push_with_force_true_passes_validation() -> None: # --------------------------------------------------------------------------- -# _TOOL_PARAMS sync test (T8) +# Canonical tool registry sync test (T8) # --------------------------------------------------------------------------- _SERVER_TOOL_MODULES = [ @@ -341,11 +343,14 @@ def test_rebase_then_push_with_force_true_passes_validation() -> None: "autoskillit.server.tools.tools_ci_merge_queue", "autoskillit.server.tools.tools_clone", "autoskillit.server.tools.tools_execution", + "autoskillit.server.tools.tools_fleet_dispatch", + "autoskillit.server.tools.tools_fleet_reset", "autoskillit.server.tools.tools_git", "autoskillit.server.tools.tools_recipe", "autoskillit.server.tools.tools_status", "autoskillit.server.tools.tools_github", "autoskillit.server.tools.tools_issue_headless", + "autoskillit.server.tools.tools_issue_composite", "autoskillit.server.tools.tools_issue_labels", "autoskillit.server.tools.tools_pr_ops", "autoskillit.server.tools.tools_workspace", @@ -369,13 +374,14 @@ def _build_handler_map() -> dict[str, object]: def test_tool_params_matches_mcp_handler_signatures() -> None: - """T8: _TOOL_PARAMS keys match actual MCP handler signatures — drift fails CI.""" - from autoskillit.recipe.rules.rules_tools import _TOOL_PARAMS + """T8: canonical tool metadata matches MCP handler signatures — drift fails CI.""" + from autoskillit.core import TOOL_REGISTRY handler_map = _build_handler_map() mismatches: list[str] = [] - for tool_name, expected_params in _TOOL_PARAMS.items(): + for tool_name, tool_def in TOOL_REGISTRY.items(): + expected_params = tool_def.handler_param_set if tool_name not in handler_map: mismatches.append(f"{tool_name}: handler not found in server modules") continue @@ -386,13 +392,13 @@ def test_tool_params_matches_mcp_handler_signatures() -> None: missing = expected_params - actual_params extra = actual_params - expected_params mismatches.append( - f"{tool_name}: _TOOL_PARAMS={sorted(expected_params)} " + f"{tool_name}: TOOL_REGISTRY={sorted(expected_params)} " f"handler={sorted(actual_params)} " f"(missing={sorted(missing)}, extra={sorted(extra)})" ) assert not mismatches, ( - "_TOOL_PARAMS is out of sync with MCP handler signatures:\n" + "\n".join(mismatches) + "TOOL_REGISTRY is out of sync with MCP handler signatures:\n" + "\n".join(mismatches) ) diff --git a/tests/recipe/test_rules_work_dir_misplacement.py b/tests/recipe/test_rules_work_dir_misplacement.py index e44c1680b..df9c0158e 100644 --- a/tests/recipe/test_rules_work_dir_misplacement.py +++ b/tests/recipe/test_rules_work_dir_misplacement.py @@ -31,6 +31,15 @@ def test_work_dir_misplacement_rule_fires(): "done": {"action": "stop", "message": "Done"}, } ) + recipe.steps["step"].with_args = { + "callable": "autoskillit.smoke_utils.annotate_pr_diff", + "args": { + "pr_number": "1", + "cwd": "/path", + "output_dir": ".autoskillit/temp/test", + "work_dir": "/path", + }, + } findings = run_semantic_rules(recipe) misplaced = [f for f in findings if f.rule == "work-dir-arg-misplacement"] assert len(misplaced) == 1 @@ -52,6 +61,10 @@ def test_work_dir_misplacement_rule_silent_for_valid_callable(): "done": {"action": "stop", "message": "Done"}, } ) + recipe.steps["step"].with_args = { + "callable": "autoskillit.recipe._cmd_rpc.review_path_rebase", + "args": {"work_dir": "/path", "base_branch": "main"}, + } findings = run_semantic_rules(recipe) misplaced = [f for f in findings if f.rule == "work-dir-arg-misplacement"] assert len(misplaced) == 0 From f1be0722d936d067d35f1679d594a0b470cc9d8d Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 24 Jul 2026 10:53:14 -0700 Subject: [PATCH 07/89] test(recipe): synchronize audit admission coverage --- .autoskillit/test-filter-manifest.yaml | 1 + tests/_test_filter.py | 6 ++++-- tests/test_test_filter_core_cascade.py | 23 +++++++++++++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/.autoskillit/test-filter-manifest.yaml b/.autoskillit/test-filter-manifest.yaml index a654e1c88..66e0e7380 100644 --- a/.autoskillit/test-filter-manifest.yaml +++ b/.autoskillit/test-filter-manifest.yaml @@ -56,6 +56,7 @@ src/autoskillit/recipe/skill_contracts.yaml: - skills/ - recipe/ - execution/ + - server/ src/autoskillit/recipe/block_budgets.yaml: - recipe/ diff --git a/tests/_test_filter.py b/tests/_test_filter.py index 28b2a1eb0..591b4572b 100644 --- a/tests/_test_filter.py +++ b/tests/_test_filter.py @@ -281,12 +281,13 @@ class ImportContext(enum.StrEnum): "_delivery_bounds": frozenset({"core", "execution", "server"}), "_type_audit_cycle": frozenset({"core", "recipe", "server"}), "_type_recipe_binding": frozenset({"core", "recipe", "server"}), + "_type_recipe_execution": frozenset({"core", "pipeline", "server"}), "_type_closure_report": frozenset({"core"}), "context_admission": frozenset({"core"}), "audit_cycle_verifier": frozenset({"core", "recipe", "server"}), "tool_registry": frozenset({"core", "recipe", "server"}), - "closure_hashing": frozenset({"core"}), - "path_containment": frozenset({"core"}), + "closure_hashing": frozenset({"core", "recipe"}), + "path_containment": frozenset({"core", "recipe"}), "closure_verifier": frozenset({"core", "execution"}), } @@ -795,6 +796,7 @@ class ImportContext(enum.StrEnum): "migration", # Server file-level entries importing autoskillit.recipe: "server/test_serve_idempotence.py", + "server/test_open_kitchen_deferred_recall.py", "server/test_backend_ingredient_injection.py", "server/test_factory.py", "server/test_tools_dispatch_validation.py", diff --git a/tests/test_test_filter_core_cascade.py b/tests/test_test_filter_core_cascade.py index cca685867..549178131 100644 --- a/tests/test_test_filter_core_cascade.py +++ b/tests/test_test_filter_core_cascade.py @@ -7,6 +7,8 @@ import pytest import tests._test_filter as tf_mod +from autoskillit._test_filter import apply_manifest as manifest_apply_manifest +from autoskillit._test_filter import load_manifest as manifest_load_manifest from tests._test_filter import ( _CORE_UNIVERSAL_MODULES, MODULE_CASCADE_CORE, @@ -16,6 +18,19 @@ pytestmark = [pytest.mark.medium] +_PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +def test_skill_contract_changes_select_server_admission() -> None: + manifest = manifest_load_manifest(_PROJECT_ROOT / ".autoskillit/test-filter-manifest.yaml") + assert manifest is not None + result = manifest_apply_manifest( + ["src/autoskillit/recipe/skill_contracts.yaml"], + manifest, + ) + assert result is not None + assert {"contracts/", "skills/", "recipe/", "execution/", "server/"} <= result + class TestCoreUniversalModules: """REQ-CORE-001: _CORE_UNIVERSAL_MODULES must exist and contain the right stems.""" @@ -121,8 +136,11 @@ def test_all_entries_present(self) -> None: "git_remote", "bash_write_targets", "_type_audit_cycle", + "_type_recipe_binding", + "_type_recipe_execution", "_type_closure_report", "audit_cycle_verifier", + "tool_registry", "closure_hashing", "path_containment", "closure_verifier", @@ -136,6 +154,11 @@ def test_audit_cycle_cascade(self) -> None: assert MODULE_CASCADE_CORE["_type_audit_cycle"] == expected assert MODULE_CASCADE_CORE["audit_cycle_verifier"] == expected + def test_recipe_execution_cascade(self) -> None: + assert MODULE_CASCADE_CORE["_type_recipe_execution"] == frozenset( + {"core", "pipeline", "server"} + ) + def test_type_resume_cascade(self) -> None: assert MODULE_CASCADE_CORE["_type_resume"] == frozenset( {"core", "cli", "execution", "fleet"} From b87ed2687591121cda0235167a60f0f2d41c7465 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sat, 25 Jul 2026 08:17:48 -0700 Subject: [PATCH 08/89] fix(review): reject report-only audit cycles without reading --- src/autoskillit/core/audit_cycle_verifier.py | 7 ++++- tests/core/test_inventory_admission.py | 28 ++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/autoskillit/core/audit_cycle_verifier.py b/src/autoskillit/core/audit_cycle_verifier.py index 8a7a993ff..66b54e314 100644 --- a/src/autoskillit/core/audit_cycle_verifier.py +++ b/src/autoskillit/core/audit_cycle_verifier.py @@ -610,10 +610,15 @@ def evaluate_paths( ) -> InventoryAdmissionDecision: evaluator = InventoryAdmissionEvaluator() if authority_path is None: + if report_path is not None: + return _reject( + AdmissionReason.REPORT_WITHOUT_AUTHORITY, + "a disposition report cannot activate without authority", + ) return evaluator.evaluate( authority=None, trusted_head=trusted_head, - report=None if report_path is None else self.load_report(report_path), + report=None, expected_generation=expected_generation, expected_plan_set_id=expected_plan_set_id, expected_scope_id=expected_scope_id, diff --git a/tests/core/test_inventory_admission.py b/tests/core/test_inventory_admission.py index 47640927e..fadb14d56 100644 --- a/tests/core/test_inventory_admission.py +++ b/tests/core/test_inventory_admission.py @@ -317,6 +317,34 @@ def test_satisfied_row_must_name_current_audit_round(tmp_path: Path) -> None: assert decision.reason is AdmissionReason.SATISFIED_ROUND_MISMATCH +def test_report_without_authority_rejects_without_read(tmp_path: Path) -> None: + reads: list[Path] = [] + + def reader( + path: str | Path, + root: str | Path, + *, + max_size_bytes: int, + ) -> tuple[Path, bytes]: + del root, max_size_bytes + reads.append(Path(path)) + raise AssertionError(f"unexpected report read: {path}") + + decision = AuditCycleVerifier(tmp_path, reader=reader).evaluate_paths( + authority_path=None, + report_path=tmp_path.parent / "escaped-report.json", + trusted_head=None, + current_plan_path=tmp_path / "plan.md", + expected_generation="generation-1", + expected_plan_set_id="plans-1", + expected_scope_id="scope-1", + expected_part_id="part-a", + ) + assert decision.status is AdmissionStatus.REJECT + assert decision.reason is AdmissionReason.REPORT_WITHOUT_AUTHORITY + assert reads == [] + + def test_trusted_go_successor_omits_without_inventory_read(tmp_path: Path) -> None: authority = _authority( tmp_path, From d54c839afea4057ded9881c6b9607471f7b1c2a7 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sat, 25 Jul 2026 08:19:22 -0700 Subject: [PATCH 09/89] test(review): cover post-open containment metadata drift --- tests/core/test_path_containment.py | 69 +++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/tests/core/test_path_containment.py b/tests/core/test_path_containment.py index 4cb37a404..6744d9b26 100644 --- a/tests/core/test_path_containment.py +++ b/tests/core/test_path_containment.py @@ -3,18 +3,34 @@ from __future__ import annotations import os +from pathlib import Path +from types import SimpleNamespace import pytest from autoskillit.core.path_containment import ( ContainmentError, check_metadata_stable, + read_stable_contained_bytes, resolve_contained_path, ) pytestmark = [pytest.mark.layer("core"), pytest.mark.small] +def _with_metadata_drift(metadata: os.stat_result, field: str) -> SimpleNamespace: + values = { + "st_dev": metadata.st_dev, + "st_ino": metadata.st_ino, + "st_mode": metadata.st_mode, + "st_mtime_ns": metadata.st_mtime_ns, + "st_nlink": metadata.st_nlink, + "st_size": metadata.st_size, + } + values[field] = values[field] ^ 0o100 if field == "st_mode" else values[field] + 1 + return SimpleNamespace(**values) + + class TestResolveContainedPath: def test_accepts_normal_file(self, tmp_path) -> None: allowed = tmp_path / "root" @@ -102,3 +118,56 @@ def test_detects_mtime_change(self, tmp_path) -> None: post = os.stat(f) with pytest.raises(ContainmentError): check_metadata_stable(f, pre, post) + + +class TestReadStableContainedBytes: + @pytest.mark.parametrize("field", ("st_dev", "st_mode", "st_nlink")) + def test_detects_open_file_metadata_drift( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + field: str, + ) -> None: + artifact = tmp_path / "artifact.txt" + artifact.write_text("stable") + real_fstat = os.fstat + calls = 0 + + def drifting_fstat(fd: int): + nonlocal calls + metadata = real_fstat(fd) + calls += 1 + return _with_metadata_drift(metadata, field) if calls == 2 else metadata + + monkeypatch.setattr(os, "fstat", drifting_fstat) + + with pytest.raises(ContainmentError, match="TOCTOU"): + read_stable_contained_bytes(artifact, tmp_path) + assert calls == 2 + + @pytest.mark.parametrize("field", ("st_dev", "st_mode", "st_nlink")) + def test_detects_path_metadata_drift_after_read( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + field: str, + ) -> None: + artifact = tmp_path / "artifact.txt" + artifact.write_text("stable") + real_stat = Path.stat + artifact_stat_calls = 0 + + def drifting_stat(path: Path, *args, **kwargs): + nonlocal artifact_stat_calls + metadata = real_stat(path, *args, **kwargs) + if path == artifact: + artifact_stat_calls += 1 + if artifact_stat_calls == 3: + return _with_metadata_drift(metadata, field) + return metadata + + monkeypatch.setattr(Path, "stat", drifting_stat) + + with pytest.raises(ContainmentError, match="TOCTOU"): + read_stable_contained_bytes(artifact, tmp_path) + assert artifact_stat_calls == 3 From 1736bde4af406d7cae48811a0e26386906c15495 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sat, 25 Jul 2026 08:20:25 -0700 Subject: [PATCH 10/89] fix(review): enforce admission decision state matrix --- .../core/types/_type_audit_cycle.py | 28 ++++++++++-- tests/core/test_inventory_admission.py | 44 +++++++++++++++++++ 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/src/autoskillit/core/types/_type_audit_cycle.py b/src/autoskillit/core/types/_type_audit_cycle.py index c8e42ad72..97886e5ca 100644 --- a/src/autoskillit/core/types/_type_audit_cycle.py +++ b/src/autoskillit/core/types/_type_audit_cycle.py @@ -675,10 +675,30 @@ def __post_init__(self) -> None: raise ValueError("InventoryAdmissionDecision.status must be an AdmissionStatus") if not isinstance(self.reason, AdmissionReason): raise ValueError("InventoryAdmissionDecision.reason must be an AdmissionReason") - if self.status is AdmissionStatus.PASS and self.reason is not AdmissionReason.ADMITTED: - raise ValueError("PASS admission requires the admitted reason") - if self.status is AdmissionStatus.OMIT and self.dispositions: - raise ValueError("OMIT admission cannot carry disposition rows") + omit_reasons = { + AdmissionReason.NO_AUTHORITY, + AdmissionReason.TRUSTED_GO, + AdmissionReason.TRUSTED_GO_SUCCESSOR, + } + if self.status is AdmissionStatus.OMIT: + if self.reason not in omit_reasons: + raise ValueError("OMIT admission requires an omission reason") + if self.dispositions or self.details: + raise ValueError("OMIT admission cannot carry payload") + elif self.status is AdmissionStatus.PASS: + if self.reason is not AdmissionReason.ADMITTED: + raise ValueError("PASS admission requires the admitted reason") + if self.details: + raise ValueError("PASS admission cannot carry rejection details") + else: + if self.reason is AdmissionReason.ADMITTED or self.reason in omit_reasons: + raise ValueError("REJECT admission requires a rejection reason") + if self.dispositions: + raise ValueError("REJECT admission cannot carry disposition rows") + if not self.details or any( + not isinstance(detail, str) or not detail.strip() for detail in self.details + ): + raise ValueError("REJECT admission requires non-empty rejection details") @classmethod def omit(cls, reason: AdmissionReason) -> Self: diff --git a/tests/core/test_inventory_admission.py b/tests/core/test_inventory_admission.py index fadb14d56..8f8521d6b 100644 --- a/tests/core/test_inventory_admission.py +++ b/tests/core/test_inventory_admission.py @@ -206,6 +206,50 @@ def test_report_without_authority_rejects(tmp_path: Path) -> None: assert decision.reason is AdmissionReason.REPORT_WITHOUT_AUTHORITY +@pytest.mark.parametrize( + "decision", + ( + lambda: InventoryAdmissionDecision( + status=AdmissionStatus.REJECT, + reason=AdmissionReason.ADMITTED, + details=("invalid",), + ), + lambda: InventoryAdmissionDecision( + status=AdmissionStatus.REJECT, + reason=AdmissionReason.NO_AUTHORITY, + details=("invalid",), + ), + lambda: InventoryAdmissionDecision( + status=AdmissionStatus.REJECT, + reason=AdmissionReason.INTERNAL_ERROR, + ), + lambda: InventoryAdmissionDecision( + status=AdmissionStatus.REJECT, + reason=AdmissionReason.INTERNAL_ERROR, + dispositions=_dispositions(), + details=("invalid",), + ), + lambda: InventoryAdmissionDecision( + status=AdmissionStatus.PASS, + reason=AdmissionReason.ADMITTED, + details=("invalid",), + ), + lambda: InventoryAdmissionDecision( + status=AdmissionStatus.OMIT, + reason=AdmissionReason.INTERNAL_ERROR, + ), + lambda: InventoryAdmissionDecision( + status=AdmissionStatus.OMIT, + reason=AdmissionReason.NO_AUTHORITY, + details=("invalid",), + ), + ), +) +def test_admission_decision_rejects_contradictory_status_matrix(decision) -> None: + with pytest.raises(ValueError): + decision() + + def test_complete_two_disposition_truth_table_passes(tmp_path: Path) -> None: authority = _authority(tmp_path) report = _report(tmp_path, authority) From 7749b49cdb6dc811b08d3f5a15ba934a47058080 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sat, 25 Jul 2026 08:21:40 -0700 Subject: [PATCH 11/89] fix(review): preserve explicit empty contract manifests --- src/autoskillit/recipe/_binding.py | 4 +-- tests/recipe/test_skill_invocation_binding.py | 27 +++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/autoskillit/recipe/_binding.py b/src/autoskillit/recipe/_binding.py index 3b8c8b508..758f8c72f 100644 --- a/src/autoskillit/recipe/_binding.py +++ b/src/autoskillit/recipe/_binding.py @@ -512,7 +512,7 @@ def bind_step_invocation( return BoundStepInvocation(step_name, tool_name, mode, None, (), (), (failure,)) failures: list[BindingFailure] = [] - active_manifest = manifest or load_bundled_manifest() + active_manifest = manifest if manifest is not None else load_bundled_manifest() authorable_params = tool_def.param_set if tool_name == "run_python": callable_name = effective_with.get("callable") @@ -768,7 +768,7 @@ def bind_recipe( for name, ingredient in (recipe.ingredients or {}).items() if getattr(ingredient, "hidden", False) ) - active_manifest = manifest or load_bundled_manifest() + active_manifest = manifest if manifest is not None else load_bundled_manifest() invocations = { step_name: bind_step_invocation( step_name, diff --git a/tests/recipe/test_skill_invocation_binding.py b/tests/recipe/test_skill_invocation_binding.py index cb5ce66ea..0a452b2bd 100644 --- a/tests/recipe/test_skill_invocation_binding.py +++ b/tests/recipe/test_skill_invocation_binding.py @@ -5,9 +5,11 @@ import json import os from pathlib import Path +from types import SimpleNamespace import pytest +import autoskillit.recipe._binding as binding_module from autoskillit.core import ( BindingFailureCode, BindingMode, @@ -99,6 +101,31 @@ def test_structured_binding_uses_contract_order_not_mapping_order() -> None: ) +def test_explicit_empty_manifest_is_not_replaced( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def unexpected_bundled_manifest_load() -> dict[str, object]: + raise AssertionError("explicit manifest must remain authoritative") + + monkeypatch.setattr( + binding_module, + "load_bundled_manifest", + unexpected_bundled_manifest_load, + ) + step = _step(_required_inputs()) + + invocation = bind_step_invocation("verify", step, manifest={}) + projection = bind_recipe( + SimpleNamespace(ingredients={}, steps={"verify": step}), + manifest={}, + ) + + assert BindingFailureCode.UNKNOWN_SKILL in {failure.code for failure in invocation.failures} + projected = projection.for_step("verify") + assert projected is not None + assert BindingFailureCode.UNKNOWN_SKILL in {failure.code for failure in projected.failures} + + def test_single_inline_input_preserves_multiword_prose_tail() -> None: manifest: dict[str, object] = { "skills": { From 8702768118ca09c8fc1946d32ccd47a858f4b53a Mon Sep 17 00:00:00 2001 From: Trecek Date: Sat, 25 Jul 2026 08:22:55 -0700 Subject: [PATCH 12/89] fix(review): remove mutable skill contract input shim --- src/autoskillit/recipe/_contracts_types.py | 6 ------ tests/execution/test_headless_enum_recovery.py | 2 +- tests/execution/test_outcome_invariants.py | 2 +- tests/recipe/test_optional_capture_guard_rule.py | 6 +++--- tests/recipe/test_rules_contracts.py | 12 ++++++------ tests/recipe/test_rules_tools.py | 2 +- 6 files changed, 12 insertions(+), 18 deletions(-) diff --git a/src/autoskillit/recipe/_contracts_types.py b/src/autoskillit/recipe/_contracts_types.py index 62300f946..9a2619475 100644 --- a/src/autoskillit/recipe/_contracts_types.py +++ b/src/autoskillit/recipe/_contracts_types.py @@ -62,12 +62,6 @@ class SkillContract: success_qualifiers: list[SuccessQualifierEntry] = dataclasses.field(default_factory=list) input_preflight: str | None = None - def __post_init__(self) -> None: - # Tests and project integrations may still construct contracts from a - # list. Canonicalize once at the boundary; consumers always observe an - # explicit immutable declaration order. - self.inputs = tuple(self.inputs) - @dataclasses.dataclass(frozen=True, slots=True) class ToolOutputFieldSpec: diff --git a/tests/execution/test_headless_enum_recovery.py b/tests/execution/test_headless_enum_recovery.py index 25c6a6b0e..2d79a4287 100644 --- a/tests/execution/test_headless_enum_recovery.py +++ b/tests/execution/test_headless_enum_recovery.py @@ -36,7 +36,7 @@ def _make_plan_contract( ) -> SkillContract: """Build a make-plan-shaped SkillContract (verdict enum bound to a conditional write).""" return SkillContract( - inputs=[], + inputs=(), outputs=[ SkillOutput( name="verdict", diff --git a/tests/execution/test_outcome_invariants.py b/tests/execution/test_outcome_invariants.py index 64b045ac5..fe868d032 100644 --- a/tests/execution/test_outcome_invariants.py +++ b/tests/execution/test_outcome_invariants.py @@ -297,7 +297,7 @@ class TestParseOutcomeFields: def _contract(self) -> SkillContract: return SkillContract( - inputs=[], + inputs=(), outputs=[ SkillOutput(name="verdict", type="string"), SkillOutput(name="accept_count", type="integer"), diff --git a/tests/recipe/test_optional_capture_guard_rule.py b/tests/recipe/test_optional_capture_guard_rule.py index a29b57010..be702ebf0 100644 --- a/tests/recipe/test_optional_capture_guard_rule.py +++ b/tests/recipe/test_optional_capture_guard_rule.py @@ -181,7 +181,7 @@ def test_identify_optional_output_fields_extracts_from_contract() -> None: # Pattern with optional group matching a known output name — field is optional contract = SkillContract( - inputs=[], + inputs=(), outputs=[SkillOutput(name="pr_url", type="string")], expected_output_patterns=[r"pr_url[ \t]*=[ \t]*(https://github\.com/.*/pull/\d+)?"], ) @@ -189,7 +189,7 @@ def test_identify_optional_output_fields_extracts_from_contract() -> None: # Pattern without optional group — field is mandatory, not returned contract_mandatory = SkillContract( - inputs=[], + inputs=(), outputs=[SkillOutput(name="pr_url", type="string")], expected_output_patterns=[r"pr_url[ \t]*=[ \t]*https://github\.com/.+"], ) @@ -197,7 +197,7 @@ def test_identify_optional_output_fields_extracts_from_contract() -> None: # Pattern whose leading identifier does not match any output name — skipped contract_no_match = SkillContract( - inputs=[], + inputs=(), outputs=[SkillOutput(name="category_summary", type="string")], expected_output_patterns=[r"pr_url[ \t]*=[ \t]*(https://github\.com/.*/pull/\d+)?"], ) diff --git a/tests/recipe/test_rules_contracts.py b/tests/recipe/test_rules_contracts.py index f63c09650..fc091bc5b 100644 --- a/tests/recipe/test_rules_contracts.py +++ b/tests/recipe/test_rules_contracts.py @@ -126,7 +126,7 @@ def _make_contract( write_expected_when: list[str] | None = None, ) -> SkillContract: return SkillContract( - inputs=[], + inputs=(), outputs=[], write_behavior=write_behavior, write_expected_when=write_expected_when or [], @@ -369,7 +369,7 @@ def test_matching_result_fields_no_finding(self) -> None: "/autoskillit:planner-generate-phases {{AUTOSKILLIT_TEMP}}/planner/analysis.json" ) contract = SkillContract( - inputs=[], + inputs=(), outputs=[], result_fields=[ ResultFieldSpec(name="id", type="str", required=True), @@ -391,7 +391,7 @@ def test_extra_required_field_fires_error(self) -> None: "/autoskillit:planner-generate-phases {{AUTOSKILLIT_TEMP}}/planner/analysis.json" ) contract = SkillContract( - inputs=[], + inputs=(), outputs=[], result_fields=[ ResultFieldSpec(name="id", type="str", required=True), @@ -417,7 +417,7 @@ def test_missing_required_field_fires_error(self) -> None: "/autoskillit:planner-generate-phases {{AUTOSKILLIT_TEMP}}/planner/analysis.json" ) contract = SkillContract( - inputs=[], + inputs=(), outputs=[], result_fields=[ ResultFieldSpec(name="id", type="str", required=True), @@ -440,7 +440,7 @@ def test_optional_extra_field_ignored(self) -> None: "/autoskillit:planner-generate-phases {{AUTOSKILLIT_TEMP}}/planner/analysis.json" ) contract = SkillContract( - inputs=[], + inputs=(), outputs=[], result_fields=[ ResultFieldSpec(name="id", type="str", required=True), @@ -708,7 +708,7 @@ def _make_write_contract( read_only: bool = False, ) -> SkillContract: return SkillContract( - inputs=[], + inputs=(), outputs=[], write_behavior=write_behavior, write_expected_when=["verdict\\s*=\\s*\\w+"] if write_behavior == "conditional" else [], diff --git a/tests/recipe/test_rules_tools.py b/tests/recipe/test_rules_tools.py index b83fe5811..ea35a93f3 100644 --- a/tests/recipe/test_rules_tools.py +++ b/tests/recipe/test_rules_tools.py @@ -554,7 +554,7 @@ def _patch_contract_for_push_rule(write_behavior: str, read_only: bool = False): from unittest.mock import patch as _patch contract = SkillContract( - inputs=[], + inputs=(), outputs=[], write_behavior=write_behavior, write_expected_when=["pat"] if write_behavior == "conditional" else [], From 9434e074e2f07282d15074194c77ff7b202b44a5 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sat, 25 Jul 2026 08:26:12 -0700 Subject: [PATCH 13/89] fix(review): enforce runtime skill input contracts --- src/autoskillit/recipe/_binding.py | 18 +--- src/autoskillit/recipe/_contracts_types.py | 19 ++++ src/autoskillit/server/_recipe_execution.py | 18 ++++ .../test_audit_cycle_delivery_integration.py | 101 ++++++++++++++++-- 4 files changed, 133 insertions(+), 23 deletions(-) diff --git a/src/autoskillit/recipe/_binding.py b/src/autoskillit/recipe/_binding.py index 758f8c72f..0ee1b4ceb 100644 --- a/src/autoskillit/recipe/_binding.py +++ b/src/autoskillit/recipe/_binding.py @@ -172,23 +172,7 @@ def _wire_value_is_valid(value: object, param: ToolParamDef) -> bool: def _skill_value_is_valid(value: BoundScalar, input_def: SkillInput) -> bool: - normalized = input_def.type - if normalized in { - "str", - "string", - "optional_string", - "file_path", - "file_path_list", - "directory_path", - }: - return isinstance(value, str) - if normalized == "integer": - return isinstance(value, int) and not isinstance(value, bool) - if normalized in {"number", "float"}: - return isinstance(value, (int, float)) and not isinstance(value, bool) - if normalized in {"boolean", "bool"}: - return isinstance(value, bool) - return False + return input_def.accepts(value) def _resolve_hidden_value( diff --git a/src/autoskillit/recipe/_contracts_types.py b/src/autoskillit/recipe/_contracts_types.py index 9a2619475..38ffd0662 100644 --- a/src/autoskillit/recipe/_contracts_types.py +++ b/src/autoskillit/recipe/_contracts_types.py @@ -20,6 +20,25 @@ class SkillInput: recommended: bool = False nullable: bool = True + def accepts(self, value: object) -> bool: + normalized = self.type + if normalized in { + "str", + "string", + "optional_string", + "file_path", + "file_path_list", + "directory_path", + }: + return isinstance(value, str) + if normalized == "integer": + return isinstance(value, int) and not isinstance(value, bool) + if normalized in {"number", "float"}: + return isinstance(value, (int, float)) and not isinstance(value, bool) + if normalized in {"boolean", "bool"}: + return isinstance(value, bool) + return False + @dataclasses.dataclass class SkillOutput: diff --git a/src/autoskillit/server/_recipe_execution.py b/src/autoskillit/server/_recipe_execution.py index d08025e14..9a972d699 100644 --- a/src/autoskillit/server/_recipe_execution.py +++ b/src/autoskillit/server/_recipe_execution.py @@ -462,6 +462,13 @@ def bind_attested_runtime_invocation( "recipe_execution_skill_mismatch", "runtime skill identity differs from the compiled template", ) + contract = get_skill_contract(invocation.skill_name or "", load_bundled_manifest()) + if contract is None: + raise RecipeExecutionAdmissionError( + "recipe_execution_contract_unavailable", + "the compiled skill contract is unavailable at runtime", + ) + contract_inputs = {input_def.name: input_def for input_def in contract.inputs} supplied = dict(skill_inputs or {}) if any( not isinstance(value, (str, int, float, bool)) or value is None @@ -484,6 +491,17 @@ def bind_attested_runtime_invocation( if value.state is not BoundValueState.PRESENT: continue actual = supplied[value.name] + input_def = contract_inputs.get(value.name) + if input_def is None: + raise RecipeExecutionAdmissionError( + "recipe_execution_contract_mismatch", + f"compiled skill input {value.name!r} is absent from the runtime contract", + ) + if not input_def.accepts(actual): + raise RecipeExecutionAdmissionError( + "recipe_execution_input_type", + f"runtime skill input {value.name!r} expects {input_def.type!r}", + ) if not _is_dynamic(value) and actual != value.effective_value: raise RecipeExecutionAdmissionError( "recipe_execution_static_input_mismatch", diff --git a/tests/server/test_audit_cycle_delivery_integration.py b/tests/server/test_audit_cycle_delivery_integration.py index b79e47b92..d3c0752ca 100644 --- a/tests/server/test_audit_cycle_delivery_integration.py +++ b/tests/server/test_audit_cycle_delivery_integration.py @@ -18,6 +18,7 @@ rule, ) +import autoskillit.server._recipe_execution as recipe_execution_module from autoskillit.core import ( AdmissionReason, AdmissionStatus, @@ -49,6 +50,7 @@ AuditCycleHeadConflict, DefaultAuditCycleHeadStore, DefaultInputPreflightResolver, + RecipeExecutionAdmissionError, bind_attested_runtime_invocation, build_bound_child_prompt, build_recipe_execution_snapshot, @@ -103,8 +105,8 @@ def _projection() -> RecipeBindingProjection: dependencies=("plan_path",), ), _present("issue_url", ""), - _present("audit_cycle_path", False), - _present("plan_disposition_path", 0), + _present("audit_cycle_path", "/tmp/audit-cycle.json"), + _present("plan_disposition_path", "/tmp/plan-disposition.json"), ), ) return RecipeBindingProjection({"dry": invocation}) @@ -300,8 +302,8 @@ def test_bound_prompt_preserves_falsey_and_metacharacter_values() -> None: values = { "plan_path": "/tmp/a path/$(touch nope);x.md", "issue_url": "", - "audit_cycle_path": False, - "plan_disposition_path": 0, + "audit_cycle_path": "/tmp/audit-cycle.json", + "plan_disposition_path": "/tmp/plan-disposition.json", } bound, _ = bind_attested_runtime_invocation( installed, @@ -320,11 +322,98 @@ def test_bound_prompt_preserves_falsey_and_metacharacter_values() -> None: assert encoded["skill_inputs"] == [ {"name": "plan_path", "value": values["plan_path"]}, {"name": "issue_url", "value": ""}, - {"name": "audit_cycle_path", "value": False}, - {"name": "plan_disposition_path", "value": 0}, + {"name": "audit_cycle_path", "value": "/tmp/audit-cycle.json"}, + {"name": "plan_disposition_path", "value": "/tmp/plan-disposition.json"}, ] +@pytest.mark.parametrize( + ("invalid_name", "invalid_value"), + (("count", "3"), ("enabled", "false")), +) +def test_runtime_binding_rejects_declared_contract_type_mismatch( + monkeypatch: pytest.MonkeyPatch, + invalid_name: str, + invalid_value: str, +) -> None: + manifest = { + "skills": { + "typed-skill": { + "inputs": [ + {"name": "count", "type": "integer", "required": True}, + {"name": "enabled", "type": "boolean", "required": True}, + ] + } + } + } + monkeypatch.setattr( + recipe_execution_module, + "load_bundled_manifest", + lambda: manifest, + ) + invocation = BoundStepInvocation( + step_name="typed", + tool_name="run_skill", + mode=BindingMode.RECIPE, + skill_name="typed-skill", + mcp_kwargs=( + _present("skill_command", "/typed-skill"), + _present("cwd", "/tmp"), + ), + skill_inputs=( + _present( + "count", + "${{ context.count }}", + origin=BoundValueOrigin.CONTEXT, + dependencies=("count",), + ), + _present( + "enabled", + "${{ context.enabled }}", + origin=BoundValueOrigin.CONTEXT, + dependencies=("enabled",), + ), + ), + ) + snapshot = build_recipe_execution_snapshot( + recipe_name="demo", + content_hash=_HASH_A, + composite_hash=_HASH_B, + projection=RecipeBindingProjection({"typed": invocation}), + execution_id="execution-1", + ) + store = DefaultAuditCycleHeadStore() + resolver = DefaultInputPreflightResolver( + allowed_root=Path("/tmp"), + head_store=store, + ) + from autoskillit.core import InstalledRecipeExecution + + installed = InstalledRecipeExecution( + snapshot=snapshot, + runtime_binding_digests={}, + audit_cycle_heads=store, + input_preflight_resolver=resolver, + ) + values: dict[str, str | int | float | bool] = {"count": 3, "enabled": False} + values[invalid_name] = invalid_value + + with pytest.raises(RecipeExecutionAdmissionError) as exc_info: + bind_attested_runtime_invocation( + installed, + execution_id="execution-1", + step_name="typed", + template_digest=snapshot.templates["typed"].template_digest, + skill_command="/typed-skill", + skill_inputs=values, + actual_mcp_kwargs={ + "skill_command": "/typed-skill", + "cwd": "/tmp", + }, + ) + assert exc_info.value.code == "recipe_execution_input_type" + + def test_head_publication_is_monotonic_compare_and_swap(tmp_path: Path) -> None: store = DefaultAuditCycleHeadStore() first = _authority( From ca8612bd0b056a0b53a13026a0f37a694a84387d Mon Sep 17 00:00:00 2001 From: Trecek Date: Sat, 25 Jul 2026 11:29:11 -0700 Subject: [PATCH 14/89] fix: resolve post-rebase validation failures --- src/autoskillit/core/__init__.pyi | 6 +++--- src/autoskillit/server/_factory.py | 2 -- src/autoskillit/server/_recipe_execution.py | 10 +++------- src/autoskillit/server/tools/_execution_helpers.py | 2 +- src/autoskillit/server/tools/tools_execution.py | 7 +------ tests/arch/test_subpackage_isolation.py | 6 ++++-- tests/server/test_tools_run_skill_retry.py | 2 +- 7 files changed, 13 insertions(+), 22 deletions(-) diff --git a/src/autoskillit/core/__init__.pyi b/src/autoskillit/core/__init__.pyi index bd68a0bd5..e2cfcdcab 100644 --- a/src/autoskillit/core/__init__.pyi +++ b/src/autoskillit/core/__init__.pyi @@ -350,6 +350,7 @@ from .types import AdmissionEventId as AdmissionEventId from .types import AdmissionOccurrence as AdmissionOccurrence from .types import AdmissionOccurrenceId as AdmissionOccurrenceId from .types import AdmissionOccurrenceRecord as AdmissionOccurrenceRecord +from .types import AdmissionReason as AdmissionReason from .types import AdmissionReplay as AdmissionReplay from .types import AdmissionRequestId as AdmissionRequestId from .types import AdmissionReservation as AdmissionReservation @@ -357,11 +358,10 @@ from .types import AdmissionReservationId as AdmissionReservationId from .types import AdmissionReservationKey as AdmissionReservationKey from .types import AdmissionSequence as AdmissionSequence from .types import AdmissionState as AdmissionState +from .types import AdmissionStatus as AdmissionStatus from .types import AdmissionTransition as AdmissionTransition from .types import AdmissionWitness as AdmissionWitness from .types import AdmissionWitnessId as AdmissionWitnessId -from .types import AdmissionReason as AdmissionReason -from .types import AdmissionStatus as AdmissionStatus from .types import AgentInstanceId as AgentInstanceId from .types import AgentPackDef as AgentPackDef from .types import AgentSessionResult as AgentSessionResult @@ -374,10 +374,10 @@ from .types import AuditCycleAuthority as AuditCycleAuthority from .types import AuditCycleHead as AuditCycleHead from .types import AuditCycleHeadStore as AuditCycleHeadStore from .types import AuditLog as AuditLog +from .types import AuditVerdict as AuditVerdict from .types import AuthoritySourceId as AuthoritySourceId from .types import AuthorityUnavailableEffect as AuthorityUnavailableEffect from .types import AuthorityUnavailableEvent as AuthorityUnavailableEvent -from .types import AuditVerdict as AuditVerdict from .types import BackendCapabilities as BackendCapabilities from .types import BackendConventions as BackendConventions from .types import BackendEventKind as BackendEventKind diff --git a/src/autoskillit/server/_factory.py b/src/autoskillit/server/_factory.py index df7ac2405..eb142b6a8 100644 --- a/src/autoskillit/server/_factory.py +++ b/src/autoskillit/server/_factory.py @@ -17,12 +17,10 @@ from autoskillit.config import AutomationConfig from autoskillit.core import ( - MARKETPLACE_PREFIX, AuditCycleHeadStore, DirectInstall, FleetLock, InputPreflightResolver, - MarketplaceInstall, PluginSource, SkillExecutionRole, SubprocessRunner, diff --git a/src/autoskillit/server/_recipe_execution.py b/src/autoskillit/server/_recipe_execution.py index 9a972d699..826464b04 100644 --- a/src/autoskillit/server/_recipe_execution.py +++ b/src/autoskillit/server/_recipe_execution.py @@ -18,7 +18,6 @@ AuditCycleHead, AuditCycleVerifier, AuditVerdict, - BindingFailureCode, BindingMode, BoundScalar, BoundValueOrigin, @@ -555,12 +554,13 @@ def build_standalone_child_prompt( inputs require a canonical contract because their names and order otherwise cannot be validated safely. """ + if skill_inputs is None: + return skill_command with_args: dict[str, object] = { "skill_command": skill_command, "cwd": cwd, + "skill_inputs": skill_inputs, } - if skill_inputs is not None: - with_args["skill_inputs"] = skill_inputs binding = bind_step_invocation( "standalone", RecipeStep( @@ -573,14 +573,10 @@ def build_standalone_child_prompt( ) if binding.failures: failure = binding.failures[0] - if failure.code is BindingFailureCode.UNKNOWN_SKILL and skill_inputs is None: - return skill_command raise RecipeExecutionAdmissionError( f"standalone_{failure.code.value}", failure.message, ) - if skill_inputs is None: - return skill_command return build_bound_child_prompt( skill_command, binding.canonical_child_invocation, diff --git a/src/autoskillit/server/tools/_execution_helpers.py b/src/autoskillit/server/tools/_execution_helpers.py index 57d77ca4b..38bae6aa1 100644 --- a/src/autoskillit/server/tools/_execution_helpers.py +++ b/src/autoskillit/server/tools/_execution_helpers.py @@ -369,7 +369,7 @@ def deserialize_skill_contract(payload: str) -> SkillContract | None: if not isinstance(completion_required, bool): raise ValueError("completion_required must be a boolean") return SkillContract( - inputs=[SkillInput(**item) for item in data["inputs"]], + inputs=tuple(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", [])), diff --git a/src/autoskillit/server/tools/tools_execution.py b/src/autoskillit/server/tools/tools_execution.py index e1d4ef270..cca3d8941 100644 --- a/src/autoskillit/server/tools/tools_execution.py +++ b/src/autoskillit/server/tools/tools_execution.py @@ -27,7 +27,6 @@ WORKTREE_SKILLS, AdmissionStatus, BoundScalar, - ClaudeDirectoryConventions, ClosureAuthoritySpec, CodingAgentBackend, EffectiveSkillInvocationAuthority, @@ -780,11 +779,7 @@ async def run_skill( skill_command=skill_command, order_id=order_id, ).to_json() - if ( - _installed_execution is None - and not step_name - and tool_ctx.active_recipe_steps - ): + if _installed_execution is None and not step_name and tool_ctx.active_recipe_steps: _resolved, _ambiguous = _resolve_step_name_from_recipe( skill_command, tool_ctx.active_recipe_steps ) diff --git a/tests/arch/test_subpackage_isolation.py b/tests/arch/test_subpackage_isolation.py index cb72a5069..de2ef3252 100644 --- a/tests/arch/test_subpackage_isolation.py +++ b/tests/arch/test_subpackage_isolation.py @@ -1022,7 +1022,7 @@ def test_data_directories_are_not_python_packages() -> None: "durable delivery commit without introducing a second finalization path", ), "tools_kitchen.py": ( - 1660, + 1670, "REQ-CNST-010-E7: kitchen tool handlers — open_kitchen and lock_ingredients require " "inline validation helpers (_check_override_keys, _build_ingredient_key_suggestions) " "for ingredient key validation; splitting would cross import-layer boundaries; " @@ -1064,7 +1064,9 @@ def test_data_directories_are_not_python_packages() -> None: "cleanup at close_kitchen (#4293 pipeline tracker split-brain, +42 net lines)" "; envelope integration on both deferred-recall and normal open_kitchen paths: " "resolve_general_output_token_limit + BackendCapabilities isinstance guard + " - "maybe_envelope_recipe_response call (#4304 Part B, +24 net lines)", + "maybe_envelope_recipe_response call (#4304 Part B, +24 net lines)" + "; compiled recipe binding publication and execution-lifecycle cleanup across " + "recipe load, kitchen open, and kitchen close (+13 net lines)", ), "tools_execution.py": ( 1800, diff --git a/tests/server/test_tools_run_skill_retry.py b/tests/server/test_tools_run_skill_retry.py index bd4e6508d..ea515d56e 100644 --- a/tests/server/test_tools_run_skill_retry.py +++ b/tests/server/test_tools_run_skill_retry.py @@ -150,7 +150,7 @@ async def test_context_limit_result_is_actionable(self, tool_ctx_kitchen_open, m ) tool_ctx_kitchen_open.runner.push(_make_result(returncode=1)) # clone guard snapshot tool_ctx_kitchen_open.runner.push(_make_result(1, stdout, "")) - result = json.loads(await run_skill("/retry-worktree plan.md /tmp", "/tmp")) + result = json.loads(await run_skill("/investigate plan.md", "/tmp")) assert "prompt is too long" not in result["result"].lower() assert result["needs_retry"] is True From e79413ffa5c7546e1304c7207743124c708e746f Mon Sep 17 00:00:00 2001 From: Trecek Date: Sat, 25 Jul 2026 12:01:10 -0700 Subject: [PATCH 15/89] fix: avoid redundant recipe resource lookups --- src/autoskillit/recipe/_io_loading.py | 2 ++ tests/recipe/test_recipe_scripts.py | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/src/autoskillit/recipe/_io_loading.py b/src/autoskillit/recipe/_io_loading.py index 26645232a..8073c498a 100644 --- a/src/autoskillit/recipe/_io_loading.py +++ b/src/autoskillit/recipe/_io_loading.py @@ -24,6 +24,8 @@ def substitute_temp_placeholder(text: str, temp_dir_relpath: str) -> str: def substitute_scripts_placeholder(text: str) -> str: """Replace the scripts placeholder with the bundled recipe scripts path.""" + if _SCRIPTS_PLACEHOLDER not in text: + return text scripts_dir = pkg_root() / "recipes" / "scripts" return text.replace(_SCRIPTS_PLACEHOLDER, str(scripts_dir)) diff --git a/tests/recipe/test_recipe_scripts.py b/tests/recipe/test_recipe_scripts.py index 82597c088..f499728ba 100644 --- a/tests/recipe/test_recipe_scripts.py +++ b/tests/recipe/test_recipe_scripts.py @@ -47,6 +47,18 @@ def test_autoskillit_scripts_placeholder_substituted_at_load_time(): ) +def test_scripts_placeholder_absence_skips_package_lookup(monkeypatch): + """Plain recipe scalars must not resolve package resources.""" + import autoskillit.recipe._io_loading as loading + + def unexpected_pkg_root(): + raise AssertionError("pkg_root must not run without a scripts placeholder") + + monkeypatch.setattr(loading, "pkg_root", unexpected_pkg_root) + + assert loading.substitute_scripts_placeholder("plain scalar") == "plain scalar" + + @pytest.mark.parametrize( "recipe_name", ["research", "research-design", "research-implement", "research-review", "research-archive"], From 18cd3f5273a0637e90e4ab10fac7383a2f38f8f0 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sat, 25 Jul 2026 12:58:59 -0700 Subject: [PATCH 16/89] fix(review): secure containment component walk --- src/autoskillit/core/path_containment.py | 35 ++++++++++++++++++++++-- tests/core/test_path_containment.py | 28 +++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/src/autoskillit/core/path_containment.py b/src/autoskillit/core/path_containment.py index ebcd32dde..b5590bd63 100644 --- a/src/autoskillit/core/path_containment.py +++ b/src/autoskillit/core/path_containment.py @@ -68,6 +68,38 @@ def check_metadata_stable(path: Path, pre_stat: os.stat_result, post_stat: os.st raise ContainmentError(f"File {path} modified between reads (TOCTOU)") +def _open_beneath_root_without_symlinks( + path: str | Path, + allowed_root: str | Path, + resolved: Path, +) -> int: + """Open a child through a trusted root descriptor without following symlinks.""" + if not hasattr(os, "O_DIRECTORY") or not hasattr(os, "O_NOFOLLOW"): + raise ContainmentError("Secure component-wise open is unavailable") + + resolved_root = Path(allowed_root).resolve(strict=True) + try: + relative = Path(path).absolute().relative_to(Path(allowed_root).absolute()) + except ValueError: + relative = resolved.relative_to(resolved_root) + if not relative.parts: + raise ContainmentError("Regular file required") + + directory_flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW + file_flags = os.O_RDONLY | os.O_NOFOLLOW + directory_fds: list[int] = [] + try: + directory_fds.append(os.open(resolved_root, directory_flags)) + for component in relative.parts[:-1]: + directory_fds.append(os.open(component, directory_flags, dir_fd=directory_fds[-1])) + return os.open(relative.parts[-1], file_flags, dir_fd=directory_fds[-1]) + except OSError as exc: + raise ContainmentError("Symlink or unsafe path component not allowed") from exc + finally: + for directory_fd in reversed(directory_fds): + os.close(directory_fd) + + def read_stable_contained_bytes( path: str | Path, allowed_root: str | Path, @@ -77,8 +109,7 @@ def read_stable_contained_bytes( """Read one bounded buffer while detecting containment and metadata drift.""" resolved = resolve_contained_path(path, allowed_root, max_size_bytes=max_size_bytes) pre_stat = resolved.stat() - flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) - fd = os.open(resolved, flags) + fd = _open_beneath_root_without_symlinks(path, allowed_root, resolved) try: opened_stat = os.fstat(fd) check_metadata_stable(resolved, pre_stat, opened_stat) diff --git a/tests/core/test_path_containment.py b/tests/core/test_path_containment.py index 6744d9b26..926fd306f 100644 --- a/tests/core/test_path_containment.py +++ b/tests/core/test_path_containment.py @@ -121,6 +121,34 @@ def test_detects_mtime_change(self, tmp_path) -> None: class TestReadStableContainedBytes: + def test_rejects_intermediate_symlink_swap( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + allowed = tmp_path / "root" + nested = allowed / "nested" + nested.mkdir(parents=True) + artifact = nested / "artifact.txt" + artifact.write_text("stable") + relocated = tmp_path / "relocated" + real_open = os.open + swapped = False + + def swapping_open(path, flags, *args, **kwargs): + nonlocal swapped + if not swapped: + nested.rename(relocated) + nested.symlink_to(relocated, target_is_directory=True) + swapped = True + return real_open(path, flags, *args, **kwargs) + + monkeypatch.setattr(os, "open", swapping_open) + + with pytest.raises(ContainmentError, match="[Ss]ymlink|component"): + read_stable_contained_bytes(artifact, allowed) + assert swapped + @pytest.mark.parametrize("field", ("st_dev", "st_mode", "st_nlink")) def test_detects_open_file_metadata_drift( self, From c06e4c48cd6f001b49095d63ce757bbe086241ac Mon Sep 17 00:00:00 2001 From: Trecek Date: Sat, 25 Jul 2026 13:00:00 -0700 Subject: [PATCH 17/89] fix(review): freeze audit cycle collections --- .../core/types/_type_audit_cycle.py | 58 ++++++++++++++- tests/core/test_audit_cycle_authority.py | 72 +++++++++++++++++++ 2 files changed, 129 insertions(+), 1 deletion(-) diff --git a/src/autoskillit/core/types/_type_audit_cycle.py b/src/autoskillit/core/types/_type_audit_cycle.py index 97886e5ca..96bef4bbc 100644 --- a/src/autoskillit/core/types/_type_audit_cycle.py +++ b/src/autoskillit/core/types/_type_audit_cycle.py @@ -6,7 +6,7 @@ from dataclasses import dataclass, field from enum import StrEnum from pathlib import Path -from typing import Any, Self +from typing import Any, Self, TypeVar from ..closure_hashing import HASH_RE, canonical_json_bytes, compute_canonical_hash @@ -34,6 +34,20 @@ _FINDINGS_DOMAIN = "autoskillit:audit-cycle:findings:v1:sha256" _REPORT_DOMAIN = "autoskillit:audit-cycle:plan-disposition:v1:sha256" _SATISFIED_RE = re.compile(r"^satisfied-by-round-([1-9][0-9]*)$") +_T = TypeVar("_T") + + +def _immutable_typed_tuple( + name: str, + values: object, + item_type: type[_T], +) -> tuple[_T, ...]: + if not isinstance(values, (list, tuple)): + raise ValueError(f"{name} must be a tuple") + normalized = tuple(values) + if not all(isinstance(value, item_type) for value in normalized): + raise ValueError(f"{name} must contain only {item_type.__name__} values") + return normalized class AuditVerdict(StrEnum): @@ -258,6 +272,24 @@ class AuditCycleAuthority: authority_digest: str def __post_init__(self) -> None: + object.__setattr__( + self, + "audited_plan_refs", + _immutable_typed_tuple( + "AuditCycleAuthority.audited_plan_refs", + self.audited_plan_refs, + ArtifactRef, + ), + ) + object.__setattr__( + self, + "assessments", + _immutable_typed_tuple( + "AuditCycleAuthority.assessments", + self.assessments, + AuditAssessmentRow, + ), + ) if ( isinstance(self.schema_version, bool) or not isinstance(self.schema_version, int) @@ -319,6 +351,16 @@ def create( remediation_ref: ArtifactRef | None, generated_at: str, ) -> Self: + audited_plan_refs = _immutable_typed_tuple( + "AuditCycleAuthority.audited_plan_refs", + audited_plan_refs, + ArtifactRef, + ) + assessments = _immutable_typed_tuple( + "AuditCycleAuthority.assessments", + assessments, + AuditAssessmentRow, + ) values: dict[str, Any] = { "assessments": [row.to_dict() for row in assessments], "audit_round": audit_round, @@ -540,6 +582,15 @@ class PlanDispositionReport: report_digest: str def __post_init__(self) -> None: + object.__setattr__( + self, + "dispositions", + _immutable_typed_tuple( + "PlanDispositionReport.dispositions", + self.dispositions, + PlanDispositionRow, + ), + ) if ( isinstance(self.schema_version, bool) or not isinstance(self.schema_version, int) @@ -578,6 +629,11 @@ def create( dispositions: tuple[PlanDispositionRow, ...], generated_at: str, ) -> Self: + dispositions = _immutable_typed_tuple( + "PlanDispositionReport.dispositions", + dispositions, + PlanDispositionRow, + ) values: dict[str, Any] = { "audit_round": audit_round, "current_plan_ref": current_plan_ref.to_dict(), diff --git a/tests/core/test_audit_cycle_authority.py b/tests/core/test_audit_cycle_authority.py index d6ff63b92..d01a56462 100644 --- a/tests/core/test_audit_cycle_authority.py +++ b/tests/core/test_audit_cycle_authority.py @@ -133,6 +133,52 @@ def test_authority_and_rows_are_frozen_and_digest_bound(tmp_path: Path) -> None: replace(authority, cycle_id="forged") +def test_authority_normalizes_mutable_typed_collections(tmp_path: Path) -> None: + audited_plan_refs = [_ref(tmp_path / "plan.md")] + assessments = [_row()] + authority = AuditCycleAuthority.create( + execution_generation="generation-1", + cycle_id="cycle-1", + plan_set_id="plans-1", + scope_id="scope-1", + part_id="part-a", + audit_round=2, + parent_authority_digest=_HASH_B, + audited_plan_refs=audited_plan_refs, # type: ignore[arg-type] + inventory_ref=_ref(tmp_path / "inventory.json", _HASH_B), + assessments=assessments, # type: ignore[arg-type] + verdict=AuditVerdict.GO, + remediation_ref=None, + generated_at="2026-07-23T00:00:00Z", + ) + + audited_plan_refs.append(_ref(tmp_path / "other-plan.md", _HASH_C)) + assessments.append(_row("REQ-002")) + + assert isinstance(authority.audited_plan_refs, tuple) + assert isinstance(authority.assessments, tuple) + assert len(authority.audited_plan_refs) == 1 + assert len(authority.assessments) == 1 + assert authority.authority_digest == authority.compute_digest() + + +@pytest.mark.parametrize( + ("field", "value", "expected_type"), + [ + ("audited_plan_refs", [object()], "ArtifactRef"), + ("assessments", [object()], "AuditAssessmentRow"), + ], +) +def test_authority_rejects_invalid_collection_elements( + tmp_path: Path, + field: str, + value: list[object], + expected_type: str, +) -> None: + with pytest.raises(ValueError, match=expected_type): + replace(_authority(tmp_path), **{field: value}) + + @pytest.mark.parametrize( ("field", "value"), [ @@ -257,6 +303,32 @@ def test_plan_disposition_report_is_bound_to_full_identity(tmp_path: Path) -> No assert PlanDispositionReport.from_dict(report.to_dict()) == report with pytest.raises(ValueError, match="report content"): replace(report, cycle_id="forged-cycle") + mutable_dispositions = [disposition] + normalized = PlanDispositionReport.create( + execution_generation=authority.execution_generation, + cycle_id=authority.cycle_id, + plan_set_id=authority.plan_set_id, + scope_id=authority.scope_id, + part_id=authority.part_id, + audit_round=authority.audit_round, + parent_authority_digest=authority.authority_digest, + inventory_digest=authority.inventory_ref.content_digest, + findings_digest=authority.findings_digest, + current_plan_ref=_ref(tmp_path / "other-current-plan.md", _HASH_C), + dispositions=mutable_dispositions, # type: ignore[arg-type] + generated_at="2026-07-23T00:01:00Z", + ) + mutable_dispositions.append( + PlanDispositionRow.create( + requirement_id="REQ-002", + disposition="satisfied-by-round-1", + ) + ) + assert isinstance(normalized.dispositions, tuple) + assert len(normalized.dispositions) == 1 + assert normalized.report_digest == normalized.compute_digest() + with pytest.raises(ValueError, match="PlanDispositionRow"): + replace(normalized, dispositions=[object()]) def test_head_allows_successor_only_for_go() -> None: From 69e27b336ac4eed39a2ab7ceac3489b5108629c9 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sat, 25 Jul 2026 13:00:35 -0700 Subject: [PATCH 18/89] test(review): cover canonical tool parameter failures --- tests/recipe/test_skill_invocation_binding.py | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/tests/recipe/test_skill_invocation_binding.py b/tests/recipe/test_skill_invocation_binding.py index 0a452b2bd..f3073a269 100644 --- a/tests/recipe/test_skill_invocation_binding.py +++ b/tests/recipe/test_skill_invocation_binding.py @@ -215,6 +215,64 @@ def test_unknown_tool_and_skill_namespaces_are_distinct() -> None: assert BindingFailureCode.UNKNOWN_SKILL_INPUT in {failure.code for failure in inner.failures} +@pytest.mark.parametrize( + ("tool_name", "with_args", "expected_code", "expected_name"), + [ + ( + "run_cmd", + {"cwd": "/repo"}, + BindingFailureCode.MISSING_TOOL_PARAMETER, + "cmd", + ), + ( + "run_cmd", + {"cmd": "pwd"}, + BindingFailureCode.MISSING_TOOL_PARAMETER, + "cwd", + ), + ( + "run_skill", + { + "skill_command": "/autoskillit:dry-walkthrough", + "cwd": "/repo", + "stale_threshold": "30", + "skill_inputs": _required_inputs(), + }, + BindingFailureCode.INVALID_TOOL_PARAMETER_TYPE, + "stale_threshold", + ), + ( + "run_python", + {"callable": "module:function", "args": "not-an-object"}, + BindingFailureCode.INVALID_TOOL_PARAMETER_TYPE, + "args", + ), + ( + "bulk_close_issues", + {"issue_numbers": "42", "comment": "done", "cwd": "/repo"}, + BindingFailureCode.INVALID_TOOL_PARAMETER_TYPE, + "issue_numbers", + ), + ], +) +def test_required_and_wire_typed_tool_parameters_reject_invalid_shapes( + tool_name: str, + with_args: dict[str, object], + expected_code: BindingFailureCode, + expected_name: str, +) -> None: + invocation = bind_step_invocation( + "step", + RecipeStep(name="step", tool=tool_name, with_args=with_args), + manifest=_manifest(), + ) + + assert any( + failure.code is expected_code and failure.name == expected_name + for failure in invocation.failures + ) + + def test_missing_and_inline_plus_structured_inputs_reject() -> None: missing = _required_inputs() del missing["plan_path"] From 76dceeb71e5bc9c50dff60f500c7c02ce7d50c8e Mon Sep 17 00:00:00 2001 From: Trecek Date: Sat, 25 Jul 2026 13:02:11 -0700 Subject: [PATCH 19/89] fix(review): reject undeclared runtime tool options --- src/autoskillit/server/_recipe_execution.py | 25 ++++++++ .../test_audit_cycle_delivery_integration.py | 61 +++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/src/autoskillit/server/_recipe_execution.py b/src/autoskillit/server/_recipe_execution.py index 826464b04..52701d02b 100644 --- a/src/autoskillit/server/_recipe_execution.py +++ b/src/autoskillit/server/_recipe_execution.py @@ -461,6 +461,31 @@ def bind_attested_runtime_invocation( "recipe_execution_skill_mismatch", "runtime skill identity differs from the compiled template", ) + compiled_mcp_names = frozenset(value.name for value in invocation.mcp_kwargs) + protocol_mcp_values = { + "step_name": step_name, + "recipe_execution_id": execution_id, + "invocation_template_digest": template_digest, + } + for name, expected in protocol_mcp_values.items(): + if name in actual_mcp_kwargs and actual_mcp_kwargs[name] != expected: + raise RecipeExecutionAdmissionError( + "recipe_execution_tool_shape", + f"attestation parameter {name!r} differs from the active invocation", + ) + undeclared_effective_names = sorted( + name + for name, value in actual_mcp_kwargs.items() + if name not in compiled_mcp_names and name not in protocol_mcp_values and value != "" + ) + if undeclared_effective_names: + raise RecipeExecutionAdmissionError( + "recipe_execution_tool_shape", + ( + "runtime tool parameters are absent from the compiled template: " + f"{undeclared_effective_names!r}" + ), + ) contract = get_skill_contract(invocation.skill_name or "", load_bundled_manifest()) if contract is None: raise RecipeExecutionAdmissionError( diff --git a/tests/server/test_audit_cycle_delivery_integration.py b/tests/server/test_audit_cycle_delivery_integration.py index d3c0752ca..1e0314fa4 100644 --- a/tests/server/test_audit_cycle_delivery_integration.py +++ b/tests/server/test_audit_cycle_delivery_integration.py @@ -315,6 +315,9 @@ def test_bound_prompt_preserves_falsey_and_metacharacter_values() -> None: actual_mcp_kwargs={ "skill_command": "/dry-walkthrough", "cwd": "/tmp/work tree", + "step_name": "dry", + "recipe_execution_id": "execution-1", + "invocation_template_digest": template.template_digest, }, ) prompt = build_bound_child_prompt("/dry-walkthrough", bound, None) @@ -327,6 +330,64 @@ def test_bound_prompt_preserves_falsey_and_metacharacter_values() -> None: ] +@pytest.mark.parametrize( + ("option_name", "option_value"), + [ + ("model", "sonnet"), + ("output_dir", "/tmp/output"), + ("resume_session_id", "session-1"), + ("stale_threshold", 30), + ], +) +def test_runtime_binding_rejects_undeclared_effective_tool_options( + option_name: str, + option_value: str | int, +) -> None: + snapshot = build_recipe_execution_snapshot( + recipe_name="demo", + content_hash=_HASH_A, + composite_hash=_HASH_B, + projection=_projection(), + execution_id="execution-1", + ) + store = DefaultAuditCycleHeadStore() + from autoskillit.core import InstalledRecipeExecution + + installed = InstalledRecipeExecution( + snapshot=snapshot, + runtime_binding_digests={}, + audit_cycle_heads=store, + input_preflight_resolver=DefaultInputPreflightResolver( + allowed_root=Path("/tmp"), + head_store=store, + ), + ) + template = snapshot.templates["dry"] + actual_mcp_kwargs: dict[str, str | int | float | bool] = { + "skill_command": "/dry-walkthrough", + "cwd": "/tmp", + option_name: option_value, + } + + with pytest.raises(RecipeExecutionAdmissionError) as exc_info: + bind_attested_runtime_invocation( + installed, + execution_id="execution-1", + step_name="dry", + template_digest=template.template_digest, + skill_command="/dry-walkthrough", + skill_inputs={ + "plan_path": "/tmp/plan.md", + "issue_url": "", + "audit_cycle_path": "/tmp/audit-cycle.json", + "plan_disposition_path": "/tmp/plan-disposition.json", + }, + actual_mcp_kwargs=actual_mcp_kwargs, + ) + assert exc_info.value.code == "recipe_execution_tool_shape" + assert option_name in str(exc_info.value) + + @pytest.mark.parametrize( ("invalid_name", "invalid_value"), (("count", "3"), ("enabled", "false")), From 51ba193fb3849383b9cb10b4ac06ed7167fb6c01 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sat, 25 Jul 2026 13:05:40 -0700 Subject: [PATCH 20/89] test(review): reject duplicate MCP registrations --- tests/server/test_tool_registry_parity.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/tests/server/test_tool_registry_parity.py b/tests/server/test_tool_registry_parity.py index 63307b837..a8e901350 100644 --- a/tests/server/test_tool_registry_parity.py +++ b/tests/server/test_tool_registry_parity.py @@ -12,8 +12,12 @@ pytestmark = [pytest.mark.layer("server"), pytest.mark.small] -def _handler_signatures() -> dict[str, tuple[tuple[str, bool], ...]]: - tools_dir = Path(__file__).resolve().parents[2] / "src" / "autoskillit" / "server" / "tools" +def _handler_signatures( + tools_dir: Path | None = None, +) -> dict[str, tuple[tuple[str, bool], ...]]: + tools_dir = tools_dir or ( + Path(__file__).resolve().parents[2] / "src" / "autoskillit" / "server" / "tools" + ) handlers: dict[str, tuple[tuple[str, bool], ...]] = {} for path in sorted(tools_dir.glob("tools_*.py")): tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) @@ -34,6 +38,7 @@ def _handler_signatures() -> dict[str, tuple[tuple[str, bool], ...]]: *zip(positional, defaults, strict=True), *zip(node.args.kwonlyargs, node.args.kw_defaults, strict=True), ] + assert node.name not in handlers, f"duplicate MCP tool registration: {node.name}" handlers[node.name] = tuple( (argument.arg, default is None) for argument, default in pairs @@ -42,6 +47,16 @@ def _handler_signatures() -> dict[str, tuple[tuple[str, bool], ...]]: return handlers +def test_handler_collection_rejects_duplicate_registrations(tmp_path: Path) -> None: + (tmp_path / "tools_duplicate.py").write_text( + "@mcp.tool()\ndef duplicate(): ...\n\n@mcp.tool()\nasync def duplicate(): ...\n", + encoding="utf-8", + ) + + with pytest.raises(AssertionError, match="duplicate MCP tool registration: duplicate"): + _handler_signatures(tmp_path) + + def test_registry_matches_handler_names_bidirectionally() -> None: assert set(TOOL_REGISTRY) == set(_handler_signatures()) From dd324e9397a6fda3f582637528f257bc7884fcf8 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sat, 25 Jul 2026 13:10:02 -0700 Subject: [PATCH 21/89] fix(review): bind trusted audit preflight identity --- .../core/types/_type_recipe_execution.py | 14 ++- src/autoskillit/server/_recipe_execution.py | 45 +++++++-- .../server/tools/tools_execution.py | 23 +++++ .../test_audit_cycle_delivery_integration.py | 91 ++++++++++++++++++- 4 files changed, 164 insertions(+), 9 deletions(-) diff --git a/src/autoskillit/core/types/_type_recipe_execution.py b/src/autoskillit/core/types/_type_recipe_execution.py index 2e0d1d947..bc83d81c0 100644 --- a/src/autoskillit/core/types/_type_recipe_execution.py +++ b/src/autoskillit/core/types/_type_recipe_execution.py @@ -9,7 +9,7 @@ from __future__ import annotations from collections.abc import Mapping -from dataclasses import dataclass +from dataclasses import dataclass, field from enum import StrEnum from types import MappingProxyType from typing import Any, Protocol, runtime_checkable @@ -239,6 +239,7 @@ class InstalledRecipeExecution: runtime_binding_digests: Mapping[str, str] audit_cycle_heads: AuditCycleHeadStore input_preflight_resolver: InputPreflightResolver + preflight_identities: Mapping[str, tuple[str, str, str]] = field(default_factory=dict) def __post_init__(self) -> None: object.__setattr__( @@ -246,6 +247,17 @@ def __post_init__(self) -> None: "runtime_binding_digests", MappingProxyType(dict(self.runtime_binding_digests)), ) + identities = dict(self.preflight_identities) + if any( + len(identity) != 3 or not all(isinstance(value, str) and value for value in identity) + for identity in identities.values() + ): + raise ValueError("preflight identities must contain three non-empty strings") + object.__setattr__( + self, + "preflight_identities", + MappingProxyType(identities), + ) def compute_runtime_binding_digest( diff --git a/src/autoskillit/server/_recipe_execution.py b/src/autoskillit/server/_recipe_execution.py index 52701d02b..84f9acaca 100644 --- a/src/autoskillit/server/_recipe_execution.py +++ b/src/autoskillit/server/_recipe_execution.py @@ -26,6 +26,7 @@ InventoryAdmissionDecision, InvocationTemplate, PreflightEvidence, + PreflightKind, RecipeBindingProjection, RecipeExecutionSnapshot, VerifiedInputPreflightRequest, @@ -240,15 +241,21 @@ def resolve( "authority is from another execution generation", ) ) + if not ( + request.expected_plan_set_id and request.expected_scope_id and request.expected_part_id + ): + return self._result( + InventoryAdmissionDecision.reject( + AdmissionReason.INTERNAL_ERROR, + "trusted preflight identity is missing from the invocation template", + ) + ) head = self._head_store.get( execution_generation=authority.execution_generation, plan_set_id=authority.plan_set_id, scope_id=authority.scope_id, part_id=authority.part_id, ) - expected_plan_set_id = request.expected_plan_set_id or authority.plan_set_id - expected_scope_id = request.expected_scope_id or authority.scope_id - expected_part_id = request.expected_part_id or authority.part_id if authority.verdict is AuditVerdict.GO and report_path is not None: return self._result( InventoryAdmissionDecision.reject( @@ -262,9 +269,9 @@ def resolve( trusted_head=head, current_plan_path=request.plan_path, expected_generation=request.execution_generation, - expected_plan_set_id=expected_plan_set_id, - expected_scope_id=expected_scope_id, - expected_part_id=expected_part_id, + expected_plan_set_id=request.expected_plan_set_id, + expected_scope_id=request.expected_scope_id, + expected_part_id=request.expected_part_id, ) return self._result(decision) @@ -624,9 +631,33 @@ def publish_verified_audit_cycle( authority = AuditCycleVerifier(tool_ctx.temp_dir).load_authority(authority_path) if authority.execution_generation != installed.snapshot.execution_id: raise AuditCycleHeadConflict("authority crosses recipe execution generations") - return installed.audit_cycle_heads.publish( + head = installed.audit_cycle_heads.publish( authority, expected_parent_digest=expected_parent_digest, expected_round=expected_round, authorized_successor_part_id=authorized_successor_part_id, ) + expected_identity = ( + head.plan_set_id, + head.scope_id, + head.authorized_successor_part_id or head.part_id, + ) + manifest = load_bundled_manifest() + preflight_identities = dict(installed.preflight_identities) + for step_name, template in installed.snapshot.templates.items(): + contract = get_skill_contract(template.invocation.skill_name or "", manifest) + if ( + contract is not None + and contract.input_preflight == PreflightKind.AUDIT_CYCLE_INVENTORY.value + ): + preflight_identities[step_name] = expected_identity + with tool_ctx.recipe_execution_lock: + if tool_ctx.active_recipe_execution is not installed: + raise AuditCycleHeadConflict( + "active recipe execution changed while publishing audit authority" + ) + tool_ctx.active_recipe_execution = replace( + installed, + preflight_identities=preflight_identities, + ) + return head diff --git a/src/autoskillit/server/tools/tools_execution.py b/src/autoskillit/server/tools/tools_execution.py index cca3d8941..665a4fecc 100644 --- a/src/autoskillit/server/tools/tools_execution.py +++ b/src/autoskillit/server/tools/tools_execution.py @@ -926,6 +926,14 @@ async def run_skill( "recipe_execution_preflight_input", "plan_disposition_path must be a string when present", ) + _expected_preflight_identity = _installed_execution.preflight_identities.get( + step_name + ) + if _audit_cycle_path and _expected_preflight_identity is None: + return _recipe_execution_deny( + "recipe_execution_preflight_identity_missing", + "authority-bearing preflight requires a trusted template identity", + ) _preflight_result = _installed_execution.input_preflight_resolver.resolve( VerifiedInputPreflightRequest( execution_generation=recipe_execution_id, @@ -934,6 +942,21 @@ async def run_skill( plan_path=_plan_path, audit_cycle_path=_audit_cycle_path or None, plan_disposition_path=_plan_disposition_path or None, + expected_plan_set_id=( + _expected_preflight_identity[0] + if _expected_preflight_identity is not None + else "" + ), + expected_scope_id=( + _expected_preflight_identity[1] + if _expected_preflight_identity is not None + else "" + ), + expected_part_id=( + _expected_preflight_identity[2] + if _expected_preflight_identity is not None + else "" + ), ) ) if _preflight_result.decision.status is AdmissionStatus.REJECT: diff --git a/tests/server/test_audit_cycle_delivery_integration.py b/tests/server/test_audit_cycle_delivery_integration.py index 1e0314fa4..cb9f49488 100644 --- a/tests/server/test_audit_cycle_delivery_integration.py +++ b/tests/server/test_audit_cycle_delivery_integration.py @@ -56,6 +56,7 @@ build_recipe_execution_snapshot, get_recipe_execution, install_recipe_execution, + publish_verified_audit_cycle, ) from autoskillit.server.tools.tools_execution import run_skill @@ -330,6 +331,43 @@ def test_bound_prompt_preserves_falsey_and_metacharacter_values() -> None: ] +def test_published_audit_head_binds_preflight_template_identity( + tool_ctx_kitchen_open, +) -> None: + snapshot = build_recipe_execution_snapshot( + recipe_name="demo", + content_hash=_HASH_A, + composite_hash=_HASH_B, + projection=_projection(), + execution_id="execution-1", + ) + install_recipe_execution(tool_ctx_kitchen_open, snapshot=snapshot) + authority = _authority( + Path(tool_ctx_kitchen_open.temp_dir), + generation="execution-1", + round_=1, + parent=None, + verdict=AuditVerdict.NO_GO, + ) + authority_path = Path(tool_ctx_kitchen_open.temp_dir) / "authority.json" + authority_path.write_bytes(authority.canonical_bytes) + + publish_verified_audit_cycle( + tool_ctx_kitchen_open, + authority_path=str(authority_path), + expected_parent_digest=None, + expected_round=0, + ) + + installed = get_recipe_execution(tool_ctx_kitchen_open) + assert installed is not None + assert installed.preflight_identities["dry"] == ( + authority.plan_set_id, + authority.scope_id, + authority.part_id, + ) + + @pytest.mark.parametrize( ("option_name", "option_value"), [ @@ -795,6 +833,50 @@ def fail_reader(*args, **kwargs): assert reads == 0 +def test_preflight_never_derives_expected_identity_from_supplied_authority( + tmp_path: Path, +) -> None: + store = DefaultAuditCycleHeadStore() + authority = _authority( + tmp_path, + generation="execution-1", + round_=1, + parent=None, + verdict=AuditVerdict.GO, + ) + store.publish(authority, expected_parent_digest=None, expected_round=0) + authority_path = tmp_path / "authority.json" + authority_path.write_bytes(authority.canonical_bytes) + resolver = DefaultInputPreflightResolver(allowed_root=tmp_path, head_store=store) + + missing = resolver.resolve( + VerifiedInputPreflightRequest( + execution_generation="execution-1", + step_name="dry", + skill_name="dry-walkthrough", + plan_path=str(tmp_path / "plan.md"), + audit_cycle_path=str(authority_path), + plan_disposition_path=None, + ) + ) + wrong_plan_set = resolver.resolve( + VerifiedInputPreflightRequest( + execution_generation="execution-1", + step_name="dry", + skill_name="dry-walkthrough", + plan_path=str(tmp_path / "plan.md"), + audit_cycle_path=str(authority_path), + plan_disposition_path=None, + expected_plan_set_id="plans-other", + expected_scope_id=authority.scope_id, + expected_part_id=authority.part_id, + ) + ) + + assert missing.decision.reason is AdmissionReason.INTERNAL_ERROR + assert wrong_plan_set.decision.reason is AdmissionReason.PLAN_SET_MISMATCH + + @pytest.mark.anyio async def test_runtime_attestation_rejects_before_executor( tool_ctx_kitchen_open, @@ -838,6 +920,9 @@ async def test_preflight_rejects_before_executor( ) -> None: class RejectingResolver: def resolve(self, request): + assert request.expected_plan_set_id == "plans-1" + assert request.expected_scope_id == "scope-1" + assert request.expected_part_id == "part-a" return VerifiedInputPreflightResult( InventoryAdmissionDecision.reject( AdmissionReason.PLAN_MISMATCH, @@ -856,7 +941,11 @@ def resolve(self, request): monkeypatch.setattr( tool_ctx_kitchen_open, "active_recipe_execution", - replace(installed, input_preflight_resolver=RejectingResolver()), + replace( + installed, + input_preflight_resolver=RejectingResolver(), + preflight_identities={"dry": ("plans-1", "scope-1", "part-a")}, + ), ) monkeypatch.setattr( tool_ctx_kitchen_open, From 9a94e4b2e7a6fd68d7bba9b38cbf4ff3cf598434 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sat, 25 Jul 2026 13:17:08 -0700 Subject: [PATCH 22/89] fix: resolve review validation regressions --- .../server/tools/tools_execution.py | 24 +++++-------------- .../test_audit_cycle_delivery_integration.py | 1 + 2 files changed, 7 insertions(+), 18 deletions(-) diff --git a/src/autoskillit/server/tools/tools_execution.py b/src/autoskillit/server/tools/tools_execution.py index 665a4fecc..0bb8a984e 100644 --- a/src/autoskillit/server/tools/tools_execution.py +++ b/src/autoskillit/server/tools/tools_execution.py @@ -926,14 +926,14 @@ async def run_skill( "recipe_execution_preflight_input", "plan_disposition_path must be a string when present", ) - _expected_preflight_identity = _installed_execution.preflight_identities.get( - step_name - ) + _preflight_identities = _installed_execution.preflight_identities + _expected_preflight_identity = _preflight_identities.get(step_name) if _audit_cycle_path and _expected_preflight_identity is None: return _recipe_execution_deny( "recipe_execution_preflight_identity_missing", "authority-bearing preflight requires a trusted template identity", ) + _expected_preflight_identity = _expected_preflight_identity or ("", "", "") _preflight_result = _installed_execution.input_preflight_resolver.resolve( VerifiedInputPreflightRequest( execution_generation=recipe_execution_id, @@ -942,21 +942,9 @@ async def run_skill( plan_path=_plan_path, audit_cycle_path=_audit_cycle_path or None, plan_disposition_path=_plan_disposition_path or None, - expected_plan_set_id=( - _expected_preflight_identity[0] - if _expected_preflight_identity is not None - else "" - ), - expected_scope_id=( - _expected_preflight_identity[1] - if _expected_preflight_identity is not None - else "" - ), - expected_part_id=( - _expected_preflight_identity[2] - if _expected_preflight_identity is not None - else "" - ), + expected_plan_set_id=_expected_preflight_identity[0], + expected_scope_id=_expected_preflight_identity[1], + expected_part_id=_expected_preflight_identity[2], ) ) if _preflight_result.decision.status is AdmissionStatus.REJECT: diff --git a/tests/server/test_audit_cycle_delivery_integration.py b/tests/server/test_audit_cycle_delivery_integration.py index cb9f49488..81a7d6071 100644 --- a/tests/server/test_audit_cycle_delivery_integration.py +++ b/tests/server/test_audit_cycle_delivery_integration.py @@ -350,6 +350,7 @@ def test_published_audit_head_binds_preflight_template_identity( verdict=AuditVerdict.NO_GO, ) authority_path = Path(tool_ctx_kitchen_open.temp_dir) / "authority.json" + authority_path.parent.mkdir(parents=True, exist_ok=True) authority_path.write_bytes(authority.canonical_bytes) publish_verified_audit_cycle( From 06ae8840fcde1774d58d4918fc4fa8d30244acf6 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sat, 25 Jul 2026 16:15:51 -0700 Subject: [PATCH 23/89] fix(review): validate invocation template digests --- .../core/types/_type_recipe_execution.py | 12 ++++++++ .../test_audit_cycle_delivery_integration.py | 29 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/src/autoskillit/core/types/_type_recipe_execution.py b/src/autoskillit/core/types/_type_recipe_execution.py index bc83d81c0..4432336b2 100644 --- a/src/autoskillit/core/types/_type_recipe_execution.py +++ b/src/autoskillit/core/types/_type_recipe_execution.py @@ -149,6 +149,18 @@ def __post_init__(self) -> None: copied = dict(self.templates) if any(name != template.invocation.step_name for name, template in copied.items()): raise ValueError("recipe execution template keys must match step names") + for template in copied.values(): + expected_template_digest = compute_invocation_template_digest( + execution_id=self.execution_id, + recipe_name=self.recipe_name, + content_hash=self.content_hash, + composite_hash=self.composite_hash, + invocation=template.invocation, + tool_contract_identity=template.tool_contract_identity, + skill_contract_identity=template.skill_contract_identity, + ) + if template.template_digest != expected_template_digest: + raise ValueError("recipe execution invocation template digest mismatch") object.__setattr__(self, "templates", MappingProxyType(copied)) expected = compute_recipe_execution_snapshot_digest( execution_id=self.execution_id, diff --git a/tests/server/test_audit_cycle_delivery_integration.py b/tests/server/test_audit_cycle_delivery_integration.py index 81a7d6071..c8ca6a89b 100644 --- a/tests/server/test_audit_cycle_delivery_integration.py +++ b/tests/server/test_audit_cycle_delivery_integration.py @@ -35,9 +35,11 @@ BoundValueState, InventoryAdmissionDecision, InventoryAdmissionEvaluator, + InvocationTemplate, PlanDispositionReport, PlanDispositionRow, RecipeBindingProjection, + RecipeExecutionSnapshot, VerifiedInputPreflightRequest, VerifiedInputPreflightResult, ) @@ -331,6 +333,33 @@ def test_bound_prompt_preserves_falsey_and_metacharacter_values() -> None: ] +def test_snapshot_rejects_digest_that_does_not_attest_invocation() -> None: + snapshot = build_recipe_execution_snapshot( + recipe_name="demo", + content_hash=_HASH_A, + composite_hash=_HASH_B, + projection=_projection(), + execution_id="execution-1", + ) + template = snapshot.templates["dry"] + tampered = InvocationTemplate( + invocation=replace(template.invocation, skill_name="different-skill"), + tool_contract_identity=template.tool_contract_identity, + skill_contract_identity=template.skill_contract_identity, + template_digest=template.template_digest, + ) + + with pytest.raises(ValueError, match="invocation template digest mismatch"): + RecipeExecutionSnapshot( + execution_id=snapshot.execution_id, + recipe_name=snapshot.recipe_name, + content_hash=snapshot.content_hash, + composite_hash=snapshot.composite_hash, + templates={"dry": tampered}, + snapshot_digest=snapshot.snapshot_digest, + ) + + def test_published_audit_head_binds_preflight_template_identity( tool_ctx_kitchen_open, ) -> None: From 8be8f8bfe6b7d3896d40c8882ab03864d543ce83 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sat, 25 Jul 2026 16:16:46 -0700 Subject: [PATCH 24/89] fix(review): log recipe execution compilation failures --- src/autoskillit/server/_recipe_delivery.py | 9 ++++- .../test_audit_cycle_delivery_integration.py | 40 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/autoskillit/server/_recipe_delivery.py b/src/autoskillit/server/_recipe_delivery.py index 45732a2c0..532f1f8e9 100644 --- a/src/autoskillit/server/_recipe_delivery.py +++ b/src/autoskillit/server/_recipe_delivery.py @@ -779,7 +779,14 @@ def finalize_recipe_delivery( composite_hash=str(candidate_payload.get("composite_hash", "")), projection=compiled_bindings, ) - except (TypeError, ValueError): + except (TypeError, ValueError) as exc: + get_logger(__name__).warning( + "recipe_execution_compilation_failed", + recipe_name=recipe_name, + surface=surface, + error_type=type(exc).__name__, + exc_info=True, + ) decision = _failure_decision( producer=RECIPE_DELIVERY_SURFACE_REGISTRY[surface].producer_tool, reason="recipe_execution_compilation_failed", diff --git a/tests/server/test_audit_cycle_delivery_integration.py b/tests/server/test_audit_cycle_delivery_integration.py index c8ca6a89b..198e1b248 100644 --- a/tests/server/test_audit_cycle_delivery_integration.py +++ b/tests/server/test_audit_cycle_delivery_integration.py @@ -6,6 +6,7 @@ from dataclasses import replace from pathlib import Path from types import SimpleNamespace +from unittest.mock import MagicMock import pytest from hypothesis import settings @@ -18,6 +19,7 @@ rule, ) +import autoskillit.server._recipe_delivery as recipe_delivery_module import autoskillit.server._recipe_execution as recipe_execution_module from autoskillit.core import ( AdmissionReason, @@ -280,6 +282,44 @@ def test_transformed_delivery_never_installs_execution(minimal_ctx) -> None: assert get_recipe_execution(minimal_ctx) is None +def test_recipe_execution_compilation_failure_logs_exception_context( + minimal_ctx, + monkeypatch: pytest.MonkeyPatch, +) -> None: + mock_logger = MagicMock() + monkeypatch.setattr(recipe_delivery_module, "get_logger", lambda _name: mock_logger) + monkeypatch.setattr( + recipe_execution_module, + "build_recipe_execution_snapshot", + MagicMock(side_effect=ValueError("invalid template")), + ) + + finalized = finalize_recipe_delivery( + { + "content": "name: demo\n", + "content_hash": _HASH_A, + "composite_hash": _HASH_B, + "valid": True, + }, + surface="load_recipe", + recipe_name="demo", + tool_ctx=minimal_ctx, + compiled_bindings=_projection(), + ) + + assert json.loads(finalized.rendered) == { + "success": False, + "error": "recipe_execution_unavailable", + } + mock_logger.warning.assert_called_once_with( + "recipe_execution_compilation_failed", + recipe_name="demo", + surface="load_recipe", + error_type="ValueError", + exc_info=True, + ) + + def test_bound_prompt_preserves_falsey_and_metacharacter_values() -> None: snapshot = build_recipe_execution_snapshot( recipe_name="demo", From 84823c58a22f4e083f2b47f1a88d79de2054b1a1 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sat, 25 Jul 2026 16:17:20 -0700 Subject: [PATCH 25/89] fix(review): isolate compiled bindings from generic cache --- src/autoskillit/recipe/_api.py | 6 +----- tests/recipe/test_api.py | 27 +++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/autoskillit/recipe/_api.py b/src/autoskillit/recipe/_api.py index e7598fa15..176de8d26 100644 --- a/src/autoskillit/recipe/_api.py +++ b/src/autoskillit/recipe/_api.py @@ -306,8 +306,6 @@ def load_and_validate( ): logger.debug("load_recipe_cache_hit", recipe=name) cached_result = _api_cache._LOAD_CACHE.copy_result(cached.result) - if not include_compiled_bindings: - cached_result.pop("_compiled_bindings", None) return cast(LoadRecipeResult, cached_result) t0 = time.perf_counter() @@ -691,7 +689,7 @@ def load_and_validate( if _deferred_guard_list: result["deferred_guards"] = _deferred_guard_list if active_recipe is not None: - if _post_prune_bindings is not None: + if include_compiled_bindings and _post_prune_bindings is not None: result["_compiled_bindings"] = _post_prune_bindings result["post_prune_step_names"] = list(active_recipe.steps.keys()) _step_names_set = set(active_recipe.steps) @@ -724,6 +722,4 @@ def load_and_validate( if result.get("valid", False): _api_cache._refresh_staleness_baseline() caller_result = _api_cache._LOAD_CACHE.copy_result(result) - if not include_compiled_bindings: - caller_result.pop("_compiled_bindings", None) return cast(LoadRecipeResult, caller_result) diff --git a/tests/recipe/test_api.py b/tests/recipe/test_api.py index 103c29423..925fe6bd0 100644 --- a/tests/recipe/test_api.py +++ b/tests/recipe/test_api.py @@ -265,6 +265,33 @@ def counting_validate(recipe): assert len(calls) == 1 # validate_recipe called only once across two loads +def test_default_cache_excludes_server_only_compiled_bindings(tmp_path, monkeypatch): + import autoskillit.recipe._api as api_mod + import autoskillit.recipe._api_cache as cache_mod + + cache = cache_mod.LoadCache() + monkeypatch.setattr(cache_mod, "_LOAD_CACHE", cache) + recipes_dir = tmp_path / ".autoskillit" / "recipes" + recipes_dir.mkdir(parents=True) + (recipes_dir / "myrecipe.yaml").write_text(MINIMAL_RECIPE_YAML) + + default_result = api_mod.load_and_validate("myrecipe", tmp_path) + + assert "_compiled_bindings" not in default_result + assert len(cache._store) == 1 + default_entry = next(iter(cache._store.values())) + assert "_compiled_bindings" not in default_entry.result + + server_result = api_mod.load_and_validate( + "myrecipe", + tmp_path, + include_compiled_bindings=True, + ) + + assert "_compiled_bindings" in server_result + assert len(cache._store) == 2 + + def test_load_and_validate_cache_invalidated_on_recipe_mtime_change(tmp_path, monkeypatch): """Changing the recipe file mtime causes a cache miss.""" import autoskillit.recipe._api as api_mod From dca2b14e2a8426bcf75224663320fe1563d54836 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sat, 25 Jul 2026 16:18:03 -0700 Subject: [PATCH 26/89] fix(review): bind recreated artifacts to snapshot hashes --- src/autoskillit/server/tools/tools_recipe.py | 4 ++ tests/server/test_tools_recipe_pull.py | 49 ++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/src/autoskillit/server/tools/tools_recipe.py b/src/autoskillit/server/tools/tools_recipe.py index bdb50aaf0..23cc74b57 100644 --- a/src/autoskillit/server/tools/tools_recipe.py +++ b/src/autoskillit/server/tools/tools_recipe.py @@ -625,6 +625,10 @@ async def get_recipe_section( if ( installed_execution is not None and installed_execution.snapshot.recipe_name == requested_recipe_name + and installed_execution.snapshot.content_hash + == _recreate.get("content_hash") + and installed_execution.snapshot.composite_hash + == _recreate.get("composite_hash") ): snapshot = installed_execution.snapshot _recreate["recipe_execution"] = { diff --git a/tests/server/test_tools_recipe_pull.py b/tests/server/test_tools_recipe_pull.py index 81c4e76c2..cdafd65ec 100644 --- a/tests/server/test_tools_recipe_pull.py +++ b/tests/server/test_tools_recipe_pull.py @@ -1439,6 +1439,55 @@ async def test_pull_tool_recreates_missing_exact_generation( ) +async def test_recreation_does_not_attach_snapshot_for_different_recipe_hashes( + tool_ctx_kitchen_open, monkeypatch: pytest.MonkeyPatch +) -> None: + import autoskillit.server.tools.tools_recipe as tools_recipe + + tool_ctx_kitchen_open.kitchen_id = "pull-recreate-stale-snapshot" + payload = _payload() + payload["content_hash"] = "sha256:" + ("a" * 64) + payload["composite_hash"] = "sha256:" + ("b" * 64) + generation = persist_recipe_artifact( + tool_ctx_kitchen_open.temp_dir, + kitchen_id=tool_ctx_kitchen_open.kitchen_id, + producer_tool="open_kitchen", + recipe_name="remediation", + payload=payload, + ) + _remove_persisted_namespace( + tool_ctx_kitchen_open.temp_dir, kitchen_id=tool_ctx_kitchen_open.kitchen_id + ) + snapshot = MagicMock( + recipe_name="remediation", + content_hash="sha256:" + ("c" * 64), + composite_hash="sha256:" + ("d" * 64), + ) + monkeypatch.setattr(tools_recipe, "serve_recipe", lambda *_args, **_kwargs: dict(payload)) + monkeypatch.setattr( + tools_recipe, + "build_open_kitchen_recipe_payload", + lambda data, *, version: data, + ) + monkeypatch.setattr( + tools_recipe, + "get_recipe_execution", + lambda _tool_ctx: MagicMock(snapshot=snapshot), + ) + + kwargs = generation.pull_identity() + kwargs.pop("pull_tool") + response = json.loads(await get_recipe_section(section="content", **kwargs)) + + assert response["success"] is True + recreated = load_recipe_artifact( + tool_ctx_kitchen_open.temp_dir, + kitchen_id=tool_ctx_kitchen_open.kitchen_id, + identity=generation, + ) + assert "recipe_execution" not in recreated + + async def test_pull_tool_reports_invalid_missing_generation_recreation( tool_ctx_kitchen_open, monkeypatch: pytest.MonkeyPatch ) -> None: From d42dfdb41746f172a27214ae3bb578bdc6e8f726 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sat, 25 Jul 2026 16:20:43 -0700 Subject: [PATCH 27/89] fix(review): publish successful audit cycle authorities --- src/autoskillit/server/_recipe_execution.py | 59 ++++++++++++++++--- .../server/tools/tools_execution.py | 24 ++++++++ .../test_audit_cycle_delivery_integration.py | 53 ++++++++++++++++- 3 files changed, 128 insertions(+), 8 deletions(-) diff --git a/src/autoskillit/server/_recipe_execution.py b/src/autoskillit/server/_recipe_execution.py index 84f9acaca..3773761b6 100644 --- a/src/autoskillit/server/_recipe_execution.py +++ b/src/autoskillit/server/_recipe_execution.py @@ -59,6 +59,7 @@ "clear_recipe_execution", "get_recipe_execution", "install_recipe_execution", + "publish_reported_audit_cycle", "publish_verified_audit_cycle", "record_runtime_binding_digest", ] @@ -616,19 +617,15 @@ def build_standalone_child_prompt( ) -def publish_verified_audit_cycle( +def _publish_loaded_audit_cycle( tool_ctx: ToolContext, *, - authority_path: str, + installed: InstalledRecipeExecution, + authority: AuditCycleAuthority, expected_parent_digest: str | None, expected_round: int, authorized_successor_part_id: str | None = None, ) -> AuditCycleHead: - """Verify an explicit child output, then CAS-publish it as trusted.""" - installed = get_recipe_execution(tool_ctx) - if installed is None: - raise AuditCycleHeadConflict("no active recipe execution") - authority = AuditCycleVerifier(tool_ctx.temp_dir).load_authority(authority_path) if authority.execution_generation != installed.snapshot.execution_id: raise AuditCycleHeadConflict("authority crosses recipe execution generations") head = installed.audit_cycle_heads.publish( @@ -661,3 +658,51 @@ def publish_verified_audit_cycle( preflight_identities=preflight_identities, ) return head + + +def publish_verified_audit_cycle( + tool_ctx: ToolContext, + *, + authority_path: str, + expected_parent_digest: str | None, + expected_round: int, + authorized_successor_part_id: str | None = None, +) -> AuditCycleHead: + """Verify an explicit child output, then CAS-publish it as trusted.""" + installed = get_recipe_execution(tool_ctx) + if installed is None: + raise AuditCycleHeadConflict("no active recipe execution") + authority = AuditCycleVerifier(tool_ctx.temp_dir).load_authority(authority_path) + return _publish_loaded_audit_cycle( + tool_ctx, + installed=installed, + authority=authority, + expected_parent_digest=expected_parent_digest, + expected_round=expected_round, + authorized_successor_part_id=authorized_successor_part_id, + ) + + +def publish_reported_audit_cycle( + tool_ctx: ToolContext, + *, + authority_path: str, +) -> AuditCycleHead: + """Verify and publish the authority path reported by a successful audit child.""" + installed = get_recipe_execution(tool_ctx) + if installed is None: + raise AuditCycleHeadConflict("no active recipe execution") + authority = AuditCycleVerifier(tool_ctx.temp_dir).load_authority(authority_path) + current = installed.audit_cycle_heads.get( + execution_generation=authority.execution_generation, + plan_set_id=authority.plan_set_id, + scope_id=authority.scope_id, + part_id=authority.part_id, + ) + return _publish_loaded_audit_cycle( + tool_ctx, + installed=installed, + authority=authority, + expected_parent_digest=(current.current_authority_digest if current is not None else None), + expected_round=current.audit_round if current is not None else 0, + ) diff --git a/src/autoskillit/server/tools/tools_execution.py b/src/autoskillit/server/tools/tools_execution.py index 0bb8a984e..4d070a971 100644 --- a/src/autoskillit/server/tools/tools_execution.py +++ b/src/autoskillit/server/tools/tools_execution.py @@ -83,6 +83,7 @@ build_bound_child_prompt, build_standalone_child_prompt, get_recipe_execution, + publish_reported_audit_cycle, record_runtime_binding_digest, ) from autoskillit.server._subprocess import _run_subprocess_captured @@ -178,6 +179,23 @@ def _recipe_execution_deny(code: str, message: str) -> str: ) +def _publish_audit_cycle_result( + tool_ctx: ToolContext, + *, + target_name: str | None, + skill_result: SkillResult, +) -> None: + """Publish a successful audit child's declared authority before step completion.""" + if not skill_result.success or target_name != "audit-impl": + return + authority_path = (skill_result.outcome_fields or {}).get("audit_cycle_path") + if not isinstance(authority_path, str) or not authority_path: + raise SkillContractError( + "Successful audit-impl result did not declare a valid audit_cycle_path" + ) + publish_reported_audit_cycle(tool_ctx, authority_path=authority_path) + + def _is_absolute_path(path: str) -> bool: """Return True if path is an absolute filesystem path.""" return Path(path).is_absolute() @@ -1634,6 +1652,12 @@ def _observe_contract_session_id(candidate_session_id: str) -> None: ) contract_lifecycle.apply_retention(skill_result.needs_retry) + if _installed_execution is not None: + _publish_audit_cycle_result( + tool_ctx, + target_name=target_name, + skill_result=skill_result, + ) if skill_result.success: tool_ctx.audit.record_success(skill_command) clear_run_skill_state(tool_ctx.project_dir) diff --git a/tests/server/test_audit_cycle_delivery_integration.py b/tests/server/test_audit_cycle_delivery_integration.py index 198e1b248..256157885 100644 --- a/tests/server/test_audit_cycle_delivery_integration.py +++ b/tests/server/test_audit_cycle_delivery_integration.py @@ -42,6 +42,8 @@ PlanDispositionRow, RecipeBindingProjection, RecipeExecutionSnapshot, + RetryReason, + SkillResult, VerifiedInputPreflightRequest, VerifiedInputPreflightResult, ) @@ -62,7 +64,7 @@ install_recipe_execution, publish_verified_audit_cycle, ) -from autoskillit.server.tools.tools_execution import run_skill +from autoskillit.server.tools.tools_execution import _publish_audit_cycle_result, run_skill pytestmark = [pytest.mark.layer("server"), pytest.mark.small] @@ -438,6 +440,55 @@ def test_published_audit_head_binds_preflight_template_identity( ) +def test_successful_audit_result_publishes_protected_successor_identity( + tool_ctx_kitchen_open, +) -> None: + snapshot = build_recipe_execution_snapshot( + recipe_name="demo", + content_hash=_HASH_A, + composite_hash=_HASH_B, + projection=_projection(), + execution_id="execution-1", + ) + install_recipe_execution(tool_ctx_kitchen_open, snapshot=snapshot) + authority = _authority( + Path(tool_ctx_kitchen_open.temp_dir), + generation="execution-1", + round_=1, + parent=None, + verdict=AuditVerdict.NO_GO, + ) + authority_path = Path(tool_ctx_kitchen_open.temp_dir) / "reported-authority.json" + authority_path.parent.mkdir(parents=True, exist_ok=True) + authority_path.write_bytes(authority.canonical_bytes) + result = SkillResult( + success=True, + result=f"audit_cycle_path = {authority_path}", + session_id="audit-session", + subtype="success", + is_error=False, + exit_code=0, + needs_retry=False, + retry_reason=RetryReason.NONE, + stderr="", + outcome_fields={"audit_cycle_path": str(authority_path)}, + ) + + _publish_audit_cycle_result( + tool_ctx_kitchen_open, + target_name="audit-impl", + skill_result=result, + ) + + installed = get_recipe_execution(tool_ctx_kitchen_open) + assert installed is not None + assert installed.preflight_identities["dry"] == ( + authority.plan_set_id, + authority.scope_id, + authority.part_id, + ) + + @pytest.mark.parametrize( ("option_name", "option_value"), [ From e296151ef097d9479d94a7c4b3b8792c6c4d32aa Mon Sep 17 00:00:00 2001 From: Trecek Date: Sat, 25 Jul 2026 16:29:30 -0700 Subject: [PATCH 28/89] fix: preserve execution module line budget --- src/autoskillit/server/_recipe_execution.py | 21 +++++++++- .../server/tools/tools_execution.py | 39 ++++--------------- .../test_audit_cycle_delivery_integration.py | 12 +++--- 3 files changed, 34 insertions(+), 38 deletions(-) diff --git a/src/autoskillit/server/_recipe_execution.py b/src/autoskillit/server/_recipe_execution.py index 3773761b6..8f058c45b 100644 --- a/src/autoskillit/server/_recipe_execution.py +++ b/src/autoskillit/server/_recipe_execution.py @@ -44,7 +44,7 @@ ) if TYPE_CHECKING: - from autoskillit.core import AuditCycleHeadStore + from autoskillit.core import AuditCycleHeadStore, SkillResult from autoskillit.pipeline import ToolContext __all__ = [ @@ -59,6 +59,7 @@ "clear_recipe_execution", "get_recipe_execution", "install_recipe_execution", + "publish_audit_cycle_result", "publish_reported_audit_cycle", "publish_verified_audit_cycle", "record_runtime_binding_digest", @@ -706,3 +707,21 @@ def publish_reported_audit_cycle( expected_parent_digest=(current.current_authority_digest if current is not None else None), expected_round=current.audit_round if current is not None else 0, ) + + +def publish_audit_cycle_result( + tool_ctx: ToolContext, + target_name: str | None, + skill_result: SkillResult, + installed: InstalledRecipeExecution | None, +) -> None: + """Publish a successful attested audit child's declared authority.""" + if not skill_result.success or target_name != "audit-impl" or installed is None: + return + authority_path = (skill_result.outcome_fields or {}).get("audit_cycle_path") + if not isinstance(authority_path, str) or not authority_path: + raise RecipeExecutionAdmissionError( + "recipe_execution_audit_output_missing", + "successful audit-impl result did not declare a valid audit_cycle_path", + ) + publish_reported_audit_cycle(tool_ctx, authority_path=authority_path) diff --git a/src/autoskillit/server/tools/tools_execution.py b/src/autoskillit/server/tools/tools_execution.py index 4d070a971..7bc176dd2 100644 --- a/src/autoskillit/server/tools/tools_execution.py +++ b/src/autoskillit/server/tools/tools_execution.py @@ -83,9 +83,11 @@ build_bound_child_prompt, build_standalone_child_prompt, get_recipe_execution, - publish_reported_audit_cycle, record_runtime_binding_digest, ) +from autoskillit.server._recipe_execution import ( + publish_audit_cycle_result as _publish_audit_result, +) from autoskillit.server._subprocess import _run_subprocess_captured from autoskillit.server.tools._backend_compat import ( _check_backend_compat, @@ -164,8 +166,6 @@ ) INGREDIENT_LOCK_DENY_PREFIX = "INGREDIENT LOCK ENFORCED" - - DEPENDENCY_DENY_PREFIX = "DEPENDENCY UNMET" @@ -179,28 +179,6 @@ def _recipe_execution_deny(code: str, message: str) -> str: ) -def _publish_audit_cycle_result( - tool_ctx: ToolContext, - *, - target_name: str | None, - skill_result: SkillResult, -) -> None: - """Publish a successful audit child's declared authority before step completion.""" - if not skill_result.success or target_name != "audit-impl": - return - authority_path = (skill_result.outcome_fields or {}).get("audit_cycle_path") - if not isinstance(authority_path, str) or not authority_path: - raise SkillContractError( - "Successful audit-impl result did not declare a valid audit_cycle_path" - ) - publish_reported_audit_cycle(tool_ctx, authority_path=authority_path) - - -def _is_absolute_path(path: str) -> bool: - """Return True if path is an absolute filesystem path.""" - return Path(path).is_absolute() - - def _check_ingredient_locks(step_name: str, order_id: str) -> str | None: """Check if step_name is locked out by ingredient locks. Returns deny JSON or None.""" from autoskillit.server import _get_ctx # circular-break @@ -684,7 +662,7 @@ async def run_skill( return tier_gate if (gate := _require_enabled()) is not None: return gate - if cwd and not _is_absolute_path(cwd): + if cwd and not Path(cwd).is_absolute(): return json.dumps( deny_envelope( ( @@ -1652,13 +1630,10 @@ def _observe_contract_session_id(candidate_session_id: str) -> None: ) contract_lifecycle.apply_retention(skill_result.needs_retry) - if _installed_execution is not None: - _publish_audit_cycle_result( - tool_ctx, - target_name=target_name, - skill_result=skill_result, - ) if skill_result.success: + _publish_audit_result( + tool_ctx, target_name, skill_result, _installed_execution + ) tool_ctx.audit.record_success(skill_command) clear_run_skill_state(tool_ctx.project_dir) if step_name: diff --git a/tests/server/test_audit_cycle_delivery_integration.py b/tests/server/test_audit_cycle_delivery_integration.py index 256157885..d6e8ac297 100644 --- a/tests/server/test_audit_cycle_delivery_integration.py +++ b/tests/server/test_audit_cycle_delivery_integration.py @@ -62,9 +62,10 @@ build_recipe_execution_snapshot, get_recipe_execution, install_recipe_execution, + publish_audit_cycle_result, publish_verified_audit_cycle, ) -from autoskillit.server.tools.tools_execution import _publish_audit_cycle_result, run_skill +from autoskillit.server.tools.tools_execution import run_skill pytestmark = [pytest.mark.layer("server"), pytest.mark.small] @@ -450,7 +451,7 @@ def test_successful_audit_result_publishes_protected_successor_identity( projection=_projection(), execution_id="execution-1", ) - install_recipe_execution(tool_ctx_kitchen_open, snapshot=snapshot) + installed = install_recipe_execution(tool_ctx_kitchen_open, snapshot=snapshot) authority = _authority( Path(tool_ctx_kitchen_open.temp_dir), generation="execution-1", @@ -474,10 +475,11 @@ def test_successful_audit_result_publishes_protected_successor_identity( outcome_fields={"audit_cycle_path": str(authority_path)}, ) - _publish_audit_cycle_result( + publish_audit_cycle_result( tool_ctx_kitchen_open, - target_name="audit-impl", - skill_result=result, + "audit-impl", + result, + installed, ) installed = get_recipe_execution(tool_ctx_kitchen_open) From 338e3b1386cb69d32a76302576d9f3f818aee218 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sat, 25 Jul 2026 17:39:38 -0700 Subject: [PATCH 29/89] test(review): bind exact diff annotation paths --- tests/contracts/test_review_pr_diff_annotation.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/contracts/test_review_pr_diff_annotation.py b/tests/contracts/test_review_pr_diff_annotation.py index 038b0620c..6a99a09b4 100644 --- a/tests/contracts/test_review_pr_diff_annotation.py +++ b/tests/contracts/test_review_pr_diff_annotation.py @@ -241,9 +241,11 @@ def test_review_skill_command_passes_diff_annotation_paths( assert review_steps, f"No {skill_name} step in {recipe_name}.yaml" for step_name, step in review_steps: skill_inputs = step.with_args["skill_inputs"] - assert "hunk_ranges_path" in skill_inputs, ( - f"{recipe_name}.yaml step '{step_name}' calls {skill_name} without hunk_ranges_path" + assert skill_inputs.get("hunk_ranges_path") == "${{ context.hunk_ranges_path }}", ( + f"{recipe_name}.yaml step '{step_name}' must bind hunk_ranges_path " + "to context.hunk_ranges_path" ) - assert "valid_lines_path" in skill_inputs, ( - f"{recipe_name}.yaml step '{step_name}' calls {skill_name} without valid_lines_path" + assert skill_inputs.get("valid_lines_path") == "${{ context.valid_lines_path }}", ( + f"{recipe_name}.yaml step '{step_name}' must bind valid_lines_path " + "to context.valid_lines_path" ) From bebd17119a9688c979a10a6a5f203491d572ded0 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sat, 25 Jul 2026 17:40:23 -0700 Subject: [PATCH 30/89] fix(review): reject noncanonical float inputs --- src/autoskillit/recipe/_contracts_types.py | 2 +- tests/recipe/test_contracts_types.py | 13 +++++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/autoskillit/recipe/_contracts_types.py b/src/autoskillit/recipe/_contracts_types.py index 38ffd0662..5a6451036 100644 --- a/src/autoskillit/recipe/_contracts_types.py +++ b/src/autoskillit/recipe/_contracts_types.py @@ -34,7 +34,7 @@ def accepts(self, value: object) -> bool: if normalized == "integer": return isinstance(value, int) and not isinstance(value, bool) if normalized in {"number", "float"}: - return isinstance(value, (int, float)) and not isinstance(value, bool) + return isinstance(value, int) and not isinstance(value, bool) if normalized in {"boolean", "bool"}: return isinstance(value, bool) return False diff --git a/tests/recipe/test_contracts_types.py b/tests/recipe/test_contracts_types.py index 0fec48ee8..1af9b0f28 100644 --- a/tests/recipe/test_contracts_types.py +++ b/tests/recipe/test_contracts_types.py @@ -1,4 +1,4 @@ -"""Tests for the SkillOutput contract dataclass. +"""Tests for recipe contract dataclasses. Verifies that SkillOutput carries the optional allowed_values field that the callable verdict routing rules depend on (recipe-routing-deadlock immunity, #3889). @@ -8,11 +8,20 @@ import pytest -from autoskillit.recipe._contracts_types import SkillOutput +from autoskillit.recipe._contracts_types import SkillInput, SkillOutput pytestmark = [pytest.mark.layer("recipe"), pytest.mark.small] +@pytest.mark.parametrize("input_type", ["number", "float"]) +def test_skill_input_rejects_noncanonical_float(input_type: str) -> None: + """Runtime-bound values must remain encodable by the canonical hash profile.""" + skill_input = SkillInput(name="value", type=input_type, required=True) + + assert skill_input.accepts(1) + assert not skill_input.accepts(1.5) + + def test_skill_output_accepts_allowed_values_kwarg() -> None: """SkillOutput must accept an `allowed_values` keyword argument and store it.""" output = SkillOutput( From 8a60636abcc0c590dc4af483d040e03344929d18 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sat, 25 Jul 2026 17:40:57 -0700 Subject: [PATCH 31/89] fix(review): validate input preflight contracts --- src/autoskillit/recipe/_contracts_types.py | 10 ++++++++++ tests/recipe/test_contracts_types.py | 13 ++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/autoskillit/recipe/_contracts_types.py b/src/autoskillit/recipe/_contracts_types.py index 5a6451036..94f894129 100644 --- a/src/autoskillit/recipe/_contracts_types.py +++ b/src/autoskillit/recipe/_contracts_types.py @@ -6,6 +6,8 @@ import regex as re +from autoskillit.core import PreflightKind + _CONTEXT_REF_RE = re.compile(r"\$\{\{\s*context\.(\w+)\s*\}\}") INPUT_REF_RE = re.compile(r"\$\{\{\s*inputs\.(\w+)\s*\}\}") _TEMPLATE_REF_RE = re.compile(r"\$\{\{[^}]+\}\}") @@ -81,6 +83,14 @@ class SkillContract: success_qualifiers: list[SuccessQualifierEntry] = dataclasses.field(default_factory=list) input_preflight: str | None = None + def __post_init__(self) -> None: + if self.input_preflight is None: + return + try: + self.input_preflight = PreflightKind(self.input_preflight).value + except ValueError as exc: + raise ValueError(f"unsupported input preflight: {self.input_preflight!r}") from exc + @dataclasses.dataclass(frozen=True, slots=True) class ToolOutputFieldSpec: diff --git a/tests/recipe/test_contracts_types.py b/tests/recipe/test_contracts_types.py index 1af9b0f28..e200e6e6b 100644 --- a/tests/recipe/test_contracts_types.py +++ b/tests/recipe/test_contracts_types.py @@ -8,7 +8,7 @@ import pytest -from autoskillit.recipe._contracts_types import SkillInput, SkillOutput +from autoskillit.recipe._contracts_types import SkillContract, SkillInput, SkillOutput pytestmark = [pytest.mark.layer("recipe"), pytest.mark.small] @@ -22,6 +22,17 @@ def test_skill_input_rejects_noncanonical_float(input_type: str) -> None: assert not skill_input.accepts(1.5) +def test_skill_contract_rejects_unknown_input_preflight() -> None: + with pytest.raises(ValueError, match="unsupported input preflight"): + SkillContract(inputs=(), outputs=[], input_preflight="unknown") + + +def test_skill_contract_accepts_supported_input_preflight() -> None: + contract = SkillContract(inputs=(), outputs=[], input_preflight="audit_cycle_inventory") + + assert contract.input_preflight == "audit_cycle_inventory" + + def test_skill_output_accepts_allowed_values_kwarg() -> None: """SkillOutput must accept an `allowed_values` keyword argument and store it.""" output = SkillOutput( From 4efd88f40d07f7390b3fb84b7e9e2c84b3806d92 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sat, 25 Jul 2026 17:42:34 -0700 Subject: [PATCH 32/89] fix(review): remove recipe loader compatibility shim --- src/autoskillit/recipe/_api.py | 4 ++-- src/autoskillit/recipe/io.py | 13 +++++-------- tests/recipe/test_io_json_precompile.py | 15 +++++++-------- tests/server/test_tools_load_recipe.py | 2 +- 4 files changed, 15 insertions(+), 19 deletions(-) diff --git a/src/autoskillit/recipe/_api.py b/src/autoskillit/recipe/_api.py index 176de8d26..52db4f51c 100644 --- a/src/autoskillit/recipe/_api.py +++ b/src/autoskillit/recipe/_api.py @@ -46,6 +46,7 @@ validate_from_path, ) from autoskillit.recipe._binding import bind_recipe +from autoskillit.recipe._io_loading import load_recipe_dict_with_declarations from autoskillit.recipe._recipe_composition import ( _assert_content_integrity, _build_active_recipe, @@ -86,7 +87,6 @@ from autoskillit.recipe.io import ( RecipeInfo, _assert_no_raw_placeholders, - _load_recipe_dict_with_declarations, _parse_recipe, builtin_recipes_dir, builtin_sub_recipes_dir, @@ -344,7 +344,7 @@ def load_and_validate( try: # Stage: yaml parse - data, declared_data = _load_recipe_dict_with_declarations( + data, declared_data = load_recipe_dict_with_declarations( match.path, raw_text=raw_declared, temp_dir_relpath=_temp_relpath, diff --git a/src/autoskillit/recipe/io.py b/src/autoskillit/recipe/io.py index a0bd6cd03..bbd5cb63a 100644 --- a/src/autoskillit/recipe/io.py +++ b/src/autoskillit/recipe/io.py @@ -22,12 +22,9 @@ from autoskillit.recipe._io_loading import ( _SCRIPTS_PLACEHOLDER as _SCRIPTS_PLACEHOLDER, ) -from autoskillit.recipe._io_loading import assert_no_raw_placeholders from autoskillit.recipe._io_loading import ( - load_recipe_dict as _load_recipe_dict, -) -from autoskillit.recipe._io_loading import ( - load_recipe_dict_with_declarations as _load_recipe_dict_with_declarations, + assert_no_raw_placeholders, + load_recipe_dict_with_declarations, ) from autoskillit.recipe._io_loading import ( substitute_scripts_placeholder as substitute_scripts_placeholder, @@ -89,7 +86,7 @@ def load_recipe(path: Path, temp_dir_relpath: str = ".autoskillit/temp") -> Reci ``_analysis.py`` imports ``iter_steps_with_context`` from this module, so a top-level import here would create a cycle. """ - data, declared_data = _load_recipe_dict_with_declarations( + data, declared_data = load_recipe_dict_with_declarations( path, temp_dir_relpath=temp_dir_relpath, ) @@ -674,8 +671,8 @@ def _collect_recipes( if f.suffix in (".yaml", ".yml") and f.is_file(): try: raw = f.read_text(encoding="utf-8") - data = _load_recipe_dict(f, raw_text=raw) - recipe = _parse_recipe(data) + data, declared_data = load_recipe_dict_with_declarations(f, raw_text=raw) + recipe = _parse_recipe(data, declared_data=declared_data) if recipe.name and recipe.name not in seen: seen.add(recipe.name) from autoskillit.recipe.staleness_cache import ( # noqa: PLC0415 diff --git a/tests/recipe/test_io_json_precompile.py b/tests/recipe/test_io_json_precompile.py index de8a7b5f0..acd7d6177 100644 --- a/tests/recipe/test_io_json_precompile.py +++ b/tests/recipe/test_io_json_precompile.py @@ -11,10 +11,9 @@ from autoskillit.core import RecipeSource from autoskillit.core.io import load_yaml +from autoskillit.recipe._io_loading import load_recipe_dict, load_recipe_dict_with_declarations from autoskillit.recipe.io import ( _collect_recipes, - _load_recipe_dict, - _load_recipe_dict_with_declarations, builtin_recipes_dir, ) @@ -55,7 +54,7 @@ def test_load_recipe_dict_prefers_json_when_fresh(tmp_path, monkeypatch): lambda *a, **kw: load_yaml_calls.append(1) or _MINIMAL_RECIPE, ) - result = _load_recipe_dict(yaml_path) + result = load_recipe_dict(yaml_path) assert result == _MINIMAL_RECIPE assert load_yaml_calls == [], "load_yaml should not be called when JSON is fresh" @@ -71,7 +70,7 @@ def test_load_recipe_dict_falls_back_when_json_missing(tmp_path, monkeypatch): lambda *a, **kw: load_yaml_calls.append(1) or _MINIMAL_RECIPE, ) - result = _load_recipe_dict(yaml_path) + result = load_recipe_dict(yaml_path) assert result == _MINIMAL_RECIPE assert load_yaml_calls == [1], "load_yaml should be called when JSON sibling is absent" @@ -93,7 +92,7 @@ def test_load_recipe_dict_falls_back_when_json_stale(tmp_path, monkeypatch): lambda *a, **kw: load_yaml_calls.append(1) or _MINIMAL_RECIPE, ) - result = _load_recipe_dict(yaml_path) + result = load_recipe_dict(yaml_path) assert result == _MINIMAL_RECIPE assert load_yaml_calls == [1], "load_yaml should be called when JSON is stale" @@ -117,7 +116,7 @@ def test_load_recipe_dict_applies_substitution_on_json(tmp_path): future_mtime_ns = yaml_path.stat().st_mtime_ns + 10_000_000_000 os.utime(json_path, ns=(future_mtime_ns, future_mtime_ns)) - result, declared = _load_recipe_dict_with_declarations( + result, declared = load_recipe_dict_with_declarations( yaml_path, temp_dir_relpath="custom/temp", ) @@ -141,7 +140,7 @@ def test_load_recipe_dict_handles_json_decode_error(tmp_path, monkeypatch): lambda *a, **kw: load_yaml_calls.append(1) or _MINIMAL_RECIPE, ) - result = _load_recipe_dict(yaml_path) + result = load_recipe_dict(yaml_path) assert result == _MINIMAL_RECIPE assert load_yaml_calls == [1], "load_yaml should be called when JSON is corrupt" @@ -162,7 +161,7 @@ def test_load_recipe_dict_falls_back_when_json_is_not_mapping(tmp_path, monkeypa lambda *a, **kw: load_yaml_calls.append(1) or _MINIMAL_RECIPE, ) - result = _load_recipe_dict(yaml_path) + result = load_recipe_dict(yaml_path) assert result == _MINIMAL_RECIPE assert load_yaml_calls == [1], "load_yaml should be called when JSON is not a mapping" diff --git a/tests/server/test_tools_load_recipe.py b/tests/server/test_tools_load_recipe.py index 135bf055c..eacedeb6b 100644 --- a/tests/server/test_tools_load_recipe.py +++ b/tests/server/test_tools_load_recipe.py @@ -209,7 +209,7 @@ async def test_yaml_error_surfaces_as_suggestion( recipes_dir.mkdir(parents=True) (recipes_dir / "test.yaml").write_text("name: test\n") with patch( - "autoskillit.recipe._api._load_recipe_dict_with_declarations", + "autoskillit.recipe._api.load_recipe_dict_with_declarations", side_effect=YAMLError("bad yaml"), ): result = json.loads(await load_recipe(name="test")) From e3afd7e8122ffa900b4347478366005f85e5ba6e Mon Sep 17 00:00:00 2001 From: Trecek Date: Sat, 25 Jul 2026 17:44:36 -0700 Subject: [PATCH 33/89] fix(review): require inventory requirement arrays --- src/autoskillit/core/audit_cycle_verifier.py | 2 ++ tests/core/test_inventory_admission.py | 38 +++++++++++++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/autoskillit/core/audit_cycle_verifier.py b/src/autoskillit/core/audit_cycle_verifier.py index 66b54e314..070e20177 100644 --- a/src/autoskillit/core/audit_cycle_verifier.py +++ b/src/autoskillit/core/audit_cycle_verifier.py @@ -572,6 +572,8 @@ def _verify_active_tuple( try: requirement_ids_raw = inventory_raw["requirement_ids"] requirements_raw = inventory_raw["requirements"] + if not isinstance(requirement_ids_raw, list) or not isinstance(requirements_raw, list): + raise TypeError("requirement_ids and requirements must be arrays") requirement_ids = tuple(requirement_ids_raw) row_ids = tuple(item["id"] for item in requirements_raw) except (KeyError, TypeError) as exc: diff --git a/tests/core/test_inventory_admission.py b/tests/core/test_inventory_admission.py index 8f8521d6b..ca9617d75 100644 --- a/tests/core/test_inventory_admission.py +++ b/tests/core/test_inventory_admission.py @@ -14,6 +14,7 @@ AuditAssessmentRow, AuditCycleAuthority, AuditCycleHead, + AuditCycleVerificationError, AuditCycleVerifier, AuditVerdict, InventoryAdmissionDecision, @@ -21,7 +22,7 @@ PlanDispositionReport, PlanDispositionRow, ) -from autoskillit.core.closure_hashing import compute_bytes_hash +from autoskillit.core.closure_hashing import canonical_json_bytes, compute_bytes_hash pytestmark = [pytest.mark.layer("core"), pytest.mark.small] @@ -342,6 +343,41 @@ def test_inventory_order_duplicates_missing_and_extra_reject( } +def test_inventory_requirement_ids_must_be_array( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + authority = _authority(tmp_path) + report = _report(tmp_path, authority) + inventory_bytes = canonical_json_bytes( + { + "schema_version": 1, + "requirement_ids": "AB", + "requirements": [{"id": "A"}, {"id": "B"}], + } + ) + verifier = AuditCycleVerifier(tmp_path) + monkeypatch.setattr(verifier, "load_report", lambda _path: report) + + def verify_artifact(ref: ArtifactRef) -> bytes: + if ref == authority.inventory_ref: + return inventory_bytes + if ref == report.current_plan_ref: + return _plan_text(report.dispositions).encode() + return b"verified" + + monkeypatch.setattr(verifier, "verify_artifact_ref", verify_artifact) + + with pytest.raises(AuditCycleVerificationError) as exc_info: + verifier._verify_active_tuple( + authority=authority, + report_path=tmp_path / "report.json", + trusted_head=_head(authority), + current_plan_path=report.current_plan_ref.locator, + ) + + assert exc_info.value.reason is AdmissionReason.INVENTORY_INVALID + + def test_report_row_reorder_rejects(tmp_path: Path) -> None: authority = _authority(tmp_path) rows = tuple(reversed(_dispositions())) From 3b37eb998e8ae1191cb9db05ee0221e9f9ce7447 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sat, 25 Jul 2026 17:45:44 -0700 Subject: [PATCH 34/89] fix(review): enforce bound value absence invariants --- .../core/types/_type_recipe_binding.py | 15 ++++++++ tests/recipe/test_skill_invocation_binding.py | 37 +++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/src/autoskillit/core/types/_type_recipe_binding.py b/src/autoskillit/core/types/_type_recipe_binding.py index c21ea5b82..ac3ca7959 100644 --- a/src/autoskillit/core/types/_type_recipe_binding.py +++ b/src/autoskillit/core/types/_type_recipe_binding.py @@ -139,6 +139,21 @@ class BoundValue: input_dependencies: tuple[str, ...] = () template_dependencies: tuple[str, ...] = () + def __post_init__(self) -> None: + declared_absent = isinstance(self.declared_value, AbsentBoundValue) + effective_absent = isinstance(self.effective_value, AbsentBoundValue) + if self.state is BoundValueState.ABSENT: + if ( + not declared_absent + or not effective_absent + or self.origin is not BoundValueOrigin.ABSENT + ): + raise ValueError( + "absent bound values require absent declared/effective values and origin" + ) + elif declared_absent or effective_absent or self.origin is BoundValueOrigin.ABSENT: + raise ValueError("present bound values cannot use an absent value or origin") + @classmethod def absent(cls, name: str) -> BoundValue: return cls( diff --git a/tests/recipe/test_skill_invocation_binding.py b/tests/recipe/test_skill_invocation_binding.py index f3073a269..a84a2a23b 100644 --- a/tests/recipe/test_skill_invocation_binding.py +++ b/tests/recipe/test_skill_invocation_binding.py @@ -11,8 +11,10 @@ import autoskillit.recipe._binding as binding_module from autoskillit.core import ( + ABSENT_BOUND_VALUE, BindingFailureCode, BindingMode, + BoundValue, BoundValueOrigin, BoundValueState, ) @@ -72,6 +74,41 @@ def _required_inputs() -> dict[str, str]: } +@pytest.mark.parametrize( + "declared,effective,state,origin", + [ + ("declared", "effective", BoundValueState.ABSENT, BoundValueOrigin.ABSENT), + ( + ABSENT_BOUND_VALUE, + ABSENT_BOUND_VALUE, + BoundValueState.PRESENT, + BoundValueOrigin.LITERAL, + ), + ( + ABSENT_BOUND_VALUE, + ABSENT_BOUND_VALUE, + BoundValueState.ABSENT, + BoundValueOrigin.LITERAL, + ), + ("declared", "effective", BoundValueState.PRESENT, BoundValueOrigin.ABSENT), + ], +) +def test_bound_value_rejects_contradictory_absence( + declared: object, + effective: object, + state: BoundValueState, + origin: BoundValueOrigin, +) -> None: + with pytest.raises(ValueError, match="absent"): + BoundValue( + name="value", + declared_value=declared, + effective_value=effective, + state=state, + origin=origin, + ) + + def test_contract_input_order_is_explicit_and_immutable() -> None: contract = get_skill_contract("dry-walkthrough", _manifest()) assert contract is not None From 1fa76d351c50e259c31562893ef2474254cb674e Mon Sep 17 00:00:00 2001 From: Trecek Date: Sat, 25 Jul 2026 17:46:19 -0700 Subject: [PATCH 35/89] fix(review): freeze bound invocation collections --- .../core/types/_type_recipe_binding.py | 14 +++++++ tests/recipe/test_skill_invocation_binding.py | 42 +++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/src/autoskillit/core/types/_type_recipe_binding.py b/src/autoskillit/core/types/_type_recipe_binding.py index ac3ca7959..9479d3317 100644 --- a/src/autoskillit/core/types/_type_recipe_binding.py +++ b/src/autoskillit/core/types/_type_recipe_binding.py @@ -203,6 +203,20 @@ class BoundStepInvocation: skill_inputs: tuple[BoundValue, ...] failures: tuple[BindingFailure, ...] = () + def __post_init__(self) -> None: + mcp_kwargs = tuple(self.mcp_kwargs) + skill_inputs = tuple(self.skill_inputs) + failures = tuple(self.failures) + if any(not isinstance(value, BoundValue) for value in mcp_kwargs): + raise TypeError("mcp_kwargs must contain only BoundValue entries") + if any(not isinstance(value, BoundValue) for value in skill_inputs): + raise TypeError("skill_inputs must contain only BoundValue entries") + if any(not isinstance(failure, BindingFailure) for failure in failures): + raise TypeError("failures must contain only BindingFailure entries") + object.__setattr__(self, "mcp_kwargs", mcp_kwargs) + object.__setattr__(self, "skill_inputs", skill_inputs) + object.__setattr__(self, "failures", failures) + @property def is_valid(self) -> bool: return not self.failures diff --git a/tests/recipe/test_skill_invocation_binding.py b/tests/recipe/test_skill_invocation_binding.py index a84a2a23b..1880c609f 100644 --- a/tests/recipe/test_skill_invocation_binding.py +++ b/tests/recipe/test_skill_invocation_binding.py @@ -14,6 +14,7 @@ ABSENT_BOUND_VALUE, BindingFailureCode, BindingMode, + BoundStepInvocation, BoundValue, BoundValueOrigin, BoundValueState, @@ -109,6 +110,47 @@ def test_bound_value_rejects_contradictory_absence( ) +def test_bound_step_invocation_freezes_collection_inputs() -> None: + value = BoundValue( + name="value", + declared_value="declared", + effective_value="effective", + state=BoundValueState.PRESENT, + origin=BoundValueOrigin.LITERAL, + ) + mcp_kwargs = [value] + skill_inputs = [value] + failures = [] + + invocation = BoundStepInvocation( + step_name="verify", + tool_name="run_skill", + mode=BindingMode.RECIPE, + skill_name="dry-walkthrough", + mcp_kwargs=mcp_kwargs, # type: ignore[arg-type] + skill_inputs=skill_inputs, # type: ignore[arg-type] + failures=failures, # type: ignore[arg-type] + ) + mcp_kwargs.clear() + skill_inputs.clear() + + assert invocation.mcp_kwargs == (value,) + assert invocation.skill_inputs == (value,) + assert invocation.failures == () + + +def test_bound_step_invocation_rejects_invalid_collection_elements() -> None: + with pytest.raises(TypeError, match="mcp_kwargs"): + BoundStepInvocation( + step_name="verify", + tool_name="run_skill", + mode=BindingMode.RECIPE, + skill_name="dry-walkthrough", + mcp_kwargs=("not-bound",), # type: ignore[arg-type] + skill_inputs=(), + ) + + def test_contract_input_order_is_explicit_and_immutable() -> None: contract = get_skill_contract("dry-walkthrough", _manifest()) assert contract is not None From 9f334f2273c070bf118697f9a54c202b8477bd78 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sat, 25 Jul 2026 17:47:09 -0700 Subject: [PATCH 36/89] fix(review): validate snapshot recipe hash identities --- .../core/types/_type_recipe_execution.py | 4 +++- .../test_audit_cycle_delivery_integration.py | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/autoskillit/core/types/_type_recipe_execution.py b/src/autoskillit/core/types/_type_recipe_execution.py index 4432336b2..e9d8fc689 100644 --- a/src/autoskillit/core/types/_type_recipe_execution.py +++ b/src/autoskillit/core/types/_type_recipe_execution.py @@ -14,7 +14,7 @@ from types import MappingProxyType from typing import Any, Protocol, runtime_checkable -from ..closure_hashing import compute_canonical_hash +from ..closure_hashing import HASH_RE, compute_canonical_hash from ._type_audit_cycle import AuditCycleAuthority, AuditCycleHead, InventoryAdmissionDecision from ._type_recipe_binding import ( AbsentBoundValue, @@ -146,6 +146,8 @@ class RecipeExecutionSnapshot: def __post_init__(self) -> None: if not self.execution_id or not self.recipe_name: raise ValueError("recipe execution identity must be non-empty") + if not HASH_RE.fullmatch(self.content_hash) or not HASH_RE.fullmatch(self.composite_hash): + raise ValueError("recipe execution hashes must be canonical sha256 identities") copied = dict(self.templates) if any(name != template.invocation.step_name for name, template in copied.items()): raise ValueError("recipe execution template keys must match step names") diff --git a/tests/server/test_audit_cycle_delivery_integration.py b/tests/server/test_audit_cycle_delivery_integration.py index d6e8ac297..f5734450e 100644 --- a/tests/server/test_audit_cycle_delivery_integration.py +++ b/tests/server/test_audit_cycle_delivery_integration.py @@ -403,6 +403,20 @@ def test_snapshot_rejects_digest_that_does_not_attest_invocation() -> None: ) +@pytest.mark.parametrize("field", ["content_hash", "composite_hash"]) +def test_snapshot_rejects_invalid_recipe_hash_identity(field: str) -> None: + snapshot = build_recipe_execution_snapshot( + recipe_name="demo", + content_hash=_HASH_A, + composite_hash=_HASH_B, + projection=_projection(), + execution_id="execution-1", + ) + + with pytest.raises(ValueError, match="canonical sha256"): + replace(snapshot, **{field: "not-a-recipe-hash"}) + + def test_published_audit_head_binds_preflight_template_identity( tool_ctx_kitchen_open, ) -> None: From 4ac972e9ba26cce26c6d60f747466eef6b223264 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sat, 25 Jul 2026 17:49:01 -0700 Subject: [PATCH 37/89] fix(review): attest full runtime binding data --- .../core/types/_type_recipe_execution.py | 13 +++++-- .../server/tools/tools_execution.py | 3 +- .../test_audit_cycle_delivery_integration.py | 39 +++++++++++++++++++ 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/autoskillit/core/types/_type_recipe_execution.py b/src/autoskillit/core/types/_type_recipe_execution.py index e9d8fc689..febca2912 100644 --- a/src/autoskillit/core/types/_type_recipe_execution.py +++ b/src/autoskillit/core/types/_type_recipe_execution.py @@ -280,18 +280,25 @@ def compute_runtime_binding_digest( step_name: str, template_digest: str, bound_inputs: tuple[tuple[str, BoundScalar], ...], - preflight: InventoryAdmissionDecision | None, + actual_mcp_kwargs: Mapping[str, BoundScalar], + preflight: VerifiedInputPreflightResult | None, ) -> str: """Hash actual ordered values independently from the template/payload.""" payload = { "bound_inputs": [{"name": name, "value": value} for name, value in bound_inputs], "execution_id": execution_id, + "mcp_kwargs": [ + {"name": name, "value": actual_mcp_kwargs[name]} for name in sorted(actual_mcp_kwargs) + ], "preflight": ( None if preflight is None else { - "reason": preflight.reason.value, - "status": preflight.status.value, + "evidence": [ + {"name": item.name, "value": item.value} for item in preflight.evidence + ], + "reason": preflight.decision.reason.value, + "status": preflight.decision.status.value, } ), "step_name": step_name, diff --git a/src/autoskillit/server/tools/tools_execution.py b/src/autoskillit/server/tools/tools_execution.py index 7bc176dd2..b1f975b2d 100644 --- a/src/autoskillit/server/tools/tools_execution.py +++ b/src/autoskillit/server/tools/tools_execution.py @@ -962,7 +962,8 @@ async def run_skill( step_name=step_name, template_digest=invocation_template_digest, bound_inputs=_bound_recipe_inputs, - preflight=(_preflight_result.decision if _preflight_result is not None else None), + actual_mcp_kwargs=_actual_mcp_kwargs, + preflight=_preflight_result, ) try: record_runtime_binding_digest( diff --git a/tests/server/test_audit_cycle_delivery_integration.py b/tests/server/test_audit_cycle_delivery_integration.py index f5734450e..33f8521d2 100644 --- a/tests/server/test_audit_cycle_delivery_integration.py +++ b/tests/server/test_audit_cycle_delivery_integration.py @@ -40,12 +40,14 @@ InvocationTemplate, PlanDispositionReport, PlanDispositionRow, + PreflightEvidence, RecipeBindingProjection, RecipeExecutionSnapshot, RetryReason, SkillResult, VerifiedInputPreflightRequest, VerifiedInputPreflightResult, + compute_runtime_binding_digest, ) from autoskillit.server._recipe_delivery import ( complete_finalized_recipe_response, @@ -403,6 +405,43 @@ def test_snapshot_rejects_digest_that_does_not_attest_invocation() -> None: ) +def test_runtime_binding_digest_attests_mcp_values_and_preflight_evidence() -> None: + preflight = VerifiedInputPreflightResult( + decision=InventoryAdmissionDecision.omit(AdmissionReason.NO_AUTHORITY), + evidence=(PreflightEvidence("inventory_dispositions", "[]"),), + ) + digest = compute_runtime_binding_digest( + execution_id="execution-1", + step_name="dry", + template_digest=_HASH_A, + bound_inputs=(("plan_path", "/plans/current.md"),), + actual_mcp_kwargs={"cwd": "/repo", "output_dir": "/output/a"}, + preflight=preflight, + ) + changed_mcp_digest = compute_runtime_binding_digest( + execution_id="execution-1", + step_name="dry", + template_digest=_HASH_A, + bound_inputs=(("plan_path", "/plans/current.md"),), + actual_mcp_kwargs={"cwd": "/repo", "output_dir": "/output/b"}, + preflight=preflight, + ) + changed_evidence_digest = compute_runtime_binding_digest( + execution_id="execution-1", + step_name="dry", + template_digest=_HASH_A, + bound_inputs=(("plan_path", "/plans/current.md"),), + actual_mcp_kwargs={"cwd": "/repo", "output_dir": "/output/a"}, + preflight=replace( + preflight, + evidence=(PreflightEvidence("inventory_dispositions", "[changed]"),), + ), + ) + + assert digest != changed_mcp_digest + assert digest != changed_evidence_digest + + @pytest.mark.parametrize("field", ["content_hash", "composite_hash"]) def test_snapshot_rejects_invalid_recipe_hash_identity(field: str) -> None: snapshot = build_recipe_execution_snapshot( From ebff549c29b3634f28fa77c99cf09f60acf49018 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sat, 25 Jul 2026 17:50:39 -0700 Subject: [PATCH 38/89] fix(review): remove CLI prefixes from structured inputs --- .../recipes/implementation-groups.json | 16 ++++++------ .../recipes/implementation-groups.yaml | 16 ++++++------ src/autoskillit/recipes/implementation.json | 24 +++++++++--------- src/autoskillit/recipes/implementation.yaml | 24 +++++++++--------- src/autoskillit/recipes/merge-prs.json | 8 +++--- src/autoskillit/recipes/merge-prs.yaml | 8 +++--- src/autoskillit/recipes/remediation.json | 24 +++++++++--------- src/autoskillit/recipes/remediation.yaml | 24 +++++++++--------- src/autoskillit/recipes/research.json | 6 ++--- src/autoskillit/recipes/research.yaml | 6 ++--- .../test_bundled_recipes_dispatch_ready.py | 25 +++++++++++++++++++ 11 files changed, 103 insertions(+), 78 deletions(-) diff --git a/src/autoskillit/recipes/implementation-groups.json b/src/autoskillit/recipes/implementation-groups.json index 4fe4b624c..fe63ecf5f 100644 --- a/src/autoskillit/recipes/implementation-groups.json +++ b/src/autoskillit/recipes/implementation-groups.json @@ -1743,8 +1743,8 @@ "skill_command": "/autoskillit:analyze-pipeline-health", "skill_inputs": { "kitchen_id": "${{ inputs.kitchen_id }}", - "dispatch_id": "--dispatch-id=${{ inputs.dispatch_id }}", - "diagnostics_log_dir": "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" + "dispatch_id": "${{ inputs.dispatch_id }}", + "diagnostics_log_dir": "${{ inputs.diagnostics_log_dir }}" }, "cwd": "${{ context.work_dir }}", "step_name": "analyze_pipeline_health" @@ -2696,8 +2696,8 @@ "skill_command": "/autoskillit:analyze-pipeline-health", "skill_inputs": { "kitchen_id": "${{ inputs.kitchen_id }}", - "dispatch_id": "--dispatch-id=${{ inputs.dispatch_id }}", - "diagnostics_log_dir": "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" + "dispatch_id": "${{ inputs.dispatch_id }}", + "diagnostics_log_dir": "${{ inputs.diagnostics_log_dir }}" }, "cwd": "${{ context.work_dir }}", "step_name": "analyze_pipeline_health" @@ -2821,8 +2821,8 @@ "skill_command": "/autoskillit:analyze-pipeline-health", "skill_inputs": { "kitchen_id": "${{ inputs.kitchen_id }}", - "dispatch_id": "--dispatch-id=${{ inputs.dispatch_id }}", - "diagnostics_log_dir": "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" + "dispatch_id": "${{ inputs.dispatch_id }}", + "diagnostics_log_dir": "${{ inputs.diagnostics_log_dir }}" }, "cwd": "${{ context.work_dir }}", "step_name": "analyze_pipeline_health" @@ -2855,8 +2855,8 @@ "skill_command": "/autoskillit:analyze-pipeline-health", "skill_inputs": { "kitchen_id": "${{ inputs.kitchen_id }}", - "dispatch_id": "--dispatch-id=${{ inputs.dispatch_id }}", - "diagnostics_log_dir": "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" + "dispatch_id": "${{ inputs.dispatch_id }}", + "diagnostics_log_dir": "${{ inputs.diagnostics_log_dir }}" }, "cwd": "${{ context.work_dir }}", "step_name": "analyze_pipeline_health" diff --git a/src/autoskillit/recipes/implementation-groups.yaml b/src/autoskillit/recipes/implementation-groups.yaml index e0dc7e000..2ae1c6d98 100644 --- a/src/autoskillit/recipes/implementation-groups.yaml +++ b/src/autoskillit/recipes/implementation-groups.yaml @@ -1551,8 +1551,8 @@ steps: skill_command: "/autoskillit:analyze-pipeline-health" skill_inputs: kitchen_id: "${{ inputs.kitchen_id }}" - dispatch_id: "--dispatch-id=${{ inputs.dispatch_id }}" - diagnostics_log_dir: "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" + dispatch_id: "${{ inputs.dispatch_id }}" + diagnostics_log_dir: "${{ inputs.diagnostics_log_dir }}" cwd: ${{ context.work_dir }} step_name: analyze_pipeline_health on_success: escalate_stop_no_ci @@ -2394,8 +2394,8 @@ steps: skill_command: "/autoskillit:analyze-pipeline-health" skill_inputs: kitchen_id: "${{ inputs.kitchen_id }}" - dispatch_id: "--dispatch-id=${{ inputs.dispatch_id }}" - diagnostics_log_dir: "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" + dispatch_id: "${{ inputs.dispatch_id }}" + diagnostics_log_dir: "${{ inputs.diagnostics_log_dir }}" cwd: ${{ context.work_dir }} step_name: analyze_pipeline_health on_success: done_unconfirmed @@ -2534,8 +2534,8 @@ steps: skill_command: "/autoskillit:analyze-pipeline-health" skill_inputs: kitchen_id: "${{ inputs.kitchen_id }}" - dispatch_id: "--dispatch-id=${{ inputs.dispatch_id }}" - diagnostics_log_dir: "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" + dispatch_id: "${{ inputs.dispatch_id }}" + diagnostics_log_dir: "${{ inputs.diagnostics_log_dir }}" cwd: ${{ context.work_dir }} step_name: analyze_pipeline_health on_success: done @@ -2567,8 +2567,8 @@ steps: skill_command: "/autoskillit:analyze-pipeline-health" skill_inputs: kitchen_id: "${{ inputs.kitchen_id }}" - dispatch_id: "--dispatch-id=${{ inputs.dispatch_id }}" - diagnostics_log_dir: "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" + dispatch_id: "${{ inputs.dispatch_id }}" + diagnostics_log_dir: "${{ inputs.diagnostics_log_dir }}" cwd: ${{ context.work_dir }} step_name: analyze_pipeline_health on_success: escalate_stop diff --git a/src/autoskillit/recipes/implementation.json b/src/autoskillit/recipes/implementation.json index f3a377345..4f3ed5d06 100644 --- a/src/autoskillit/recipes/implementation.json +++ b/src/autoskillit/recipes/implementation.json @@ -1814,8 +1814,8 @@ "skill_command": "/autoskillit:analyze-pipeline-health", "skill_inputs": { "kitchen_id": "${{ inputs.kitchen_id }}", - "dispatch_id": "--dispatch-id=${{ inputs.dispatch_id }}", - "diagnostics_log_dir": "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" + "dispatch_id": "${{ inputs.dispatch_id }}", + "diagnostics_log_dir": "${{ inputs.diagnostics_log_dir }}" }, "cwd": "${{ context.work_dir }}", "step_name": "analyze_pipeline_health" @@ -2841,8 +2841,8 @@ "skill_command": "/autoskillit:analyze-pipeline-health", "skill_inputs": { "kitchen_id": "${{ inputs.kitchen_id }}", - "dispatch_id": "--dispatch-id=${{ inputs.dispatch_id }}", - "diagnostics_log_dir": "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" + "dispatch_id": "${{ inputs.dispatch_id }}", + "diagnostics_log_dir": "${{ inputs.diagnostics_log_dir }}" }, "cwd": "${{ context.work_dir }}", "step_name": "analyze_pipeline_health" @@ -2902,8 +2902,8 @@ "skill_command": "/autoskillit:analyze-pipeline-health", "skill_inputs": { "kitchen_id": "${{ inputs.kitchen_id }}", - "dispatch_id": "--dispatch-id=${{ inputs.dispatch_id }}", - "diagnostics_log_dir": "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" + "dispatch_id": "${{ inputs.dispatch_id }}", + "diagnostics_log_dir": "${{ inputs.diagnostics_log_dir }}" }, "cwd": "${{ context.work_dir }}", "step_name": "analyze_pipeline_health" @@ -2925,8 +2925,8 @@ "skill_command": "/autoskillit:analyze-pipeline-health", "skill_inputs": { "kitchen_id": "${{ inputs.kitchen_id }}", - "dispatch_id": "--dispatch-id=${{ inputs.dispatch_id }}", - "diagnostics_log_dir": "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" + "dispatch_id": "${{ inputs.dispatch_id }}", + "diagnostics_log_dir": "${{ inputs.diagnostics_log_dir }}" }, "cwd": "${{ context.work_dir }}", "step_name": "analyze_pipeline_health" @@ -2959,8 +2959,8 @@ "skill_command": "/autoskillit:analyze-pipeline-health", "skill_inputs": { "kitchen_id": "${{ inputs.kitchen_id }}", - "dispatch_id": "--dispatch-id=${{ inputs.dispatch_id }}", - "diagnostics_log_dir": "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" + "dispatch_id": "${{ inputs.dispatch_id }}", + "diagnostics_log_dir": "${{ inputs.diagnostics_log_dir }}" }, "cwd": "${{ context.work_dir }}", "step_name": "analyze_pipeline_health" @@ -2993,8 +2993,8 @@ "skill_command": "/autoskillit:analyze-pipeline-health", "skill_inputs": { "kitchen_id": "${{ inputs.kitchen_id }}", - "dispatch_id": "--dispatch-id=${{ inputs.dispatch_id }}", - "diagnostics_log_dir": "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" + "dispatch_id": "${{ inputs.dispatch_id }}", + "diagnostics_log_dir": "${{ inputs.diagnostics_log_dir }}" }, "cwd": "${{ context.work_dir }}", "step_name": "analyze_pipeline_health" diff --git a/src/autoskillit/recipes/implementation.yaml b/src/autoskillit/recipes/implementation.yaml index c93294981..2d21bcb0f 100644 --- a/src/autoskillit/recipes/implementation.yaml +++ b/src/autoskillit/recipes/implementation.yaml @@ -1597,8 +1597,8 @@ steps: skill_command: "/autoskillit:analyze-pipeline-health" skill_inputs: kitchen_id: "${{ inputs.kitchen_id }}" - dispatch_id: "--dispatch-id=${{ inputs.dispatch_id }}" - diagnostics_log_dir: "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" + dispatch_id: "${{ inputs.dispatch_id }}" + diagnostics_log_dir: "${{ inputs.diagnostics_log_dir }}" cwd: ${{ context.work_dir }} step_name: analyze_pipeline_health on_success: escalate_stop_no_ci @@ -2525,8 +2525,8 @@ steps: skill_command: "/autoskillit:analyze-pipeline-health" skill_inputs: kitchen_id: "${{ inputs.kitchen_id }}" - dispatch_id: "--dispatch-id=${{ inputs.dispatch_id }}" - diagnostics_log_dir: "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" + dispatch_id: "${{ inputs.dispatch_id }}" + diagnostics_log_dir: "${{ inputs.diagnostics_log_dir }}" cwd: ${{ context.work_dir }} step_name: analyze_pipeline_health on_success: done_unconfirmed @@ -2584,8 +2584,8 @@ steps: skill_command: "/autoskillit:analyze-pipeline-health" skill_inputs: kitchen_id: "${{ inputs.kitchen_id }}" - dispatch_id: "--dispatch-id=${{ inputs.dispatch_id }}" - diagnostics_log_dir: "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" + dispatch_id: "${{ inputs.dispatch_id }}" + diagnostics_log_dir: "${{ inputs.diagnostics_log_dir }}" cwd: ${{ context.work_dir }} step_name: analyze_pipeline_health on_success: done @@ -2607,8 +2607,8 @@ steps: skill_command: "/autoskillit:analyze-pipeline-health" skill_inputs: kitchen_id: "${{ inputs.kitchen_id }}" - dispatch_id: "--dispatch-id=${{ inputs.dispatch_id }}" - diagnostics_log_dir: "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" + dispatch_id: "${{ inputs.dispatch_id }}" + diagnostics_log_dir: "${{ inputs.diagnostics_log_dir }}" cwd: ${{ context.work_dir }} step_name: analyze_pipeline_health on_success: escalate_stop @@ -2639,8 +2639,8 @@ steps: skill_command: "/autoskillit:analyze-pipeline-health" skill_inputs: kitchen_id: "${{ inputs.kitchen_id }}" - dispatch_id: "--dispatch-id=${{ inputs.dispatch_id }}" - diagnostics_log_dir: "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" + dispatch_id: "${{ inputs.dispatch_id }}" + diagnostics_log_dir: "${{ inputs.diagnostics_log_dir }}" cwd: ${{ context.work_dir }} step_name: analyze_pipeline_health on_success: done_no_changes @@ -2671,8 +2671,8 @@ steps: skill_command: "/autoskillit:analyze-pipeline-health" skill_inputs: kitchen_id: "${{ inputs.kitchen_id }}" - dispatch_id: "--dispatch-id=${{ inputs.dispatch_id }}" - diagnostics_log_dir: "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" + dispatch_id: "${{ inputs.dispatch_id }}" + diagnostics_log_dir: "${{ inputs.diagnostics_log_dir }}" cwd: ${{ context.work_dir }} step_name: analyze_pipeline_health on_success: done_already_done diff --git a/src/autoskillit/recipes/merge-prs.json b/src/autoskillit/recipes/merge-prs.json index 0c8b0a792..7357ffdda 100644 --- a/src/autoskillit/recipes/merge-prs.json +++ b/src/autoskillit/recipes/merge-prs.json @@ -1944,8 +1944,8 @@ "skill_command": "/autoskillit:analyze-pipeline-health", "skill_inputs": { "kitchen_id": "${{ inputs.kitchen_id }}", - "dispatch_id": "--dispatch-id=${{ inputs.dispatch_id }}", - "diagnostics_log_dir": "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" + "dispatch_id": "${{ inputs.dispatch_id }}", + "diagnostics_log_dir": "${{ inputs.diagnostics_log_dir }}" }, "cwd": "${{ context.work_dir }}", "step_name": "analyze_pipeline_health" @@ -1967,8 +1967,8 @@ "skill_command": "/autoskillit:analyze-pipeline-health", "skill_inputs": { "kitchen_id": "${{ inputs.kitchen_id }}", - "dispatch_id": "--dispatch-id=${{ inputs.dispatch_id }}", - "diagnostics_log_dir": "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" + "dispatch_id": "${{ inputs.dispatch_id }}", + "diagnostics_log_dir": "${{ inputs.diagnostics_log_dir }}" }, "cwd": "${{ context.work_dir }}", "step_name": "analyze_pipeline_health" diff --git a/src/autoskillit/recipes/merge-prs.yaml b/src/autoskillit/recipes/merge-prs.yaml index 83c1e78e0..7c49d8af6 100644 --- a/src/autoskillit/recipes/merge-prs.yaml +++ b/src/autoskillit/recipes/merge-prs.yaml @@ -1834,8 +1834,8 @@ steps: skill_command: "/autoskillit:analyze-pipeline-health" skill_inputs: kitchen_id: "${{ inputs.kitchen_id }}" - dispatch_id: "--dispatch-id=${{ inputs.dispatch_id }}" - diagnostics_log_dir: "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" + dispatch_id: "${{ inputs.dispatch_id }}" + diagnostics_log_dir: "${{ inputs.diagnostics_log_dir }}" cwd: ${{ context.work_dir }} step_name: analyze_pipeline_health on_success: done @@ -1857,8 +1857,8 @@ steps: skill_command: "/autoskillit:analyze-pipeline-health" skill_inputs: kitchen_id: "${{ inputs.kitchen_id }}" - dispatch_id: "--dispatch-id=${{ inputs.dispatch_id }}" - diagnostics_log_dir: "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" + dispatch_id: "${{ inputs.dispatch_id }}" + diagnostics_log_dir: "${{ inputs.diagnostics_log_dir }}" cwd: ${{ context.work_dir }} step_name: analyze_pipeline_health on_success: escalate_stop diff --git a/src/autoskillit/recipes/remediation.json b/src/autoskillit/recipes/remediation.json index 952d313d7..dceb0d5be 100644 --- a/src/autoskillit/recipes/remediation.json +++ b/src/autoskillit/recipes/remediation.json @@ -2082,8 +2082,8 @@ "skill_command": "/autoskillit:analyze-pipeline-health", "skill_inputs": { "kitchen_id": "${{ inputs.kitchen_id }}", - "dispatch_id": "--dispatch-id=${{ inputs.dispatch_id }}", - "diagnostics_log_dir": "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" + "dispatch_id": "${{ inputs.dispatch_id }}", + "diagnostics_log_dir": "${{ inputs.diagnostics_log_dir }}" }, "cwd": "${{ context.work_dir }}", "step_name": "analyze_pipeline_health" @@ -3081,8 +3081,8 @@ "skill_command": "/autoskillit:analyze-pipeline-health", "skill_inputs": { "kitchen_id": "${{ inputs.kitchen_id }}", - "dispatch_id": "--dispatch-id=${{ inputs.dispatch_id }}", - "diagnostics_log_dir": "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" + "dispatch_id": "${{ inputs.dispatch_id }}", + "diagnostics_log_dir": "${{ inputs.diagnostics_log_dir }}" }, "cwd": "${{ context.work_dir }}", "step_name": "analyze_pipeline_health" @@ -3170,8 +3170,8 @@ "skill_command": "/autoskillit:analyze-pipeline-health", "skill_inputs": { "kitchen_id": "${{ inputs.kitchen_id }}", - "dispatch_id": "--dispatch-id=${{ inputs.dispatch_id }}", - "diagnostics_log_dir": "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" + "dispatch_id": "${{ inputs.dispatch_id }}", + "diagnostics_log_dir": "${{ inputs.diagnostics_log_dir }}" }, "cwd": "${{ context.work_dir }}", "step_name": "analyze_pipeline_health" @@ -3193,8 +3193,8 @@ "skill_command": "/autoskillit:analyze-pipeline-health", "skill_inputs": { "kitchen_id": "${{ inputs.kitchen_id }}", - "dispatch_id": "--dispatch-id=${{ inputs.dispatch_id }}", - "diagnostics_log_dir": "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" + "dispatch_id": "${{ inputs.dispatch_id }}", + "diagnostics_log_dir": "${{ inputs.diagnostics_log_dir }}" }, "cwd": "${{ context.work_dir }}", "step_name": "analyze_pipeline_health" @@ -3227,8 +3227,8 @@ "skill_command": "/autoskillit:analyze-pipeline-health", "skill_inputs": { "kitchen_id": "${{ inputs.kitchen_id }}", - "dispatch_id": "--dispatch-id=${{ inputs.dispatch_id }}", - "diagnostics_log_dir": "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" + "dispatch_id": "${{ inputs.dispatch_id }}", + "diagnostics_log_dir": "${{ inputs.diagnostics_log_dir }}" }, "cwd": "${{ context.work_dir }}", "step_name": "analyze_pipeline_health" @@ -3261,8 +3261,8 @@ "skill_command": "/autoskillit:analyze-pipeline-health", "skill_inputs": { "kitchen_id": "${{ inputs.kitchen_id }}", - "dispatch_id": "--dispatch-id=${{ inputs.dispatch_id }}", - "diagnostics_log_dir": "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" + "dispatch_id": "${{ inputs.dispatch_id }}", + "diagnostics_log_dir": "${{ inputs.diagnostics_log_dir }}" }, "cwd": "${{ context.work_dir }}", "step_name": "analyze_pipeline_health" diff --git a/src/autoskillit/recipes/remediation.yaml b/src/autoskillit/recipes/remediation.yaml index f97489c8a..144101718 100644 --- a/src/autoskillit/recipes/remediation.yaml +++ b/src/autoskillit/recipes/remediation.yaml @@ -1851,8 +1851,8 @@ steps: skill_command: "/autoskillit:analyze-pipeline-health" skill_inputs: kitchen_id: "${{ inputs.kitchen_id }}" - dispatch_id: "--dispatch-id=${{ inputs.dispatch_id }}" - diagnostics_log_dir: "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" + dispatch_id: "${{ inputs.dispatch_id }}" + diagnostics_log_dir: "${{ inputs.diagnostics_log_dir }}" cwd: ${{ context.work_dir }} step_name: analyze_pipeline_health on_success: escalate_stop_no_ci @@ -2747,8 +2747,8 @@ steps: skill_command: "/autoskillit:analyze-pipeline-health" skill_inputs: kitchen_id: "${{ inputs.kitchen_id }}" - dispatch_id: "--dispatch-id=${{ inputs.dispatch_id }}" - diagnostics_log_dir: "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" + dispatch_id: "${{ inputs.dispatch_id }}" + diagnostics_log_dir: "${{ inputs.diagnostics_log_dir }}" cwd: ${{ context.work_dir }} step_name: analyze_pipeline_health on_success: done_unconfirmed @@ -2838,8 +2838,8 @@ steps: skill_command: "/autoskillit:analyze-pipeline-health" skill_inputs: kitchen_id: "${{ inputs.kitchen_id }}" - dispatch_id: "--dispatch-id=${{ inputs.dispatch_id }}" - diagnostics_log_dir: "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" + dispatch_id: "${{ inputs.dispatch_id }}" + diagnostics_log_dir: "${{ inputs.diagnostics_log_dir }}" cwd: ${{ context.work_dir }} step_name: analyze_pipeline_health on_success: done @@ -2861,8 +2861,8 @@ steps: skill_command: "/autoskillit:analyze-pipeline-health" skill_inputs: kitchen_id: "${{ inputs.kitchen_id }}" - dispatch_id: "--dispatch-id=${{ inputs.dispatch_id }}" - diagnostics_log_dir: "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" + dispatch_id: "${{ inputs.dispatch_id }}" + diagnostics_log_dir: "${{ inputs.diagnostics_log_dir }}" cwd: ${{ context.work_dir }} step_name: analyze_pipeline_health on_success: escalate_stop @@ -2893,8 +2893,8 @@ steps: skill_command: "/autoskillit:analyze-pipeline-health" skill_inputs: kitchen_id: "${{ inputs.kitchen_id }}" - dispatch_id: "--dispatch-id=${{ inputs.dispatch_id }}" - diagnostics_log_dir: "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" + dispatch_id: "${{ inputs.dispatch_id }}" + diagnostics_log_dir: "${{ inputs.diagnostics_log_dir }}" cwd: ${{ context.work_dir }} step_name: analyze_pipeline_health on_success: done_no_changes @@ -2925,8 +2925,8 @@ steps: skill_command: "/autoskillit:analyze-pipeline-health" skill_inputs: kitchen_id: "${{ inputs.kitchen_id }}" - dispatch_id: "--dispatch-id=${{ inputs.dispatch_id }}" - diagnostics_log_dir: "--diagnostics-log-dir=${{ inputs.diagnostics_log_dir }}" + dispatch_id: "${{ inputs.dispatch_id }}" + diagnostics_log_dir: "${{ inputs.diagnostics_log_dir }}" cwd: ${{ context.work_dir }} step_name: analyze_pipeline_health on_success: done_already_done diff --git a/src/autoskillit/recipes/research.json b/src/autoskillit/recipes/research.json index 1008ed539..2794df5c8 100644 --- a/src/autoskillit/recipes/research.json +++ b/src/autoskillit/recipes/research.json @@ -319,10 +319,10 @@ "with": { "skill_command": "/autoskillit:synthesize-vis-plan", "skill_inputs": { - "methodology_tradition": "--methodology-tradition=${{ context.methodology_tradition }}", - "disambiguation_rule_applied": "--disambiguation-rule-applied=${{ context.disambiguation_rule_applied }}", + "methodology_tradition": "${{ context.methodology_tradition }}", + "disambiguation_rule_applied": "${{ context.disambiguation_rule_applied }}", "experiment_plan_path": "${{ context.experiment_plan }}", - "tier_c_lens": "--tier-c-lens=${{ context.tier_c_lens }}", + "tier_c_lens": "${{ context.tier_c_lens }}", "source_dir": "${{ inputs.source_dir }}", "capture_dir": "{{AUTOSKILLIT_TEMP}}/run-vis-lenses" }, diff --git a/src/autoskillit/recipes/research.yaml b/src/autoskillit/recipes/research.yaml index db55d2e43..ca332997c 100644 --- a/src/autoskillit/recipes/research.yaml +++ b/src/autoskillit/recipes/research.yaml @@ -314,10 +314,10 @@ steps: with: skill_command: "/autoskillit:synthesize-vis-plan" skill_inputs: - methodology_tradition: "--methodology-tradition=${{ context.methodology_tradition }}" - disambiguation_rule_applied: "--disambiguation-rule-applied=${{ context.disambiguation_rule_applied }}" + methodology_tradition: "${{ context.methodology_tradition }}" + disambiguation_rule_applied: "${{ context.disambiguation_rule_applied }}" experiment_plan_path: "${{ context.experiment_plan }}" - tier_c_lens: "--tier-c-lens=${{ context.tier_c_lens }}" + tier_c_lens: "${{ context.tier_c_lens }}" source_dir: "${{ inputs.source_dir }}" capture_dir: "{{AUTOSKILLIT_TEMP}}/run-vis-lenses" cwd: "${{ inputs.source_dir }}" diff --git a/tests/recipe/test_bundled_recipes_dispatch_ready.py b/tests/recipe/test_bundled_recipes_dispatch_ready.py index 4f7b55039..1f16302c0 100644 --- a/tests/recipe/test_bundled_recipes_dispatch_ready.py +++ b/tests/recipe/test_bundled_recipes_dispatch_ready.py @@ -36,6 +36,13 @@ ) _DISPATCH_GATE_STEMS = [s for s in _RECIPE_STEMS if s not in _RECIPES_WITH_OTHER_ERROR_RULES] +_STRUCTURED_INPUT_RECIPES = ( + "implementation", + "implementation-groups", + "merge-prs", + "remediation", + "research", +) def _part_a_dispatch_gate_params() -> list: @@ -46,6 +53,24 @@ def _part_a_recipe_params() -> list: return [pytest.param(n) for n in _RECIPE_STEMS] +@pytest.mark.parametrize("recipe_name", _STRUCTURED_INPUT_RECIPES) +def test_structured_skill_inputs_do_not_embed_cli_option_prefixes(recipe_name: str) -> None: + recipe = load_recipe(_RECIPES_DIR / f"{recipe_name}.yaml") + + for step_name, step in recipe.steps.items(): + skill_inputs = step.with_args.get("skill_inputs", {}) + if not isinstance(skill_inputs, dict): + continue + for name, value in skill_inputs.items(): + if not isinstance(value, str): + continue + cli_prefix = f"--{name.replace('_', '-')}=" + assert not value.startswith(cli_prefix), ( + f"{recipe_name}:{step_name} structured input {name!r} " + f"must not embed CLI prefix {cli_prefix!r}" + ) + + @pytest.mark.parametrize( "recipe_name,step_name", _SALVAGE_ROUTE_SITES, From 0bffd8685302cd4996f2734b305f668ffb8d1e50 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sat, 25 Jul 2026 17:51:55 -0700 Subject: [PATCH 39/89] fix(review): bind current planning group input --- .../recipes/implementation-groups.json | 4 ++-- .../recipes/implementation-groups.yaml | 8 ++++---- .../recipes/research-implement.json | 4 ++-- .../recipes/research-implement.yaml | 8 ++++---- .../test_bundled_recipes_dispatch_ready.py | 20 +++++++++++++++++++ 5 files changed, 32 insertions(+), 12 deletions(-) diff --git a/src/autoskillit/recipes/implementation-groups.json b/src/autoskillit/recipes/implementation-groups.json index fe63ecf5f..aba271b10 100644 --- a/src/autoskillit/recipes/implementation-groups.json +++ b/src/autoskillit/recipes/implementation-groups.json @@ -228,7 +228,7 @@ "skill_command": "/autoskillit:make-plan", "skill_inputs": { "adversarial_review_level": "${{ inputs.adversarial_review_level }}", - "task": "${{ context.group_files }}", + "task": "CURRENT group file path from ${{ context.group_files }}", "issue_url": "${{ inputs.issue_url }}", "audit_cycle_path": "${{ context.audit_cycle_path }}" }, @@ -258,7 +258,7 @@ "on_failure": "release_issue_failure", "on_rate_limit": "release_issue_failure", "on_context_limit": "salvage_plan", - "note": "Produces plan in {{AUTOSKILLIT_TEMP}}/make-plan/. Glob plan_dir for *_part_*.md or single plan file. plan_parts context key contains the full ordered list of part files (sorted alphabetically). For a single-part plan, plan_parts has one entry equal to plan_path. GROUPS MODE: Replace the skill_command task with the current group's file path from context.group_files extracted from the group step's capture — do NOT read the file; pass the path directly to the skill. REMEDIATION MODE: on re-entry, pass the exact current audit_cycle_path; make-plan emits the matching plan_disposition_path. ACCUMULATION: After each plan step execution in groups mode, the agent MUST append the captured plan_path to context.all_plan_paths using a comma separator instead of letting the capture block overwrite the accumulated value. For multi-group runs, the accumulated value looks like \"/path/groupA_plan.md,/path/groupB_plan.md\". ISSUE CONTEXT: If inputs.issue_url is a non-empty string, pass it to the skill so the make-plan skill can call fetch_github_issue internally to get full issue content: \"/autoskillit:make-plan {group_path}\\n\\nGitHub Issue: {inputs.issue_url}\" ADVERSARIAL REVIEW LEVEL: If inputs.adversarial_review_level is a non-empty string, append it to the skill_command: \"/autoskillit:make-plan {group_path}\\n\\nadversarial_review_level={inputs.adversarial_review_level}\" verdict=plan is the only successful result.\n" + "note": "Produces plan in {{AUTOSKILLIT_TEMP}}/make-plan/. Glob plan_dir for *_part_*.md or single plan file. plan_parts context key contains the full ordered list of part files (sorted alphabetically). For a single-part plan, plan_parts has one entry equal to plan_path. GROUPS MODE: Set skill_inputs.task to the current group's file path selected from context.group_files — do NOT pass the entire list or read the file; pass the path directly to the skill. REMEDIATION MODE: on re-entry, pass the exact current audit_cycle_path; make-plan emits the matching plan_disposition_path. ACCUMULATION: After each plan step execution in groups mode, the agent MUST append the captured plan_path to context.all_plan_paths using a comma separator instead of letting the capture block overwrite the accumulated value. For multi-group runs, the accumulated value looks like \"/path/groupA_plan.md,/path/groupB_plan.md\". ISSUE CONTEXT: If inputs.issue_url is a non-empty string, pass it to the skill so the make-plan skill can call fetch_github_issue internally to get full issue content: \"/autoskillit:make-plan {group_path}\\n\\nGitHub Issue: {inputs.issue_url}\" ADVERSARIAL REVIEW LEVEL: If inputs.adversarial_review_level is a non-empty string, append it to the skill_command: \"/autoskillit:make-plan {group_path}\\n\\nadversarial_review_level={inputs.adversarial_review_level}\" verdict=plan is the only successful result.\n" }, "salvage_plan": { "tool": "run_python", diff --git a/src/autoskillit/recipes/implementation-groups.yaml b/src/autoskillit/recipes/implementation-groups.yaml index 2ae1c6d98..9bd433924 100644 --- a/src/autoskillit/recipes/implementation-groups.yaml +++ b/src/autoskillit/recipes/implementation-groups.yaml @@ -214,7 +214,7 @@ steps: skill_command: "/autoskillit:make-plan" skill_inputs: adversarial_review_level: "${{ inputs.adversarial_review_level }}" - task: "${{ context.group_files }}" + task: "CURRENT group file path from ${{ context.group_files }}" issue_url: "${{ inputs.issue_url }}" audit_cycle_path: "${{ context.audit_cycle_path }}" cwd: "${{ context.work_dir }}" @@ -240,9 +240,9 @@ steps: Produces plan in {{AUTOSKILLIT_TEMP}}/make-plan/. Glob plan_dir for *_part_*.md or single plan file. plan_parts context key contains the full ordered list of part files (sorted alphabetically). For a single-part plan, plan_parts has one entry equal to plan_path. - GROUPS MODE: Replace the skill_command task with the current group's file path from - context.group_files extracted from the group step's capture — do NOT read the file; - pass the path directly to the skill. + GROUPS MODE: Set skill_inputs.task to the current group's file path selected from + context.group_files — do NOT pass the entire list or read the file; pass the path + directly to the skill. REMEDIATION MODE: on re-entry, pass the exact current audit_cycle_path; make-plan emits the matching plan_disposition_path. ACCUMULATION: After each plan step execution in groups mode, the agent MUST diff --git a/src/autoskillit/recipes/research-implement.json b/src/autoskillit/recipes/research-implement.json index 738fa8098..94b2a84a3 100644 --- a/src/autoskillit/recipes/research-implement.json +++ b/src/autoskillit/recipes/research-implement.json @@ -184,7 +184,7 @@ "with": { "skill_command": "/autoskillit:make-plan", "skill_inputs": { - "task": "${{ context.group_files }}", + "task": "FIRST REMAINING phase file path from ${{ context.group_files }}", "audit_cycle_path": "${{ context.audit_cycle_path }}" }, "cwd": "${{ inputs.worktree_path }}", @@ -208,7 +208,7 @@ ], "on_failure": "escalate_stop", "on_context_limit": "salvage_plan_phase", - "note": "Plans the current setup phase. Replace ${{ context.group_files }} in the skill_command with the FIRST REMAINING phase file path from the list — do NOT pass the entire list. Pass the path directly to the skill (do not read it). Glob plan_dir for *_part_*.md or single plan file. ITERATION: After each implement_phase cycle, advance to the next entry in context.group_files for the following plan_phase iteration. REMEDIATION MODE: consume the exact audit_cycle_path from the NO GO producer and emit its plan_disposition_path. verdict=plan is the only success.\n", + "note": "Plans the current setup phase. Set skill_inputs.task to the FIRST REMAINING phase file path selected from context.group_files — do NOT pass the entire list. Pass the path directly to the skill (do not read it). Glob plan_dir for *_part_*.md or single plan file. ITERATION: After each implement_phase cycle, advance to the next entry in context.group_files for the following plan_phase iteration. REMEDIATION MODE: consume the exact audit_cycle_path from the NO GO producer and emit its plan_disposition_path. verdict=plan is the only success.\n", "optional_context_refs": [ "audit_cycle_path" ] diff --git a/src/autoskillit/recipes/research-implement.yaml b/src/autoskillit/recipes/research-implement.yaml index 9ca0c1c39..3860e8ebd 100644 --- a/src/autoskillit/recipes/research-implement.yaml +++ b/src/autoskillit/recipes/research-implement.yaml @@ -196,7 +196,7 @@ steps: with: skill_command: "/autoskillit:make-plan" skill_inputs: - task: "${{ context.group_files }}" + task: "FIRST REMAINING phase file path from ${{ context.group_files }}" audit_cycle_path: "${{ context.audit_cycle_path }}" cwd: "${{ inputs.worktree_path }}" step_name: plan_phase @@ -214,9 +214,9 @@ steps: on_failure: escalate_stop on_context_limit: salvage_plan_phase note: > - Plans the current setup phase. Replace ${{ context.group_files }} in the - skill_command with the FIRST REMAINING phase file path from the list — - do NOT pass the entire list. Pass the path directly to the skill (do not read it). + Plans the current setup phase. Set skill_inputs.task to the FIRST REMAINING phase + file path selected from context.group_files — do NOT pass the entire list. Pass the + path directly to the skill (do not read it). Glob plan_dir for *_part_*.md or single plan file. ITERATION: After each implement_phase cycle, advance to the next entry in context.group_files for the following plan_phase iteration. diff --git a/tests/recipe/test_bundled_recipes_dispatch_ready.py b/tests/recipe/test_bundled_recipes_dispatch_ready.py index 1f16302c0..b99699c7a 100644 --- a/tests/recipe/test_bundled_recipes_dispatch_ready.py +++ b/tests/recipe/test_bundled_recipes_dispatch_ready.py @@ -71,6 +71,26 @@ def test_structured_skill_inputs_do_not_embed_cli_option_prefixes(recipe_name: s ) +@pytest.mark.parametrize( + "recipe_name,step_name,selection_marker", + [ + ("implementation-groups", "plan", "CURRENT group file path"), + ("research-implement", "plan_phase", "FIRST REMAINING phase file path"), + ], +) +def test_group_planning_binds_the_selected_file_in_structured_input( + recipe_name: str, + step_name: str, + selection_marker: str, +) -> None: + recipe = load_recipe(_RECIPES_DIR / f"{recipe_name}.yaml") + task = recipe.steps[step_name].with_args["skill_inputs"]["task"] + + assert task != "${{ context.group_files }}" + assert selection_marker in task + assert "${{ context.group_files }}" in task + + @pytest.mark.parametrize( "recipe_name,step_name", _SALVAGE_ROUTE_SITES, From 416647850f9c896af7bf0d2aa6b66e21490eaa9a Mon Sep 17 00:00:00 2001 From: Trecek Date: Sat, 25 Jul 2026 17:52:47 -0700 Subject: [PATCH 40/89] fix(review): keep server templates statically bound --- src/autoskillit/server/_recipe_execution.py | 5 +- .../test_audit_cycle_delivery_integration.py | 59 +++++++++++++++++++ 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/src/autoskillit/server/_recipe_execution.py b/src/autoskillit/server/_recipe_execution.py index 8f058c45b..046eebcdb 100644 --- a/src/autoskillit/server/_recipe_execution.py +++ b/src/autoskillit/server/_recipe_execution.py @@ -20,7 +20,6 @@ AuditVerdict, BindingMode, BoundScalar, - BoundValueOrigin, BoundValueState, InstalledRecipeExecution, InventoryAdmissionDecision, @@ -431,9 +430,7 @@ def record_runtime_binding_digest( def _is_dynamic(value: Any) -> bool: - return bool(value.context_dependencies or value.input_dependencies) or ( - value.origin is not BoundValueOrigin.LITERAL - ) + return bool(value.context_dependencies or value.input_dependencies) def bind_attested_runtime_invocation( diff --git a/tests/server/test_audit_cycle_delivery_integration.py b/tests/server/test_audit_cycle_delivery_integration.py index 33f8521d2..af41631c8 100644 --- a/tests/server/test_audit_cycle_delivery_integration.py +++ b/tests/server/test_audit_cycle_delivery_integration.py @@ -602,6 +602,65 @@ def test_runtime_binding_rejects_undeclared_effective_tool_options( assert option_name in str(exc_info.value) +def test_runtime_binding_rejects_template_only_tool_override() -> None: + invocation = _projection().invocations["dry"] + template_only_cwd = BoundValue( + name="cwd", + declared_value="{{AUTOSKILLIT_TEMP}}/worktree", + effective_value="/resolved/worktree", + state=BoundValueState.PRESENT, + origin=BoundValueOrigin.TEMPLATE, + template_dependencies=("AUTOSKILLIT_TEMP",), + ) + snapshot = build_recipe_execution_snapshot( + recipe_name="demo", + content_hash=_HASH_A, + composite_hash=_HASH_B, + projection=RecipeBindingProjection( + { + "dry": replace( + invocation, + mcp_kwargs=(invocation.mcp_kwargs[0], template_only_cwd), + ) + } + ), + execution_id="execution-1", + ) + store = DefaultAuditCycleHeadStore() + from autoskillit.core import InstalledRecipeExecution + + installed = InstalledRecipeExecution( + snapshot=snapshot, + runtime_binding_digests={}, + audit_cycle_heads=store, + input_preflight_resolver=DefaultInputPreflightResolver( + allowed_root=Path("/tmp"), + head_store=store, + ), + ) + + with pytest.raises(RecipeExecutionAdmissionError) as exc_info: + bind_attested_runtime_invocation( + installed, + execution_id="execution-1", + step_name="dry", + template_digest=snapshot.templates["dry"].template_digest, + skill_command="/dry-walkthrough", + skill_inputs={ + "plan_path": "/tmp/plan.md", + "issue_url": "", + "audit_cycle_path": "/tmp/audit-cycle.json", + "plan_disposition_path": "/tmp/plan-disposition.json", + }, + actual_mcp_kwargs={ + "skill_command": "/dry-walkthrough", + "cwd": "/caller/override", + }, + ) + + assert exc_info.value.code == "recipe_execution_static_tool_mismatch" + + @pytest.mark.parametrize( ("invalid_name", "invalid_value"), (("count", "3"), ("enabled", "false")), From 1268163076264c1c43293720ded3e25d03f5110e Mon Sep 17 00:00:00 2001 From: Trecek Date: Sat, 25 Jul 2026 17:54:12 -0700 Subject: [PATCH 41/89] fix(review): bind attested inline skill arguments --- src/autoskillit/server/_recipe_execution.py | 29 ++++++++- .../test_audit_cycle_delivery_integration.py | 64 +++++++++++++++++++ 2 files changed, 92 insertions(+), 1 deletion(-) diff --git a/src/autoskillit/server/_recipe_execution.py b/src/autoskillit/server/_recipe_execution.py index 046eebcdb..97cd3d6b6 100644 --- a/src/autoskillit/server/_recipe_execution.py +++ b/src/autoskillit/server/_recipe_execution.py @@ -492,7 +492,8 @@ def bind_attested_runtime_invocation( f"{undeclared_effective_names!r}" ), ) - contract = get_skill_contract(invocation.skill_name or "", load_bundled_manifest()) + manifest = load_bundled_manifest() + contract = get_skill_contract(invocation.skill_name or "", manifest) if contract is None: raise RecipeExecutionAdmissionError( "recipe_execution_contract_unavailable", @@ -500,6 +501,32 @@ def bind_attested_runtime_invocation( ) contract_inputs = {input_def.name: input_def for input_def in contract.inputs} supplied = dict(skill_inputs or {}) + if skill_inputs is None: + runtime_with_args: dict[str, object] = {"skill_command": skill_command} + runtime_cwd = actual_mcp_kwargs.get("cwd") + if isinstance(runtime_cwd, str): + runtime_with_args["cwd"] = runtime_cwd + runtime_inline = bind_step_invocation( + step_name, + RecipeStep( + name=step_name, + tool="run_skill", + with_args=runtime_with_args, + declared_with_args=dict(runtime_with_args), + ), + manifest=manifest, + ) + if runtime_inline.failures: + raise RecipeExecutionAdmissionError( + "recipe_execution_input_shape", + runtime_inline.failures[0].message, + ) + supplied = { + value.name: value.effective_value + for value in runtime_inline.skill_inputs + if value.state is BoundValueState.PRESENT + and isinstance(value.effective_value, (str, int, float, bool)) + } if any( not isinstance(value, (str, int, float, bool)) or value is None for value in supplied.values() diff --git a/tests/server/test_audit_cycle_delivery_integration.py b/tests/server/test_audit_cycle_delivery_integration.py index af41631c8..99856337a 100644 --- a/tests/server/test_audit_cycle_delivery_integration.py +++ b/tests/server/test_audit_cycle_delivery_integration.py @@ -49,6 +49,7 @@ VerifiedInputPreflightResult, compute_runtime_binding_digest, ) +from autoskillit.recipe import RecipeStep, bind_step_invocation from autoskillit.server._recipe_delivery import ( complete_finalized_recipe_response, finalize_recipe_delivery, @@ -661,6 +662,69 @@ def test_runtime_binding_rejects_template_only_tool_override() -> None: assert exc_info.value.code == "recipe_execution_static_tool_mismatch" +def test_runtime_binding_accepts_compiled_inline_skill_arguments() -> None: + declared_command = ( + "/dry-walkthrough " + "plan_path=${{ context.plan_path }} " + "issue_url=${{ context.issue_url }} " + "audit_cycle_path=${{ context.audit_cycle_path }} " + "plan_disposition_path=${{ context.plan_disposition_path }}" + ) + invocation = bind_step_invocation( + "dry", + RecipeStep( + name="dry", + tool="run_skill", + with_args={"skill_command": declared_command, "cwd": "/tmp"}, + declared_with_args={"skill_command": declared_command, "cwd": "/tmp"}, + ), + ) + assert invocation.is_valid + snapshot = build_recipe_execution_snapshot( + recipe_name="demo", + content_hash=_HASH_A, + composite_hash=_HASH_B, + projection=RecipeBindingProjection({"dry": invocation}), + execution_id="execution-1", + ) + store = DefaultAuditCycleHeadStore() + from autoskillit.core import InstalledRecipeExecution + + installed = InstalledRecipeExecution( + snapshot=snapshot, + runtime_binding_digests={}, + audit_cycle_heads=store, + input_preflight_resolver=DefaultInputPreflightResolver( + allowed_root=Path("/tmp"), + head_store=store, + ), + ) + runtime_command = ( + "/dry-walkthrough " + "plan_path=/tmp/plan.md " + "issue_url=https://example.test/42 " + "audit_cycle_path=/tmp/audit.json " + "plan_disposition_path=/tmp/disposition.json" + ) + + bound, _template = bind_attested_runtime_invocation( + installed, + execution_id="execution-1", + step_name="dry", + template_digest=snapshot.templates["dry"].template_digest, + skill_command=runtime_command, + skill_inputs=None, + actual_mcp_kwargs={"skill_command": runtime_command, "cwd": "/tmp"}, + ) + + assert bound == ( + ("plan_path", "/tmp/plan.md"), + ("issue_url", "https://example.test/42"), + ("audit_cycle_path", "/tmp/audit.json"), + ("plan_disposition_path", "/tmp/disposition.json"), + ) + + @pytest.mark.parametrize( ("invalid_name", "invalid_value"), (("count", "3"), ("enabled", "false")), From 811c61da6fac7c9dcdedd3600a92fdaa954c0968 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sat, 25 Jul 2026 17:54:39 -0700 Subject: [PATCH 42/89] test(review): cover static attestation tampering --- .../test_audit_cycle_delivery_integration.py | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/tests/server/test_audit_cycle_delivery_integration.py b/tests/server/test_audit_cycle_delivery_integration.py index 99856337a..e247c5283 100644 --- a/tests/server/test_audit_cycle_delivery_integration.py +++ b/tests/server/test_audit_cycle_delivery_integration.py @@ -662,6 +662,62 @@ def test_runtime_binding_rejects_template_only_tool_override() -> None: assert exc_info.value.code == "recipe_execution_static_tool_mismatch" +@pytest.mark.parametrize( + "tamper_target,expected_code", + [ + ("skill_input", "recipe_execution_static_input_mismatch"), + ("mcp_parameter", "recipe_execution_static_tool_mismatch"), + ], +) +def test_runtime_binding_rejects_declared_static_value_tampering( + tamper_target: str, + expected_code: str, +) -> None: + snapshot = build_recipe_execution_snapshot( + recipe_name="demo", + content_hash=_HASH_A, + composite_hash=_HASH_B, + projection=_projection(), + execution_id="execution-1", + ) + store = DefaultAuditCycleHeadStore() + from autoskillit.core import InstalledRecipeExecution + + installed = InstalledRecipeExecution( + snapshot=snapshot, + runtime_binding_digests={}, + audit_cycle_heads=store, + input_preflight_resolver=DefaultInputPreflightResolver( + allowed_root=Path("/tmp"), + head_store=store, + ), + ) + runtime_command = ( + "/dry-walkthrough unexpected-inline-value" + if tamper_target == "mcp_parameter" + else "/dry-walkthrough" + ) + runtime_inputs = { + "plan_path": "/tmp/plan.md", + "issue_url": "tampered" if tamper_target == "skill_input" else "", + "audit_cycle_path": "/tmp/audit-cycle.json", + "plan_disposition_path": "/tmp/plan-disposition.json", + } + + with pytest.raises(RecipeExecutionAdmissionError) as exc_info: + bind_attested_runtime_invocation( + installed, + execution_id="execution-1", + step_name="dry", + template_digest=snapshot.templates["dry"].template_digest, + skill_command=runtime_command, + skill_inputs=runtime_inputs, + actual_mcp_kwargs={"skill_command": runtime_command, "cwd": "/tmp"}, + ) + + assert exc_info.value.code == expected_code + + def test_runtime_binding_accepts_compiled_inline_skill_arguments() -> None: declared_command = ( "/dry-walkthrough " From e033a6d4f2eccd7da9d37d83731cb9a4127ebd79 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sat, 25 Jul 2026 17:56:27 -0700 Subject: [PATCH 43/89] test(review): cover successful attested execution --- .../test_audit_cycle_delivery_integration.py | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/tests/server/test_audit_cycle_delivery_integration.py b/tests/server/test_audit_cycle_delivery_integration.py index e247c5283..e9db6c34e 100644 --- a/tests/server/test_audit_cycle_delivery_integration.py +++ b/tests/server/test_audit_cycle_delivery_integration.py @@ -69,6 +69,7 @@ publish_verified_audit_cycle, ) from autoskillit.server.tools.tools_execution import run_skill +from tests.conftest import _make_result pytestmark = [pytest.mark.layer("server"), pytest.mark.small] @@ -1267,6 +1268,54 @@ async def test_runtime_attestation_rejects_before_executor( assert len(tool_ctx_kitchen_open.runner.call_args_list) == calls_before +@pytest.mark.anyio +async def test_runtime_attestation_executes_bound_prompt_and_records_digest( + tool_ctx_kitchen_open, + tmp_path: Path, +) -> None: + snapshot = build_recipe_execution_snapshot( + recipe_name="demo", + content_hash=_HASH_A, + composite_hash=_HASH_B, + projection=_preflight_projection(), + execution_id="execution-1", + ) + install_recipe_execution(tool_ctx_kitchen_open, snapshot=snapshot) + tool_ctx_kitchen_open.runner.push(_make_result(returncode=1)) + tool_ctx_kitchen_open.runner.push( + _make_result( + 0, + '{"type":"result","subtype":"success","is_error":false,' + '"result":"done","session_id":"session-1"}', + "", + ) + ) + calls_before = len(tool_ctx_kitchen_open.runner.call_args_list) + plan_path = str(tmp_path / "plan.md") + + result = json.loads( + await run_skill( + "/dry-walkthrough", + str(tmp_path), + step_name="dry", + recipe_execution_id="execution-1", + invocation_template_digest=snapshot.templates["dry"].template_digest, + skill_inputs={"plan_path": plan_path, "issue_url": ""}, + ) + ) + + assert result["success"] is True + assert len(tool_ctx_kitchen_open.runner.call_args_list) > calls_before + cmd = tool_ctx_kitchen_open.runner.call_args_list[-1][0] + prompt_index = cmd.index("--print") + 1 if "--print" in cmd else cmd.index("-p") + 1 + prompt = cmd[prompt_index] + assert "AUTOSKILLIT_BOUND_INVOCATION_V1" in prompt + assert f'"value":"{plan_path}"' in prompt + installed = get_recipe_execution(tool_ctx_kitchen_open) + assert installed is not None + assert installed.runtime_binding_digests["dry"].startswith("sha256:") + + @pytest.mark.anyio async def test_preflight_rejects_before_executor( tool_ctx_kitchen_open, From b3de2f47ebfa56d0a3b77f6aa9c424327f5edb0c Mon Sep 17 00:00:00 2001 From: Trecek Date: Sat, 25 Jul 2026 17:57:01 -0700 Subject: [PATCH 44/89] test(review): model audit heads independently --- .../test_audit_cycle_delivery_integration.py | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/tests/server/test_audit_cycle_delivery_integration.py b/tests/server/test_audit_cycle_delivery_integration.py index e9db6c34e..a04e31ef9 100644 --- a/tests/server/test_audit_cycle_delivery_integration.py +++ b/tests/server/test_audit_cycle_delivery_integration.py @@ -957,15 +957,26 @@ def publish_current_part(self, verdict: AuditVerdict) -> None: part_id=self.part_id, ) ) - head = self.store.publish( + successor = f"{self.part_id}-successor" if verdict is AuditVerdict.GO else None + expected_head = AuditCycleHead( + execution_generation=authority.execution_generation, + cycle_id=authority.cycle_id, + plan_set_id=authority.plan_set_id, + scope_id=authority.scope_id, + part_id=authority.part_id, + current_authority_digest=authority.authority_digest, + audit_round=authority.audit_round, + verdict=authority.verdict, + authorized_successor_part_id=successor, + ) + published_head = self.store.publish( authority, expected_parent_digest=(None if current is None else current.current_authority_digest), expected_round=0 if current is None else current.audit_round, - authorized_successor_part_id=( - f"{self.part_id}-successor" if verdict is AuditVerdict.GO else None - ), + authorized_successor_part_id=successor, ) - self.model_heads[self._model_key()] = head + assert published_head == expected_head + self.model_heads[self._model_key()] = expected_head self.authorities[authority.authority_digest] = authority self.reports.pop(self._model_key(), None) self.history.append(authority) From 0a2047542e3ba0cae76031593b6474bf94514bef Mon Sep 17 00:00:00 2001 From: Trecek Date: Sat, 25 Jul 2026 17:58:37 -0700 Subject: [PATCH 45/89] fix(review): preserve audit artifact lineage --- src/autoskillit/core/audit_cycle_verifier.py | 10 ++++ .../core/types/_type_audit_cycle.py | 10 ++++ src/autoskillit/server/_recipe_execution.py | 2 + tests/core/test_audit_cycle_attacks.py | 55 ++++++++++++++++++- tests/core/test_audit_cycle_authority.py | 4 +- tests/core/test_inventory_admission.py | 4 ++ .../test_audit_cycle_delivery_integration.py | 2 + 7 files changed, 83 insertions(+), 4 deletions(-) diff --git a/src/autoskillit/core/audit_cycle_verifier.py b/src/autoskillit/core/audit_cycle_verifier.py index 070e20177..d29418fe7 100644 --- a/src/autoskillit/core/audit_cycle_verifier.py +++ b/src/autoskillit/core/audit_cycle_verifier.py @@ -490,6 +490,16 @@ def verify_successor(candidate: AuditCycleAuthority, trusted_head: AuditCycleHea AdmissionReason.PARENT_MISMATCH, "successor parent is not the trusted current head", ) + if candidate.inventory_ref != trusted_head.inventory_ref: + raise AuditCycleVerificationError( + AdmissionReason.INVENTORY_MISMATCH, + "successor inventory identity differs from the trusted head", + ) + if candidate.audited_plan_refs != trusted_head.audited_plan_refs: + raise AuditCycleVerificationError( + AdmissionReason.PLAN_MISMATCH, + "successor audited-plan identities differ from the trusted head", + ) def verify_active_tuple( self, diff --git a/src/autoskillit/core/types/_type_audit_cycle.py b/src/autoskillit/core/types/_type_audit_cycle.py index 96bef4bbc..d8a92df9d 100644 --- a/src/autoskillit/core/types/_type_audit_cycle.py +++ b/src/autoskillit/core/types/_type_audit_cycle.py @@ -470,6 +470,8 @@ class AuditCycleHead: part_id: str current_authority_digest: str audit_round: int + audited_plan_refs: tuple[ArtifactRef, ...] + inventory_ref: ArtifactRef verdict: AuditVerdict authorized_successor_part_id: str | None = None @@ -478,6 +480,14 @@ def __post_init__(self) -> None: _require_nonempty(f"AuditCycleHead.{name}", getattr(self, name)) _require_digest("AuditCycleHead.current_authority_digest", self.current_authority_digest) _require_positive_int("AuditCycleHead.audit_round", self.audit_round) + audited_plan_refs = tuple(self.audited_plan_refs) + if not audited_plan_refs or any( + not isinstance(ref, ArtifactRef) for ref in audited_plan_refs + ): + raise ValueError("AuditCycleHead.audited_plan_refs must contain ArtifactRef entries") + if not isinstance(self.inventory_ref, ArtifactRef): + raise ValueError("AuditCycleHead.inventory_ref must be an ArtifactRef") + object.__setattr__(self, "audited_plan_refs", audited_plan_refs) if not isinstance(self.verdict, AuditVerdict): raise ValueError("AuditCycleHead.verdict must be an AuditVerdict") if self.authorized_successor_part_id is not None: diff --git a/src/autoskillit/server/_recipe_execution.py b/src/autoskillit/server/_recipe_execution.py index 97cd3d6b6..b691e0de3 100644 --- a/src/autoskillit/server/_recipe_execution.py +++ b/src/autoskillit/server/_recipe_execution.py @@ -160,6 +160,8 @@ def publish( part_id=authority.part_id, current_authority_digest=authority.authority_digest, audit_round=authority.audit_round, + audited_plan_refs=authority.audited_plan_refs, + inventory_ref=authority.inventory_ref, verdict=authority.verdict, authorized_successor_part_id=authorized_successor_part_id, ) diff --git a/tests/core/test_audit_cycle_attacks.py b/tests/core/test_audit_cycle_attacks.py index 8f815498a..5e89aee9d 100644 --- a/tests/core/test_audit_cycle_attacks.py +++ b/tests/core/test_audit_cycle_attacks.py @@ -41,9 +41,16 @@ def _ref(path: Path, content: bytes, *, schema_version: int = 1) -> ArtifactRef: ) -def _authority(root: Path, *, parent: str | None = None, round_: int = 1) -> AuditCycleAuthority: - plan = _ref(root / "plan.md", b"plan") - inventory = _ref(root / "inventory.json", b"inventory") +def _authority( + root: Path, + *, + parent: str | None = None, + round_: int = 1, + plan_content: bytes = b"plan", + inventory_content: bytes = b"inventory", +) -> AuditCycleAuthority: + plan = _ref(root / "plan.md", plan_content) + inventory = _ref(root / "inventory.json", inventory_content) remediation = _ref(root / "remediation.md", b"remediation") row = AuditAssessmentRow.create( requirement_id="REQ-001", @@ -154,6 +161,8 @@ def test_successor_must_descend_from_trusted_current_head(tmp_path: Path) -> Non part_id=first.part_id, current_authority_digest=first.authority_digest, audit_round=first.audit_round, + audited_plan_refs=first.audited_plan_refs, + inventory_ref=first.inventory_ref, verdict=first.verdict, ) successor = _authority(tmp_path, parent=first.authority_digest, round_=2) @@ -164,6 +173,44 @@ def test_successor_must_descend_from_trusted_current_head(tmp_path: Path) -> Non assert caught.value.reason is AdmissionReason.PARENT_MISMATCH +@pytest.mark.parametrize( + "replacement,reason", + [ + ({"inventory_content": b"replacement-inventory"}, AdmissionReason.INVENTORY_MISMATCH), + ({"plan_content": b"replacement-plan"}, AdmissionReason.PLAN_MISMATCH), + ], +) +def test_successor_preserves_trusted_artifact_lineage( + tmp_path: Path, + replacement: dict[str, bytes], + reason: AdmissionReason, +) -> None: + first = _authority(tmp_path) + head = AuditCycleHead( + execution_generation=first.execution_generation, + cycle_id=first.cycle_id, + plan_set_id=first.plan_set_id, + scope_id=first.scope_id, + part_id=first.part_id, + current_authority_digest=first.authority_digest, + audit_round=first.audit_round, + audited_plan_refs=first.audited_plan_refs, + inventory_ref=first.inventory_ref, + verdict=first.verdict, + ) + successor = _authority( + tmp_path, + parent=first.authority_digest, + round_=2, + **replacement, + ) + + with pytest.raises(AuditCycleVerificationError) as caught: + AuditCycleVerifier.verify_successor(successor, head) + + assert caught.value.reason is reason + + def test_stale_authority_replay_is_rejected_before_inventory_read(tmp_path: Path) -> None: stale = _authority(tmp_path) current = _authority(tmp_path, parent=stale.authority_digest, round_=2) @@ -189,6 +236,8 @@ def recording_reader( part_id=current.part_id, current_authority_digest=current.authority_digest, audit_round=current.audit_round, + audited_plan_refs=current.audited_plan_refs, + inventory_ref=current.inventory_ref, verdict=current.verdict, ) decision = verifier.evaluate_paths( diff --git a/tests/core/test_audit_cycle_authority.py b/tests/core/test_audit_cycle_authority.py index d01a56462..b4740f97e 100644 --- a/tests/core/test_audit_cycle_authority.py +++ b/tests/core/test_audit_cycle_authority.py @@ -331,7 +331,7 @@ def test_plan_disposition_report_is_bound_to_full_identity(tmp_path: Path) -> No replace(normalized, dispositions=[object()]) -def test_head_allows_successor_only_for_go() -> None: +def test_head_allows_successor_only_for_go(tmp_path: Path) -> None: with pytest.raises(ValueError, match="only a GO"): AuditCycleHead( execution_generation="generation-1", @@ -341,6 +341,8 @@ def test_head_allows_successor_only_for_go() -> None: part_id="part-a", current_authority_digest=_HASH_A, audit_round=1, + audited_plan_refs=(_ref(tmp_path / "plan.md"),), + inventory_ref=_ref(tmp_path / "inventory.json", _HASH_B), verdict=AuditVerdict.NO_GO, authorized_successor_part_id="part-b", ) diff --git a/tests/core/test_inventory_admission.py b/tests/core/test_inventory_admission.py index ca9617d75..9978c1b76 100644 --- a/tests/core/test_inventory_admission.py +++ b/tests/core/test_inventory_admission.py @@ -88,6 +88,8 @@ def _head(authority: AuditCycleAuthority, *, successor: str | None = None) -> Au part_id=authority.part_id, current_authority_digest=authority.authority_digest, audit_round=authority.audit_round, + audited_plan_refs=authority.audited_plan_refs, + inventory_ref=authority.inventory_ref, verdict=authority.verdict, authorized_successor_part_id=successor, ) @@ -475,6 +477,8 @@ def test_stale_no_go_authority_rejects(tmp_path: Path) -> None: part_id=authority.part_id, current_authority_digest="sha256:" + "d" * 64, audit_round=authority.audit_round, + audited_plan_refs=authority.audited_plan_refs, + inventory_ref=authority.inventory_ref, verdict=authority.verdict, ) decision = _evaluate(authority, report, head=stale_head) diff --git a/tests/server/test_audit_cycle_delivery_integration.py b/tests/server/test_audit_cycle_delivery_integration.py index a04e31ef9..877c39c81 100644 --- a/tests/server/test_audit_cycle_delivery_integration.py +++ b/tests/server/test_audit_cycle_delivery_integration.py @@ -966,6 +966,8 @@ def publish_current_part(self, verdict: AuditVerdict) -> None: part_id=authority.part_id, current_authority_digest=authority.authority_digest, audit_round=authority.audit_round, + audited_plan_refs=authority.audited_plan_refs, + inventory_ref=authority.inventory_ref, verdict=authority.verdict, authorized_successor_part_id=successor, ) From a6ab38324b81704c1c906fd60be21e0b234ee312 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 26 Jul 2026 12:48:31 -0700 Subject: [PATCH 46/89] fix(review): verify audit references before publication --- src/autoskillit/server/_recipe_execution.py | 6 ++ .../test_audit_cycle_delivery_integration.py | 89 +++++++++++++++++-- 2 files changed, 86 insertions(+), 9 deletions(-) diff --git a/src/autoskillit/server/_recipe_execution.py b/src/autoskillit/server/_recipe_execution.py index b691e0de3..0458b429f 100644 --- a/src/autoskillit/server/_recipe_execution.py +++ b/src/autoskillit/server/_recipe_execution.py @@ -655,6 +655,12 @@ def _publish_loaded_audit_cycle( ) -> AuditCycleHead: if authority.execution_generation != installed.snapshot.execution_id: raise AuditCycleHeadConflict("authority crosses recipe execution generations") + verifier = AuditCycleVerifier(tool_ctx.temp_dir) + for audited_plan_ref in authority.audited_plan_refs: + verifier.verify_artifact_ref(audited_plan_ref) + verifier.verify_artifact_ref(authority.inventory_ref) + if authority.remediation_ref is not None: + verifier.verify_artifact_ref(authority.remediation_ref) head = installed.audit_cycle_heads.publish( authority, expected_parent_digest=expected_parent_digest, diff --git a/tests/server/test_audit_cycle_delivery_integration.py b/tests/server/test_audit_cycle_delivery_integration.py index 877c39c81..a08549784 100644 --- a/tests/server/test_audit_cycle_delivery_integration.py +++ b/tests/server/test_audit_cycle_delivery_integration.py @@ -29,6 +29,7 @@ AuditAssessmentRow, AuditCycleAuthority, AuditCycleHead, + AuditCycleVerificationError, AuditVerdict, BindingMode, BoundStepInvocation, @@ -49,6 +50,7 @@ VerifiedInputPreflightResult, compute_runtime_binding_digest, ) +from autoskillit.core.closure_hashing import compute_bytes_hash from autoskillit.recipe import RecipeStep, bind_step_invocation from autoskillit.server._recipe_delivery import ( complete_finalized_recipe_response, @@ -141,12 +143,12 @@ def _preflight_projection() -> RecipeBindingProjection: ) -def _artifact(path: Path, digest: str) -> ArtifactRef: +def _artifact(path: Path, digest: str, *, byte_size: int = 1) -> ArtifactRef: return ArtifactRef( locator=str(path), media_type="application/json", schema_version=1, - byte_size=1, + byte_size=byte_size, content_digest=digest, ) @@ -159,7 +161,35 @@ def _authority( parent: str | None, verdict: AuditVerdict, part_id: str = "part-a", + materialize: bool = False, ) -> AuditCycleAuthority: + plan_ref = _artifact(tmp_path / "plan.md", _HASH_A) + inventory_ref = _artifact(tmp_path / "inventory.json", _HASH_B) + remediation_ref = _artifact(tmp_path / "remediation.md", _HASH_A) + if materialize: + artifact_payloads = { + tmp_path / "plan.md": b"audited plan", + tmp_path / "inventory.json": b'{"schema_version":1}', + tmp_path / "remediation.md": b"remediation", + } + for path, payload in artifact_payloads.items(): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(payload) + plan_ref = _artifact( + tmp_path / "plan.md", + compute_bytes_hash(artifact_payloads[tmp_path / "plan.md"]), + byte_size=len(artifact_payloads[tmp_path / "plan.md"]), + ) + inventory_ref = _artifact( + tmp_path / "inventory.json", + compute_bytes_hash(artifact_payloads[tmp_path / "inventory.json"]), + byte_size=len(artifact_payloads[tmp_path / "inventory.json"]), + ) + remediation_ref = _artifact( + tmp_path / "remediation.md", + compute_bytes_hash(artifact_payloads[tmp_path / "remediation.md"]), + byte_size=len(artifact_payloads[tmp_path / "remediation.md"]), + ) assessment = AuditAssessmentRow.create( requirement_id="REQ-001", requirement_text="requirement", @@ -174,15 +204,11 @@ def _authority( part_id=part_id, audit_round=round_, parent_authority_digest=parent, - audited_plan_refs=(_artifact(tmp_path / "plan.md", _HASH_A),), - inventory_ref=_artifact(tmp_path / "inventory.json", _HASH_B), + audited_plan_refs=(plan_ref,), + inventory_ref=inventory_ref, assessments=(assessment,), verdict=verdict, - remediation_ref=( - _artifact(tmp_path / "remediation.md", _HASH_A) - if verdict is AuditVerdict.NO_GO - else None - ), + remediation_ref=(remediation_ref if verdict is AuditVerdict.NO_GO else None), generated_at="2026-07-23T00:00:00Z", ) @@ -475,6 +501,7 @@ def test_published_audit_head_binds_preflight_template_identity( round_=1, parent=None, verdict=AuditVerdict.NO_GO, + materialize=True, ) authority_path = Path(tool_ctx_kitchen_open.temp_dir) / "authority.json" authority_path.parent.mkdir(parents=True, exist_ok=True) @@ -496,6 +523,49 @@ def test_published_audit_head_binds_preflight_template_identity( ) +def test_audit_publication_rejects_tampered_referenced_artifact( + tool_ctx_kitchen_open, +) -> None: + snapshot = build_recipe_execution_snapshot( + recipe_name="demo", + content_hash=_HASH_A, + composite_hash=_HASH_B, + projection=_projection(), + execution_id="execution-1", + ) + installed = install_recipe_execution(tool_ctx_kitchen_open, snapshot=snapshot) + root = Path(tool_ctx_kitchen_open.temp_dir) + authority = _authority( + root, + generation="execution-1", + round_=1, + parent=None, + verdict=AuditVerdict.GO, + materialize=True, + ) + authority_path = root / "tampered-authority.json" + authority_path.write_bytes(authority.canonical_bytes) + Path(authority.inventory_ref.locator).write_bytes(b"tampered") + + with pytest.raises(AuditCycleVerificationError): + publish_verified_audit_cycle( + tool_ctx_kitchen_open, + authority_path=str(authority_path), + expected_parent_digest=None, + expected_round=0, + ) + + assert ( + installed.audit_cycle_heads.get( + execution_generation=authority.execution_generation, + plan_set_id=authority.plan_set_id, + scope_id=authority.scope_id, + part_id=authority.part_id, + ) + is None + ) + + def test_successful_audit_result_publishes_protected_successor_identity( tool_ctx_kitchen_open, ) -> None: @@ -513,6 +583,7 @@ def test_successful_audit_result_publishes_protected_successor_identity( round_=1, parent=None, verdict=AuditVerdict.NO_GO, + materialize=True, ) authority_path = Path(tool_ctx_kitchen_open.temp_dir) / "reported-authority.json" authority_path.parent.mkdir(parents=True, exist_ok=True) From 1eb2f4cf3992d55d3835ce9907ab1eb73563995d Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 26 Jul 2026 13:00:45 -0700 Subject: [PATCH 47/89] test(review): synchronize review fix validation gates --- .../recipes/diagrams/implementation-groups.md | 2 +- .../recipes/diagrams/implementation.md | 2 +- src/autoskillit/recipes/diagrams/merge-prs.md | 2 +- .../recipes/diagrams/remediation.md | 2 +- src/autoskillit/recipes/diagrams/research.md | 2 +- tests/_test_filter.py | 3 ++- tests/infra/test_pretty_output_recipe.py | 4 ++-- .../test_audit_cycle_delivery_integration.py | 18 ++++++++++++++++-- 8 files changed, 25 insertions(+), 10 deletions(-) diff --git a/src/autoskillit/recipes/diagrams/implementation-groups.md b/src/autoskillit/recipes/diagrams/implementation-groups.md index 3299580e6..2c2f3b836 100644 --- a/src/autoskillit/recipes/diagrams/implementation-groups.md +++ b/src/autoskillit/recipes/diagrams/implementation-groups.md @@ -1,4 +1,4 @@ - + ## implementation-groups diff --git a/src/autoskillit/recipes/diagrams/implementation.md b/src/autoskillit/recipes/diagrams/implementation.md index 86bd6bdce..bd0cc7d04 100644 --- a/src/autoskillit/recipes/diagrams/implementation.md +++ b/src/autoskillit/recipes/diagrams/implementation.md @@ -1,4 +1,4 @@ - + ## implementation diff --git a/src/autoskillit/recipes/diagrams/merge-prs.md b/src/autoskillit/recipes/diagrams/merge-prs.md index bd6b00876..f055fa47a 100644 --- a/src/autoskillit/recipes/diagrams/merge-prs.md +++ b/src/autoskillit/recipes/diagrams/merge-prs.md @@ -1,4 +1,4 @@ - + ## merge-prs Merge multiple PRs into an integration branch with conflict resolution and CI gates. diff --git a/src/autoskillit/recipes/diagrams/remediation.md b/src/autoskillit/recipes/diagrams/remediation.md index 53247d22e..358f6b306 100644 --- a/src/autoskillit/recipes/diagrams/remediation.md +++ b/src/autoskillit/recipes/diagrams/remediation.md @@ -1,4 +1,4 @@ - + ## remediation diff --git a/src/autoskillit/recipes/diagrams/research.md b/src/autoskillit/recipes/diagrams/research.md index 83c3aaa00..5dc7b156b 100644 --- a/src/autoskillit/recipes/diagrams/research.md +++ b/src/autoskillit/recipes/diagrams/research.md @@ -1,4 +1,4 @@ - + ## research diff --git a/tests/_test_filter.py b/tests/_test_filter.py index 591b4572b..18b428c8c 100644 --- a/tests/_test_filter.py +++ b/tests/_test_filter.py @@ -281,7 +281,7 @@ class ImportContext(enum.StrEnum): "_delivery_bounds": frozenset({"core", "execution", "server"}), "_type_audit_cycle": frozenset({"core", "recipe", "server"}), "_type_recipe_binding": frozenset({"core", "recipe", "server"}), - "_type_recipe_execution": frozenset({"core", "pipeline", "server"}), + "_type_recipe_execution": frozenset({"core", "pipeline", "recipe", "server"}), "_type_closure_report": frozenset({"core"}), "context_admission": frozenset({"core"}), "audit_cycle_verifier": frozenset({"core", "recipe", "server"}), @@ -819,6 +819,7 @@ class ImportContext(enum.StrEnum): "server/test_admission_dispatch_agreement.py", "server/test_pipeline_deps_derivation.py", "server/test_pipeline_tracker.py", + "server/test_audit_cycle_delivery_integration.py", # CLI file-level entries (6 of 38 import autoskillit.recipe): "cli/test_cli_prompts.py", "cli/test_l3_orchestrator_prompt.py", diff --git a/tests/infra/test_pretty_output_recipe.py b/tests/infra/test_pretty_output_recipe.py index 64cc49766..426b773a8 100644 --- a/tests/infra/test_pretty_output_recipe.py +++ b/tests/infra/test_pretty_output_recipe.py @@ -1091,8 +1091,8 @@ def test_canonical_recipe_responses_fit_independent_registry_ceilings(tmp_path, for ingredients_only in (False, True) } assert maxima == { - "load_recipe": (168_998, "remediation", "all_truthy"), - "open_kitchen": (169_051, "remediation", "all_truthy"), + "load_recipe": (168_933, "remediation", "all_truthy"), + "open_kitchen": (168_986, "remediation", "all_truthy"), } diff --git a/tests/server/test_audit_cycle_delivery_integration.py b/tests/server/test_audit_cycle_delivery_integration.py index a08549784..50d2e63be 100644 --- a/tests/server/test_audit_cycle_delivery_integration.py +++ b/tests/server/test_audit_cycle_delivery_integration.py @@ -1356,7 +1356,14 @@ async def test_runtime_attestation_rejects_before_executor( async def test_runtime_attestation_executes_bound_prompt_and_records_digest( tool_ctx_kitchen_open, tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: + marker = "%%ORDER_UP::12345678%%" + monkeypatch.setattr( + "autoskillit.server.tools.tools_execution.uuid4", + lambda: SimpleNamespace(hex="12345678000000000000000000000000"), + ) + tool_ctx_kitchen_open.write_expected_resolver = None snapshot = build_recipe_execution_snapshot( recipe_name="demo", content_hash=_HASH_A, @@ -1369,8 +1376,15 @@ async def test_runtime_attestation_executes_bound_prompt_and_records_digest( tool_ctx_kitchen_open.runner.push( _make_result( 0, - '{"type":"result","subtype":"success","is_error":false,' - '"result":"done","session_id":"session-1"}', + json.dumps( + { + "type": "result", + "subtype": "success", + "is_error": False, + "result": f"done\n{marker}", + "session_id": "session-1", + } + ), "", ) ) From f17017d20fdadb00dcf6867efcddd989d4ac3f53 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 26 Jul 2026 13:07:18 -0700 Subject: [PATCH 48/89] test(review): correct validation synchronization --- tests/infra/test_pretty_output_recipe.py | 4 ++-- tests/server/test_audit_cycle_delivery_integration.py | 2 +- tests/test_test_filter_core_cascade.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/infra/test_pretty_output_recipe.py b/tests/infra/test_pretty_output_recipe.py index 426b773a8..7d056bc86 100644 --- a/tests/infra/test_pretty_output_recipe.py +++ b/tests/infra/test_pretty_output_recipe.py @@ -1091,8 +1091,8 @@ def test_canonical_recipe_responses_fit_independent_registry_ceilings(tmp_path, for ingredients_only in (False, True) } assert maxima == { - "load_recipe": (168_933, "remediation", "all_truthy"), - "open_kitchen": (168_986, "remediation", "all_truthy"), + "load_recipe": (168_782, "remediation", "all_truthy"), + "open_kitchen": (168_835, "remediation", "all_truthy"), } diff --git a/tests/server/test_audit_cycle_delivery_integration.py b/tests/server/test_audit_cycle_delivery_integration.py index 50d2e63be..c60ed1500 100644 --- a/tests/server/test_audit_cycle_delivery_integration.py +++ b/tests/server/test_audit_cycle_delivery_integration.py @@ -1360,7 +1360,7 @@ async def test_runtime_attestation_executes_bound_prompt_and_records_digest( ) -> None: marker = "%%ORDER_UP::12345678%%" monkeypatch.setattr( - "autoskillit.server.tools.tools_execution.uuid4", + "uuid.uuid4", lambda: SimpleNamespace(hex="12345678000000000000000000000000"), ) tool_ctx_kitchen_open.write_expected_resolver = None diff --git a/tests/test_test_filter_core_cascade.py b/tests/test_test_filter_core_cascade.py index 549178131..fed7fb8dc 100644 --- a/tests/test_test_filter_core_cascade.py +++ b/tests/test_test_filter_core_cascade.py @@ -156,7 +156,7 @@ def test_audit_cycle_cascade(self) -> None: def test_recipe_execution_cascade(self) -> None: assert MODULE_CASCADE_CORE["_type_recipe_execution"] == frozenset( - {"core", "pipeline", "server"} + {"core", "pipeline", "recipe", "server"} ) def test_type_resume_cascade(self) -> None: From e6ea500d3bd32fd93db2e51c745978ba548489d0 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 26 Jul 2026 13:08:45 -0700 Subject: [PATCH 49/89] fix(review): preserve bound child prompt rendering --- src/autoskillit/server/tools/tools_execution.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/autoskillit/server/tools/tools_execution.py b/src/autoskillit/server/tools/tools_execution.py index b1f975b2d..d663aeeec 100644 --- a/src/autoskillit/server/tools/tools_execution.py +++ b/src/autoskillit/server/tools/tools_execution.py @@ -1216,7 +1216,7 @@ async def run_skill( if invocation.root.source_ref is None: raise SkillContractError("Effective skill source identity is missing") resolved_command = render_target_skill_command( - skill_command, + child_skill_command, invocation.root.source_ref, ( _effective_backend_obj.conventions From 25b78580ceb09c01ae7455c3a7ca87d2f7837837 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 26 Jul 2026 13:10:15 -0700 Subject: [PATCH 50/89] fix(review): install execution before receipt commit --- src/autoskillit/server/_recipe_delivery.py | 69 ++++++++----------- tests/arch/test_subpackage_isolation.py | 2 +- .../test_audit_cycle_delivery_integration.py | 42 +++++++++++ 3 files changed, 72 insertions(+), 41 deletions(-) diff --git a/src/autoskillit/server/_recipe_delivery.py b/src/autoskillit/server/_recipe_delivery.py index 532f1f8e9..c6e9446f6 100644 --- a/src/autoskillit/server/_recipe_delivery.py +++ b/src/autoskillit/server/_recipe_delivery.py @@ -985,52 +985,41 @@ def complete_finalized_recipe_response( *, now_unix: int | None = None, ) -> Any: - """Commit only an exact enforced response; otherwise abort its pending receipt.""" + """Install and commit an exact enforced response; otherwise abort its receipt.""" handle = finalized.receipt_handle ledger = finalized.receipt_ledger if enforced == finalized.rendered: - if handle is None: - completed = enforced - elif ledger is not None and ledger.commit( - handle, - now_unix=int(time.time()) if now_unix is None else now_unix, - ): - completed = enforced - else: - completed = json.dumps( + if finalized.execution_snapshot is not None and finalized.tool_ctx is not None: + try: + from autoskillit.server._recipe_execution import ( # circular-break + install_recipe_execution, + ) + + install_recipe_execution( + finalized.tool_ctx, + snapshot=finalized.execution_snapshot, + ) + except Exception: + get_logger(__name__).error( + "recipe execution snapshot installation failed", + exc_info=True, + ) + enforced = json.dumps( + {"success": False, "error": "recipe_execution_install_failed"}, + separators=(",", ":"), + ) + if enforced == finalized.rendered: + if handle is None: + return enforced + if ledger is not None and ledger.commit( + handle, + now_unix=int(time.time()) if now_unix is None else now_unix, + ): + return enforced + enforced = json.dumps( {"success": False, "error": "recipe_delivery_receipt_commit_failed"}, separators=(",", ":"), ) - if completed == finalized.rendered: - if finalized.execution_snapshot is not None and finalized.tool_ctx is not None: - try: - from autoskillit.server._recipe_execution import ( # circular-break - install_recipe_execution, - ) - - install_recipe_execution( - finalized.tool_ctx, - snapshot=finalized.execution_snapshot, - ) - except Exception: - get_logger(__name__).error( - "recipe execution snapshot installation failed", - exc_info=True, - ) - from autoskillit.server._recipe_execution import ( # circular-break - clear_recipe_execution, - ) - - clear_recipe_execution(finalized.tool_ctx) - return json.dumps( - { - "success": False, - "error": "recipe_execution_install_failed", - }, - separators=(",", ":"), - ) - return completed - enforced = completed if finalized.execution_snapshot is not None and finalized.tool_ctx is not None: from autoskillit.server._recipe_execution import ( # circular-break clear_recipe_execution, diff --git a/tests/arch/test_subpackage_isolation.py b/tests/arch/test_subpackage_isolation.py index de2ef3252..b36b86ae0 100644 --- a/tests/arch/test_subpackage_isolation.py +++ b/tests/arch/test_subpackage_isolation.py @@ -1018,7 +1018,7 @@ def test_data_directories_are_not_python_packages() -> None: 1100, "REQ-CNST-010-E12: immutable recipe generation persistence, host-attested delivery " "selection, receipt reservation, and compiled-execution publication form one " - "transactional authority boundary; the snapshot carrier keeps installation after " + "transactional authority boundary; the snapshot carrier keeps installation before " "durable delivery commit without introducing a second finalization path", ), "tools_kitchen.py": ( diff --git a/tests/server/test_audit_cycle_delivery_integration.py b/tests/server/test_audit_cycle_delivery_integration.py index c60ed1500..befa27b46 100644 --- a/tests/server/test_audit_cycle_delivery_integration.py +++ b/tests/server/test_audit_cycle_delivery_integration.py @@ -296,6 +296,48 @@ def test_delivery_persists_and_installs_matching_execution( ) +def test_failed_execution_install_aborts_receipt_before_commit( + minimal_ctx, + monkeypatch: pytest.MonkeyPatch, +) -> None: + finalized = finalize_recipe_delivery( + { + "content": "name: demo\n", + "content_hash": _HASH_A, + "composite_hash": _HASH_B, + "valid": True, + }, + surface="load_recipe", + recipe_name="demo", + tool_ctx=minimal_ctx, + compiled_bindings=_projection(), + ) + handle = MagicMock() + ledger = MagicMock() + ledger.commit.return_value = True + ledger.abort.return_value = True + finalized = replace( + finalized, + receipt_handle=handle, + receipt_ledger=ledger, + ) + monkeypatch.setattr( + recipe_execution_module, + "install_recipe_execution", + MagicMock(side_effect=RuntimeError("install failed")), + ) + + result = json.loads(complete_finalized_recipe_response(finalized, finalized.rendered)) + + assert result == { + "success": False, + "error": "recipe_execution_install_failed", + } + ledger.commit.assert_not_called() + ledger.abort.assert_called_once_with(handle) + assert get_recipe_execution(minimal_ctx) is None + + def test_transformed_delivery_never_installs_execution(minimal_ctx) -> None: finalized = finalize_recipe_delivery( { From 54de7cc51e5983d1c3e24a19696fede4b2b631de Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 26 Jul 2026 13:53:32 -0700 Subject: [PATCH 51/89] test(core): constrain context admission model transitions --- .../test_context_admission_state_machine.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/core/test_context_admission_state_machine.py b/tests/core/test_context_admission_state_machine.py index 6c247601c..897b14dec 100644 --- a/tests/core/test_context_admission_state_machine.py +++ b/tests/core/test_context_admission_state_machine.py @@ -164,6 +164,21 @@ def _owner(reserve_class: ReserveClass) -> ProtectedPoolOwnerId | None: return None +def _allows_release_witness( + state: ActiveContextAdmissionState, + batch: AdmissionBatch, + witness_kind: WitnessKind, +) -> bool: + if batch.protected_pool_owner_id is None: + return True + return any( + pool.reserve_class is batch.reserve_class + and pool.capability_owner_id == batch.protected_pool_owner_id + and pool.required_release_witness_kind is witness_kind + for pool in state.protected_pools + ) + + def _occurrence( slot: int, reserve_class: ReserveClass, @@ -953,6 +968,9 @@ def release_or_rollback_preacceptance(self, rollback: bool) -> None: record = self._find_batch(batch.batch_id) if record is None: continue + witness_kind = WitnessKind.ROLLBACK if rollback else WitnessKind.NON_ADMISSION + if not _allows_release_witness(self.state, batch, witness_kind): + continue if rollback: if record.state not in { AdmissionState.HISTORY_STAGED, @@ -999,6 +1017,9 @@ def resolve_indeterminate_nonacceptance(self, rollback: bool) -> None: record = self._find_batch(batch.batch_id) if record is None or record.state is not AdmissionState.INDETERMINATE: continue + witness_kind = WitnessKind.ROLLBACK if rollback else WitnessKind.NON_ADMISSION + if not _allows_release_witness(self.state, batch, witness_kind): + continue if rollback: event: Any = ResolveIndeterminateRollbackEvent( **self._fields("resolve-indeterminate-rollback"), @@ -1080,6 +1101,12 @@ def start_and_reconcile_generation(self, exact_usage: int) -> None: @rule() def expire_terminal_idempotency_key(self) -> None: for record in self.state.idempotency_records: + if any( + tombstone.namespace == record.namespace + and tombstone.reservation_key == record.reservation_key + for tombstone in self.state.expired_idempotency_tombstones + ): + continue batch = record.original_descriptor.batch occurrence = next( ( From 1a953b7d92da8e7e422d320f1ad8c7a77d06acda Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 26 Jul 2026 14:46:53 -0700 Subject: [PATCH 52/89] fix(review): validate authority artifact references --- src/autoskillit/core/types/_type_audit_cycle.py | 4 ++++ tests/core/test_audit_cycle_authority.py | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/src/autoskillit/core/types/_type_audit_cycle.py b/src/autoskillit/core/types/_type_audit_cycle.py index d8a92df9d..cc8033509 100644 --- a/src/autoskillit/core/types/_type_audit_cycle.py +++ b/src/autoskillit/core/types/_type_audit_cycle.py @@ -312,6 +312,10 @@ def __post_init__(self) -> None: self.audited_plan_refs ): raise ValueError("AuditCycleAuthority.audited_plan_refs contain duplicate content") + if not isinstance(self.inventory_ref, ArtifactRef): + raise ValueError("AuditCycleAuthority.inventory_ref must be an ArtifactRef") + if self.remediation_ref is not None and not isinstance(self.remediation_ref, ArtifactRef): + raise ValueError("AuditCycleAuthority.remediation_ref must be an ArtifactRef") ids = tuple(row.requirement_id for row in self.assessments) if len(set(ids)) != len(ids): raise ValueError("AuditCycleAuthority.assessments contain duplicate requirement IDs") diff --git a/tests/core/test_audit_cycle_authority.py b/tests/core/test_audit_cycle_authority.py index b4740f97e..d4732a8f2 100644 --- a/tests/core/test_audit_cycle_authority.py +++ b/tests/core/test_audit_cycle_authority.py @@ -179,6 +179,12 @@ def test_authority_rejects_invalid_collection_elements( replace(_authority(tmp_path), **{field: value}) +@pytest.mark.parametrize("field", ["inventory_ref", "remediation_ref"]) +def test_authority_rejects_non_artifact_references(tmp_path: Path, field: str) -> None: + with pytest.raises(ValueError, match=rf"{field} must be an ArtifactRef"): + replace(_authority(tmp_path), **{field: object()}) + + @pytest.mark.parametrize( ("field", "value"), [ From e4be1566931a7a8fcde7bb71fc34046a860b88b6 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 26 Jul 2026 14:47:28 -0700 Subject: [PATCH 53/89] fix(review): validate bound value enum types --- .../core/types/_type_recipe_binding.py | 4 +++ tests/recipe/test_skill_invocation_binding.py | 25 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/src/autoskillit/core/types/_type_recipe_binding.py b/src/autoskillit/core/types/_type_recipe_binding.py index 9479d3317..ed69e1c03 100644 --- a/src/autoskillit/core/types/_type_recipe_binding.py +++ b/src/autoskillit/core/types/_type_recipe_binding.py @@ -140,6 +140,10 @@ class BoundValue: template_dependencies: tuple[str, ...] = () def __post_init__(self) -> None: + if not isinstance(self.state, BoundValueState): + raise ValueError("BoundValue.state must be a BoundValueState") + if not isinstance(self.origin, BoundValueOrigin): + raise ValueError("BoundValue.origin must be a BoundValueOrigin") declared_absent = isinstance(self.declared_value, AbsentBoundValue) effective_absent = isinstance(self.effective_value, AbsentBoundValue) if self.state is BoundValueState.ABSENT: diff --git a/tests/recipe/test_skill_invocation_binding.py b/tests/recipe/test_skill_invocation_binding.py index 1880c609f..a3c0fe9fb 100644 --- a/tests/recipe/test_skill_invocation_binding.py +++ b/tests/recipe/test_skill_invocation_binding.py @@ -110,6 +110,31 @@ def test_bound_value_rejects_contradictory_absence( ) +@pytest.mark.parametrize( + ("field", "value", "expected_type"), + [ + ("state", "present", "BoundValueState"), + ("origin", "literal", "BoundValueOrigin"), + ], +) +def test_bound_value_rejects_raw_enum_values( + field: str, + value: str, + expected_type: str, +) -> None: + kwargs: dict[str, object] = { + "name": "value", + "declared_value": "declared", + "effective_value": "effective", + "state": BoundValueState.PRESENT, + "origin": BoundValueOrigin.LITERAL, + } + kwargs[field] = value + + with pytest.raises(ValueError, match=expected_type): + BoundValue(**kwargs) # type: ignore[arg-type] + + def test_bound_step_invocation_freezes_collection_inputs() -> None: value = BoundValue( name="value", From 7636bde588c72ecee78c1c1a67402bd733230b13 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 26 Jul 2026 14:48:25 -0700 Subject: [PATCH 54/89] fix(review): reject effective-only recipe arguments --- src/autoskillit/recipe/_binding.py | 15 ++++++++++++--- tests/recipe/test_skill_invocation_binding.py | 15 +++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/autoskillit/recipe/_binding.py b/src/autoskillit/recipe/_binding.py index 0ee1b4ceb..df37565df 100644 --- a/src/autoskillit/recipe/_binding.py +++ b/src/autoskillit/recipe/_binding.py @@ -482,9 +482,8 @@ def bind_step_invocation( tool_name = step.tool or "" effective_with: Mapping[str, object] = step.with_args or {} - declared_with: Mapping[str, object] = ( - getattr(step, "declared_with_args", None) or effective_with - ) + declared_candidate: Mapping[str, object] | None = getattr(step, "declared_with_args", None) + declared_with = effective_with if declared_candidate is None else declared_candidate tool_def = get_tool_def(tool_name) if tool_def is None: failure = _failure( @@ -497,6 +496,16 @@ def bind_step_invocation( failures: list[BindingFailure] = [] active_manifest = manifest if manifest is not None else load_bundled_manifest() + undeclared_effective_params = frozenset(effective_with) - frozenset(declared_with) + for name in sorted(undeclared_effective_params): + failures.append( + _failure( + BindingFailureCode.UNKNOWN_TOOL_PARAMETER, + step_name, + name, + f"effective tool parameter {name!r} is absent from the declaration", + ) + ) authorable_params = tool_def.param_set if tool_name == "run_python": callable_name = effective_with.get("callable") diff --git a/tests/recipe/test_skill_invocation_binding.py b/tests/recipe/test_skill_invocation_binding.py index a3c0fe9fb..2b9cf9766 100644 --- a/tests/recipe/test_skill_invocation_binding.py +++ b/tests/recipe/test_skill_invocation_binding.py @@ -283,6 +283,21 @@ def test_falsey_values_remain_present() -> None: assert invocation.skill_input("round").effective_value == 0 # type: ignore[union-attr] +def test_explicit_empty_declaration_rejects_effective_only_inputs() -> None: + step = _step(_required_inputs()) + step.declared_with_args = {} + + invocation = bind_step_invocation("verify", step, manifest=_manifest()) + + assert not invocation.attested + undeclared = { + failure.name + for failure in invocation.failures + if "absent from the declaration" in failure.message + } + assert undeclared == {"cwd", "skill_command", "skill_inputs"} + + def test_spaces_and_shell_metacharacters_round_trip_as_data() -> None: values = _required_inputs() values["optional_note"] = "a path; $(touch never) && 'quoted value'" From 9673c8a81d171dd3addea3b1d7e8819270a732bc Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 26 Jul 2026 14:50:03 -0700 Subject: [PATCH 55/89] fix(review): remove placeholder compatibility alias --- src/autoskillit/recipe/_api.py | 8 +++-- src/autoskillit/recipe/io.py | 2 -- tests/recipe/test_recipe_temp_substitution.py | 34 +++++++++---------- 3 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/autoskillit/recipe/_api.py b/src/autoskillit/recipe/_api.py index 52db4f51c..40c15f9e1 100644 --- a/src/autoskillit/recipe/_api.py +++ b/src/autoskillit/recipe/_api.py @@ -46,7 +46,10 @@ validate_from_path, ) from autoskillit.recipe._binding import bind_recipe -from autoskillit.recipe._io_loading import load_recipe_dict_with_declarations +from autoskillit.recipe._io_loading import ( + assert_no_raw_placeholders, + load_recipe_dict_with_declarations, +) from autoskillit.recipe._recipe_composition import ( _assert_content_integrity, _build_active_recipe, @@ -86,7 +89,6 @@ ) from autoskillit.recipe.io import ( RecipeInfo, - _assert_no_raw_placeholders, _parse_recipe, builtin_recipes_dir, builtin_sub_recipes_dir, @@ -633,7 +635,7 @@ def load_and_validate( if active_recipe is not None else None ) - _assert_no_raw_placeholders(raw, context=name, hidden_ingredient_names=_hidden_names) + assert_no_raw_placeholders(raw, context=name, hidden_ingredient_names=_hidden_names) result: LoadRecipeResult = { "content": raw, "errors": errors, diff --git a/src/autoskillit/recipe/io.py b/src/autoskillit/recipe/io.py index bbd5cb63a..805e217f5 100644 --- a/src/autoskillit/recipe/io.py +++ b/src/autoskillit/recipe/io.py @@ -23,7 +23,6 @@ _SCRIPTS_PLACEHOLDER as _SCRIPTS_PLACEHOLDER, ) from autoskillit.recipe._io_loading import ( - assert_no_raw_placeholders, load_recipe_dict_with_declarations, ) from autoskillit.recipe._io_loading import ( @@ -47,7 +46,6 @@ ) logger = get_logger(__name__) -_assert_no_raw_placeholders = assert_no_raw_placeholders def step_byte_ranges_from_yaml(content: str) -> dict[str, tuple[int, int]]: diff --git a/tests/recipe/test_recipe_temp_substitution.py b/tests/recipe/test_recipe_temp_substitution.py index 9f58d8aff..0e6ddd2a2 100644 --- a/tests/recipe/test_recipe_temp_substitution.py +++ b/tests/recipe/test_recipe_temp_substitution.py @@ -159,19 +159,19 @@ def test_load_and_validate_different_temp_dir_relpath_produces_different_content def test_assert_no_raw_placeholders_raises_on_literal_placeholder() -> None: - """_assert_no_raw_placeholders must raise ValueError on unresolved placeholders.""" - from autoskillit.recipe.io import _assert_no_raw_placeholders + """assert_no_raw_placeholders must raise ValueError on unresolved placeholders.""" + from autoskillit.recipe._io_loading import assert_no_raw_placeholders with pytest.raises(ValueError, match="Unresolved"): - _assert_no_raw_placeholders("some text with {{AUTOSKILLIT_TEMP}} inside") + assert_no_raw_placeholders("some text with {{AUTOSKILLIT_TEMP}} inside") def test_assert_no_raw_placeholders_passes_on_substituted_text() -> None: - """_assert_no_raw_placeholders must pass silently on substituted text.""" - from autoskillit.recipe.io import _assert_no_raw_placeholders + """assert_no_raw_placeholders must pass silently on substituted text.""" + from autoskillit.recipe._io_loading import assert_no_raw_placeholders - _assert_no_raw_placeholders("already substituted .autoskillit/temp/path") - _assert_no_raw_placeholders("") + assert_no_raw_placeholders("already substituted .autoskillit/temp/path") + assert_no_raw_placeholders("") def test_all_bundled_recipes_content_no_raw_placeholders( @@ -198,36 +198,36 @@ def test_all_bundled_recipes_content_no_raw_placeholders( # --------------------------------------------------------------------------- -# Tests for hidden ingredient residual check in _assert_no_raw_placeholders +# Tests for hidden ingredient residual check in assert_no_raw_placeholders # --------------------------------------------------------------------------- def test_assert_no_raw_placeholders_rejects_hidden_input_template() -> None: - """_assert_no_raw_placeholders raises ValueError for residual hidden ${{ inputs.X }}.""" - from autoskillit.recipe.io import _assert_no_raw_placeholders + """assert_no_raw_placeholders raises ValueError for residual hidden inputs.""" + from autoskillit.recipe._io_loading import assert_no_raw_placeholders with pytest.raises(ValueError, match="kitchen_id"): - _assert_no_raw_placeholders( + assert_no_raw_placeholders( "skill_command: /autoskillit:diag ${{ inputs.kitchen_id }}", hidden_ingredient_names=frozenset({"kitchen_id"}), ) def test_assert_no_raw_placeholders_allows_visible_ingredient_template() -> None: - """_assert_no_raw_placeholders does NOT raise for visible ingredient ${{ inputs.X }}.""" - from autoskillit.recipe.io import _assert_no_raw_placeholders + """assert_no_raw_placeholders allows visible ingredient templates.""" + from autoskillit.recipe._io_loading import assert_no_raw_placeholders - _assert_no_raw_placeholders( + assert_no_raw_placeholders( "skill_command: /autoskillit:implement ${{ inputs.task }}", hidden_ingredient_names=frozenset({"kitchen_id"}), ) def test_assert_no_raw_placeholders_no_hidden_names_passes() -> None: - """_assert_no_raw_placeholders skips hidden check when hidden_ingredient_names is None.""" - from autoskillit.recipe.io import _assert_no_raw_placeholders + """assert_no_raw_placeholders skips hidden checks when names are absent.""" + from autoskillit.recipe._io_loading import assert_no_raw_placeholders - _assert_no_raw_placeholders( + assert_no_raw_placeholders( "skill_command: /autoskillit:diag ${{ inputs.kitchen_id }}", hidden_ingredient_names=None, ) From 4e736594084348e0c73d667dcd5b064b0a899927 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 26 Jul 2026 14:51:18 -0700 Subject: [PATCH 56/89] fix(review): derive attested tool contract identity --- src/autoskillit/core/__init__.pyi | 1 + src/autoskillit/core/tool_registry.py | 24 +++++++++++++++++++++ src/autoskillit/server/_recipe_execution.py | 7 +++++- tests/server/test_tool_registry_parity.py | 17 ++++++++++++++- 4 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/autoskillit/core/__init__.pyi b/src/autoskillit/core/__init__.pyi index e2cfcdcab..5ebff1cea 100644 --- a/src/autoskillit/core/__init__.pyi +++ b/src/autoskillit/core/__init__.pyi @@ -153,6 +153,7 @@ from .runtime.session_registry import registry_path as registry_path from .runtime.session_registry import write_registry_entry as write_registry_entry from .tool_registry import TOOL_REGISTRY as TOOL_REGISTRY from .tool_registry import all_tool_names as all_tool_names +from .tool_registry import compute_tool_contract_identity as compute_tool_contract_identity from .tool_registry import get_tool_def as get_tool_def from .tool_registry import unsupported_tool_params as unsupported_tool_params from .tool_sequence_analysis import DFG as DFG diff --git a/src/autoskillit/core/tool_registry.py b/src/autoskillit/core/tool_registry.py index 79adf9c3a..95e929669 100644 --- a/src/autoskillit/core/tool_registry.py +++ b/src/autoskillit/core/tool_registry.py @@ -10,16 +10,20 @@ from collections.abc import Mapping from types import MappingProxyType +from .closure_hashing import compute_canonical_hash from .types._type_constants_registries import HEADLESS_TOOLS from .types._type_recipe_binding import ToolDef, ToolParamDef, ToolWireType __all__ = [ "TOOL_REGISTRY", "all_tool_names", + "compute_tool_contract_identity", "get_tool_def", "unsupported_tool_params", ] +_TOOL_CONTRACT_IDENTITY_DOMAIN = "autoskillit:tool-contract:v1:sha256" + def _tool( name: str, @@ -407,6 +411,26 @@ def get_tool_def(tool_name: str) -> ToolDef | None: return TOOL_REGISTRY.get(tool_name) +def compute_tool_contract_identity(tool_def: ToolDef) -> str: + """Hash every canonical parameter property that defines a tool contract.""" + return compute_canonical_hash( + { + "name": tool_def.name, + "params": [ + { + "handler_parameter": param.handler_parameter, + "name": param.name, + "required": param.required, + "structured_skill_inputs": param.structured_skill_inputs, + "wire_type": param.wire_type.value, + } + for param in tool_def.params + ], + }, + domain=_TOOL_CONTRACT_IDENTITY_DOMAIN, + ) + + def all_tool_names() -> frozenset[str]: return frozenset(TOOL_REGISTRY) diff --git a/src/autoskillit/server/_recipe_execution.py b/src/autoskillit/server/_recipe_execution.py index 0458b429f..9b51249f4 100644 --- a/src/autoskillit/server/_recipe_execution.py +++ b/src/autoskillit/server/_recipe_execution.py @@ -32,7 +32,9 @@ VerifiedInputPreflightResult, compute_invocation_template_digest, compute_recipe_execution_snapshot_digest, + compute_tool_contract_identity, get_logger, + get_tool_def, resolve_skill_name, ) from autoskillit.recipe import ( @@ -64,7 +66,10 @@ "record_runtime_binding_digest", ] -_TOOL_CONTRACT_IDENTITY = "autoskillit:tool-registry:v1:run_skill" +_RUN_SKILL_TOOL_DEF = get_tool_def("run_skill") +if _RUN_SKILL_TOOL_DEF is None: + raise RuntimeError("canonical run_skill tool contract is unavailable") +_TOOL_CONTRACT_IDENTITY = compute_tool_contract_identity(_RUN_SKILL_TOOL_DEF) _SKILL_CONTRACT_IDENTITY_DOMAIN = b"autoskillit:skill-contract:v1\0" diff --git a/tests/server/test_tool_registry_parity.py b/tests/server/test_tool_registry_parity.py index a8e901350..44dc56b39 100644 --- a/tests/server/test_tool_registry_parity.py +++ b/tests/server/test_tool_registry_parity.py @@ -3,11 +3,16 @@ from __future__ import annotations import ast +from dataclasses import replace from pathlib import Path import pytest -from autoskillit.core import TOOL_REGISTRY +from autoskillit.core import ( + TOOL_REGISTRY, + ToolParamDef, + compute_tool_contract_identity, +) pytestmark = [pytest.mark.layer("server"), pytest.mark.small] @@ -77,3 +82,13 @@ def test_run_skill_has_one_compiler_owned_structured_input_channel() -> None: ) assert tuple(param.name for param in structured) == ("skill_inputs",) assert structured[0].handler_parameter + + +def test_tool_contract_identity_tracks_registry_parameter_shape() -> None: + run_skill = TOOL_REGISTRY["run_skill"] + changed = replace( + run_skill, + params=(*run_skill.params, ToolParamDef("future_parameter")), + ) + + assert compute_tool_contract_identity(changed) != compute_tool_contract_identity(run_skill) From 5f351fee085ee400fa2adbd820c1fdf5d903e0d7 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 26 Jul 2026 14:53:12 -0700 Subject: [PATCH 57/89] fix(review): reject runtime skill contract drift --- src/autoskillit/server/_recipe_execution.py | 19 +++++-- .../test_audit_cycle_delivery_integration.py | 51 +++++++++++++++++++ 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/src/autoskillit/server/_recipe_execution.py b/src/autoskillit/server/_recipe_execution.py index 9b51249f4..25fc3b488 100644 --- a/src/autoskillit/server/_recipe_execution.py +++ b/src/autoskillit/server/_recipe_execution.py @@ -284,9 +284,13 @@ def resolve( return self._result(decision) -def _skill_contract_identity(skill_name: str) -> str: - manifest = load_bundled_manifest() - contract = get_skill_contract(skill_name, manifest) +def _skill_contract_identity( + skill_name: str, + *, + manifest: dict[str, Any] | None = None, +) -> str: + active_manifest = manifest if manifest is not None else load_bundled_manifest() + contract = get_skill_contract(skill_name, active_manifest) if contract is None: raise ValueError(f"skill contract is unavailable for {skill_name!r}") payload = json.dumps( @@ -506,6 +510,15 @@ def bind_attested_runtime_invocation( "recipe_execution_contract_unavailable", "the compiled skill contract is unavailable at runtime", ) + runtime_skill_identity = _skill_contract_identity( + invocation.skill_name or "", + manifest=manifest, + ) + if runtime_skill_identity != template.skill_contract_identity: + raise RecipeExecutionAdmissionError( + "recipe_execution_contract_mismatch", + "runtime skill contract differs from the compiled template", + ) contract_inputs = {input_def.name: input_def for input_def in contract.inputs} supplied = dict(skill_inputs or {}) if skill_inputs is None: diff --git a/tests/server/test_audit_cycle_delivery_integration.py b/tests/server/test_audit_cycle_delivery_integration.py index befa27b46..55aeebb19 100644 --- a/tests/server/test_audit_cycle_delivery_integration.py +++ b/tests/server/test_audit_cycle_delivery_integration.py @@ -448,6 +448,57 @@ def test_bound_prompt_preserves_falsey_and_metacharacter_values() -> None: ] +def test_runtime_binding_rejects_skill_contract_identity_drift( + monkeypatch: pytest.MonkeyPatch, +) -> None: + snapshot = build_recipe_execution_snapshot( + recipe_name="demo", + content_hash=_HASH_A, + composite_hash=_HASH_B, + projection=_projection(), + execution_id="execution-1", + ) + template = snapshot.templates["dry"] + store = DefaultAuditCycleHeadStore() + from autoskillit.core import InstalledRecipeExecution + + installed = InstalledRecipeExecution( + snapshot=snapshot, + runtime_binding_digests={}, + audit_cycle_heads=store, + input_preflight_resolver=DefaultInputPreflightResolver( + allowed_root=Path("/tmp"), + head_store=store, + ), + ) + monkeypatch.setattr( + recipe_execution_module, + "_skill_contract_identity", + lambda *args, **kwargs: "sha256:" + "f" * 64, + ) + + with pytest.raises(RecipeExecutionAdmissionError) as exc_info: + bind_attested_runtime_invocation( + installed, + execution_id="execution-1", + step_name="dry", + template_digest=template.template_digest, + skill_command="/dry-walkthrough", + skill_inputs={ + "plan_path": "/tmp/plan.md", + "issue_url": "", + "audit_cycle_path": "/tmp/audit-cycle.json", + "plan_disposition_path": "/tmp/plan-disposition.json", + }, + actual_mcp_kwargs={ + "skill_command": "/dry-walkthrough", + "cwd": "/tmp", + }, + ) + + assert exc_info.value.code == "recipe_execution_contract_mismatch" + + def test_snapshot_rejects_digest_that_does_not_attest_invocation() -> None: snapshot = build_recipe_execution_snapshot( recipe_name="demo", From 65967403001e9428c40c793b437d67ba3b1539e4 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 26 Jul 2026 14:58:15 -0700 Subject: [PATCH 58/89] fix(review): replace recipe execution atomically --- src/autoskillit/core/__init__.pyi | 1 + .../core/types/_type_recipe_execution.py | 14 +++ src/autoskillit/pipeline/context.py | 6 +- src/autoskillit/server/_factory.py | 47 ++++------ src/autoskillit/server/_recipe_delivery.py | 44 +++++---- src/autoskillit/server/_recipe_execution.py | 64 +++++++------ src/autoskillit/server/tools/tools_kitchen.py | 1 - src/autoskillit/server/tools/tools_recipe.py | 4 +- .../test_audit_cycle_delivery_integration.py | 92 ++++++++++++++++++- .../test_tools_kitchen_gate_features.py | 17 ++++ tests/server/test_tools_load_recipe.py | 15 ++- 11 files changed, 213 insertions(+), 92 deletions(-) diff --git a/src/autoskillit/core/__init__.pyi b/src/autoskillit/core/__init__.pyi index 5ebff1cea..7d8008d2a 100644 --- a/src/autoskillit/core/__init__.pyi +++ b/src/autoskillit/core/__init__.pyi @@ -560,6 +560,7 @@ from .types import RecipeDeliveryMode as RecipeDeliveryMode from .types import RecipeDeliveryRequest as RecipeDeliveryRequest from .types import RecipeDeliverySurfaceDef as RecipeDeliverySurfaceDef from .types import RecipeExecutionLock as RecipeExecutionLock +from .types import RecipeExecutionFactory as RecipeExecutionFactory from .types import RecipeExecutionSnapshot as RecipeExecutionSnapshot from .types import RecipeIdentity as RecipeIdentity from .types import RecipeLoadError as RecipeLoadError diff --git a/src/autoskillit/core/types/_type_recipe_execution.py b/src/autoskillit/core/types/_type_recipe_execution.py index febca2912..0b11e3401 100644 --- a/src/autoskillit/core/types/_type_recipe_execution.py +++ b/src/autoskillit/core/types/_type_recipe_execution.py @@ -11,6 +11,7 @@ from collections.abc import Mapping from dataclasses import dataclass, field from enum import StrEnum +from pathlib import Path from types import MappingProxyType from typing import Any, Protocol, runtime_checkable @@ -30,6 +31,7 @@ "InvocationTemplate", "PreflightEvidence", "PreflightKind", + "RecipeExecutionFactory", "RecipeExecutionLock", "RecipeExecutionSnapshot", "VerifiedInputPreflightRequest", @@ -274,6 +276,18 @@ def __post_init__(self) -> None: ) +@runtime_checkable +class RecipeExecutionFactory(Protocol): + """Composition-root factory for a fully wired execution generation.""" + + def __call__( + self, + *, + snapshot: RecipeExecutionSnapshot, + allowed_root: Path, + ) -> InstalledRecipeExecution: ... + + def compute_runtime_binding_digest( *, execution_id: str, diff --git a/src/autoskillit/pipeline/context.py b/src/autoskillit/pipeline/context.py index a4c2133b6..21464abfb 100644 --- a/src/autoskillit/pipeline/context.py +++ b/src/autoskillit/pipeline/context.py @@ -14,7 +14,6 @@ from autoskillit.config import AutomationConfig from autoskillit.core import ( - AuditCycleHeadStore, AuditLog, BackgroundSupervisor, CampaignProtector, @@ -30,7 +29,6 @@ GitHubFetcher, HeadlessExecutor, InputContractResolver, - InputPreflightResolver, InstalledRecipeExecution, McpResponseLog, MergeQueueWatcher, @@ -39,6 +37,7 @@ PluginSource, QuotaRefreshTask, ReadOnlyResolver, + RecipeExecutionFactory, RecipeExecutionLock, RecipeRepository, ServeOverridesSnapshot, @@ -180,8 +179,7 @@ class ToolContext: input_contract_resolver: InputContractResolver | None = field(default=None) completion_required_resolver: CompletionRequiredResolver | None = field(default=None) skill_contract_resolver: SkillContractResolver | None = field(default=None) - audit_cycle_head_store: AuditCycleHeadStore | None = field(default=None) - input_preflight_resolver: InputPreflightResolver | None = field(default=None) + recipe_execution_factory: RecipeExecutionFactory | None = field(default=None) backend: CodingAgentBackend | None = field(default=None) session_skill_manager: SessionSkillManager | None = field(default=None) skill_resolver: SkillResolver | None = field(default=None) diff --git a/src/autoskillit/server/_factory.py b/src/autoskillit/server/_factory.py index eb142b6a8..795e4892f 100644 --- a/src/autoskillit/server/_factory.py +++ b/src/autoskillit/server/_factory.py @@ -17,11 +17,11 @@ from autoskillit.config import AutomationConfig from autoskillit.core import ( - AuditCycleHeadStore, DirectInstall, FleetLock, - InputPreflightResolver, + InstalledRecipeExecution, PluginSource, + RecipeExecutionSnapshot, SkillExecutionRole, SubprocessRunner, WriteBehaviorSpec, @@ -68,6 +68,10 @@ resolve_input_specs, resolve_skill_name, ) +from autoskillit.server._recipe_execution import ( + DefaultAuditCycleHeadStore, + DefaultInputPreflightResolver, +) from autoskillit.workspace import ( DefaultCloneManager, DefaultSessionSkillManager, @@ -83,28 +87,21 @@ logger = get_logger(__name__) -def make_audit_cycle_head_store() -> AuditCycleHeadStore: - """Construct the server-owned trusted-head ledger implementation.""" - from autoskillit.server._recipe_execution import ( # circular-break - DefaultAuditCycleHeadStore, - ) - - return DefaultAuditCycleHeadStore() - - -def make_input_preflight_resolver( +def make_recipe_execution( *, + snapshot: RecipeExecutionSnapshot, allowed_root: Path, - head_store: AuditCycleHeadStore, -) -> InputPreflightResolver: - """Construct the bounded verifier backed by the supplied trusted ledger.""" - from autoskillit.server._recipe_execution import ( # circular-break - DefaultInputPreflightResolver, - ) - - return DefaultInputPreflightResolver( - allowed_root=allowed_root, - head_store=head_store, +) -> InstalledRecipeExecution: + """Build one execution generation from server-owned protocol implementations.""" + head_store = DefaultAuditCycleHeadStore() + return InstalledRecipeExecution( + snapshot=snapshot, + runtime_binding_digests={}, + audit_cycle_heads=head_store, + input_preflight_resolver=DefaultInputPreflightResolver( + allowed_root=allowed_root, + head_store=head_store, + ), ) @@ -418,11 +415,7 @@ def _resolve_skill_contract(skill_command: str) -> SkillContract | None: ctx.completion_required_resolver = _resolve_completion_required ctx.skill_contract_resolver = _resolve_skill_contract ctx.input_contract_resolver = resolve_input_specs - ctx.audit_cycle_head_store = make_audit_cycle_head_store() - ctx.input_preflight_resolver = make_input_preflight_resolver( - allowed_root=ctx.temp_dir, - head_store=ctx.audit_cycle_head_store, - ) + ctx.recipe_execution_factory = make_recipe_execution ctx.token_factory = token_factory ctx.build_protected_campaign_ids = build_protected_campaign_ids ctx.executor = DefaultHeadlessExecutor(ctx) diff --git a/src/autoskillit/server/_recipe_delivery.py b/src/autoskillit/server/_recipe_delivery.py index c6e9446f6..752ab4591 100644 --- a/src/autoskillit/server/_recipe_delivery.py +++ b/src/autoskillit/server/_recipe_delivery.py @@ -768,10 +768,8 @@ def finalize_recipe_delivery( if compiled_bindings is not None: from autoskillit.server._recipe_execution import ( # circular-break build_recipe_execution_snapshot, - clear_recipe_execution, ) - clear_recipe_execution(tool_ctx) try: execution_snapshot = build_recipe_execution_snapshot( recipe_name=recipe_name, @@ -985,17 +983,18 @@ def complete_finalized_recipe_response( *, now_unix: int | None = None, ) -> Any: - """Install and commit an exact enforced response; otherwise abort its receipt.""" + """Commit and install an exact enforced response; otherwise preserve prior state.""" handle = finalized.receipt_handle ledger = finalized.receipt_ledger + prepared_execution = None if enforced == finalized.rendered: if finalized.execution_snapshot is not None and finalized.tool_ctx is not None: try: from autoskillit.server._recipe_execution import ( # circular-break - install_recipe_execution, + prepare_recipe_execution, ) - install_recipe_execution( + prepared_execution = prepare_recipe_execution( finalized.tool_ctx, snapshot=finalized.execution_snapshot, ) @@ -1009,23 +1008,28 @@ def complete_finalized_recipe_response( separators=(",", ":"), ) if enforced == finalized.rendered: - if handle is None: - return enforced - if ledger is not None and ledger.commit( - handle, - now_unix=int(time.time()) if now_unix is None else now_unix, + if handle is not None and ( + ledger is None + or not ledger.commit( + handle, + now_unix=int(time.time()) if now_unix is None else now_unix, + ) ): - return enforced - enforced = json.dumps( - {"success": False, "error": "recipe_delivery_receipt_commit_failed"}, - separators=(",", ":"), - ) - if finalized.execution_snapshot is not None and finalized.tool_ctx is not None: - from autoskillit.server._recipe_execution import ( # circular-break - clear_recipe_execution, - ) + enforced = json.dumps( + {"success": False, "error": "recipe_delivery_receipt_commit_failed"}, + separators=(",", ":"), + ) + if enforced == finalized.rendered: + if prepared_execution is not None and finalized.tool_ctx is not None: + from autoskillit.server._recipe_execution import ( # circular-break + install_recipe_execution, + ) - clear_recipe_execution(finalized.tool_ctx) + install_recipe_execution( + finalized.tool_ctx, + prepared_execution=prepared_execution, + ) + return enforced if handle is not None and (ledger is None or not ledger.abort(handle)): return json.dumps( {"success": False, "error": "recipe_delivery_receipt_abort_failed"}, diff --git a/src/autoskillit/server/_recipe_execution.py b/src/autoskillit/server/_recipe_execution.py index 25fc3b488..8e514ec7a 100644 --- a/src/autoskillit/server/_recipe_execution.py +++ b/src/autoskillit/server/_recipe_execution.py @@ -60,6 +60,7 @@ "clear_recipe_execution", "get_recipe_execution", "install_recipe_execution", + "prepare_recipe_execution", "publish_audit_cycle_result", "publish_reported_audit_cycle", "publish_verified_audit_cycle", @@ -369,38 +370,46 @@ def get_recipe_execution(tool_ctx: ToolContext) -> InstalledRecipeExecution | No return tool_ctx.active_recipe_execution -def install_recipe_execution( +def prepare_recipe_execution( tool_ctx: ToolContext, *, snapshot: RecipeExecutionSnapshot, ) -> InstalledRecipeExecution: - """Atomically install a snapshot, empty runtime map, and empty head ledger.""" - from autoskillit.server._factory import ( # circular-break - make_audit_cycle_head_store, - make_input_preflight_resolver, - ) + """Build a replacement generation without changing the active execution.""" + factory = tool_ctx.recipe_execution_factory + if factory is None: + raise RecipeExecutionAdmissionError( + "recipe_execution_factory_unavailable", + "recipe execution factory is not configured", + ) + return factory(snapshot=snapshot, allowed_root=tool_ctx.temp_dir) - head_store = make_audit_cycle_head_store() - resolver = make_input_preflight_resolver( - allowed_root=tool_ctx.temp_dir, - head_store=head_store, - ) - installed = InstalledRecipeExecution( - snapshot=snapshot, - runtime_binding_digests={}, - audit_cycle_heads=head_store, - input_preflight_resolver=resolver, - ) + +def install_recipe_execution( + tool_ctx: ToolContext, + *, + snapshot: RecipeExecutionSnapshot | None = None, + prepared_execution: InstalledRecipeExecution | None = None, +) -> InstalledRecipeExecution: + """Atomically install a snapshot, empty runtime map, and empty head ledger.""" + if (snapshot is None) == (prepared_execution is None): + raise TypeError("provide exactly one of snapshot or prepared_execution") + if prepared_execution is not None: + installed = prepared_execution + else: + assert snapshot is not None + installed = prepare_recipe_execution(tool_ctx, snapshot=snapshot) with tool_ctx.recipe_execution_lock: previous = tool_ctx.active_recipe_execution - previous_store = tool_ctx.audit_cycle_head_store tool_ctx.active_recipe_execution = installed - tool_ctx.audit_cycle_head_store = head_store - tool_ctx.input_preflight_resolver = resolver - if previous is not None: + if previous is not None and previous is not installed: + try: previous.audit_cycle_heads.clear_generation(previous.snapshot.execution_id) - elif previous_store is not None: - previous_store.clear_all() + except Exception: + get_logger(__name__).warning( + "prior recipe execution cleanup failed", + exc_info=True, + ) return installed @@ -408,14 +417,9 @@ def clear_recipe_execution(tool_ctx: ToolContext) -> None: """Clear the complete active attestation generation in one locked transition.""" with tool_ctx.recipe_execution_lock: previous = tool_ctx.active_recipe_execution - previous_store = tool_ctx.audit_cycle_head_store tool_ctx.active_recipe_execution = None - tool_ctx.audit_cycle_head_store = None - tool_ctx.input_preflight_resolver = None - if previous is not None: - previous.audit_cycle_heads.clear_generation(previous.snapshot.execution_id) - elif previous_store is not None: - previous_store.clear_all() + if previous is not None: + previous.audit_cycle_heads.clear_generation(previous.snapshot.execution_id) def record_runtime_binding_digest( diff --git a/src/autoskillit/server/tools/tools_kitchen.py b/src/autoskillit/server/tools/tools_kitchen.py index b74ca7c57..26f8d2322 100644 --- a/src/autoskillit/server/tools/tools_kitchen.py +++ b/src/autoskillit/server/tools/tools_kitchen.py @@ -676,7 +676,6 @@ def get_recipe(name: str) -> str: ctx = _get_ctx_or_none() if ctx is None or ctx.recipes is None: return json.dumps({"error": "Kitchen not open."}) - clear_recipe_execution(ctx) match = ctx.recipes.find(name, ctx.project_dir) if match is None: return json.dumps({"error": f"No recipe named '{name}'."}) diff --git a/src/autoskillit/server/tools/tools_recipe.py b/src/autoskillit/server/tools/tools_recipe.py index 23cc74b57..ab80a98a2 100644 --- a/src/autoskillit/server/tools/tools_recipe.py +++ b/src/autoskillit/server/tools/tools_recipe.py @@ -48,7 +48,7 @@ recipe_pull_producers, recipe_recreation_producers, ) -from autoskillit.server._recipe_execution import clear_recipe_execution, get_recipe_execution +from autoskillit.server._recipe_execution import get_recipe_execution from autoskillit.server._recipe_section_pagination import ( RecipeSectionBoundError, RecipeSectionNonConvergenceError, @@ -338,8 +338,6 @@ async def load_recipe( tool_ctx = _get_ctx_or_none() if tool_ctx is None or tool_ctx.recipes is None: return json.dumps({"error": "Server not initialized"}) - if not ingredients_only: - clear_recipe_execution(tool_ctx) suppressed = tool_ctx.config.migration.suppressed _defaults = resolve_ingredient_defaults(tool_ctx.project_dir) _recipe_info_pre = tool_ctx.recipes.find(name, tool_ctx.project_dir) diff --git a/tests/server/test_audit_cycle_delivery_integration.py b/tests/server/test_audit_cycle_delivery_integration.py index 55aeebb19..7c4bf5b74 100644 --- a/tests/server/test_audit_cycle_delivery_integration.py +++ b/tests/server/test_audit_cycle_delivery_integration.py @@ -143,6 +143,24 @@ def _preflight_projection() -> RecipeBindingProjection: ) +def _wire_recipe_execution_factory(tool_ctx) -> None: + def _factory(*, snapshot: RecipeExecutionSnapshot, allowed_root: Path): + from autoskillit.core import InstalledRecipeExecution + + store = DefaultAuditCycleHeadStore() + return InstalledRecipeExecution( + snapshot=snapshot, + runtime_binding_digests={}, + audit_cycle_heads=store, + input_preflight_resolver=DefaultInputPreflightResolver( + allowed_root=allowed_root, + head_store=store, + ), + ) + + tool_ctx.recipe_execution_factory = _factory + + def _artifact(path: Path, digest: str, *, byte_size: int = 1) -> ArtifactRef: return ArtifactRef( locator=str(path), @@ -254,6 +272,7 @@ def _plan_text(round_: int) -> str: def test_delivery_persists_and_installs_matching_execution( minimal_ctx, ) -> None: + _wire_recipe_execution_factory(minimal_ctx) finalized = finalize_recipe_delivery( { "content": "name: demo\n", @@ -296,10 +315,11 @@ def test_delivery_persists_and_installs_matching_execution( ) -def test_failed_execution_install_aborts_receipt_before_commit( +def test_failed_execution_preparation_aborts_receipt_before_commit( minimal_ctx, monkeypatch: pytest.MonkeyPatch, ) -> None: + _wire_recipe_execution_factory(minimal_ctx) finalized = finalize_recipe_delivery( { "content": "name: demo\n", @@ -323,7 +343,7 @@ def test_failed_execution_install_aborts_receipt_before_commit( ) monkeypatch.setattr( recipe_execution_module, - "install_recipe_execution", + "prepare_recipe_execution", MagicMock(side_effect=RuntimeError("install failed")), ) @@ -338,7 +358,66 @@ def test_failed_execution_install_aborts_receipt_before_commit( assert get_recipe_execution(minimal_ctx) is None -def test_transformed_delivery_never_installs_execution(minimal_ctx) -> None: +def test_receipt_commit_failure_preserves_previous_execution( + tool_ctx_kitchen_open, +) -> None: + previous_snapshot = build_recipe_execution_snapshot( + recipe_name="previous", + content_hash=_HASH_A, + composite_hash=_HASH_B, + projection=_projection(), + execution_id="previous-execution", + ) + previous = install_recipe_execution( + tool_ctx_kitchen_open, + snapshot=previous_snapshot, + ) + finalized = finalize_recipe_delivery( + { + "content": "name: replacement\n", + "content_hash": _HASH_A, + "composite_hash": _HASH_B, + "valid": True, + }, + surface="load_recipe", + recipe_name="replacement", + tool_ctx=tool_ctx_kitchen_open, + compiled_bindings=_projection(), + ) + handle = MagicMock() + ledger = MagicMock() + ledger.commit.return_value = False + ledger.abort.return_value = True + finalized = replace( + finalized, + receipt_handle=handle, + receipt_ledger=ledger, + ) + + result = json.loads(complete_finalized_recipe_response(finalized, finalized.rendered)) + + assert result == { + "success": False, + "error": "recipe_delivery_receipt_commit_failed", + } + ledger.abort.assert_called_once_with(handle) + assert get_recipe_execution(tool_ctx_kitchen_open) is previous + + +def test_transformed_delivery_preserves_previous_execution( + tool_ctx_kitchen_open, +) -> None: + previous_snapshot = build_recipe_execution_snapshot( + recipe_name="previous", + content_hash=_HASH_A, + composite_hash=_HASH_B, + projection=_projection(), + execution_id="previous-execution", + ) + previous = install_recipe_execution( + tool_ctx_kitchen_open, + snapshot=previous_snapshot, + ) finalized = finalize_recipe_delivery( { "content": "name: demo\n", @@ -348,19 +427,21 @@ def test_transformed_delivery_never_installs_execution(minimal_ctx) -> None: }, surface="load_recipe", recipe_name="demo", - tool_ctx=minimal_ctx, + tool_ctx=tool_ctx_kitchen_open, compiled_bindings=_projection(), ) assert complete_finalized_recipe_response(finalized, "bounded replacement") == ( "bounded replacement" ) - assert get_recipe_execution(minimal_ctx) is None + assert get_recipe_execution(tool_ctx_kitchen_open) is previous def test_recipe_execution_compilation_failure_logs_exception_context( minimal_ctx, monkeypatch: pytest.MonkeyPatch, ) -> None: + previous = MagicMock() + minimal_ctx.active_recipe_execution = previous mock_logger = MagicMock() monkeypatch.setattr(recipe_delivery_module, "get_logger", lambda _name: mock_logger) monkeypatch.setattr( @@ -393,6 +474,7 @@ def test_recipe_execution_compilation_failure_logs_exception_context( error_type="ValueError", exc_info=True, ) + assert get_recipe_execution(minimal_ctx) is previous def test_bound_prompt_preserves_falsey_and_metacharacter_values() -> None: diff --git a/tests/server/test_tools_kitchen_gate_features.py b/tests/server/test_tools_kitchen_gate_features.py index ea13ac36a..45d6c6489 100644 --- a/tests/server/test_tools_kitchen_gate_features.py +++ b/tests/server/test_tools_kitchen_gate_features.py @@ -438,6 +438,23 @@ def test_recipe_resource_returns_composed_content(): assert "skip_when_false:" not in parsed["content"] +def test_missing_recipe_resource_preserves_active_execution(): + mock_ctx = _make_mock_ctx() + mock_ctx.recipes = MagicMock() + mock_ctx.recipes.find.return_value = None + previous = MagicMock() + previous.snapshot.execution_id = "previous-execution" + mock_ctx.active_recipe_execution = previous + + with patch("autoskillit.server._state._get_ctx_or_none", return_value=mock_ctx): + from autoskillit.server.tools.tools_kitchen import get_recipe + + result = json.loads(get_recipe("missing")) + + assert result == {"error": "No recipe named 'missing'."} + assert mock_ctx.active_recipe_execution is previous + + # 1h: get_recipe resource returns error for invalid recipe def test_recipe_resource_returns_error_for_invalid_recipe(): """When load_and_validate returns valid=False, get_recipe must return an error diff --git a/tests/server/test_tools_load_recipe.py b/tests/server/test_tools_load_recipe.py index eacedeb6b..d83a0aa3d 100644 --- a/tests/server/test_tools_load_recipe.py +++ b/tests/server/test_tools_load_recipe.py @@ -4,7 +4,7 @@ import json from pathlib import Path -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -80,12 +80,23 @@ async def test_load_returns_json_with_content(self, tmp_path, monkeypatch): # SS3 @pytest.mark.anyio - async def test_load_unknown_returns_error(self, tmp_path, monkeypatch): + async def test_load_unknown_preserves_active_execution( + self, + tmp_path, + monkeypatch, + tool_ctx_kitchen_open, + ): """load_recipe returns error JSON for unknown recipe name.""" monkeypatch.chdir(tmp_path) + previous = MagicMock() + previous.snapshot.execution_id = "previous-execution" + tool_ctx_kitchen_open.active_recipe_execution = previous + result = json.loads(await load_recipe(name="nonexistent")) + assert "error" in result assert "nonexistent" in result["error"] + assert tool_ctx_kitchen_open.active_recipe_execution is previous # SS7 @pytest.mark.anyio From ade8dda32b4ad8c45b6edc84c6ff1e0b1023d2ea Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 26 Jul 2026 14:59:32 -0700 Subject: [PATCH 59/89] test(review): assert exact runtime binding digest --- .../test_audit_cycle_delivery_integration.py | 54 +++++++++++++++++-- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/tests/server/test_audit_cycle_delivery_integration.py b/tests/server/test_audit_cycle_delivery_integration.py index 7c4bf5b74..6d9bb413b 100644 --- a/tests/server/test_audit_cycle_delivery_integration.py +++ b/tests/server/test_audit_cycle_delivery_integration.py @@ -36,6 +36,7 @@ BoundValue, BoundValueOrigin, BoundValueState, + InputPreflightResolver, InventoryAdmissionDecision, InventoryAdmissionEvaluator, InvocationTemplate, @@ -1546,7 +1547,26 @@ async def test_runtime_attestation_executes_bound_prompt_and_records_digest( projection=_preflight_projection(), execution_id="execution-1", ) - install_recipe_execution(tool_ctx_kitchen_open, snapshot=snapshot) + installed = install_recipe_execution(tool_ctx_kitchen_open, snapshot=snapshot) + + class RecordingResolver: + def __init__(self, wrapped: InputPreflightResolver) -> None: + self._wrapped = wrapped + self.result: VerifiedInputPreflightResult | None = None + + def resolve( + self, + request: VerifiedInputPreflightRequest, + ) -> VerifiedInputPreflightResult: + self.result = self._wrapped.resolve(request) + return self.result + + recording_resolver = RecordingResolver(installed.input_preflight_resolver) + installed = replace( + installed, + input_preflight_resolver=recording_resolver, + ) + tool_ctx_kitchen_open.active_recipe_execution = installed tool_ctx_kitchen_open.runner.push(_make_result(returncode=1)) tool_ctx_kitchen_open.runner.push( _make_result( @@ -1584,9 +1604,35 @@ async def test_runtime_attestation_executes_bound_prompt_and_records_digest( prompt = cmd[prompt_index] assert "AUTOSKILLIT_BOUND_INVOCATION_V1" in prompt assert f'"value":"{plan_path}"' in prompt - installed = get_recipe_execution(tool_ctx_kitchen_open) - assert installed is not None - assert installed.runtime_binding_digests["dry"].startswith("sha256:") + recorded = get_recipe_execution(tool_ctx_kitchen_open) + assert recorded is not None + assert recording_resolver.result is not None + expected_digest = compute_runtime_binding_digest( + execution_id="execution-1", + step_name="dry", + template_digest=snapshot.templates["dry"].template_digest, + bound_inputs=(("plan_path", plan_path), ("issue_url", "")), + actual_mcp_kwargs={ + "skill_command": "/dry-walkthrough", + "cwd": str(tmp_path), + "model": "", + "step_name": "dry", + "recipe_execution_id": "execution-1", + "invocation_template_digest": snapshot.templates["dry"].template_digest, + "step_provider": "", + "order_id": "", + "output_dir": "", + "resume_session_id": "", + "closure_authority_path": "", + "closure_authority_hash": "", + "closure_plan_paths": "", + "closure_base_sha": "", + "closure_diff_sha": "", + "closure_target_sha": "", + }, + preflight=recording_resolver.result, + ) + assert recorded.runtime_binding_digests["dry"] == expected_digest @pytest.mark.anyio From 4b082744e1e8eb615f80b2984393c6fa87da2821 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 26 Jul 2026 15:06:27 -0700 Subject: [PATCH 60/89] fix(review): satisfy architectural validation gates --- src/autoskillit/core/__init__.pyi | 2 +- src/autoskillit/server/_recipe_execution.py | 16 ++++++++++------ tests/arch/test_pyright_suppression_allowlist.py | 2 +- tests/infra/test_schema_version_convention.py | 2 +- 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/autoskillit/core/__init__.pyi b/src/autoskillit/core/__init__.pyi index 7d8008d2a..df28822e2 100644 --- a/src/autoskillit/core/__init__.pyi +++ b/src/autoskillit/core/__init__.pyi @@ -559,8 +559,8 @@ from .types import RecipeDeliveryEvidenceDef as RecipeDeliveryEvidenceDef from .types import RecipeDeliveryMode as RecipeDeliveryMode from .types import RecipeDeliveryRequest as RecipeDeliveryRequest from .types import RecipeDeliverySurfaceDef as RecipeDeliverySurfaceDef -from .types import RecipeExecutionLock as RecipeExecutionLock from .types import RecipeExecutionFactory as RecipeExecutionFactory +from .types import RecipeExecutionLock as RecipeExecutionLock from .types import RecipeExecutionSnapshot as RecipeExecutionSnapshot from .types import RecipeIdentity as RecipeIdentity from .types import RecipeLoadError as RecipeLoadError diff --git a/src/autoskillit/server/_recipe_execution.py b/src/autoskillit/server/_recipe_execution.py index 8e514ec7a..4cfc1f1da 100644 --- a/src/autoskillit/server/_recipe_execution.py +++ b/src/autoskillit/server/_recipe_execution.py @@ -67,10 +67,6 @@ "record_runtime_binding_digest", ] -_RUN_SKILL_TOOL_DEF = get_tool_def("run_skill") -if _RUN_SKILL_TOOL_DEF is None: - raise RuntimeError("canonical run_skill tool contract is unavailable") -_TOOL_CONTRACT_IDENTITY = compute_tool_contract_identity(_RUN_SKILL_TOOL_DEF) _SKILL_CONTRACT_IDENTITY_DOMAIN = b"autoskillit:skill-contract:v1\0" @@ -316,6 +312,13 @@ def _skill_contract_identity( return "sha256:" + hashlib.sha256(_SKILL_CONTRACT_IDENTITY_DOMAIN + payload).hexdigest() +def _run_skill_tool_contract_identity() -> str: + tool_def = get_tool_def("run_skill") + if tool_def is None: + raise RuntimeError("canonical run_skill tool contract is unavailable") + return compute_tool_contract_identity(tool_def) + + def build_recipe_execution_snapshot( *, recipe_name: str, @@ -326,6 +329,7 @@ def build_recipe_execution_snapshot( ) -> RecipeExecutionSnapshot: """Create a fresh attested snapshot from the exact post-prune projection.""" active_execution_id = execution_id or uuid4().hex + tool_identity = _run_skill_tool_contract_identity() templates: dict[str, InvocationTemplate] = {} for step_name, invocation in projection.invocations.items(): if invocation.tool_name != "run_skill" or not invocation.attested: @@ -339,12 +343,12 @@ def build_recipe_execution_snapshot( content_hash=content_hash, composite_hash=composite_hash, invocation=invocation, - tool_contract_identity=_TOOL_CONTRACT_IDENTITY, + tool_contract_identity=tool_identity, skill_contract_identity=skill_identity, ) templates[step_name] = InvocationTemplate( invocation=invocation, - tool_contract_identity=_TOOL_CONTRACT_IDENTITY, + tool_contract_identity=tool_identity, skill_contract_identity=skill_identity, template_digest=digest, ) diff --git a/tests/arch/test_pyright_suppression_allowlist.py b/tests/arch/test_pyright_suppression_allowlist.py index e4abef609..a05e92ca6 100644 --- a/tests/arch/test_pyright_suppression_allowlist.py +++ b/tests/arch/test_pyright_suppression_allowlist.py @@ -22,7 +22,7 @@ "recipe/__init__.py", 289, ): "lazy-registry: method added by _register_rule_module() side effects", - ("recipe/_api.py", 289): "lazy-registry: RULE_REGISTRY_HASH set by _finalize_registry()", + ("recipe/_api.py", 291): "lazy-registry: RULE_REGISTRY_HASH set by _finalize_registry()", } TEST_ALLOWLIST: dict[tuple[str, int], str] = { diff --git a/tests/infra/test_schema_version_convention.py b/tests/infra/test_schema_version_convention.py index 66115c9d0..900c88b35 100644 --- a/tests/infra/test_schema_version_convention.py +++ b/tests/infra/test_schema_version_convention.py @@ -122,7 +122,7 @@ def _is_yaml_dump(node: ast.expr) -> bool: ("src/autoskillit/server/tools/tools_kitchen.py", 285), ("src/autoskillit/server/tools/tools_kitchen.py", 304), ("src/autoskillit/server/tools/tools_kitchen.py", 338), - ("src/autoskillit/server/tools/tools_kitchen.py", 1454), + ("src/autoskillit/server/tools/tools_kitchen.py", 1453), # tools_pipeline_tracker.py — tracker_data dict (init) and mark_step_complete write # (same tracker file schema as init — not a new format, grandfathered alongside it) ("src/autoskillit/server/tools/tools_pipeline_tracker.py", 256), From d8628c5e1304409da8b51a2882d43278251d2ed4 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 26 Jul 2026 16:49:24 -0700 Subject: [PATCH 61/89] test(review): cover report provenance mismatches --- tests/core/test_inventory_admission.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/tests/core/test_inventory_admission.py b/tests/core/test_inventory_admission.py index 9978c1b76..c44208c36 100644 --- a/tests/core/test_inventory_admission.py +++ b/tests/core/test_inventory_admission.py @@ -116,8 +116,11 @@ def _report( rows: tuple[PlanDispositionRow, ...] | None = None, cycle_id: str | None = None, generation: str | None = None, + plan_set_id: str | None = None, scope_id: str | None = None, part_id: str | None = None, + audit_round: int | None = None, + parent_authority_digest: str | None = None, inventory_digest: str | None = None, findings_digest: str | None = None, ) -> PlanDispositionReport: @@ -128,11 +131,11 @@ def _report( return PlanDispositionReport.create( execution_generation=generation or authority.execution_generation, cycle_id=cycle_id or authority.cycle_id, - plan_set_id=authority.plan_set_id, + plan_set_id=plan_set_id or authority.plan_set_id, scope_id=scope_id or authority.scope_id, part_id=part_id or authority.part_id, - audit_round=authority.audit_round, - parent_authority_digest=authority.authority_digest, + audit_round=audit_round if audit_round is not None else authority.audit_round, + parent_authority_digest=parent_authority_digest or authority.authority_digest, inventory_digest=inventory_digest or authority.inventory_ref.content_digest, findings_digest=findings_digest or authority.findings_digest, current_plan_ref=_ref( @@ -302,8 +305,14 @@ def test_authoritative_empty_findings_satisfies_every_row(tmp_path: Path) -> Non [ ({"cycle_id": "cycle-other"}, AdmissionReason.CYCLE_MISMATCH), ({"generation": "generation-other"}, AdmissionReason.GENERATION_MISMATCH), + ({"plan_set_id": "plans-other"}, AdmissionReason.PLAN_SET_MISMATCH), ({"scope_id": "scope-other"}, AdmissionReason.SCOPE_MISMATCH), ({"part_id": "part-b"}, AdmissionReason.PART_MISMATCH), + ({"audit_round": 3}, AdmissionReason.ROUND_MISMATCH), + ( + {"parent_authority_digest": "sha256:" + "d" * 64}, + AdmissionReason.PARENT_MISMATCH, + ), ({"inventory_digest": _HASH_A}, AdmissionReason.INVENTORY_MISMATCH), ( {"findings_digest": "sha256:" + "c" * 64}, From c638c34c97593d0a3639862d644b6ec5ea2e3472 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 26 Jul 2026 16:51:24 -0700 Subject: [PATCH 62/89] fix(review): type canonical tool parameters --- src/autoskillit/core/tool_registry.py | 22 +++++++++++++++ tests/server/test_tool_registry_parity.py | 34 +++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/src/autoskillit/core/tool_registry.py b/src/autoskillit/core/tool_registry.py index 95e929669..5382d7d5f 100644 --- a/src/autoskillit/core/tool_registry.py +++ b/src/autoskillit/core/tool_registry.py @@ -204,6 +204,16 @@ def _run_skill() -> ToolDef: "inspector_model", "default_model", ), + wire_types={ + "max_concurrent_dispatches": ToolWireType.INTEGER, + "max_total_issues": ToolWireType.INTEGER, + "default_timeout_sec": ToolWireType.INTEGER, + "max_extension_seconds": ToolWireType.INTEGER, + "idle_output_timeout": ToolWireType.INTEGER, + "acquire_timeout_sec": ToolWireType.INTEGER, + "max_issues_per_food_truck": ToolWireType.INTEGER, + "enable_deadline_extension": ToolWireType.BOOLEAN, + }, ), _tool( "configure_order", @@ -214,6 +224,12 @@ def _run_skill() -> ToolDef: "max_suppression_seconds", "default_model", ), + wire_types={ + "timeout": ToolWireType.INTEGER, + "stale_threshold": ToolWireType.INTEGER, + "idle_output_timeout": ToolWireType.INTEGER, + "max_suppression_seconds": ToolWireType.INTEGER, + }, ), _tool("run_cmd", ("cmd", "cwd", "timeout", "step_name"), required=("cmd", "cwd")), _tool( @@ -252,11 +268,16 @@ def _run_skill() -> ToolDef: "record_gate_dispatch", ("dispatch_name", "approved"), required=("dispatch_name", "approved"), + wire_types={"approved": ToolWireType.BOOLEAN}, ), _tool( "reset_dispatch", ("dispatch_id", "reset_to", "force", "destroy_artifacts"), required=("dispatch_id",), + wire_types={ + "force": ToolWireType.BOOLEAN, + "destroy_artifacts": ToolWireType.BOOLEAN, + }, ), _tool( "merge_worktree", @@ -316,6 +337,7 @@ def _run_skill() -> ToolDef: ("name", "overrides", "ingredients_only", "delivery_request"), wire_types={ "overrides": ToolWireType.OBJECT, + "ingredients_only": ToolWireType.BOOLEAN, "delivery_request": ToolWireType.OBJECT, }, ), diff --git a/tests/server/test_tool_registry_parity.py b/tests/server/test_tool_registry_parity.py index 44dc56b39..34616d9a8 100644 --- a/tests/server/test_tool_registry_parity.py +++ b/tests/server/test_tool_registry_parity.py @@ -11,6 +11,7 @@ from autoskillit.core import ( TOOL_REGISTRY, ToolParamDef, + ToolWireType, compute_tool_contract_identity, ) @@ -76,6 +77,39 @@ def test_registry_matches_handler_order_and_requiredness() -> None: assert registry_params == handler_params, name +def test_registry_preserves_typed_handler_wire_contracts() -> None: + expected = { + "configure_fleet": { + "max_concurrent_dispatches": ToolWireType.INTEGER, + "max_total_issues": ToolWireType.INTEGER, + "default_timeout_sec": ToolWireType.INTEGER, + "max_extension_seconds": ToolWireType.INTEGER, + "idle_output_timeout": ToolWireType.INTEGER, + "acquire_timeout_sec": ToolWireType.INTEGER, + "max_issues_per_food_truck": ToolWireType.INTEGER, + "enable_deadline_extension": ToolWireType.BOOLEAN, + }, + "configure_order": { + "timeout": ToolWireType.INTEGER, + "stale_threshold": ToolWireType.INTEGER, + "idle_output_timeout": ToolWireType.INTEGER, + "max_suppression_seconds": ToolWireType.INTEGER, + }, + "record_gate_dispatch": {"approved": ToolWireType.BOOLEAN}, + "reset_dispatch": { + "force": ToolWireType.BOOLEAN, + "destroy_artifacts": ToolWireType.BOOLEAN, + }, + "open_kitchen": {"ingredients_only": ToolWireType.BOOLEAN}, + } + + for tool_name, wire_types in expected.items(): + for param_name, wire_type in wire_types.items(): + param = TOOL_REGISTRY[tool_name].param_def(param_name) + assert param is not None + assert param.wire_type is wire_type + + def test_run_skill_has_one_compiler_owned_structured_input_channel() -> None: structured = tuple( param for param in TOOL_REGISTRY["run_skill"].params if param.structured_skill_inputs From 4142c41b7fd57054bfab6282f547a4330149c35e Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 26 Jul 2026 16:52:02 -0700 Subject: [PATCH 63/89] fix(review): validate disposition plan references --- src/autoskillit/core/types/_type_audit_cycle.py | 2 ++ tests/core/test_audit_cycle_authority.py | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/src/autoskillit/core/types/_type_audit_cycle.py b/src/autoskillit/core/types/_type_audit_cycle.py index cc8033509..2200911f1 100644 --- a/src/autoskillit/core/types/_type_audit_cycle.py +++ b/src/autoskillit/core/types/_type_audit_cycle.py @@ -618,6 +618,8 @@ def __post_init__(self) -> None: _require_positive_int("PlanDispositionReport.audit_round", self.audit_round) for name in ("parent_authority_digest", "inventory_digest", "findings_digest"): _require_digest(f"PlanDispositionReport.{name}", getattr(self, name)) + if not isinstance(self.current_plan_ref, ArtifactRef): + raise ValueError("PlanDispositionReport.current_plan_ref must be an ArtifactRef") ids = tuple(row.requirement_id for row in self.dispositions) if len(set(ids)) != len(ids): raise ValueError("PlanDispositionReport.dispositions contain duplicate IDs") diff --git a/tests/core/test_audit_cycle_authority.py b/tests/core/test_audit_cycle_authority.py index d4732a8f2..1c0c54732 100644 --- a/tests/core/test_audit_cycle_authority.py +++ b/tests/core/test_audit_cycle_authority.py @@ -336,6 +336,13 @@ def test_plan_disposition_report_is_bound_to_full_identity(tmp_path: Path) -> No with pytest.raises(ValueError, match="PlanDispositionRow"): replace(normalized, dispositions=[object()]) + class MutableArtifactRef: + def to_dict(self) -> dict[str, object]: + return report.current_plan_ref.to_dict() + + with pytest.raises(ValueError, match="current_plan_ref must be an ArtifactRef"): + replace(report, current_plan_ref=MutableArtifactRef()) # type: ignore[arg-type] + def test_head_allows_successor_only_for_go(tmp_path: Path) -> None: with pytest.raises(ValueError, match="only a GO"): From eebb2afad28f1c667e8b49188113f4cd007ca969 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 26 Jul 2026 16:52:39 -0700 Subject: [PATCH 64/89] fix(review): snapshot admission decision payloads --- .../core/types/_type_audit_cycle.py | 18 ++++++++++ tests/core/test_inventory_admission.py | 33 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/src/autoskillit/core/types/_type_audit_cycle.py b/src/autoskillit/core/types/_type_audit_cycle.py index 2200911f1..47eed52e7 100644 --- a/src/autoskillit/core/types/_type_audit_cycle.py +++ b/src/autoskillit/core/types/_type_audit_cycle.py @@ -743,6 +743,24 @@ class InventoryAdmissionDecision: details: tuple[str, ...] = () def __post_init__(self) -> None: + object.__setattr__( + self, + "dispositions", + _immutable_typed_tuple( + "InventoryAdmissionDecision.dispositions", + self.dispositions, + PlanDispositionRow, + ), + ) + object.__setattr__( + self, + "details", + _immutable_typed_tuple( + "InventoryAdmissionDecision.details", + self.details, + str, + ), + ) if not isinstance(self.status, AdmissionStatus): raise ValueError("InventoryAdmissionDecision.status must be an AdmissionStatus") if not isinstance(self.reason, AdmissionReason): diff --git a/tests/core/test_inventory_admission.py b/tests/core/test_inventory_admission.py index c44208c36..15d4a7337 100644 --- a/tests/core/test_inventory_admission.py +++ b/tests/core/test_inventory_admission.py @@ -256,6 +256,39 @@ def test_admission_decision_rejects_contradictory_status_matrix(decision) -> Non decision() +def test_admission_decision_snapshots_typed_payload_collections() -> None: + mutable_dispositions = list(_dispositions()) + admitted = InventoryAdmissionDecision( + status=AdmissionStatus.PASS, + reason=AdmissionReason.ADMITTED, + dispositions=mutable_dispositions, # type: ignore[arg-type] + ) + mutable_dispositions.clear() + assert admitted.dispositions == _dispositions() + + mutable_details = ["rejected"] + rejected = InventoryAdmissionDecision( + status=AdmissionStatus.REJECT, + reason=AdmissionReason.INTERNAL_ERROR, + details=mutable_details, # type: ignore[arg-type] + ) + mutable_details.clear() + assert rejected.details == ("rejected",) + + with pytest.raises(ValueError, match="PlanDispositionRow"): + InventoryAdmissionDecision( + status=AdmissionStatus.PASS, + reason=AdmissionReason.ADMITTED, + dispositions=[object()], # type: ignore[list-item, arg-type] + ) + with pytest.raises(ValueError, match="str"): + InventoryAdmissionDecision( + status=AdmissionStatus.REJECT, + reason=AdmissionReason.INTERNAL_ERROR, + details=[object()], # type: ignore[list-item, arg-type] + ) + + def test_complete_two_disposition_truth_table_passes(tmp_path: Path) -> None: authority = _authority(tmp_path) report = _report(tmp_path, authority) From 4ee120567ccb2f7970d2ce32b63fc30d4fcb8f9d Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 26 Jul 2026 16:54:32 -0700 Subject: [PATCH 65/89] fix(review): enforce integer-only binding scalars --- src/autoskillit/core/types/_type_recipe_binding.py | 4 ++-- src/autoskillit/recipe/_binding.py | 4 ++-- src/autoskillit/server/_recipe_execution.py | 5 ++--- src/autoskillit/server/tools/tools_execution.py | 2 +- tests/recipe/test_skill_invocation_binding.py | 10 ++++++++-- 5 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/autoskillit/core/types/_type_recipe_binding.py b/src/autoskillit/core/types/_type_recipe_binding.py index ed69e1c03..bf62aca69 100644 --- a/src/autoskillit/core/types/_type_recipe_binding.py +++ b/src/autoskillit/core/types/_type_recipe_binding.py @@ -30,7 +30,7 @@ ] -BoundScalar: TypeAlias = str | int | float | bool +BoundScalar: TypeAlias = str | int | bool class ToolWireType(StrEnum): @@ -246,7 +246,7 @@ def canonical_child_invocation(self) -> tuple[tuple[str, BoundScalar], ...]: effective = value.effective_value if isinstance(effective, AbsentBoundValue): continue - if not isinstance(effective, (str, int, float, bool)): + if not isinstance(effective, (str, int, bool)): raise TypeError(f"child-skill input {value.name!r} is not a strict scalar") result.append((value.name, effective)) return tuple(result) diff --git a/src/autoskillit/recipe/_binding.py b/src/autoskillit/recipe/_binding.py index df37565df..d9423c81e 100644 --- a/src/autoskillit/recipe/_binding.py +++ b/src/autoskillit/recipe/_binding.py @@ -40,7 +40,7 @@ _AUTOSKILLIT_TEMPLATE_RE: Final = re.compile(r"\{\{(AUTOSKILLIT_[A-Z0-9_]+)\}\}") _EXACT_CONTEXT_REF_RE: Final = re.compile(r"^\$\{\{\s*context\.([A-Za-z_]\w*)\s*\}\}$") _EXACT_INPUT_REF_RE: Final = re.compile(r"^\$\{\{\s*inputs\.([A-Za-z_]\w*)\s*\}\}$") -_SCALAR_TYPES = (str, int, float, bool) +_SCALAR_TYPES = (str, int, bool) def _is_scalar(value: object) -> TypeGuard[BoundScalar]: @@ -160,7 +160,7 @@ def _wire_value_is_valid(value: object, param: ToolParamDef) -> bool: case ToolWireType.INTEGER: return isinstance(value, int) and not isinstance(value, bool) case ToolWireType.NUMBER: - return isinstance(value, (int, float)) and not isinstance(value, bool) + return isinstance(value, int) and not isinstance(value, bool) case ToolWireType.BOOLEAN: return isinstance(value, bool) case ToolWireType.SCALAR: diff --git a/src/autoskillit/server/_recipe_execution.py b/src/autoskillit/server/_recipe_execution.py index 4cfc1f1da..2483fff77 100644 --- a/src/autoskillit/server/_recipe_execution.py +++ b/src/autoskillit/server/_recipe_execution.py @@ -553,11 +553,10 @@ def bind_attested_runtime_invocation( value.name: value.effective_value for value in runtime_inline.skill_inputs if value.state is BoundValueState.PRESENT - and isinstance(value.effective_value, (str, int, float, bool)) + and isinstance(value.effective_value, (str, int, bool)) } if any( - not isinstance(value, (str, int, float, bool)) or value is None - for value in supplied.values() + not isinstance(value, (str, int, bool)) or value is None for value in supplied.values() ): raise RecipeExecutionAdmissionError( "recipe_execution_input_type", diff --git a/src/autoskillit/server/tools/tools_execution.py b/src/autoskillit/server/tools/tools_execution.py index d663aeeec..315693b12 100644 --- a/src/autoskillit/server/tools/tools_execution.py +++ b/src/autoskillit/server/tools/tools_execution.py @@ -621,7 +621,7 @@ async def run_skill( closure_base_sha: str = "", closure_diff_sha: str = "", closure_target_sha: str = "", - skill_inputs: dict[str, str | int | float | bool] | None = None, + skill_inputs: dict[str, str | int | bool] | None = None, ctx: Context = CurrentContext(), ) -> str: """Delegate one already-selected recipe step to a separate L1 headless coding-agent worker. diff --git a/tests/recipe/test_skill_invocation_binding.py b/tests/recipe/test_skill_invocation_binding.py index 2b9cf9766..ab0af45a3 100644 --- a/tests/recipe/test_skill_invocation_binding.py +++ b/tests/recipe/test_skill_invocation_binding.py @@ -51,7 +51,7 @@ def _manifest() -> dict[str, object]: def _step( - skill_inputs: dict[str, str | int | float | bool], + skill_inputs: dict[str, str | int | bool], **extra: str, ) -> RecipeStep: return RecipeStep( @@ -269,7 +269,7 @@ def test_optional_absence_does_not_shift_later_values() -> None: def test_falsey_values_remain_present() -> None: - values: dict[str, str | int | float | bool] = _required_inputs() + values: dict[str, str | int | bool] = _required_inputs() values.update(optional_note="", enabled=False, round=0) invocation = bind_step_invocation( "verify", @@ -372,6 +372,12 @@ def test_unknown_tool_and_skill_namespaces_are_distinct() -> None: BindingFailureCode.INVALID_TOOL_PARAMETER_TYPE, "issue_numbers", ), + ( + "run_cmd", + {"cmd": "pwd", "cwd": "/repo", "timeout": 1.5}, + BindingFailureCode.INVALID_TOOL_PARAMETER_TYPE, + "timeout", + ), ], ) def test_required_and_wire_typed_tool_parameters_reject_invalid_shapes( From 45c72a02ff6ffea284d9b2f7762f68af2c0b06e5 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 26 Jul 2026 16:55:21 -0700 Subject: [PATCH 66/89] fix(review): require canonical binding projections --- src/autoskillit/recipe/_analysis.py | 4 +--- tests/recipe/test_analysis_public_api.py | 2 ++ tests/recipe/test_rules_features.py | 2 ++ 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/autoskillit/recipe/_analysis.py b/src/autoskillit/recipe/_analysis.py index 996e8c702..54a764670 100644 --- a/src/autoskillit/recipe/_analysis.py +++ b/src/autoskillit/recipe/_analysis.py @@ -73,6 +73,7 @@ class ValidationContext: recipe: Recipe step_graph: dict[str, set[str]] dataflow: DataFlowReport + binding_projection: RecipeBindingProjection available_recipes: frozenset[str] = field(default_factory=frozenset) available_skills: frozenset[str] = field(default_factory=frozenset) available_sub_recipes: frozenset[str] = field(default_factory=frozenset) @@ -89,9 +90,6 @@ class ValidationContext: backend_origin_map: dict[str, str] | None = None blocks: tuple[RecipeBlock, ...] = field(default_factory=tuple) predecessors: dict[str, set[str]] = field(default_factory=dict) - binding_projection: RecipeBindingProjection = field( - default_factory=lambda: RecipeBindingProjection({}) - ) # --------------------------------------------------------------------------- diff --git a/tests/recipe/test_analysis_public_api.py b/tests/recipe/test_analysis_public_api.py index 72535f763..aa9cf241a 100644 --- a/tests/recipe/test_analysis_public_api.py +++ b/tests/recipe/test_analysis_public_api.py @@ -51,6 +51,7 @@ def test_bfs_reachable_traverses_graph() -> None: def test_validation_context_skill_resolver_default() -> None: """ValidationContext.skill_resolver defaults to None.""" from autoskillit.recipe._analysis import ValidationContext + from autoskillit.recipe._binding import bind_recipe from autoskillit.recipe.schema import DataFlowReport, Recipe recipe = Recipe(name="t", description="t", steps={}, kitchen_rules=["t"]) @@ -58,6 +59,7 @@ def test_validation_context_skill_resolver_default() -> None: recipe=recipe, step_graph={}, dataflow=DataFlowReport(warnings=[], summary=""), + binding_projection=bind_recipe(recipe), ) assert ctx.skill_resolver is None diff --git a/tests/recipe/test_rules_features.py b/tests/recipe/test_rules_features.py index aeeaf0a04..b04dd1750 100644 --- a/tests/recipe/test_rules_features.py +++ b/tests/recipe/test_rules_features.py @@ -74,6 +74,7 @@ def test_feature_gate_rule_fires_on_disabled_feature_skill(monkeypatch) -> None: _build_step_graph, analyze_dataflow, ) + from autoskillit.recipe._binding import bind_recipe from autoskillit.recipe.registry import run_semantic_rules from autoskillit.recipe.schema import Recipe, RecipeStep @@ -104,6 +105,7 @@ def test_feature_gate_rule_fires_on_disabled_feature_skill(monkeypatch) -> None: recipe=recipe, step_graph=step_graph, dataflow=analyze_dataflow(recipe, step_graph=step_graph), + binding_projection=bind_recipe(recipe), disabled_features=frozenset({"test-skill-gate"}), skill_category_map={"arch-lens-c4-container": frozenset({"arch-lens"})}, ) From cee641dd177bf1756580e62941dd6ebaf19746b4 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 26 Jul 2026 16:56:56 -0700 Subject: [PATCH 67/89] fix(review): snapshot canonical structured bindings --- src/autoskillit/recipe/_binding.py | 35 ++++++++++++++- tests/recipe/test_skill_invocation_binding.py | 45 +++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/src/autoskillit/recipe/_binding.py b/src/autoskillit/recipe/_binding.py index d9423c81e..27aece88c 100644 --- a/src/autoskillit/recipe/_binding.py +++ b/src/autoskillit/recipe/_binding.py @@ -144,6 +144,20 @@ def _bound_structured_value(name: str, declared: object, effective: object) -> B ) +def _snapshot_json_value(value: object) -> object: + if value is None or isinstance(value, (str, int, bool)): + return value + if isinstance(value, float): + raise ValueError("floating-point values are outside the canonical JSON profile") + if isinstance(value, Mapping): + if any(not isinstance(key, str) for key in value): + raise ValueError("object keys must be strings") + return {key: _snapshot_json_value(nested) for key, nested in value.items()} + if isinstance(value, (list, tuple)): + return tuple(_snapshot_json_value(nested) for nested in value) + raise ValueError(f"unsupported nested value {type(value).__name__}") + + def _failure( code: BindingFailureCode, step_name: str, @@ -566,7 +580,26 @@ def bind_step_invocation( ) ) continue - mcp_kwargs.append(_bound_structured_value(param.name, declared, effective)) + try: + declared_snapshot = _snapshot_json_value(declared) + effective_snapshot = _snapshot_json_value(effective) + except ValueError as exc: + failures.append( + _failure( + BindingFailureCode.INVALID_TOOL_PARAMETER_TYPE, + step_name, + param.name, + f"tool parameter {param.name!r} is not canonical JSON: {exc}", + ) + ) + continue + mcp_kwargs.append( + _bound_structured_value( + param.name, + declared_snapshot, + effective_snapshot, + ) + ) continue if not _is_scalar(declared) or not _is_scalar(effective): failures.append( diff --git a/tests/recipe/test_skill_invocation_binding.py b/tests/recipe/test_skill_invocation_binding.py index ab0af45a3..51ea84e42 100644 --- a/tests/recipe/test_skill_invocation_binding.py +++ b/tests/recipe/test_skill_invocation_binding.py @@ -398,6 +398,51 @@ def test_required_and_wire_typed_tool_parameters_reject_invalid_shapes( ) +@pytest.mark.parametrize( + "args", + [ + {1: "non-string-key"}, + {"nested": {"bad": object()}}, + {"nested": [1.5]}, + ], +) +def test_structured_tool_parameters_reject_noncanonical_json(args: object) -> None: + invocation = bind_step_invocation( + "step", + RecipeStep( + name="step", + tool="run_python", + with_args={"callable": "module:function", "args": args}, + ), + manifest=_manifest(), + ) + + assert any( + failure.code is BindingFailureCode.INVALID_TOOL_PARAMETER_TYPE and failure.name == "args" + for failure in invocation.failures + ) + + +def test_structured_tool_parameters_snapshot_nested_values() -> None: + nested = ["original"] + args = {"nested": nested} + invocation = bind_step_invocation( + "step", + RecipeStep( + name="step", + tool="run_python", + with_args={"callable": "module:function", "args": args}, + ), + manifest=_manifest(), + ) + bound_args = next(value for value in invocation.mcp_kwargs if value.name == "args") + + nested.append("mutated") + + assert bound_args.declared_value == {"nested": ("original",)} + assert bound_args.effective_value == {"nested": ("original",)} + + def test_missing_and_inline_plus_structured_inputs_reject() -> None: missing = _required_inputs() del missing["plan_path"] From 33d7abed010a5167ab3dea205c374ce6ec7440e1 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 26 Jul 2026 16:58:16 -0700 Subject: [PATCH 68/89] fix(review): centralize bound skill lookup --- src/autoskillit/recipe/_skill_helpers.py | 7 +++++ .../recipe/rules/rules_contracts.py | 26 ++++++++----------- .../rules/rules_inventory_gate_bilateral.py | 22 +++++++--------- .../test_rules_inventory_gate_bilateral.py | 16 ++++++++++++ 4 files changed, 43 insertions(+), 28 deletions(-) diff --git a/src/autoskillit/recipe/_skill_helpers.py b/src/autoskillit/recipe/_skill_helpers.py index 290307b64..34e330fde 100644 --- a/src/autoskillit/recipe/_skill_helpers.py +++ b/src/autoskillit/recipe/_skill_helpers.py @@ -13,6 +13,13 @@ if TYPE_CHECKING: from autoskillit.core import SkillResolver + from autoskillit.recipe._analysis import ValidationContext + + +def bound_skill_name(ctx: ValidationContext, step_name: str) -> str | None: + """Return the compiler-resolved skill identity for a recipe step.""" + invocation = ctx.binding_projection.for_step(step_name) + return invocation.skill_name if invocation is not None else None def get_allowed_values_for_skill(skill_name: str) -> dict[str, list[str]]: diff --git a/src/autoskillit/recipe/rules/rules_contracts.py b/src/autoskillit/recipe/rules/rules_contracts.py index aa1b852fc..130cbfd5f 100644 --- a/src/autoskillit/recipe/rules/rules_contracts.py +++ b/src/autoskillit/recipe/rules/rules_contracts.py @@ -6,6 +6,7 @@ from autoskillit.core import SKILL_TOOLS, Severity, get_logger, pkg_root from autoskillit.recipe._analysis import ValidationContext +from autoskillit.recipe._skill_helpers import bound_skill_name from autoskillit.recipe.contracts import ( get_skill_contract, load_bundled_manifest, @@ -32,11 +33,6 @@ _DELIM_BACKTICK_RE = re.compile(r"`(---/?\w[\w:.-]*---)`") -def _bound_skill_name(ctx: ValidationContext, step_name: str) -> str | None: - invocation = ctx.binding_projection.for_step(step_name) - return invocation.skill_name if invocation is not None else None - - def _normalize_for_pattern_match(text: str) -> str: """Collapse HR-split delimiters and strip delimiter decorators. @@ -64,7 +60,7 @@ def _check_missing_output_patterns(ctx: ValidationContext) -> list[RuleFinding]: if step.tool != "run_skill": continue - name = _bound_skill_name(ctx, step_name) + name = bound_skill_name(ctx, step_name) if not name: continue @@ -104,7 +100,7 @@ def _check_pattern_examples_match(ctx: ValidationContext) -> list[RuleFinding]: for step_name, step in ctx.recipe.steps.items(): if step.tool != "run_skill": continue - name = _bound_skill_name(ctx, step_name) + name = bound_skill_name(ctx, step_name) if not name: continue contract = get_skill_contract(name, manifest) @@ -155,7 +151,7 @@ def _check_missing_pattern_examples(ctx: ValidationContext) -> list[RuleFinding] for step_name, step in ctx.recipe.steps.items(): if step.tool != "run_skill": continue - name = _bound_skill_name(ctx, step_name) + name = bound_skill_name(ctx, step_name) if not name: continue contract = get_skill_contract(name, manifest) @@ -193,7 +189,7 @@ def _check_write_behavior_consistency(ctx: ValidationContext) -> list[RuleFindin if step.tool != "run_skill": continue - skill_name = _bound_skill_name(ctx, step_name) + skill_name = bound_skill_name(ctx, step_name) if not skill_name: continue @@ -298,7 +294,7 @@ def _check_always_has_no_write_exit(ctx: ValidationContext) -> list[RuleFinding] if step.tool != "run_skill": continue - name = _bound_skill_name(ctx, step_name) + name = bound_skill_name(ctx, step_name) if not name: continue @@ -383,7 +379,7 @@ def _check_result_field_drift(ctx: ValidationContext) -> list[RuleFinding]: if step.tool != "run_skill": continue - name = _bound_skill_name(ctx, step_name) + name = bound_skill_name(ctx, step_name) if not name or name not in skill_schemas: continue @@ -448,7 +444,7 @@ def _check_example_covers_all_allowed_values(ctx: ValidationContext) -> list[Rul for step_name, step in ctx.recipe.steps.items(): if step.tool != "run_skill": continue - name = _bound_skill_name(ctx, step_name) + name = bound_skill_name(ctx, step_name) if not name: continue contract = get_skill_contract(name, manifest) @@ -508,7 +504,7 @@ def _check_all_examples_match_all_patterns(ctx: ValidationContext) -> list[RuleF for step_name, step in ctx.recipe.steps.items(): if step.tool != "run_skill": continue - name = _bound_skill_name(ctx, step_name) + name = bound_skill_name(ctx, step_name) if not name: continue contract = get_skill_contract(name, manifest) @@ -565,7 +561,7 @@ def _check_path_output_recovery_coverage(ctx: ValidationContext) -> list[RuleFin for step_name, step in ctx.recipe.steps.items(): if step.tool != "run_skill": continue - name = _bound_skill_name(ctx, step_name) + name = bound_skill_name(ctx, step_name) if not name: continue contract = get_skill_contract(name, manifest) @@ -627,7 +623,7 @@ def _check_write_skill_requires_source_output_dir(ctx: ValidationContext) -> lis for step_name, step in ctx.recipe.steps.items(): if step.tool not in SKILL_TOOLS: continue - name = _bound_skill_name(ctx, step_name) + name = bound_skill_name(ctx, step_name) if not name: continue contract = get_skill_contract(name, manifest) diff --git a/src/autoskillit/recipe/rules/rules_inventory_gate_bilateral.py b/src/autoskillit/recipe/rules/rules_inventory_gate_bilateral.py index 05363a465..7f50f6273 100644 --- a/src/autoskillit/recipe/rules/rules_inventory_gate_bilateral.py +++ b/src/autoskillit/recipe/rules/rules_inventory_gate_bilateral.py @@ -5,19 +5,11 @@ from autoskillit.core import Severity from autoskillit.recipe._analysis import ValidationContext from autoskillit.recipe._analysis_bfs import all_paths_cross, bfs_reachable -from autoskillit.recipe.contracts import resolve_skill_name +from autoskillit.recipe._skill_helpers import bound_skill_name from autoskillit.recipe.registry import RuleFinding, make_finding, semantic_rule from autoskillit.recipe.schema import RecipeStep -def _skill_name(ctx: ValidationContext, step_name: str) -> str | None: - invocation = ctx.binding_projection.for_step(step_name) - if invocation is not None and invocation.skill_name is not None: - return invocation.skill_name - step = ctx.recipe.steps[step_name] - return resolve_skill_name(str(step.with_args.get("skill_command", ""))) - - def _has_bound_input(ctx: ValidationContext, step_name: str, name: str) -> bool: invocation = ctx.binding_projection.for_step(step_name) if invocation is None: @@ -56,7 +48,9 @@ def _no_go_routes(step: RecipeStep) -> tuple[str, ...]: severity=Severity.ERROR, ) def _check_inventory_gate_bilateral(ctx: ValidationContext) -> list[RuleFinding]: - audit_steps = [name for name in ctx.recipe.steps if _skill_name(ctx, name) == "audit-impl"] + audit_steps = [ + name for name in ctx.recipe.steps if bound_skill_name(ctx, name) == "audit-impl" + ] if not audit_steps: return [] @@ -74,12 +68,14 @@ def _check_inventory_gate_bilateral(ctx: ValidationContext) -> list[RuleFinding] ) ) - make_plan_steps = {name for name in ctx.recipe.steps if _skill_name(ctx, name) == "make-plan"} + make_plan_steps = { + name for name in ctx.recipe.steps if bound_skill_name(ctx, name) == "make-plan" + } disposition_produced = any( "plan_disposition_path" in ctx.recipe.steps[name].capture for name in make_plan_steps ) for step_name in ctx.recipe.steps: - if _skill_name(ctx, step_name) != "dry-walkthrough": + if bound_skill_name(ctx, step_name) != "dry-walkthrough": continue missing = [ name @@ -146,7 +142,7 @@ def _check_inventory_gate_bilateral(ctx: ValidationContext) -> list[RuleFinding] for dry_name in sorted( name for name in reachable - if name in ctx.recipe.steps and _skill_name(ctx, name) == "dry-walkthrough" + if name in ctx.recipe.steps and bound_skill_name(ctx, name) == "dry-walkthrough" ): if any( all_paths_cross( diff --git a/tests/recipe/test_rules_inventory_gate_bilateral.py b/tests/recipe/test_rules_inventory_gate_bilateral.py index 100fb4b58..1f6679473 100644 --- a/tests/recipe/test_rules_inventory_gate_bilateral.py +++ b/tests/recipe/test_rules_inventory_gate_bilateral.py @@ -79,6 +79,22 @@ def _findings(steps: dict[str, RecipeStep]) -> list: ] +def test_inventory_gate_uses_only_compiler_resolved_skill_identity() -> None: + steps = { + "not_a_skill": RecipeStep( + tool="run_cmd", + with_args={ + "cmd": "true", + "cwd": "/repo", + "skill_command": "/autoskillit:audit-impl", + }, + ), + "done": RecipeStep(action="stop"), + } + + assert _findings(steps) == [] + + def test_inventory_gate_bilateral_fires_for_missing_dry_tuple() -> None: findings = _findings(_audit_cycle_steps()) assert len(findings) == 1 From 3e15915a3c7f1d60720fbc5b5188e20d322f615a Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 26 Jul 2026 16:58:45 -0700 Subject: [PATCH 69/89] fix(review): reject float skill input bindings --- src/autoskillit/recipe/schema.py | 2 +- tests/recipe/test_skill_invocation_binding.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/autoskillit/recipe/schema.py b/src/autoskillit/recipe/schema.py index 1d5b6fbc1..2b2307a86 100644 --- a/src/autoskillit/recipe/schema.py +++ b/src/autoskillit/recipe/schema.py @@ -161,7 +161,7 @@ def __post_init__(self) -> None: ) if not all( isinstance(input_name, str) - and isinstance(input_value, (str, int, float, bool)) + and isinstance(input_value, (str, int, bool)) and input_value is not None for input_name, input_value in value.items() ): diff --git a/tests/recipe/test_skill_invocation_binding.py b/tests/recipe/test_skill_invocation_binding.py index 51ea84e42..9c77098a9 100644 --- a/tests/recipe/test_skill_invocation_binding.py +++ b/tests/recipe/test_skill_invocation_binding.py @@ -283,6 +283,11 @@ def test_falsey_values_remain_present() -> None: assert invocation.skill_input("round").effective_value == 0 # type: ignore[union-attr] +def test_recipe_step_rejects_float_skill_inputs() -> None: + with pytest.raises(TypeError, match="strict scalar"): + _step({"round": 1.5}) # type: ignore[dict-item] + + def test_explicit_empty_declaration_rejects_effective_only_inputs() -> None: step = _step(_required_inputs()) step.declared_with_args = {} From 6a8c31cf86062084df3e9d9ff250ebedca20a59e Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 26 Jul 2026 16:59:51 -0700 Subject: [PATCH 70/89] fix(review): preserve empty argument declarations --- src/autoskillit/recipe/schema.py | 9 +++++---- tests/recipe/test_recipe_temp_substitution.py | 7 +++---- tests/recipe/test_skill_invocation_binding.py | 13 +++++++++++-- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/src/autoskillit/recipe/schema.py b/src/autoskillit/recipe/schema.py index 2b2307a86..ff054ef85 100644 --- a/src/autoskillit/recipe/schema.py +++ b/src/autoskillit/recipe/schema.py @@ -118,7 +118,7 @@ class RecipeStep: # Tool-specific binding validates ordinary values. Runtime validation below # reserves the one generic nested channel for structured child-skill inputs. with_args: dict[str, Any] = field(default_factory=dict) - declared_with_args: dict[str, Any] = field(default_factory=dict, repr=False) + declared_with_args: dict[str, Any] | None = field(default=None, repr=False) on_success: str | None = None on_failure: str | None = None on_context_limit: str | None = None @@ -168,10 +168,11 @@ def __post_init__(self) -> None: raise TypeError( "run_skill.with.skill_inputs must map names to strict scalar values" ) - if self.declared_with_args: - if self.declared_with_args.keys() != self.with_args.keys(): + if self.declared_with_args is not None: + if not self.declared_with_args.keys() <= self.with_args.keys(): raise ValueError( - "RecipeStep declared/effective with mappings must have identical keys" + "RecipeStep declared with mapping cannot contain keys absent " + "from the effective mapping" ) else: self.declared_with_args = dict(self.with_args) diff --git a/tests/recipe/test_recipe_temp_substitution.py b/tests/recipe/test_recipe_temp_substitution.py index 0e6ddd2a2..eb093956f 100644 --- a/tests/recipe/test_recipe_temp_substitution.py +++ b/tests/recipe/test_recipe_temp_substitution.py @@ -58,10 +58,9 @@ def test_load_recipe_custom_temp_dir_substituted(tmp_path: Path) -> None: recipe = load_recipe(path, temp_dir_relpath="custom/x") cmd = recipe.steps["setup"].with_args["cmd"] assert cmd == 'mkdir -p "custom/x/worktrees"' - assert ( - recipe.steps["setup"].declared_with_args["cmd"] - == 'mkdir -p "{{AUTOSKILLIT_TEMP}}/worktrees"' - ) + declared_with_args = recipe.steps["setup"].declared_with_args + assert declared_with_args is not None + assert declared_with_args["cmd"] == 'mkdir -p "{{AUTOSKILLIT_TEMP}}/worktrees"' def test_load_recipe_rejects_yaml_unsafe_temp_dir_relpath(tmp_path: Path) -> None: diff --git a/tests/recipe/test_skill_invocation_binding.py b/tests/recipe/test_skill_invocation_binding.py index 9c77098a9..1d8d980c2 100644 --- a/tests/recipe/test_skill_invocation_binding.py +++ b/tests/recipe/test_skill_invocation_binding.py @@ -289,8 +289,16 @@ def test_recipe_step_rejects_float_skill_inputs() -> None: def test_explicit_empty_declaration_rejects_effective_only_inputs() -> None: - step = _step(_required_inputs()) - step.declared_with_args = {} + step = RecipeStep( + name="verify", + tool="run_skill", + with_args={ + "skill_command": "/autoskillit:dry-walkthrough", + "cwd": "/repo", + "skill_inputs": _required_inputs(), + }, + declared_with_args={}, + ) invocation = bind_step_invocation("verify", step, manifest=_manifest()) @@ -458,6 +466,7 @@ def test_missing_and_inline_plus_structured_inputs_reject() -> None: ) ambiguous_step = _step(_required_inputs()) ambiguous_step.with_args["skill_command"] = "/autoskillit:dry-walkthrough /tmp/inline-plan.md" + assert ambiguous_step.declared_with_args is not None ambiguous_step.declared_with_args["skill_command"] = ( "/autoskillit:dry-walkthrough /tmp/inline-plan.md" ) From 1be15aad82fe94c5923b7be0bbaf6922ceb6de31 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 26 Jul 2026 17:00:25 -0700 Subject: [PATCH 71/89] fix(review): type revision guidance as a file --- src/autoskillit/recipe/skill_contracts.yaml | 2 +- tests/recipe/test_contracts.py | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/autoskillit/recipe/skill_contracts.yaml b/src/autoskillit/recipe/skill_contracts.yaml index 1d4728d7e..130c43e19 100644 --- a/src/autoskillit/recipe/skill_contracts.yaml +++ b/src/autoskillit/recipe/skill_contracts.yaml @@ -81,7 +81,7 @@ skills: type: file_path required: true - name: revision_guidance - type: directory_path + type: file_path required: false outputs: - name: resolution diff --git a/tests/recipe/test_contracts.py b/tests/recipe/test_contracts.py index 52e162569..41ddd4a39 100644 --- a/tests/recipe/test_contracts.py +++ b/tests/recipe/test_contracts.py @@ -58,6 +58,17 @@ def test_load_bundled_manifest_callable_inputs_have_explicit_required() -> None: assert "required" in inp, f"{dotted_path}: input {inp['name']} missing 'required'" +def test_resolve_design_review_revision_guidance_is_a_file() -> None: + manifest = load_bundled_manifest() + contract = get_skill_contract("resolve-design-review", manifest) + assert contract is not None + revision_guidance = next( + input_def for input_def in contract.inputs if input_def.name == "revision_guidance" + ) + + assert revision_guidance.type == "file_path" + + def test_get_skill_contract_defaults_required_false() -> None: from autoskillit.recipe.contracts import get_skill_contract From 47ccb241e00afec3e501726283f9baf7dbe521bd Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 26 Jul 2026 17:01:22 -0700 Subject: [PATCH 72/89] fix(review): preserve verifier programming errors --- src/autoskillit/server/_recipe_execution.py | 3 +- .../test_audit_cycle_delivery_integration.py | 35 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/autoskillit/server/_recipe_execution.py b/src/autoskillit/server/_recipe_execution.py index 2483fff77..ad4bc69dd 100644 --- a/src/autoskillit/server/_recipe_execution.py +++ b/src/autoskillit/server/_recipe_execution.py @@ -16,6 +16,7 @@ AdmissionReason, AuditCycleAuthority, AuditCycleHead, + AuditCycleVerificationError, AuditCycleVerifier, AuditVerdict, BindingMode, @@ -147,7 +148,7 @@ def publish( raise AuditCycleHeadConflict("audit-cycle head compare-and-swap failed") try: AuditCycleVerifier.verify_successor(authority, current) - except Exception as exc: + except AuditCycleVerificationError as exc: raise AuditCycleHeadConflict(str(exc)) from exc if ( authorized_successor_part_id is not None diff --git a/tests/server/test_audit_cycle_delivery_integration.py b/tests/server/test_audit_cycle_delivery_integration.py index 6d9bb413b..26e328a04 100644 --- a/tests/server/test_audit_cycle_delivery_integration.py +++ b/tests/server/test_audit_cycle_delivery_integration.py @@ -30,6 +30,7 @@ AuditCycleAuthority, AuditCycleHead, AuditCycleVerificationError, + AuditCycleVerifier, AuditVerdict, BindingMode, BoundStepInvocation, @@ -1153,6 +1154,40 @@ def test_head_publication_is_monotonic_compare_and_swap(tmp_path: Path) -> None: assert terminal.authorized_successor_part_id == "part-b" +def test_head_publication_does_not_mask_unexpected_verifier_faults( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + store = DefaultAuditCycleHeadStore() + first = _authority( + tmp_path, + generation="execution-1", + round_=1, + parent=None, + verdict=AuditVerdict.NO_GO, + ) + store.publish(first, expected_parent_digest=None, expected_round=0) + successor = _authority( + tmp_path, + generation="execution-1", + round_=2, + parent=first.authority_digest, + verdict=AuditVerdict.GO, + ) + + def fail_unexpectedly(*_args: object) -> None: + raise RuntimeError("verifier implementation fault") + + monkeypatch.setattr(AuditCycleVerifier, "verify_successor", fail_unexpectedly) + + with pytest.raises(RuntimeError, match="implementation fault"): + store.publish( + successor, + expected_parent_digest=first.authority_digest, + expected_round=1, + ) + + class AuditCycleLifecycleStateMachine(RuleBasedStateMachine): """Model trusted-head monotonicity across retries, parts, and generations.""" From 3e7f4ca0ec1030b27784985638036018f8c97420 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 26 Jul 2026 17:02:01 -0700 Subject: [PATCH 73/89] test(review): cover terminal go disposition rejection --- .../test_audit_cycle_delivery_integration.py | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/server/test_audit_cycle_delivery_integration.py b/tests/server/test_audit_cycle_delivery_integration.py index 26e328a04..fb3bee6cd 100644 --- a/tests/server/test_audit_cycle_delivery_integration.py +++ b/tests/server/test_audit_cycle_delivery_integration.py @@ -1528,6 +1528,40 @@ def test_preflight_never_derives_expected_identity_from_supplied_authority( assert wrong_plan_set.decision.reason is AdmissionReason.PLAN_SET_MISMATCH +def test_real_preflight_rejects_terminal_go_with_disposition_report(tmp_path: Path) -> None: + store = DefaultAuditCycleHeadStore() + authority = _authority( + tmp_path, + generation="execution-1", + round_=1, + parent=None, + verdict=AuditVerdict.GO, + ) + store.publish(authority, expected_parent_digest=None, expected_round=0) + authority_path = tmp_path / "authority.json" + authority_path.write_bytes(authority.canonical_bytes) + report_path = tmp_path / "disposition.json" + report_path.write_text("{}") + resolver = DefaultInputPreflightResolver(allowed_root=tmp_path, head_store=store) + + result = resolver.resolve( + VerifiedInputPreflightRequest( + execution_generation="execution-1", + step_name="dry", + skill_name="dry-walkthrough", + plan_path=str(tmp_path / "plan.md"), + audit_cycle_path=str(authority_path), + plan_disposition_path=str(report_path), + expected_plan_set_id=authority.plan_set_id, + expected_scope_id=authority.scope_id, + expected_part_id=authority.part_id, + ) + ) + + assert result.decision.status is AdmissionStatus.REJECT + assert result.decision.reason is AdmissionReason.DISPOSITION_MISMATCH + + @pytest.mark.anyio async def test_runtime_attestation_rejects_before_executor( tool_ctx_kitchen_open, From 980ad9814b992673791c80c5dbbcc32c13aa398b Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 26 Jul 2026 17:02:50 -0700 Subject: [PATCH 74/89] fix(review): fail closed on stale skill contracts --- src/autoskillit/server/_recipe_execution.py | 2 +- .../test_audit_cycle_delivery_integration.py | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/autoskillit/server/_recipe_execution.py b/src/autoskillit/server/_recipe_execution.py index ad4bc69dd..776792486 100644 --- a/src/autoskillit/server/_recipe_execution.py +++ b/src/autoskillit/server/_recipe_execution.py @@ -294,7 +294,7 @@ def _skill_contract_identity( payload = json.dumps( { "completion_required": contract.completion_required, - "input_preflight": getattr(contract, "input_preflight", None), + "input_preflight": contract.input_preflight, "inputs": [ { "name": item.name, diff --git a/tests/server/test_audit_cycle_delivery_integration.py b/tests/server/test_audit_cycle_delivery_integration.py index fb3bee6cd..b9d41b5b6 100644 --- a/tests/server/test_audit_cycle_delivery_integration.py +++ b/tests/server/test_audit_cycle_delivery_integration.py @@ -583,6 +583,23 @@ def test_runtime_binding_rejects_skill_contract_identity_drift( assert exc_info.value.code == "recipe_execution_contract_mismatch" +def test_skill_contract_identity_fails_closed_for_stale_contract_shape( + monkeypatch: pytest.MonkeyPatch, +) -> None: + stale_contract = SimpleNamespace( + completion_required=True, + inputs=(), + ) + monkeypatch.setattr( + recipe_execution_module, + "get_skill_contract", + lambda *_args, **_kwargs: stale_contract, + ) + + with pytest.raises(AttributeError, match="input_preflight"): + recipe_execution_module._skill_contract_identity("stale", manifest={}) # noqa: SLF001 + + def test_snapshot_rejects_digest_that_does_not_attest_invocation() -> None: snapshot = build_recipe_execution_snapshot( recipe_name="demo", From 14742d6f44f25834d2f6c0d604bf74ca69e793af Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 26 Jul 2026 17:03:23 -0700 Subject: [PATCH 75/89] test(review): guard stale runtime digest writes --- .../test_audit_cycle_delivery_integration.py | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/server/test_audit_cycle_delivery_integration.py b/tests/server/test_audit_cycle_delivery_integration.py index b9d41b5b6..f00ae9fa2 100644 --- a/tests/server/test_audit_cycle_delivery_integration.py +++ b/tests/server/test_audit_cycle_delivery_integration.py @@ -71,6 +71,7 @@ install_recipe_execution, publish_audit_cycle_result, publish_verified_audit_cycle, + record_runtime_binding_digest, ) from autoskillit.server.tools.tools_execution import run_skill from tests.conftest import _make_result @@ -1579,6 +1580,39 @@ def test_real_preflight_rejects_terminal_go_with_disposition_report(tmp_path: Pa assert result.decision.reason is AdmissionReason.DISPOSITION_MISMATCH +def test_runtime_binding_digest_rejects_replaced_execution( + tool_ctx_kitchen_open, +) -> None: + first = build_recipe_execution_snapshot( + recipe_name="demo", + content_hash=_HASH_A, + composite_hash=_HASH_B, + projection=_projection(), + execution_id="execution-1", + ) + install_recipe_execution(tool_ctx_kitchen_open, snapshot=first) + replacement = build_recipe_execution_snapshot( + recipe_name="demo", + content_hash=_HASH_A, + composite_hash=_HASH_B, + projection=_projection(), + execution_id="execution-2", + ) + active = install_recipe_execution(tool_ctx_kitchen_open, snapshot=replacement) + + with pytest.raises(RecipeExecutionAdmissionError) as exc_info: + record_runtime_binding_digest( + tool_ctx_kitchen_open, + execution_id="execution-1", + step_name="dry", + digest=_HASH_A, + ) + + assert exc_info.value.code == "recipe_execution_replaced" + assert get_recipe_execution(tool_ctx_kitchen_open) is active + assert dict(active.runtime_binding_digests) == {} + + @pytest.mark.anyio async def test_runtime_attestation_rejects_before_executor( tool_ctx_kitchen_open, From b699385339d6f16823e036dfc62c8a4f194407bd Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 26 Jul 2026 17:07:24 -0700 Subject: [PATCH 76/89] fix(review): move runtime binding into recipe domain --- src/autoskillit/recipe/__init__.py | 11 +- src/autoskillit/recipe/_binding.py | 190 ++++++++++++++++++ src/autoskillit/server/_recipe_execution.py | 182 ++--------------- .../test_audit_cycle_delivery_integration.py | 11 +- 4 files changed, 222 insertions(+), 172 deletions(-) diff --git a/src/autoskillit/recipe/__init__.py b/src/autoskillit/recipe/__init__.py index 0e80491eb..d40f58909 100644 --- a/src/autoskillit/recipe/__init__.py +++ b/src/autoskillit/recipe/__init__.py @@ -18,7 +18,13 @@ load_and_validate, validate_from_path, ) -from autoskillit.recipe._binding import bind_recipe, bind_step_invocation # noqa: E402 +from autoskillit.recipe._binding import ( # noqa: E402 + RuntimeBindingError, + bind_recipe, + bind_runtime_skill_invocation, + bind_step_invocation, + compute_skill_contract_identity, +) from autoskillit.recipe._recipe_ingredients import ( # noqa: E402 ListRecipesResult, LoadRecipeResult, @@ -342,7 +348,10 @@ "parse_recipe_metadata", "load_and_validate", "bind_recipe", + "bind_runtime_skill_invocation", "bind_step_invocation", + "compute_skill_contract_identity", + "RuntimeBindingError", "validate_from_path", "list_all", "format_ingredients_table", diff --git a/src/autoskillit/recipe/_binding.py b/src/autoskillit/recipe/_binding.py index 27aece88c..ee4841ce2 100644 --- a/src/autoskillit/recipe/_binding.py +++ b/src/autoskillit/recipe/_binding.py @@ -2,6 +2,8 @@ from __future__ import annotations +import hashlib +import json from collections.abc import Mapping from typing import Any, Final, TypeGuard @@ -16,6 +18,7 @@ BoundValue, BoundValueOrigin, BoundValueState, + InvocationTemplate, RecipeBindingProjection, ToolParamDef, ToolWireType, @@ -28,12 +31,18 @@ load_bundled_manifest, ) from autoskillit.recipe._contracts_types import SkillContract, SkillInput +from autoskillit.recipe.schema import RecipeStep __all__ = [ + "RuntimeBindingError", "bind_recipe", + "bind_runtime_skill_invocation", "bind_step_invocation", + "compute_skill_contract_identity", ] +_SKILL_CONTRACT_IDENTITY_DOMAIN = b"autoskillit:skill-contract:v1\0" + _CONTEXT_REF_RE: Final = re.compile(r"\$\{\{\s*context\.([A-Za-z_]\w*)\s*\}\}") _INPUT_REF_RE: Final = re.compile(r"\$\{\{\s*inputs\.([A-Za-z_]\w*)\s*\}\}") @@ -43,6 +52,14 @@ _SCALAR_TYPES = (str, int, bool) +class RuntimeBindingError(ValueError): + """Stable recipe-domain rejection for one runtime invocation binding.""" + + def __init__(self, code: str, message: str) -> None: + super().__init__(message) + self.code = code + + def _is_scalar(value: object) -> TypeGuard[BoundScalar]: return isinstance(value, _SCALAR_TYPES) and value is not None @@ -808,3 +825,176 @@ def bind_recipe( if step.tool is not None } return RecipeBindingProjection(invocations) + + +def compute_skill_contract_identity( + skill_name: str, + *, + manifest: dict[str, Any] | None = None, +) -> str: + """Hash the canonical runtime-relevant shape of one skill contract.""" + active_manifest = manifest if manifest is not None else load_bundled_manifest() + contract = get_skill_contract(skill_name, active_manifest) + if contract is None: + raise ValueError(f"skill contract is unavailable for {skill_name!r}") + payload = json.dumps( + { + "completion_required": contract.completion_required, + "input_preflight": contract.input_preflight, + "inputs": [ + { + "name": item.name, + "nullable": item.nullable, + "required": item.required, + "type": item.type, + } + for item in contract.inputs + ], + "skill_name": skill_name, + }, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return "sha256:" + hashlib.sha256(_SKILL_CONTRACT_IDENTITY_DOMAIN + payload).hexdigest() + + +def _is_dynamic_binding(value: BoundValue) -> bool: + return bool(value.context_dependencies or value.input_dependencies) + + +def bind_runtime_skill_invocation( + template: InvocationTemplate, + *, + execution_id: str, + step_name: str, + skill_command: str, + skill_inputs: Mapping[str, BoundScalar] | None, + actual_mcp_kwargs: Mapping[str, BoundScalar], +) -> tuple[tuple[str, BoundScalar], ...]: + """Bind runtime values against one immutable compiled recipe template.""" + invocation = template.invocation + if resolve_skill_name(skill_command) != invocation.skill_name: + raise RuntimeBindingError( + "recipe_execution_skill_mismatch", + "runtime skill identity differs from the compiled template", + ) + compiled_mcp_names = frozenset(value.name for value in invocation.mcp_kwargs) + protocol_mcp_values = { + "step_name": step_name, + "recipe_execution_id": execution_id, + "invocation_template_digest": template.template_digest, + } + for name, expected in protocol_mcp_values.items(): + if name in actual_mcp_kwargs and actual_mcp_kwargs[name] != expected: + raise RuntimeBindingError( + "recipe_execution_tool_shape", + f"attestation parameter {name!r} differs from the active invocation", + ) + undeclared_effective_names = sorted( + name + for name, value in actual_mcp_kwargs.items() + if name not in compiled_mcp_names and name not in protocol_mcp_values and value != "" + ) + if undeclared_effective_names: + raise RuntimeBindingError( + "recipe_execution_tool_shape", + ( + "runtime tool parameters are absent from the compiled template: " + f"{undeclared_effective_names!r}" + ), + ) + manifest = load_bundled_manifest() + contract = get_skill_contract(invocation.skill_name or "", manifest) + if contract is None: + raise RuntimeBindingError( + "recipe_execution_contract_unavailable", + "the compiled skill contract is unavailable at runtime", + ) + runtime_skill_identity = compute_skill_contract_identity( + invocation.skill_name or "", + manifest=manifest, + ) + if runtime_skill_identity != template.skill_contract_identity: + raise RuntimeBindingError( + "recipe_execution_contract_mismatch", + "runtime skill contract differs from the compiled template", + ) + contract_inputs = {input_def.name: input_def for input_def in contract.inputs} + supplied = dict(skill_inputs or {}) + if skill_inputs is None: + runtime_with_args: dict[str, object] = {"skill_command": skill_command} + runtime_cwd = actual_mcp_kwargs.get("cwd") + if isinstance(runtime_cwd, str): + runtime_with_args["cwd"] = runtime_cwd + runtime_inline = bind_step_invocation( + step_name, + RecipeStep( + name=step_name, + tool="run_skill", + with_args=runtime_with_args, + declared_with_args=dict(runtime_with_args), + ), + manifest=manifest, + ) + if runtime_inline.failures: + raise RuntimeBindingError( + "recipe_execution_input_shape", + runtime_inline.failures[0].message, + ) + supplied = { + value.name: value.effective_value + for value in runtime_inline.skill_inputs + if value.state is BoundValueState.PRESENT + and isinstance(value.effective_value, (str, int, bool)) + } + if any( + not isinstance(value, (str, int, bool)) or value is None for value in supplied.values() + ): + raise RuntimeBindingError( + "recipe_execution_input_type", + "skill_inputs values must be strict JSON scalars", + ) + expected_names = tuple( + value.name for value in invocation.skill_inputs if value.state is BoundValueState.PRESENT + ) + if frozenset(supplied) != frozenset(expected_names): + raise RuntimeBindingError( + "recipe_execution_input_shape", + "skill_inputs keys do not exactly match the compiled template", + ) + bound_inputs: list[tuple[str, BoundScalar]] = [] + for value in invocation.skill_inputs: + if value.state is not BoundValueState.PRESENT: + continue + actual = supplied[value.name] + input_def = contract_inputs.get(value.name) + if input_def is None: + raise RuntimeBindingError( + "recipe_execution_contract_mismatch", + f"compiled skill input {value.name!r} is absent from the runtime contract", + ) + if not input_def.accepts(actual): + raise RuntimeBindingError( + "recipe_execution_input_type", + f"runtime skill input {value.name!r} expects {input_def.type!r}", + ) + if not _is_dynamic_binding(value) and actual != value.effective_value: + raise RuntimeBindingError( + "recipe_execution_static_input_mismatch", + f"static skill input {value.name!r} differs from the template", + ) + bound_inputs.append((value.name, actual)) + for value in invocation.mcp_kwargs: + if value.name not in actual_mcp_kwargs: + raise RuntimeBindingError( + "recipe_execution_tool_shape", + f"compiled tool parameter {value.name!r} is absent", + ) + actual = actual_mcp_kwargs[value.name] + if not _is_dynamic_binding(value) and actual != value.effective_value: + raise RuntimeBindingError( + "recipe_execution_static_tool_mismatch", + f"static tool parameter {value.name!r} differs from the template", + ) + return tuple(bound_inputs) diff --git a/src/autoskillit/server/_recipe_execution.py b/src/autoskillit/server/_recipe_execution.py index 776792486..3727009cb 100644 --- a/src/autoskillit/server/_recipe_execution.py +++ b/src/autoskillit/server/_recipe_execution.py @@ -2,14 +2,13 @@ from __future__ import annotations -import hashlib import json import threading from collections.abc import Mapping from dataclasses import replace from pathlib import Path from types import MappingProxyType -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from uuid import uuid4 from autoskillit.core import ( @@ -21,7 +20,6 @@ AuditVerdict, BindingMode, BoundScalar, - BoundValueState, InstalledRecipeExecution, InventoryAdmissionDecision, InvocationTemplate, @@ -36,11 +34,13 @@ compute_tool_contract_identity, get_logger, get_tool_def, - resolve_skill_name, ) from autoskillit.recipe import ( RecipeStep, + RuntimeBindingError, + bind_runtime_skill_invocation, bind_step_invocation, + compute_skill_contract_identity, get_skill_contract, load_bundled_manifest, ) @@ -68,8 +68,6 @@ "record_runtime_binding_digest", ] -_SKILL_CONTRACT_IDENTITY_DOMAIN = b"autoskillit:skill-contract:v1\0" - class AuditCycleHeadConflict(RuntimeError): """A candidate authority failed the trusted-head compare-and-swap.""" @@ -282,37 +280,6 @@ def resolve( return self._result(decision) -def _skill_contract_identity( - skill_name: str, - *, - manifest: dict[str, Any] | None = None, -) -> str: - active_manifest = manifest if manifest is not None else load_bundled_manifest() - contract = get_skill_contract(skill_name, active_manifest) - if contract is None: - raise ValueError(f"skill contract is unavailable for {skill_name!r}") - payload = json.dumps( - { - "completion_required": contract.completion_required, - "input_preflight": contract.input_preflight, - "inputs": [ - { - "name": item.name, - "nullable": item.nullable, - "required": item.required, - "type": item.type, - } - for item in contract.inputs - ], - "skill_name": skill_name, - }, - ensure_ascii=False, - sort_keys=True, - separators=(",", ":"), - ).encode("utf-8") - return "sha256:" + hashlib.sha256(_SKILL_CONTRACT_IDENTITY_DOMAIN + payload).hexdigest() - - def _run_skill_tool_contract_identity() -> str: tool_def = get_tool_def("run_skill") if tool_def is None: @@ -337,7 +304,7 @@ def build_recipe_execution_snapshot( continue if invocation.skill_name is None: raise AssertionError("attested invocation is missing its skill identity") - skill_identity = _skill_contract_identity(invocation.skill_name) + skill_identity = compute_skill_contract_identity(invocation.skill_name) digest = compute_invocation_template_digest( execution_id=active_execution_id, recipe_name=recipe_name, @@ -449,10 +416,6 @@ def record_runtime_binding_digest( ) -def _is_dynamic(value: Any) -> bool: - return bool(value.context_dependencies or value.input_dependencies) - - def bind_attested_runtime_invocation( installed: InstalledRecipeExecution, *, @@ -481,131 +444,18 @@ def bind_attested_runtime_invocation( "invocation_template_digest_mismatch", "invocation template digest does not match the active step", ) - invocation = template.invocation - if resolve_skill_name(skill_command) != invocation.skill_name: - raise RecipeExecutionAdmissionError( - "recipe_execution_skill_mismatch", - "runtime skill identity differs from the compiled template", - ) - compiled_mcp_names = frozenset(value.name for value in invocation.mcp_kwargs) - protocol_mcp_values = { - "step_name": step_name, - "recipe_execution_id": execution_id, - "invocation_template_digest": template_digest, - } - for name, expected in protocol_mcp_values.items(): - if name in actual_mcp_kwargs and actual_mcp_kwargs[name] != expected: - raise RecipeExecutionAdmissionError( - "recipe_execution_tool_shape", - f"attestation parameter {name!r} differs from the active invocation", - ) - undeclared_effective_names = sorted( - name - for name, value in actual_mcp_kwargs.items() - if name not in compiled_mcp_names and name not in protocol_mcp_values and value != "" - ) - if undeclared_effective_names: - raise RecipeExecutionAdmissionError( - "recipe_execution_tool_shape", - ( - "runtime tool parameters are absent from the compiled template: " - f"{undeclared_effective_names!r}" - ), - ) - manifest = load_bundled_manifest() - contract = get_skill_contract(invocation.skill_name or "", manifest) - if contract is None: - raise RecipeExecutionAdmissionError( - "recipe_execution_contract_unavailable", - "the compiled skill contract is unavailable at runtime", + try: + bound_inputs = bind_runtime_skill_invocation( + template, + execution_id=execution_id, + step_name=step_name, + skill_command=skill_command, + skill_inputs=skill_inputs, + actual_mcp_kwargs=actual_mcp_kwargs, ) - runtime_skill_identity = _skill_contract_identity( - invocation.skill_name or "", - manifest=manifest, - ) - if runtime_skill_identity != template.skill_contract_identity: - raise RecipeExecutionAdmissionError( - "recipe_execution_contract_mismatch", - "runtime skill contract differs from the compiled template", - ) - contract_inputs = {input_def.name: input_def for input_def in contract.inputs} - supplied = dict(skill_inputs or {}) - if skill_inputs is None: - runtime_with_args: dict[str, object] = {"skill_command": skill_command} - runtime_cwd = actual_mcp_kwargs.get("cwd") - if isinstance(runtime_cwd, str): - runtime_with_args["cwd"] = runtime_cwd - runtime_inline = bind_step_invocation( - step_name, - RecipeStep( - name=step_name, - tool="run_skill", - with_args=runtime_with_args, - declared_with_args=dict(runtime_with_args), - ), - manifest=manifest, - ) - if runtime_inline.failures: - raise RecipeExecutionAdmissionError( - "recipe_execution_input_shape", - runtime_inline.failures[0].message, - ) - supplied = { - value.name: value.effective_value - for value in runtime_inline.skill_inputs - if value.state is BoundValueState.PRESENT - and isinstance(value.effective_value, (str, int, bool)) - } - if any( - not isinstance(value, (str, int, bool)) or value is None for value in supplied.values() - ): - raise RecipeExecutionAdmissionError( - "recipe_execution_input_type", - "skill_inputs values must be strict JSON scalars", - ) - expected_names = tuple( - value.name for value in invocation.skill_inputs if value.state is BoundValueState.PRESENT - ) - if frozenset(supplied) != frozenset(expected_names): - raise RecipeExecutionAdmissionError( - "recipe_execution_input_shape", - "skill_inputs keys do not exactly match the compiled template", - ) - bound_inputs: list[tuple[str, BoundScalar]] = [] - for value in invocation.skill_inputs: - if value.state is not BoundValueState.PRESENT: - continue - actual = supplied[value.name] - input_def = contract_inputs.get(value.name) - if input_def is None: - raise RecipeExecutionAdmissionError( - "recipe_execution_contract_mismatch", - f"compiled skill input {value.name!r} is absent from the runtime contract", - ) - if not input_def.accepts(actual): - raise RecipeExecutionAdmissionError( - "recipe_execution_input_type", - f"runtime skill input {value.name!r} expects {input_def.type!r}", - ) - if not _is_dynamic(value) and actual != value.effective_value: - raise RecipeExecutionAdmissionError( - "recipe_execution_static_input_mismatch", - f"static skill input {value.name!r} differs from the template", - ) - bound_inputs.append((value.name, actual)) - for value in invocation.mcp_kwargs: - if value.name not in actual_mcp_kwargs: - raise RecipeExecutionAdmissionError( - "recipe_execution_tool_shape", - f"compiled tool parameter {value.name!r} is absent", - ) - actual = actual_mcp_kwargs[value.name] - if not _is_dynamic(value) and actual != value.effective_value: - raise RecipeExecutionAdmissionError( - "recipe_execution_static_tool_mismatch", - f"static tool parameter {value.name!r} differs from the template", - ) - return tuple(bound_inputs), template + except RuntimeBindingError as exc: + raise RecipeExecutionAdmissionError(exc.code, str(exc)) from exc + return bound_inputs, template def build_bound_child_prompt( diff --git a/tests/server/test_audit_cycle_delivery_integration.py b/tests/server/test_audit_cycle_delivery_integration.py index f00ae9fa2..f8b38bd1f 100644 --- a/tests/server/test_audit_cycle_delivery_integration.py +++ b/tests/server/test_audit_cycle_delivery_integration.py @@ -19,6 +19,7 @@ rule, ) +import autoskillit.recipe._binding as binding_module import autoskillit.server._recipe_delivery as recipe_delivery_module import autoskillit.server._recipe_execution as recipe_execution_module from autoskillit.core import ( @@ -557,8 +558,8 @@ def test_runtime_binding_rejects_skill_contract_identity_drift( ), ) monkeypatch.setattr( - recipe_execution_module, - "_skill_contract_identity", + binding_module, + "compute_skill_contract_identity", lambda *args, **kwargs: "sha256:" + "f" * 64, ) @@ -592,13 +593,13 @@ def test_skill_contract_identity_fails_closed_for_stale_contract_shape( inputs=(), ) monkeypatch.setattr( - recipe_execution_module, + binding_module, "get_skill_contract", lambda *_args, **_kwargs: stale_contract, ) with pytest.raises(AttributeError, match="input_preflight"): - recipe_execution_module._skill_contract_identity("stale", manifest={}) # noqa: SLF001 + binding_module.compute_skill_contract_identity("stale", manifest={}) def test_snapshot_rejects_digest_that_does_not_attest_invocation() -> None: @@ -1068,7 +1069,7 @@ def test_runtime_binding_rejects_declared_contract_type_mismatch( } } monkeypatch.setattr( - recipe_execution_module, + binding_module, "load_bundled_manifest", lambda: manifest, ) From 6f18ccffa0bb8244d891f8c0ec54be8fbf28680e Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 26 Jul 2026 17:08:33 -0700 Subject: [PATCH 77/89] fix(review): publish audit heads under execution lock --- src/autoskillit/server/_recipe_execution.py | 38 +++++------ .../test_audit_cycle_delivery_integration.py | 67 +++++++++++++++++++ 2 files changed, 86 insertions(+), 19 deletions(-) diff --git a/src/autoskillit/server/_recipe_execution.py b/src/autoskillit/server/_recipe_execution.py index 3727009cb..c64455564 100644 --- a/src/autoskillit/server/_recipe_execution.py +++ b/src/autoskillit/server/_recipe_execution.py @@ -537,31 +537,31 @@ def _publish_loaded_audit_cycle( verifier.verify_artifact_ref(authority.inventory_ref) if authority.remediation_ref is not None: verifier.verify_artifact_ref(authority.remediation_ref) - head = installed.audit_cycle_heads.publish( - authority, - expected_parent_digest=expected_parent_digest, - expected_round=expected_round, - authorized_successor_part_id=authorized_successor_part_id, - ) - expected_identity = ( - head.plan_set_id, - head.scope_id, - head.authorized_successor_part_id or head.part_id, - ) manifest = load_bundled_manifest() - preflight_identities = dict(installed.preflight_identities) - for step_name, template in installed.snapshot.templates.items(): - contract = get_skill_contract(template.invocation.skill_name or "", manifest) - if ( - contract is not None - and contract.input_preflight == PreflightKind.AUDIT_CYCLE_INVENTORY.value - ): - preflight_identities[step_name] = expected_identity with tool_ctx.recipe_execution_lock: if tool_ctx.active_recipe_execution is not installed: raise AuditCycleHeadConflict( "active recipe execution changed while publishing audit authority" ) + head = installed.audit_cycle_heads.publish( + authority, + expected_parent_digest=expected_parent_digest, + expected_round=expected_round, + authorized_successor_part_id=authorized_successor_part_id, + ) + expected_identity = ( + head.plan_set_id, + head.scope_id, + head.authorized_successor_part_id or head.part_id, + ) + preflight_identities = dict(installed.preflight_identities) + for step_name, template in installed.snapshot.templates.items(): + contract = get_skill_contract(template.invocation.skill_name or "", manifest) + if ( + contract is not None + and contract.input_preflight == PreflightKind.AUDIT_CYCLE_INVENTORY.value + ): + preflight_identities[step_name] = expected_identity tool_ctx.active_recipe_execution = replace( installed, preflight_identities=preflight_identities, diff --git a/tests/server/test_audit_cycle_delivery_integration.py b/tests/server/test_audit_cycle_delivery_integration.py index f8b38bd1f..05a4783c8 100644 --- a/tests/server/test_audit_cycle_delivery_integration.py +++ b/tests/server/test_audit_cycle_delivery_integration.py @@ -719,6 +719,73 @@ def test_published_audit_head_binds_preflight_template_identity( ) +def test_audit_publication_does_not_mutate_replaced_execution( + tool_ctx_kitchen_open, + monkeypatch: pytest.MonkeyPatch, +) -> None: + snapshot = build_recipe_execution_snapshot( + recipe_name="demo", + content_hash=_HASH_A, + composite_hash=_HASH_B, + projection=_projection(), + execution_id="execution-1", + ) + installed = install_recipe_execution(tool_ctx_kitchen_open, snapshot=snapshot) + replacement = replace( + installed, + runtime_binding_digests={"replacement": _HASH_B}, + ) + root = Path(tool_ctx_kitchen_open.temp_dir) + authority = _authority( + root, + generation="execution-1", + round_=1, + parent=None, + verdict=AuditVerdict.NO_GO, + materialize=True, + ) + authority_path = root / "authority-replaced.json" + authority_path.write_bytes(authority.canonical_bytes) + original_verify = AuditCycleVerifier.verify_artifact_ref + replaced = False + + def replace_during_verification( + verifier: AuditCycleVerifier, + artifact_ref: ArtifactRef, + ) -> bytes: + nonlocal replaced + if not replaced: + tool_ctx_kitchen_open.active_recipe_execution = replacement + replaced = True + return original_verify(verifier, artifact_ref) + + monkeypatch.setattr( + AuditCycleVerifier, + "verify_artifact_ref", + replace_during_verification, + ) + + with pytest.raises(AuditCycleHeadConflict, match="active recipe execution changed"): + publish_verified_audit_cycle( + tool_ctx_kitchen_open, + authority_path=str(authority_path), + expected_parent_digest=None, + expected_round=0, + ) + + assert tool_ctx_kitchen_open.active_recipe_execution is replacement + assert dict(replacement.runtime_binding_digests) == {"replacement": _HASH_B} + assert ( + replacement.audit_cycle_heads.get( + execution_generation=authority.execution_generation, + plan_set_id=authority.plan_set_id, + scope_id=authority.scope_id, + part_id=authority.part_id, + ) + is None + ) + + def test_audit_publication_rejects_tampered_referenced_artifact( tool_ctx_kitchen_open, ) -> None: From 101975a2bdc5a54b27ac72637beacb69b4944db7 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 26 Jul 2026 17:22:35 -0700 Subject: [PATCH 78/89] fix(review): bind reported audits to prior lineage --- src/autoskillit/server/_recipe_execution.py | 57 ++++++++++++--- .../server/tools/tools_execution.py | 6 +- .../test_audit_cycle_delivery_integration.py | 71 ++++++++++++++++++- 3 files changed, 124 insertions(+), 10 deletions(-) diff --git a/src/autoskillit/server/_recipe_execution.py b/src/autoskillit/server/_recipe_execution.py index c64455564..353f60146 100644 --- a/src/autoskillit/server/_recipe_execution.py +++ b/src/autoskillit/server/_recipe_execution.py @@ -596,18 +596,49 @@ def publish_reported_audit_cycle( tool_ctx: ToolContext, *, authority_path: str, + prior_authority_path: str | None, ) -> AuditCycleHead: """Verify and publish the authority path reported by a successful audit child.""" installed = get_recipe_execution(tool_ctx) if installed is None: raise AuditCycleHeadConflict("no active recipe execution") - authority = AuditCycleVerifier(tool_ctx.temp_dir).load_authority(authority_path) - current = installed.audit_cycle_heads.get( - execution_generation=authority.execution_generation, - plan_set_id=authority.plan_set_id, - scope_id=authority.scope_id, - part_id=authority.part_id, - ) + verifier = AuditCycleVerifier(tool_ctx.temp_dir) + authority = verifier.load_authority(authority_path) + if prior_authority_path: + prior = verifier.load_authority(prior_authority_path) + if ( + authority.execution_generation, + authority.cycle_id, + authority.plan_set_id, + authority.scope_id, + authority.part_id, + ) != ( + prior.execution_generation, + prior.cycle_id, + prior.plan_set_id, + prior.scope_id, + prior.part_id, + ): + raise AuditCycleHeadConflict( + "reported authority identity differs from its attested prior authority" + ) + current = installed.audit_cycle_heads.get( + execution_generation=prior.execution_generation, + plan_set_id=prior.plan_set_id, + scope_id=prior.scope_id, + part_id=prior.part_id, + ) + if current is None or current.current_authority_digest != prior.authority_digest: + raise AuditCycleHeadConflict( + "attested prior authority is not the trusted current head" + ) + else: + current = installed.audit_cycle_heads.get( + execution_generation=authority.execution_generation, + plan_set_id=authority.plan_set_id, + scope_id=authority.scope_id, + part_id=authority.part_id, + ) return _publish_loaded_audit_cycle( tool_ctx, installed=installed, @@ -622,6 +653,7 @@ def publish_audit_cycle_result( target_name: str | None, skill_result: SkillResult, installed: InstalledRecipeExecution | None, + bound_inputs: tuple[tuple[str, BoundScalar], ...], ) -> None: """Publish a successful attested audit child's declared authority.""" if not skill_result.success or target_name != "audit-impl" or installed is None: @@ -632,4 +664,13 @@ def publish_audit_cycle_result( "recipe_execution_audit_output_missing", "successful audit-impl result did not declare a valid audit_cycle_path", ) - publish_reported_audit_cycle(tool_ctx, authority_path=authority_path) + prior_authority_path = dict(bound_inputs).get("prior_audit_cycle_path") + publish_reported_audit_cycle( + tool_ctx, + authority_path=authority_path, + prior_authority_path=( + prior_authority_path + if isinstance(prior_authority_path, str) and prior_authority_path + else None + ), + ) diff --git a/src/autoskillit/server/tools/tools_execution.py b/src/autoskillit/server/tools/tools_execution.py index 315693b12..f70e81bdd 100644 --- a/src/autoskillit/server/tools/tools_execution.py +++ b/src/autoskillit/server/tools/tools_execution.py @@ -1633,7 +1633,11 @@ def _observe_contract_session_id(candidate_session_id: str) -> None: if skill_result.success: _publish_audit_result( - tool_ctx, target_name, skill_result, _installed_execution + tool_ctx, + target_name, + skill_result, + _installed_execution, + _bound_recipe_inputs, ) tool_ctx.audit.record_success(skill_command) clear_run_skill_state(tool_ctx.project_dir) diff --git a/tests/server/test_audit_cycle_delivery_integration.py b/tests/server/test_audit_cycle_delivery_integration.py index 05a4783c8..e37bbe6dc 100644 --- a/tests/server/test_audit_cycle_delivery_integration.py +++ b/tests/server/test_audit_cycle_delivery_integration.py @@ -71,6 +71,7 @@ get_recipe_execution, install_recipe_execution, publish_audit_cycle_result, + publish_reported_audit_cycle, publish_verified_audit_cycle, record_runtime_binding_digest, ) @@ -182,6 +183,7 @@ def _authority( round_: int, parent: str | None, verdict: AuditVerdict, + plan_set_id: str = "plans-1", part_id: str = "part-a", materialize: bool = False, ) -> AuditCycleAuthority: @@ -221,7 +223,7 @@ def _authority( return AuditCycleAuthority.create( execution_generation=generation, cycle_id="cycle-1", - plan_set_id="plans-1", + plan_set_id=plan_set_id, scope_id="scope-1", part_id=part_id, audit_round=round_, @@ -869,6 +871,7 @@ def test_successful_audit_result_publishes_protected_successor_identity( "audit-impl", result, installed, + (), ) installed = get_recipe_execution(tool_ctx_kitchen_open) @@ -880,6 +883,72 @@ def test_successful_audit_result_publishes_protected_successor_identity( ) +def test_reported_audit_cycle_cannot_replace_attested_prior_lineage( + tool_ctx_kitchen_open, +) -> None: + snapshot = build_recipe_execution_snapshot( + recipe_name="demo", + content_hash=_HASH_A, + composite_hash=_HASH_B, + projection=_projection(), + execution_id="execution-1", + ) + installed = install_recipe_execution(tool_ctx_kitchen_open, snapshot=snapshot) + root = Path(tool_ctx_kitchen_open.temp_dir) + prior = _authority( + root, + generation="execution-1", + round_=1, + parent=None, + verdict=AuditVerdict.NO_GO, + materialize=True, + ) + prior_path = root / "prior-authority.json" + prior_path.write_bytes(prior.canonical_bytes) + publish_verified_audit_cycle( + tool_ctx_kitchen_open, + authority_path=str(prior_path), + expected_parent_digest=None, + expected_round=0, + ) + unrelated = _authority( + root, + generation="execution-1", + round_=1, + parent=None, + verdict=AuditVerdict.NO_GO, + plan_set_id="plans-unrelated", + materialize=True, + ) + unrelated_path = root / "unrelated-authority.json" + unrelated_path.write_bytes(unrelated.canonical_bytes) + + with pytest.raises(AuditCycleHeadConflict, match="attested prior authority"): + publish_reported_audit_cycle( + tool_ctx_kitchen_open, + authority_path=str(unrelated_path), + prior_authority_path=str(prior_path), + ) + + current = installed.audit_cycle_heads.get( + execution_generation=prior.execution_generation, + plan_set_id=prior.plan_set_id, + scope_id=prior.scope_id, + part_id=prior.part_id, + ) + assert current is not None + assert current.current_authority_digest == prior.authority_digest + assert ( + installed.audit_cycle_heads.get( + execution_generation=unrelated.execution_generation, + plan_set_id=unrelated.plan_set_id, + scope_id=unrelated.scope_id, + part_id=unrelated.part_id, + ) + is None + ) + + @pytest.mark.parametrize( ("option_name", "option_value"), [ From aecf22bfea66517cf37a0d1c0f9686507021f821 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 26 Jul 2026 17:24:45 -0700 Subject: [PATCH 79/89] fix(review): dispatch audit publication from contract metadata --- src/autoskillit/recipe/_binding.py | 8 +++++ src/autoskillit/recipe/_contracts_manifest.py | 23 +++++++++++++++ src/autoskillit/recipe/_contracts_types.py | 7 +++++ src/autoskillit/recipe/skill_contracts.yaml | 3 ++ src/autoskillit/server/_recipe_execution.py | 13 ++++++--- tests/recipe/test_contracts.py | 4 +++ .../test_audit_cycle_delivery_integration.py | 29 +++++++++++++++++-- 7 files changed, 80 insertions(+), 7 deletions(-) diff --git a/src/autoskillit/recipe/_binding.py b/src/autoskillit/recipe/_binding.py index ee4841ce2..66ff38dfe 100644 --- a/src/autoskillit/recipe/_binding.py +++ b/src/autoskillit/recipe/_binding.py @@ -839,6 +839,14 @@ def compute_skill_contract_identity( raise ValueError(f"skill contract is unavailable for {skill_name!r}") payload = json.dumps( { + "audit_authority_publication": ( + { + "output_field": contract.audit_authority_publication.output_field, + "prior_input_field": (contract.audit_authority_publication.prior_input_field), + } + if contract.audit_authority_publication is not None + else None + ), "completion_required": contract.completion_required, "input_preflight": contract.input_preflight, "inputs": [ diff --git a/src/autoskillit/recipe/_contracts_manifest.py b/src/autoskillit/recipe/_contracts_manifest.py index eb023c750..1d3ee4761 100644 --- a/src/autoskillit/recipe/_contracts_manifest.py +++ b/src/autoskillit/recipe/_contracts_manifest.py @@ -20,6 +20,7 @@ _CONTEXT_REF_RE, _TEMPLATE_REF_RE, INPUT_REF_RE, + AuditAuthorityPublicationSpec, OutcomeInvariantEntry, ResultFieldSpec, SkillContract, @@ -118,6 +119,27 @@ def get_skill_contract(skill_name: str, manifest: dict[str, Any]) -> SkillContra ) success_qualifiers.append(SuccessQualifierEntry(when=w, qualifier=q)) + authority_data = skill_data.get("audit_authority_publication") + authority_publication = None + if authority_data is not None: + output_field = authority_data.get("output_field", "") + prior_input_field = authority_data.get("prior_input_field", "") + input_names = {item.name for item in inputs} + if output_field not in output_names: + raise ValueError( + f"audit_authority_publication references undeclared output " + f"'{output_field}' in skill '{skill_name}'" + ) + if prior_input_field not in input_names: + raise ValueError( + f"audit_authority_publication references undeclared input " + f"'{prior_input_field}' in skill '{skill_name}'" + ) + authority_publication = AuditAuthorityPublicationSpec( + output_field=output_field, + prior_input_field=prior_input_field, + ) + return SkillContract( inputs=inputs, outputs=outputs, @@ -131,6 +153,7 @@ def get_skill_contract(skill_name: str, manifest: dict[str, Any]) -> SkillContra outcome_invariants=outcome_invariants, success_qualifiers=success_qualifiers, input_preflight=skill_data.get("input_preflight"), + audit_authority_publication=authority_publication, ) diff --git a/src/autoskillit/recipe/_contracts_types.py b/src/autoskillit/recipe/_contracts_types.py index 94f894129..69a6be4c8 100644 --- a/src/autoskillit/recipe/_contracts_types.py +++ b/src/autoskillit/recipe/_contracts_types.py @@ -68,6 +68,12 @@ class SuccessQualifierEntry: qualifier: str +@dataclasses.dataclass(frozen=True, slots=True) +class AuditAuthorityPublicationSpec: + output_field: str + prior_input_field: str + + @dataclasses.dataclass class SkillContract: inputs: tuple[SkillInput, ...] @@ -82,6 +88,7 @@ class SkillContract: outcome_invariants: list[OutcomeInvariantEntry] = dataclasses.field(default_factory=list) success_qualifiers: list[SuccessQualifierEntry] = dataclasses.field(default_factory=list) input_preflight: str | None = None + audit_authority_publication: AuditAuthorityPublicationSpec | None = None def __post_init__(self) -> None: if self.input_preflight is None: diff --git a/src/autoskillit/recipe/skill_contracts.yaml b/src/autoskillit/recipe/skill_contracts.yaml index 130c43e19..00d1f9de7 100644 --- a/src/autoskillit/recipe/skill_contracts.yaml +++ b/src/autoskillit/recipe/skill_contracts.yaml @@ -422,6 +422,9 @@ skills: inputs: [] outputs: [] audit-impl: + audit_authority_publication: + output_field: audit_cycle_path + prior_input_field: prior_audit_cycle_path inputs: - name: all_plan_paths type: file_path_list diff --git a/src/autoskillit/server/_recipe_execution.py b/src/autoskillit/server/_recipe_execution.py index 353f60146..bb8455cc6 100644 --- a/src/autoskillit/server/_recipe_execution.py +++ b/src/autoskillit/server/_recipe_execution.py @@ -656,15 +656,20 @@ def publish_audit_cycle_result( bound_inputs: tuple[tuple[str, BoundScalar], ...], ) -> None: """Publish a successful attested audit child's declared authority.""" - if not skill_result.success or target_name != "audit-impl" or installed is None: + if not skill_result.success or target_name is None or installed is None: return - authority_path = (skill_result.outcome_fields or {}).get("audit_cycle_path") + contract = get_skill_contract(target_name, load_bundled_manifest()) + publication = contract.audit_authority_publication if contract is not None else None + if publication is None: + return + authority_path = (skill_result.outcome_fields or {}).get(publication.output_field) if not isinstance(authority_path, str) or not authority_path: raise RecipeExecutionAdmissionError( "recipe_execution_audit_output_missing", - "successful audit-impl result did not declare a valid audit_cycle_path", + "successful authority-producing skill result did not declare " + f"a valid {publication.output_field}", ) - prior_authority_path = dict(bound_inputs).get("prior_audit_cycle_path") + prior_authority_path = dict(bound_inputs).get(publication.prior_input_field) publish_reported_audit_cycle( tool_ctx, authority_path=authority_path, diff --git a/tests/recipe/test_contracts.py b/tests/recipe/test_contracts.py index 41ddd4a39..3a20fe97d 100644 --- a/tests/recipe/test_contracts.py +++ b/tests/recipe/test_contracts.py @@ -606,6 +606,10 @@ def test_sc1_audit_impl_has_real_inputs_and_outputs() -> None: assert verdict_out["type"] == "string" remediation_out = next(o for o in audit_impl["outputs"] if o["name"] == "remediation_path") assert remediation_out["type"] == "file_path" + assert audit_impl["audit_authority_publication"] == { + "output_field": "audit_cycle_path", + "prior_input_field": "prior_audit_cycle_path", + } def test_sc2_resolve_failures_declares_verdict_output() -> None: diff --git a/tests/server/test_audit_cycle_delivery_integration.py b/tests/server/test_audit_cycle_delivery_integration.py index e37bbe6dc..8040b51bc 100644 --- a/tests/server/test_audit_cycle_delivery_integration.py +++ b/tests/server/test_audit_cycle_delivery_integration.py @@ -833,6 +833,7 @@ def test_audit_publication_rejects_tampered_referenced_artifact( def test_successful_audit_result_publishes_protected_successor_identity( tool_ctx_kitchen_open, + monkeypatch: pytest.MonkeyPatch, ) -> None: snapshot = build_recipe_execution_snapshot( recipe_name="demo", @@ -855,7 +856,7 @@ def test_successful_audit_result_publishes_protected_successor_identity( authority_path.write_bytes(authority.canonical_bytes) result = SkillResult( success=True, - result=f"audit_cycle_path = {authority_path}", + result=f"authority_file = {authority_path}", session_id="audit-session", subtype="success", is_error=False, @@ -863,12 +864,34 @@ def test_successful_audit_result_publishes_protected_successor_identity( needs_retry=False, retry_reason=RetryReason.NONE, stderr="", - outcome_fields={"audit_cycle_path": str(authority_path)}, + outcome_fields={"authority_file": str(authority_path)}, + ) + monkeypatch.setattr( + recipe_execution_module, + "load_bundled_manifest", + lambda: { + "skills": { + "renamed-auditor": { + "audit_authority_publication": { + "output_field": "authority_file", + "prior_input_field": "previous_authority", + }, + "inputs": [ + { + "name": "previous_authority", + "type": "file_path", + "required": False, + } + ], + "outputs": [{"name": "authority_file", "type": "file_path"}], + } + } + }, ) publish_audit_cycle_result( tool_ctx_kitchen_open, - "audit-impl", + "renamed-auditor", result, installed, (), From 0784c4da917fe189b3e6b62fa4655e628950aa92 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 26 Jul 2026 17:27:37 -0700 Subject: [PATCH 80/89] fix(review): attest dynamic recipe skill steps --- .../core/types/_type_recipe_execution.py | 10 +++ src/autoskillit/server/_recipe_execution.py | 14 +++- .../server/tools/tools_execution.py | 26 ++++++- .../test_audit_cycle_delivery_integration.py | 72 +++++++++++++++++++ 4 files changed, 119 insertions(+), 3 deletions(-) diff --git a/src/autoskillit/core/types/_type_recipe_execution.py b/src/autoskillit/core/types/_type_recipe_execution.py index 0b11e3401..b5ff34070 100644 --- a/src/autoskillit/core/types/_type_recipe_execution.py +++ b/src/autoskillit/core/types/_type_recipe_execution.py @@ -116,11 +116,13 @@ def compute_recipe_execution_snapshot_digest( content_hash: str, composite_hash: str, templates: Mapping[str, InvocationTemplate], + dynamic_skill_step_names: frozenset[str] = frozenset(), ) -> str: """Hash the compiled execution snapshot without any delivery hash.""" payload = { "composite_hash": composite_hash, "content_hash": content_hash, + "dynamic_skill_step_names": sorted(dynamic_skill_step_names), "execution_id": execution_id, "recipe_name": recipe_name, "templates": [ @@ -144,6 +146,7 @@ class RecipeExecutionSnapshot: composite_hash: str templates: Mapping[str, InvocationTemplate] snapshot_digest: str + dynamic_skill_step_names: frozenset[str] = frozenset() def __post_init__(self) -> None: if not self.execution_id or not self.recipe_name: @@ -151,8 +154,13 @@ def __post_init__(self) -> None: if not HASH_RE.fullmatch(self.content_hash) or not HASH_RE.fullmatch(self.composite_hash): raise ValueError("recipe execution hashes must be canonical sha256 identities") copied = dict(self.templates) + dynamic_skill_step_names = frozenset(self.dynamic_skill_step_names) if any(name != template.invocation.step_name for name, template in copied.items()): raise ValueError("recipe execution template keys must match step names") + if any(not name for name in dynamic_skill_step_names): + raise ValueError("dynamic recipe skill step names must be non-empty") + if dynamic_skill_step_names.intersection(copied): + raise ValueError("recipe skill steps cannot be both dynamic and attested") for template in copied.values(): expected_template_digest = compute_invocation_template_digest( execution_id=self.execution_id, @@ -166,12 +174,14 @@ def __post_init__(self) -> None: if template.template_digest != expected_template_digest: raise ValueError("recipe execution invocation template digest mismatch") object.__setattr__(self, "templates", MappingProxyType(copied)) + object.__setattr__(self, "dynamic_skill_step_names", dynamic_skill_step_names) expected = compute_recipe_execution_snapshot_digest( execution_id=self.execution_id, recipe_name=self.recipe_name, content_hash=self.content_hash, composite_hash=self.composite_hash, templates=copied, + dynamic_skill_step_names=dynamic_skill_step_names, ) if self.snapshot_digest != expected: raise ValueError("recipe execution snapshot digest does not match content") diff --git a/src/autoskillit/server/_recipe_execution.py b/src/autoskillit/server/_recipe_execution.py index bb8455cc6..a7002324b 100644 --- a/src/autoskillit/server/_recipe_execution.py +++ b/src/autoskillit/server/_recipe_execution.py @@ -299,8 +299,18 @@ def build_recipe_execution_snapshot( active_execution_id = execution_id or uuid4().hex tool_identity = _run_skill_tool_contract_identity() templates: dict[str, InvocationTemplate] = {} + dynamic_skill_step_names: set[str] = set() for step_name, invocation in projection.invocations.items(): - if invocation.tool_name != "run_skill" or not invocation.attested: + if invocation.tool_name != "run_skill": + continue + if ( + invocation.mode is BindingMode.RECIPE + and invocation.is_valid + and invocation.skill_name is None + ): + dynamic_skill_step_names.add(step_name) + continue + if not invocation.attested: continue if invocation.skill_name is None: raise AssertionError("attested invocation is missing its skill identity") @@ -326,6 +336,7 @@ def build_recipe_execution_snapshot( content_hash=content_hash, composite_hash=composite_hash, templates=templates, + dynamic_skill_step_names=frozenset(dynamic_skill_step_names), ) return RecipeExecutionSnapshot( execution_id=active_execution_id, @@ -334,6 +345,7 @@ def build_recipe_execution_snapshot( composite_hash=composite_hash, templates=templates, snapshot_digest=snapshot_digest, + dynamic_skill_step_names=frozenset(dynamic_skill_step_names), ) diff --git a/src/autoskillit/server/tools/tools_execution.py b/src/autoskillit/server/tools/tools_execution.py index f70e81bdd..dd27b5213 100644 --- a/src/autoskillit/server/tools/tools_execution.py +++ b/src/autoskillit/server/tools/tools_execution.py @@ -30,6 +30,7 @@ ClosureAuthoritySpec, CodingAgentBackend, EffectiveSkillInvocationAuthority, + InvocationTemplate, PreflightKind, SkillContractError, SkillExecutionRole, @@ -839,9 +840,30 @@ async def run_skill( _preflight_result = None _bound_recipe_inputs: tuple[tuple[str, BoundScalar], ...] = () + _invocation_template: InvocationTemplate | None = None child_skill_command = skill_command _claims_recipe_execution = bool(recipe_execution_id or invocation_template_digest) - if _installed_execution is not None: + _dynamic_recipe_call = bool( + _installed_execution is not None + and step_name + and step_name in _installed_execution.snapshot.dynamic_skill_step_names + ) + if _dynamic_recipe_call: + if _claims_recipe_execution: + return _recipe_execution_deny( + "recipe_execution_dynamic_attestation", + "a dynamic recipe skill step cannot claim a concrete invocation template", + ) + if not resume_session_id: + try: + child_skill_command = build_standalone_child_prompt( + skill_command, + cwd, + skill_inputs, + ) + except RecipeExecutionAdmissionError as exc: + return _recipe_execution_deny(exc.code, str(exc)) + elif _installed_execution is not None: if not recipe_execution_id or not invocation_template_digest: return _recipe_execution_deny( "recipe_execution_attestation_missing", @@ -1636,7 +1658,7 @@ def _observe_contract_session_id(candidate_session_id: str) -> None: tool_ctx, target_name, skill_result, - _installed_execution, + (_installed_execution if _invocation_template is not None else None), _bound_recipe_inputs, ) tool_ctx.audit.record_success(skill_command) diff --git a/tests/server/test_audit_cycle_delivery_integration.py b/tests/server/test_audit_cycle_delivery_integration.py index 8040b51bc..425a83605 100644 --- a/tests/server/test_audit_cycle_delivery_integration.py +++ b/tests/server/test_audit_cycle_delivery_integration.py @@ -1808,6 +1808,78 @@ async def test_runtime_attestation_rejects_before_executor( assert len(tool_ctx_kitchen_open.runner.call_args_list) == calls_before +@pytest.mark.anyio +async def test_dynamic_recipe_skill_step_executes_without_template_attestation( + tool_ctx_kitchen_open, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + marker = "%%ORDER_UP::12345678%%" + monkeypatch.setattr( + "uuid.uuid4", + lambda: SimpleNamespace(hex="12345678000000000000000000000000"), + ) + tool_ctx_kitchen_open.write_expected_resolver = None + projection = RecipeBindingProjection( + { + "fanout": BoundStepInvocation( + step_name="fanout", + tool_name="run_skill", + mode=BindingMode.RECIPE, + skill_name=None, + mcp_kwargs=( + _present("skill_command", "Choose the applicable audit skills"), + _present("cwd", str(tmp_path)), + ), + skill_inputs=(), + failures=(), + ) + } + ) + snapshot = build_recipe_execution_snapshot( + recipe_name="demo", + content_hash=_HASH_A, + composite_hash=_HASH_B, + projection=projection, + execution_id="execution-1", + ) + install_recipe_execution(tool_ctx_kitchen_open, snapshot=snapshot) + assert snapshot.templates == {} + assert snapshot.dynamic_skill_step_names == frozenset({"fanout"}) + tool_ctx_kitchen_open.runner.push(_make_result(returncode=1)) + tool_ctx_kitchen_open.runner.push( + _make_result( + 0, + json.dumps( + { + "type": "result", + "subtype": "success", + "is_error": False, + "result": f"done\n{marker}", + "session_id": "session-1", + } + ), + "", + ) + ) + calls_before = len(tool_ctx_kitchen_open.runner.call_args_list) + + result = json.loads( + await run_skill( + "/dry-walkthrough", + str(tmp_path), + step_name="fanout", + skill_inputs={"plan_path": str(tmp_path / "plan.md"), "issue_url": ""}, + ) + ) + + assert result["success"] is True + assert len(tool_ctx_kitchen_open.runner.call_args_list) > calls_before + installed = get_recipe_execution(tool_ctx_kitchen_open) + assert installed is not None + assert dict(installed.runtime_binding_digests) == {} + + @pytest.mark.anyio async def test_runtime_attestation_executes_bound_prompt_and_records_digest( tool_ctx_kitchen_open, From e876bb1656e397bf2efe8dba93c245c3618e6855 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 26 Jul 2026 17:38:41 -0700 Subject: [PATCH 81/89] refactor(review): preserve attestation module boundaries --- src/autoskillit/recipe/__init__.py | 14 ++-- src/autoskillit/recipe/_binding.py | 46 +---------- src/autoskillit/recipe/_contracts_manifest.py | 43 +++++++++++ src/autoskillit/server/_recipe_execution.py | 77 +++++++++++++++++++ .../server/tools/tools_execution.py | 77 +++---------------- 5 files changed, 140 insertions(+), 117 deletions(-) diff --git a/src/autoskillit/recipe/__init__.py b/src/autoskillit/recipe/__init__.py index d40f58909..f0c7bd508 100644 --- a/src/autoskillit/recipe/__init__.py +++ b/src/autoskillit/recipe/__init__.py @@ -18,13 +18,7 @@ load_and_validate, validate_from_path, ) -from autoskillit.recipe._binding import ( # noqa: E402 - RuntimeBindingError, - bind_recipe, - bind_runtime_skill_invocation, - bind_step_invocation, - compute_skill_contract_identity, -) +from autoskillit.recipe._binding import bind_recipe, bind_step_invocation # noqa: E402 from autoskillit.recipe._recipe_ingredients import ( # noqa: E402 ListRecipesResult, LoadRecipeResult, @@ -295,6 +289,12 @@ _reg._finalize_registry() # pyright: ignore[reportAttributeAccessIssue] # lazy-registry: method added by _register_rule_module() side effects del _reg +from autoskillit.recipe._binding import ( # noqa: E402 + RuntimeBindingError, + bind_runtime_skill_invocation, + compute_skill_contract_identity, +) + __all__ = [ "GROUP_LABELS", "all_validated_recipe_names", diff --git a/src/autoskillit/recipe/_binding.py b/src/autoskillit/recipe/_binding.py index 66ff38dfe..e2083b0d0 100644 --- a/src/autoskillit/recipe/_binding.py +++ b/src/autoskillit/recipe/_binding.py @@ -2,8 +2,6 @@ from __future__ import annotations -import hashlib -import json from collections.abc import Mapping from typing import Any, Final, TypeGuard @@ -26,6 +24,7 @@ resolve_skill_name, ) from autoskillit.recipe._contracts_manifest import ( + compute_skill_contract_identity, get_callable_contract, get_skill_contract, load_bundled_manifest, @@ -41,9 +40,6 @@ "compute_skill_contract_identity", ] -_SKILL_CONTRACT_IDENTITY_DOMAIN = b"autoskillit:skill-contract:v1\0" - - _CONTEXT_REF_RE: Final = re.compile(r"\$\{\{\s*context\.([A-Za-z_]\w*)\s*\}\}") _INPUT_REF_RE: Final = re.compile(r"\$\{\{\s*inputs\.([A-Za-z_]\w*)\s*\}\}") _AUTOSKILLIT_TEMPLATE_RE: Final = re.compile(r"\{\{(AUTOSKILLIT_[A-Z0-9_]+)\}\}") @@ -827,46 +823,6 @@ def bind_recipe( return RecipeBindingProjection(invocations) -def compute_skill_contract_identity( - skill_name: str, - *, - manifest: dict[str, Any] | None = None, -) -> str: - """Hash the canonical runtime-relevant shape of one skill contract.""" - active_manifest = manifest if manifest is not None else load_bundled_manifest() - contract = get_skill_contract(skill_name, active_manifest) - if contract is None: - raise ValueError(f"skill contract is unavailable for {skill_name!r}") - payload = json.dumps( - { - "audit_authority_publication": ( - { - "output_field": contract.audit_authority_publication.output_field, - "prior_input_field": (contract.audit_authority_publication.prior_input_field), - } - if contract.audit_authority_publication is not None - else None - ), - "completion_required": contract.completion_required, - "input_preflight": contract.input_preflight, - "inputs": [ - { - "name": item.name, - "nullable": item.nullable, - "required": item.required, - "type": item.type, - } - for item in contract.inputs - ], - "skill_name": skill_name, - }, - ensure_ascii=False, - sort_keys=True, - separators=(",", ":"), - ).encode("utf-8") - return "sha256:" + hashlib.sha256(_SKILL_CONTRACT_IDENTITY_DOMAIN + payload).hexdigest() - - def _is_dynamic_binding(value: BoundValue) -> bool: return bool(value.context_dependencies or value.input_dependencies) diff --git a/src/autoskillit/recipe/_contracts_manifest.py b/src/autoskillit/recipe/_contracts_manifest.py index 1d3ee4761..737a64182 100644 --- a/src/autoskillit/recipe/_contracts_manifest.py +++ b/src/autoskillit/recipe/_contracts_manifest.py @@ -3,6 +3,7 @@ from __future__ import annotations import hashlib +import json from pathlib import Path from typing import Any, Literal, assert_never, cast @@ -34,6 +35,7 @@ logger = get_logger(__name__) _MANIFEST_CACHE = YamlFileCache() +_SKILL_CONTRACT_IDENTITY_DOMAIN = b"autoskillit:skill-contract:v1\0" def load_bundled_manifest() -> dict[str, Any]: @@ -157,6 +159,47 @@ def get_skill_contract(skill_name: str, manifest: dict[str, Any]) -> SkillContra ) +def compute_skill_contract_identity( + skill_name: str, + *, + manifest: dict[str, Any] | None = None, +) -> str: + """Hash the canonical runtime-relevant shape of one skill contract.""" + active_manifest = manifest if manifest is not None else load_bundled_manifest() + contract = get_skill_contract(skill_name, active_manifest) + if contract is None: + raise ValueError(f"skill contract is unavailable for {skill_name!r}") + publication = contract.audit_authority_publication + payload = json.dumps( + { + "audit_authority_publication": ( + { + "output_field": publication.output_field, + "prior_input_field": publication.prior_input_field, + } + if publication is not None + else None + ), + "completion_required": contract.completion_required, + "input_preflight": contract.input_preflight, + "inputs": [ + { + "name": item.name, + "nullable": item.nullable, + "required": item.required, + "type": item.type, + } + for item in contract.inputs + ], + "skill_name": skill_name, + }, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return "sha256:" + hashlib.sha256(_SKILL_CONTRACT_IDENTITY_DOMAIN + payload).hexdigest() + + def resolve_input_specs(skill_command: str) -> tuple[InputSpec, ...]: """Resolve InputSpec entries for path-typed inputs from a skill's contract. diff --git a/src/autoskillit/server/_recipe_execution.py b/src/autoskillit/server/_recipe_execution.py index a7002324b..9deeae1fe 100644 --- a/src/autoskillit/server/_recipe_execution.py +++ b/src/autoskillit/server/_recipe_execution.py @@ -13,6 +13,7 @@ from autoskillit.core import ( AdmissionReason, + AdmissionStatus, AuditCycleAuthority, AuditCycleHead, AuditCycleVerificationError, @@ -66,6 +67,7 @@ "publish_reported_audit_cycle", "publish_verified_audit_cycle", "record_runtime_binding_digest", + "resolve_attested_input_preflight", ] @@ -470,6 +472,81 @@ def bind_attested_runtime_invocation( return bound_inputs, template +def resolve_attested_input_preflight( + tool_ctx: ToolContext, + installed: InstalledRecipeExecution, + *, + skill_command: str, + execution_id: str, + step_name: str, + template: InvocationTemplate, + bound_inputs: tuple[tuple[str, BoundScalar], ...], +) -> VerifiedInputPreflightResult | None: + """Resolve a compiled invocation's optional input preflight or fail closed.""" + contract = ( + tool_ctx.skill_contract_resolver(skill_command) + if tool_ctx.skill_contract_resolver is not None + else None + ) + preflight_name = getattr(contract, "input_preflight", None) + if not isinstance(preflight_name, str) or not preflight_name: + return None + if preflight_name != PreflightKind.AUDIT_CYCLE_INVENTORY.value: + raise RecipeExecutionAdmissionError( + "recipe_execution_preflight_unknown", + f"unsupported input preflight {preflight_name!r}", + ) + bound_input_map = dict(bound_inputs) + plan_path = bound_input_map.get("plan_path") + audit_cycle_path = bound_input_map.get("audit_cycle_path") + plan_disposition_path = bound_input_map.get("plan_disposition_path") + if not isinstance(plan_path, str): + raise RecipeExecutionAdmissionError( + "recipe_execution_preflight_input", + "audit-cycle preflight requires a bound string plan_path", + ) + if audit_cycle_path is not None and not isinstance(audit_cycle_path, str): + raise RecipeExecutionAdmissionError( + "recipe_execution_preflight_input", + "audit_cycle_path must be a string when present", + ) + if plan_disposition_path is not None and not isinstance(plan_disposition_path, str): + raise RecipeExecutionAdmissionError( + "recipe_execution_preflight_input", + "plan_disposition_path must be a string when present", + ) + expected_identity = installed.preflight_identities.get(step_name) + if audit_cycle_path and expected_identity is None: + raise RecipeExecutionAdmissionError( + "recipe_execution_preflight_identity_missing", + "authority-bearing preflight requires a trusted template identity", + ) + expected_identity = expected_identity or ("", "", "") + result = installed.input_preflight_resolver.resolve( + VerifiedInputPreflightRequest( + execution_generation=execution_id, + step_name=step_name, + skill_name=template.invocation.skill_name or "", + plan_path=plan_path, + audit_cycle_path=audit_cycle_path or None, + plan_disposition_path=plan_disposition_path or None, + expected_plan_set_id=expected_identity[0], + expected_scope_id=expected_identity[1], + expected_part_id=expected_identity[2], + ) + ) + if result.decision.status is AdmissionStatus.REJECT: + raise RecipeExecutionAdmissionError( + f"input_preflight_{result.decision.reason.value}", + ( + result.decision.details[0] + if result.decision.details + else "verified input preflight rejected the invocation" + ), + ) + return result + + def build_bound_child_prompt( skill_command: str, bound_inputs: tuple[tuple[str, BoundScalar], ...], diff --git a/src/autoskillit/server/tools/tools_execution.py b/src/autoskillit/server/tools/tools_execution.py index dd27b5213..279b4ff49 100644 --- a/src/autoskillit/server/tools/tools_execution.py +++ b/src/autoskillit/server/tools/tools_execution.py @@ -25,19 +25,16 @@ DISPATCH_ID_ENV_VAR, SKILL_COMMAND_DISPLAY_MAX, WORKTREE_SKILLS, - AdmissionStatus, BoundScalar, ClosureAuthoritySpec, CodingAgentBackend, EffectiveSkillInvocationAuthority, InvocationTemplate, - PreflightKind, SkillContractError, SkillExecutionRole, SkillResult, TerminationReason, ValidatedAddDir, - VerifiedInputPreflightRequest, WriteBehaviorSpec, closure_authority_spec_from_args, compute_runtime_binding_digest, @@ -85,6 +82,7 @@ build_standalone_child_prompt, get_recipe_execution, record_runtime_binding_digest, + resolve_attested_input_preflight, ) from autoskillit.server._recipe_execution import ( publish_audit_cycle_result as _publish_audit_result, @@ -911,69 +909,18 @@ async def run_skill( ) except RecipeExecutionAdmissionError as exc: return _recipe_execution_deny(exc.code, str(exc)) - _contract = ( - tool_ctx.skill_contract_resolver(skill_command) - if tool_ctx.skill_contract_resolver is not None - else None - ) - _preflight_name = getattr(_contract, "input_preflight", None) - if isinstance(_preflight_name, str) and _preflight_name: - if _preflight_name != PreflightKind.AUDIT_CYCLE_INVENTORY.value: - return _recipe_execution_deny( - "recipe_execution_preflight_unknown", - f"unsupported input preflight {_preflight_name!r}", - ) - _bound_input_map = dict(_bound_recipe_inputs) - _plan_path = _bound_input_map.get("plan_path") - _audit_cycle_path = _bound_input_map.get("audit_cycle_path") - _plan_disposition_path = _bound_input_map.get("plan_disposition_path") - if not isinstance(_plan_path, str): - return _recipe_execution_deny( - "recipe_execution_preflight_input", - "audit-cycle preflight requires a bound string plan_path", - ) - if _audit_cycle_path is not None and not isinstance(_audit_cycle_path, str): - return _recipe_execution_deny( - "recipe_execution_preflight_input", - "audit_cycle_path must be a string when present", - ) - if _plan_disposition_path is not None and not isinstance( - _plan_disposition_path, str - ): - return _recipe_execution_deny( - "recipe_execution_preflight_input", - "plan_disposition_path must be a string when present", - ) - _preflight_identities = _installed_execution.preflight_identities - _expected_preflight_identity = _preflight_identities.get(step_name) - if _audit_cycle_path and _expected_preflight_identity is None: - return _recipe_execution_deny( - "recipe_execution_preflight_identity_missing", - "authority-bearing preflight requires a trusted template identity", - ) - _expected_preflight_identity = _expected_preflight_identity or ("", "", "") - _preflight_result = _installed_execution.input_preflight_resolver.resolve( - VerifiedInputPreflightRequest( - execution_generation=recipe_execution_id, - step_name=step_name, - skill_name=_invocation_template.invocation.skill_name or "", - plan_path=_plan_path, - audit_cycle_path=_audit_cycle_path or None, - plan_disposition_path=_plan_disposition_path or None, - expected_plan_set_id=_expected_preflight_identity[0], - expected_scope_id=_expected_preflight_identity[1], - expected_part_id=_expected_preflight_identity[2], - ) + try: + _preflight_result = resolve_attested_input_preflight( + tool_ctx, + _installed_execution, + skill_command=skill_command, + execution_id=recipe_execution_id, + step_name=step_name, + template=_invocation_template, + bound_inputs=_bound_recipe_inputs, ) - if _preflight_result.decision.status is AdmissionStatus.REJECT: - return _recipe_execution_deny( - f"input_preflight_{_preflight_result.decision.reason.value}", - ( - _preflight_result.decision.details[0] - if _preflight_result.decision.details - else "verified input preflight rejected the invocation" - ), - ) + except RecipeExecutionAdmissionError as exc: + return _recipe_execution_deny(exc.code, str(exc)) child_skill_command = build_bound_child_prompt( skill_command, _bound_recipe_inputs, From 935ad0cbf2d6a864a22fc3257208ffdbb8f5ce0a Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 26 Jul 2026 17:39:06 -0700 Subject: [PATCH 82/89] test(review): align contract metadata fixtures --- tests/server/test_audit_cycle_delivery_integration.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/server/test_audit_cycle_delivery_integration.py b/tests/server/test_audit_cycle_delivery_integration.py index 425a83605..7872313e1 100644 --- a/tests/server/test_audit_cycle_delivery_integration.py +++ b/tests/server/test_audit_cycle_delivery_integration.py @@ -591,6 +591,7 @@ def test_skill_contract_identity_fails_closed_for_stale_contract_shape( monkeypatch: pytest.MonkeyPatch, ) -> None: stale_contract = SimpleNamespace( + audit_authority_publication=None, completion_required=True, inputs=(), ) @@ -884,7 +885,12 @@ def test_successful_audit_result_publishes_protected_successor_identity( } ], "outputs": [{"name": "authority_file", "type": "file_path"}], - } + }, + "dry-walkthrough": { + "input_preflight": "audit_cycle_inventory", + "inputs": [], + "outputs": [], + }, } }, ) From b4a4782b79899bc42f05a37e82d15761078e2203 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 26 Jul 2026 17:41:27 -0700 Subject: [PATCH 83/89] docs(review): clarify revision guidance file input --- .../skills_extended/resolve-design-review/SKILL.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/autoskillit/skills_extended/resolve-design-review/SKILL.md b/src/autoskillit/skills_extended/resolve-design-review/SKILL.md index dd60dfdd9..a8085d31e 100644 --- a/src/autoskillit/skills_extended/resolve-design-review/SKILL.md +++ b/src/autoskillit/skills_extended/resolve-design-review/SKILL.md @@ -29,8 +29,9 @@ loop or halt. `/autoskillit:resolve-design-review [prior_revision_guidance_path]` `evaluation_dashboard` and `experiment_plan` each identify one Markdown file; read and -parse those files as immutable inputs. `prior_revision_guidance_path`, when present, -likewise identifies one file. +parse those files as immutable inputs. `revision_guidance`, supplied positionally as +`prior_revision_guidance_path` when present, likewise identifies one Markdown file; +read the file as an immutable input. ## When to Use @@ -193,12 +194,12 @@ When resolution = failed, emit as your final output: resolution = failed ``` -`revision_guidance` is ONLY emitted when resolution = revised. +The revision-guidance path is ONLY emitted when resolution = revised. ## Output All output files are written to `${RESOLVE_DESIGN_REVIEW_OUTPUT_DIR}/`, which defaults to -`{{AUTOSKILLIT_TEMP}}/resolve-design-review/` relative to the current working directory. +`{{AUTOSKILLIT_TEMP}}/resolve-design-review/` relative to the current working path. ``` ${RESOLVE_DESIGN_REVIEW_OUTPUT_DIR}/ From 27366d6865a53ed0fa78030bcfac83ae714af842 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 26 Jul 2026 17:48:44 -0700 Subject: [PATCH 84/89] fix(review): preserve contract resolver seams --- src/autoskillit/recipe/_binding.py | 18 +++++++++++++++++- src/autoskillit/recipe/_contracts_manifest.py | 9 +++++++-- .../resolve-design-review/SKILL.md | 2 ++ 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/autoskillit/recipe/_binding.py b/src/autoskillit/recipe/_binding.py index e2083b0d0..746cf58fd 100644 --- a/src/autoskillit/recipe/_binding.py +++ b/src/autoskillit/recipe/_binding.py @@ -24,7 +24,9 @@ resolve_skill_name, ) from autoskillit.recipe._contracts_manifest import ( - compute_skill_contract_identity, + compute_skill_contract_identity as _compute_skill_contract_identity, +) +from autoskillit.recipe._contracts_manifest import ( get_callable_contract, get_skill_contract, load_bundled_manifest, @@ -56,6 +58,20 @@ def __init__(self, code: str, message: str) -> None: self.code = code +def compute_skill_contract_identity( + skill_name: str, + *, + manifest: dict[str, Any] | None = None, +) -> str: + """Hash a skill contract through this module's patchable resolver seam.""" + return _compute_skill_contract_identity( + skill_name, + manifest=manifest, + contract_resolver=get_skill_contract, + manifest_loader=load_bundled_manifest, + ) + + def _is_scalar(value: object) -> TypeGuard[BoundScalar]: return isinstance(value, _SCALAR_TYPES) and value is not None diff --git a/src/autoskillit/recipe/_contracts_manifest.py b/src/autoskillit/recipe/_contracts_manifest.py index 737a64182..5a85029ea 100644 --- a/src/autoskillit/recipe/_contracts_manifest.py +++ b/src/autoskillit/recipe/_contracts_manifest.py @@ -4,6 +4,7 @@ import hashlib import json +from collections.abc import Callable from pathlib import Path from typing import Any, Literal, assert_never, cast @@ -163,10 +164,14 @@ def compute_skill_contract_identity( skill_name: str, *, manifest: dict[str, Any] | None = None, + contract_resolver: Callable[[str, dict[str, Any]], SkillContract | None] | None = None, + manifest_loader: Callable[[], dict[str, Any]] | None = None, ) -> str: """Hash the canonical runtime-relevant shape of one skill contract.""" - active_manifest = manifest if manifest is not None else load_bundled_manifest() - contract = get_skill_contract(skill_name, active_manifest) + active_manifest = ( + manifest if manifest is not None else (manifest_loader or load_bundled_manifest)() + ) + contract = (contract_resolver or get_skill_contract)(skill_name, active_manifest) if contract is None: raise ValueError(f"skill contract is unavailable for {skill_name!r}") publication = contract.audit_authority_publication diff --git a/src/autoskillit/skills_extended/resolve-design-review/SKILL.md b/src/autoskillit/skills_extended/resolve-design-review/SKILL.md index a8085d31e..8ffd59869 100644 --- a/src/autoskillit/skills_extended/resolve-design-review/SKILL.md +++ b/src/autoskillit/skills_extended/resolve-design-review/SKILL.md @@ -69,6 +69,8 @@ abandoning the partial triage. ### Step 0: Validate Arguments and Parse Dashboard +Resolve relative output paths from the current working directory before writing. + 1. Set `RESOLVE_DESIGN_REVIEW_OUTPUT_DIR="${AUTOSKILLIT_ALLOWED_WRITE_PREFIX:-{{AUTOSKILLIT_TEMP}}/resolve-design-review}"` and create it if absent 2. Parse two positional path arguments: `evaluation_dashboard_path`, `experiment_plan_path` - If missing: print `"Error: missing required argument(s) — expected "`, then emit `resolution=failed`, and return From 72232a172528476356baa124b4711958567a2dd6 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 26 Jul 2026 18:38:43 -0700 Subject: [PATCH 85/89] test(review): attest bound skill inputs --- tests/server/test_audit_cycle_delivery_integration.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/server/test_audit_cycle_delivery_integration.py b/tests/server/test_audit_cycle_delivery_integration.py index 7872313e1..af15189b3 100644 --- a/tests/server/test_audit_cycle_delivery_integration.py +++ b/tests/server/test_audit_cycle_delivery_integration.py @@ -653,6 +653,14 @@ def test_runtime_binding_digest_attests_mcp_values_and_preflight_evidence() -> N actual_mcp_kwargs={"cwd": "/repo", "output_dir": "/output/b"}, preflight=preflight, ) + changed_bound_inputs_digest = compute_runtime_binding_digest( + execution_id="execution-1", + step_name="dry", + template_digest=_HASH_A, + bound_inputs=(("plan_path", "/plans/changed.md"),), + actual_mcp_kwargs={"cwd": "/repo", "output_dir": "/output/a"}, + preflight=preflight, + ) changed_evidence_digest = compute_runtime_binding_digest( execution_id="execution-1", step_name="dry", @@ -666,6 +674,7 @@ def test_runtime_binding_digest_attests_mcp_values_and_preflight_evidence() -> N ) assert digest != changed_mcp_digest + assert digest != changed_bound_inputs_digest assert digest != changed_evidence_digest From 3ec17e0bfc004ab1c0d5bca694ac754d6140f106 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 26 Jul 2026 18:39:15 -0700 Subject: [PATCH 86/89] fix(review): type load recipe ingredients flag --- src/autoskillit/core/tool_registry.py | 1 + tests/server/test_tool_registry_parity.py | 1 + 2 files changed, 2 insertions(+) diff --git a/src/autoskillit/core/tool_registry.py b/src/autoskillit/core/tool_registry.py index 5382d7d5f..2d6417dd2 100644 --- a/src/autoskillit/core/tool_registry.py +++ b/src/autoskillit/core/tool_registry.py @@ -360,6 +360,7 @@ def _run_skill() -> ToolDef: required=("name",), wire_types={ "overrides": ToolWireType.OBJECT, + "ingredients_only": ToolWireType.BOOLEAN, "delivery_request": ToolWireType.OBJECT, }, ), diff --git a/tests/server/test_tool_registry_parity.py b/tests/server/test_tool_registry_parity.py index 34616d9a8..93495d8be 100644 --- a/tests/server/test_tool_registry_parity.py +++ b/tests/server/test_tool_registry_parity.py @@ -101,6 +101,7 @@ def test_registry_preserves_typed_handler_wire_contracts() -> None: "destroy_artifacts": ToolWireType.BOOLEAN, }, "open_kitchen": {"ingredients_only": ToolWireType.BOOLEAN}, + "load_recipe": {"ingredients_only": ToolWireType.BOOLEAN}, } for tool_name, wire_types in expected.items(): From 47023f11ae89b855ed2f16c3cfa9617e7621f917 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 26 Jul 2026 18:40:12 -0700 Subject: [PATCH 87/89] fix(review): freeze tool parameter definitions --- src/autoskillit/core/types/_type_recipe_binding.py | 10 ++++++++-- tests/server/test_tool_registry_parity.py | 14 ++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/autoskillit/core/types/_type_recipe_binding.py b/src/autoskillit/core/types/_type_recipe_binding.py index bf62aca69..ef65d981b 100644 --- a/src/autoskillit/core/types/_type_recipe_binding.py +++ b/src/autoskillit/core/types/_type_recipe_binding.py @@ -64,10 +64,16 @@ class ToolDef: params: tuple[ToolParamDef, ...] def __post_init__(self) -> None: - names = tuple(param.name for param in self.params) + params = tuple(self.params) + for index, param in enumerate(params): + if not isinstance(param, ToolParamDef): + raise TypeError(f"ToolDef {self.name!r} parameter {index} must be a ToolParamDef") + object.__setattr__(self, "params", params) + + names = tuple(param.name for param in params) if len(names) != len(set(names)): raise ValueError(f"ToolDef {self.name!r} contains duplicate parameters") - structured = tuple(param.name for param in self.params if param.structured_skill_inputs) + structured = tuple(param.name for param in params if param.structured_skill_inputs) if structured not in {(), ("skill_inputs",)}: raise ValueError( f"ToolDef {self.name!r} has invalid structured parameters: {structured!r}" diff --git a/tests/server/test_tool_registry_parity.py b/tests/server/test_tool_registry_parity.py index 93495d8be..eace05dc6 100644 --- a/tests/server/test_tool_registry_parity.py +++ b/tests/server/test_tool_registry_parity.py @@ -119,6 +119,20 @@ def test_run_skill_has_one_compiler_owned_structured_input_channel() -> None: assert structured[0].handler_parameter +def test_tool_def_freezes_caller_owned_parameter_sequences() -> None: + params = [ToolParamDef("first")] + + tool = replace(TOOL_REGISTRY["close_kitchen"], params=params) + params.append(ToolParamDef("later")) + + assert tool.params == (ToolParamDef("first"),) + + +def test_tool_def_rejects_non_parameter_definitions() -> None: + with pytest.raises(TypeError, match="parameter 0 must be a ToolParamDef"): + replace(TOOL_REGISTRY["close_kitchen"], params=(object(),)) + + def test_tool_contract_identity_tracks_registry_parameter_shape() -> None: run_skill = TOOL_REGISTRY["run_skill"] changed = replace( From 91c6924fd977a6ced032fa32271d672f10e6d283 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 26 Jul 2026 18:40:50 -0700 Subject: [PATCH 88/89] fix(review): validate audit publication sections --- src/autoskillit/recipe/_contracts_manifest.py | 6 +++++- tests/recipe/test_contracts_manifest.py | 21 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/autoskillit/recipe/_contracts_manifest.py b/src/autoskillit/recipe/_contracts_manifest.py index 5a85029ea..e2d44eb56 100644 --- a/src/autoskillit/recipe/_contracts_manifest.py +++ b/src/autoskillit/recipe/_contracts_manifest.py @@ -4,7 +4,7 @@ import hashlib import json -from collections.abc import Callable +from collections.abc import Callable, Mapping from pathlib import Path from typing import Any, Literal, assert_never, cast @@ -125,6 +125,10 @@ def get_skill_contract(skill_name: str, manifest: dict[str, Any]) -> SkillContra authority_data = skill_data.get("audit_authority_publication") authority_publication = None if authority_data is not None: + if not isinstance(authority_data, Mapping): + raise ValueError( + f"audit_authority_publication for skill '{skill_name}' must be a mapping" + ) output_field = authority_data.get("output_field", "") prior_input_field = authority_data.get("prior_input_field", "") input_names = {item.name for item in inputs} diff --git a/tests/recipe/test_contracts_manifest.py b/tests/recipe/test_contracts_manifest.py index 5059cf350..9241d14c2 100644 --- a/tests/recipe/test_contracts_manifest.py +++ b/tests/recipe/test_contracts_manifest.py @@ -105,3 +105,24 @@ def test_get_skill_contract_defaults_allowed_values_to_empty_list() -> None: assert o.allowed_values == [] return pytest.skip("No suitable skill found for default-empty assertion") + + +@pytest.mark.parametrize("authority_data", [[], "invalid", 1]) +def test_get_skill_contract_rejects_non_mapping_authority_publication( + authority_data: object, +) -> None: + manifest = { + "skills": { + "demo-skill": { + "inputs": [{"name": "prior_authority", "type": "file_path"}], + "outputs": [{"name": "authority", "type": "file_path"}], + "audit_authority_publication": authority_data, + } + } + } + + with pytest.raises( + ValueError, + match="audit_authority_publication for skill 'demo-skill' must be a mapping", + ): + get_skill_contract("demo-skill", manifest) From 0e77ed42d953c6eae6bb3cb4fc643402db5b7f81 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 26 Jul 2026 18:43:30 -0700 Subject: [PATCH 89/89] fix(review): log plan disposition rejections --- src/autoskillit/recipe/_cmd_rpc_guards.py | 116 +++++++++++++++--- .../test_cmd_rpc_verify_plan_artifacts.py | 55 +++++++++ 2 files changed, 152 insertions(+), 19 deletions(-) diff --git a/src/autoskillit/recipe/_cmd_rpc_guards.py b/src/autoskillit/recipe/_cmd_rpc_guards.py index fd8d48a50..7653c8bc8 100644 --- a/src/autoskillit/recipe/_cmd_rpc_guards.py +++ b/src/autoskillit/recipe/_cmd_rpc_guards.py @@ -227,6 +227,23 @@ def _normalize_plan_parts(plan_parts: str) -> list[str] | None: _MAX_ASSOCIATION_FILES = 256 +def _log_plan_disposition_rejection( + reason: str, + *, + authority_path: Path, + current_plan_path: Path, + error: BaseException | None = None, +) -> None: + logger.warning( + "plan_disposition_validation_rejected", + reason=reason, + audit_cycle_path=str(authority_path), + current_plan_path=str(current_plan_path), + error=None if error is None else f"{type(error).__name__}: {error}", + exc_info=error is not None, + ) + + def _resolve_plan_disposition(*, audit_cycle_path: str, current_plan_path: Path) -> str | None: authority_path = Path(audit_cycle_path) if not authority_path.is_absolute() or not current_plan_path.is_absolute(): @@ -234,7 +251,13 @@ def _resolve_plan_disposition(*, audit_cycle_path: str, current_plan_path: Path) cycle_dir = authority_path.parent try: authority = AuditCycleVerifier(cycle_dir).load_authority(authority_path) - except (OSError, ValueError): + except (OSError, ValueError) as exc: + _log_plan_disposition_rejection( + "authority loading or validation failed", + authority_path=authority_path, + current_plan_path=current_plan_path, + error=exc, + ) return None if authority.verdict is not AuditVerdict.NO_GO: return None @@ -244,8 +267,14 @@ def _resolve_plan_disposition(*, audit_cycle_path: str, current_plan_path: Path) associations_dir = cycle_dir / "associations" candidates = tuple(sorted(associations_dir.glob("*.json"))) if len(candidates) > _MAX_ASSOCIATION_FILES: + _log_plan_disposition_rejection( + f"association file count exceeds {_MAX_ASSOCIATION_FILES}: {len(candidates)}", + authority_path=authority_path, + current_plan_path=current_plan_path, + ) return None records: list[tuple[Path, dict[str, object]]] = [] + invalid_candidate_count = 0 for candidate in candidates: _, association_bytes = read_stable_contained_bytes( candidate, @@ -258,25 +287,48 @@ def _resolve_plan_disposition(*, audit_cycle_path: str, current_plan_path: Path) require_canonical=True, ) if raw is None or frozenset(raw) != _PLAN_ASSOCIATION_KEYS: + invalid_candidate_count += 1 continue try: plan_ref = ArtifactRef.from_dict(raw["plan_ref"]) except (TypeError, ValueError): + invalid_candidate_count += 1 continue if Path(plan_ref.locator) == current_plan_path: plan_ref_candidates.append(plan_ref) records.append((candidate, raw)) if len(plan_ref_candidates) != 1 or len(records) != 1: + _log_plan_disposition_rejection( + "expected exactly one matching plan association; " + f"matches={len(plan_ref_candidates)}, records={len(records)}, " + f"invalid_candidates={invalid_candidate_count}, candidates={len(candidates)}", + authority_path=authority_path, + current_plan_path=current_plan_path, + ) return None plan_ref = plan_ref_candidates[0] association_path, association = records[0] if association_path.name != f"{plan_ref.content_digest}.json": + _log_plan_disposition_rejection( + "association filename does not match the plan content digest", + authority_path=authority_path, + current_plan_path=current_plan_path, + ) return None - if ( - association["schema_version"] != AUDIT_CYCLE_SCHEMA_VERSION - or association["parent_authority_digest"] != authority.authority_digest - ): + if association["schema_version"] != AUDIT_CYCLE_SCHEMA_VERSION: + _log_plan_disposition_rejection( + "association schema version does not match the audit-cycle schema", + authority_path=authority_path, + current_plan_path=current_plan_path, + ) + return None + if association["parent_authority_digest"] != authority.authority_digest: + _log_plan_disposition_rejection( + "association parent digest does not match the active authority", + authority_path=authority_path, + current_plan_path=current_plan_path, + ) return None association_payload = { key: value for key, value in association.items() if key != "association_digest" @@ -285,33 +337,59 @@ def _resolve_plan_disposition(*, audit_cycle_path: str, current_plan_path: Path) association_payload, domain=_PLAN_ASSOCIATION_DOMAIN, ): + _log_plan_disposition_rejection( + "association digest does not attest its payload", + authority_path=authority_path, + current_plan_path=current_plan_path, + ) return None AuditCycleVerifier(current_plan_path.parent).verify_artifact_ref(plan_ref) disposition_data = association["disposition_ref"] if not isinstance(disposition_data, dict): + _log_plan_disposition_rejection( + "association disposition_ref is not an object", + authority_path=authority_path, + current_plan_path=current_plan_path, + ) return None disposition_ref = ArtifactRef.from_dict(disposition_data) cycle_verifier = AuditCycleVerifier(cycle_dir) cycle_verifier.verify_artifact_ref(disposition_ref) report = cycle_verifier.load_report(disposition_ref.locator) - except (KeyError, OSError, TypeError, ValueError): + except (KeyError, OSError, TypeError, ValueError) as exc: + _log_plan_disposition_rejection( + "association or disposition validation failed", + authority_path=authority_path, + current_plan_path=current_plan_path, + error=exc, + ) return None - identity_matches = ( - report.execution_generation == authority.execution_generation - and report.cycle_id == authority.cycle_id - and report.plan_set_id == authority.plan_set_id - and report.scope_id == authority.scope_id - and report.part_id == authority.part_id - and report.audit_round == authority.audit_round - and report.parent_authority_digest == authority.authority_digest - and report.inventory_digest == authority.inventory_ref.content_digest - and report.findings_digest == authority.findings_digest - and report.current_plan_ref == plan_ref - and Path(report.current_plan_ref.locator) == current_plan_path + identity_checks = { + "execution_generation": report.execution_generation == authority.execution_generation, + "cycle_id": report.cycle_id == authority.cycle_id, + "plan_set_id": report.plan_set_id == authority.plan_set_id, + "scope_id": report.scope_id == authority.scope_id, + "part_id": report.part_id == authority.part_id, + "audit_round": report.audit_round == authority.audit_round, + "parent_authority_digest": (report.parent_authority_digest == authority.authority_digest), + "inventory_digest": report.inventory_digest == authority.inventory_ref.content_digest, + "findings_digest": report.findings_digest == authority.findings_digest, + "current_plan_ref": report.current_plan_ref == plan_ref, + "current_plan_path": Path(report.current_plan_ref.locator) == current_plan_path, + } + mismatched_fields = tuple( + field_name for field_name, matches in identity_checks.items() if not matches ) - return disposition_ref.locator if identity_matches else None + if mismatched_fields: + _log_plan_disposition_rejection( + f"disposition identity mismatch: {', '.join(mismatched_fields)}", + authority_path=authority_path, + current_plan_path=current_plan_path, + ) + return None + return disposition_ref.locator def verify_plan_artifacts( diff --git a/tests/recipe/test_cmd_rpc_verify_plan_artifacts.py b/tests/recipe/test_cmd_rpc_verify_plan_artifacts.py index 2294e19ad..33da2eeef 100644 --- a/tests/recipe/test_cmd_rpc_verify_plan_artifacts.py +++ b/tests/recipe/test_cmd_rpc_verify_plan_artifacts.py @@ -3,8 +3,11 @@ from __future__ import annotations +from types import SimpleNamespace + import pytest +import autoskillit.recipe._cmd_rpc_guards as cmd_rpc_guards from autoskillit.core.closure_hashing import ( canonical_json_bytes, compute_bytes_hash, @@ -214,3 +217,55 @@ def test_active_no_go_salvage_rejects_missing_association(tmp_path): plan_parts=str(plan), audit_cycle_path=str(authority), ) == {"verdict": "unsalvageable"} + + +def test_active_no_go_salvage_logs_authority_validation_failure(tmp_path, monkeypatch): + plan = tmp_path / "plan.md" + plan.write_text("plan") + cycle = tmp_path / "cycle" + cycle.mkdir() + authority = cycle / "authority.json" + authority.write_text("not-json") + warnings = [] + monkeypatch.setattr( + cmd_rpc_guards, + "logger", + SimpleNamespace(warning=lambda event, **context: warnings.append((event, context))), + ) + + assert verify_plan_artifacts( + plan_parts=str(plan), + audit_cycle_path=str(authority), + ) == {"verdict": "unsalvageable"} + assert warnings[0][0] == "plan_disposition_validation_rejected" + assert warnings[0][1]["reason"] == "authority loading or validation failed" + assert warnings[0][1]["audit_cycle_path"] == str(authority) + assert warnings[0][1]["current_plan_path"] == str(plan) + assert warnings[0][1]["error"].startswith("AuditCycleVerificationError:") + assert warnings[0][1]["exc_info"] is True + + +def test_active_no_go_salvage_logs_malformed_association_context(tmp_path, monkeypatch): + plan, authority, _ = _write_audit_tuple(tmp_path) + association = next((authority.parent / "associations").iterdir()) + association.write_text("{}") + warnings = [] + monkeypatch.setattr( + cmd_rpc_guards, + "logger", + SimpleNamespace(warning=lambda event, **context: warnings.append((event, context))), + ) + + assert verify_plan_artifacts( + plan_parts=str(plan), + audit_cycle_path=str(authority), + ) == {"verdict": "unsalvageable"} + assert warnings[0][0] == "plan_disposition_validation_rejected" + assert warnings[0][1]["reason"] == ( + "expected exactly one matching plan association; " + "matches=0, records=0, invalid_candidates=1, candidates=1" + ) + assert warnings[0][1]["audit_cycle_path"] == str(authority) + assert warnings[0][1]["current_plan_path"] == str(plan) + assert warnings[0][1]["error"] is None + assert warnings[0][1]["exc_info"] is False