diff --git a/.autoskillit/test-filter-manifest.yaml b/.autoskillit/test-filter-manifest.yaml index a654e1c88a..66e0e7380b 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/src/autoskillit/core/AGENTS.md b/src/autoskillit/core/AGENTS.md index dff29aa691..c303ec3803 100644 --- a/src/autoskillit/core/AGENTS.md +++ b/src/autoskillit/core/AGENTS.md @@ -33,6 +33,8 @@ 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 | +| `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 0eced8ce94..df28822e24 100644 --- a/src/autoskillit/core/__init__.pyi +++ b/src/autoskillit/core/__init__.pyi @@ -43,12 +43,26 @@ 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 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 ( @@ -79,6 +93,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,9 +104,14 @@ 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 +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 @@ -131,6 +151,11 @@ 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 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 from .tool_sequence_analysis import AnalysisResult as AnalysisResult from .tool_sequence_analysis import AssistantTurn as AssistantTurn @@ -150,6 +175,7 @@ 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 AGENT_BACKEND_CLAUDE_CODE as AGENT_BACKEND_CLAUDE_CODE from .types import AGENT_BACKEND_CODEX as AGENT_BACKEND_CODEX @@ -158,6 +184,7 @@ 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 @@ -310,6 +337,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 @@ -323,6 +351,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 @@ -330,6 +359,7 @@ 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 @@ -338,7 +368,14 @@ from .types import AgentPackDef as AgentPackDef from .types import AgentSessionResult as AgentSessionResult from .types import AggregateRevision as AggregateRevision 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 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 @@ -347,6 +384,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 @@ -441,13 +486,17 @@ 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 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 @@ -478,7 +527,11 @@ 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 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 @@ -498,6 +551,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 @@ -505,6 +559,9 @@ 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 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 from .types import RecipeNotFoundError as RecipeNotFoundError @@ -597,6 +654,9 @@ 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 ( @@ -604,6 +664,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 @@ -613,7 +675,13 @@ 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_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_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 new file mode 100644 index 0000000000..d29418fe75 --- /dev/null +++ b/src/autoskillit/core/audit_cycle_verifier.py @@ -0,0 +1,680 @@ +"""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 .logging import get_logger +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") +logger = get_logger(__name__) +_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: + logger.error("inventory admission evaluation failed", exc_info=True) + 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", + ) + 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, + *, + 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"] + 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: + 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: + 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, + 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 0b70c2a5ca..84c7694aa7 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 af62131e92..e7f21d925c 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 0d73539b39..c959c9aa99 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: @@ -46,6 +85,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 +93,7 @@ "spill_output", "temp_dir_display_str", "write_versioned_json", + "write_canonical_versioned_json", ] @@ -287,6 +328,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, @@ -406,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/path_containment.py b/src/autoskillit/core/path_containment.py index 01d5009dc1..b5590bd639 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,68 @@ 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 _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, + *, + 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() + fd = _open_beneath_root_without_symlinks(path, allowed_root, resolved) + 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/tool_registry.py b/src/autoskillit/core/tool_registry.py new file mode 100644 index 0000000000..2d6417dd2f --- /dev/null +++ b/src/autoskillit/core/tool_registry.py @@ -0,0 +1,469 @@ +"""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 .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, + 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, + wire_type=declared_wire_types.get(param, ToolWireType.SCALAR), + required=param in required_set, + ) + for param in params + ), + ) + + +def _run_skill() -> ToolDef: + string_params = ( + "skill_command", + "cwd", + "model", + "step_name", + "recipe_execution_id", + "invocation_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", + ) + params = [ + ToolParamDef( + name, + wire_type=ToolWireType.STRING, + required=name in {"skill_command", "cwd"}, + ) + for name in string_params + ] + params[8:8] = [ + 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", + ToolWireType.OBJECT, + structured_skill_inputs=True, + handler_parameter=True, + ) + ) + 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", + ), + 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", + ( + "timeout", + "stale_threshold", + "idle_output_timeout", + "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( + "run_python", + ("callable", "args", "timeout", "work_dir"), + required=("callable",), + wire_types={"args": ToolWireType.OBJECT}, + ), + _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"), + wire_types={ + "ingredients": ToolWireType.OBJECT, + "capture": ToolWireType.OBJECT, + "resume_checkpoint": ToolWireType.OBJECT, + }, + ), + _tool( + "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", + ("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"), + wire_types={ + "overrides": ToolWireType.OBJECT, + "ingredients_only": ToolWireType.BOOLEAN, + "delivery_request": ToolWireType.OBJECT, + }, + ), + _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"), + 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, + "ingredients_only": ToolWireType.BOOLEAN, + "delivery_request": ToolWireType.OBJECT, + }, + ), + _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"), + 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",)), +) + + +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) +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: + 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) + + +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 1d4b7fc807..7f2c56549a 100644 --- a/src/autoskillit/core/types/AGENTS.md +++ b/src/autoskillit/core/types/AGENTS.md @@ -21,6 +21,9 @@ 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_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 61a051ca79..741b42a5b0 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 @@ -58,8 +60,12 @@ 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_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 @@ -80,7 +86,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 @@ -108,6 +115,8 @@ + _protocols_backend_all + _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_audit_cycle.py b/src/autoskillit/core/types/_type_audit_cycle.py new file mode 100644 index 0000000000..47eed52e7d --- /dev/null +++ b/src/autoskillit/core/types/_type_audit_cycle.py @@ -0,0 +1,811 @@ +"""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, TypeVar + +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]*)$") +_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): + 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: + 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) + 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") + 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") + _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: + 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, + "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 + audited_plan_refs: tuple[ArtifactRef, ...] + inventory_ref: ArtifactRef + 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) + 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: + _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: + 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) + 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)) + 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") + _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: + dispositions = _immutable_typed_tuple( + "PlanDispositionReport.dispositions", + dispositions, + PlanDispositionRow, + ) + 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: + 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): + raise ValueError("InventoryAdmissionDecision.reason must be an AdmissionReason") + 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: + 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/src/autoskillit/core/types/_type_protocols_recipe.py b/src/autoskillit/core/types/_type_protocols_recipe.py index 1697c310e8..1c24edd613 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_binding.py b/src/autoskillit/core/types/_type_recipe_binding.py new file mode 100644 index 0000000000..ef65d981b3 --- /dev/null +++ b/src/autoskillit/core/types/_type_recipe_binding.py @@ -0,0 +1,280 @@ +"""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 | 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: + 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 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: object | AbsentBoundValue + effective_value: object | AbsentBoundValue + state: BoundValueState + origin: BoundValueOrigin + context_dependencies: tuple[str, ...] = () + input_dependencies: tuple[str, ...] = () + 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: + 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( + 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, ...] = () + + 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 + + @property + def attested(self) -> bool: + """Whether this binding is eligible for later recipe attestation.""" + + 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], ...]: + """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 + 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) + + 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/core/types/_type_recipe_execution.py b/src/autoskillit/core/types/_type_recipe_execution.py new file mode 100644 index 0000000000..b5ff340708 --- /dev/null +++ b/src/autoskillit/core/types/_type_recipe_execution.py @@ -0,0 +1,331 @@ +"""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, field +from enum import StrEnum +from pathlib import Path +from types import MappingProxyType +from typing import Any, Protocol, runtime_checkable + +from ..closure_hashing import HASH_RE, 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", + "RecipeExecutionFactory", + "RecipeExecutionLock", + "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" + + +@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 + + +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, + "template_dependencies": list(value.template_dependencies), + } + + +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], + 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": [ + { + "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 + dynamic_skill_step_names: frozenset[str] = frozenset() + + 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) + 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, + 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)) + 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") + + @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 + preflight_identities: Mapping[str, tuple[str, str, str]] = field(default_factory=dict) + + def __post_init__(self) -> None: + object.__setattr__( + self, + "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), + ) + + +@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, + step_name: str, + template_digest: str, + bound_inputs: tuple[tuple[str, BoundScalar], ...], + 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 { + "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, + "template_digest": template_digest, + } + return compute_canonical_hash(payload, domain=_RUNTIME_BINDING_DOMAIN) diff --git a/src/autoskillit/hooks/formatters/_fmt_recipe.py b/src/autoskillit/hooks/formatters/_fmt_recipe.py index 35551f89f9..54f9b0059d 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/pipeline/context.py b/src/autoskillit/pipeline/context.py index 645c28be61..21464abfba 100644 --- a/src/autoskillit/pipeline/context.py +++ b/src/autoskillit/pipeline/context.py @@ -7,6 +7,7 @@ from __future__ import annotations +import threading from dataclasses import dataclass, field from pathlib import Path from typing import Any @@ -28,6 +29,7 @@ GitHubFetcher, HeadlessExecutor, InputContractResolver, + InstalledRecipeExecution, McpResponseLog, MergeQueueWatcher, MigrationService, @@ -35,6 +37,8 @@ PluginSource, QuotaRefreshTask, ReadOnlyResolver, + RecipeExecutionFactory, + RecipeExecutionLock, RecipeRepository, ServeOverridesSnapshot, SessionSkillManager, @@ -131,6 +135,10 @@ 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. + 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=. @@ -171,6 +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) + 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) @@ -192,6 +201,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: RecipeExecutionLock = field( + default_factory=threading.RLock, + repr=False, + ) def __post_init__(self) -> None: if self.temp_dir is _MISSING: diff --git a/src/autoskillit/recipe/AGENTS.md b/src/autoskillit/recipe/AGENTS.md index c4bedec01b..976d5804b5 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 | @@ -36,6 +37,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 633e559902..f0c7bd5087 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, @@ -288,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", @@ -340,6 +347,11 @@ "DefaultRecipeRepository", "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/_analysis.py b/src/autoskillit/recipe/_analysis.py index d43f686eb8..54a764670a 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, @@ -70,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) @@ -139,6 +143,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 +177,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/_analysis_detectors.py b/src/autoskillit/recipe/_analysis_detectors.py index b82d4896a9..92a1ffe3b4 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/_api.py b/src/autoskillit/recipe/_api.py index 733b7586dd..40c15f9e1b 100644 --- a/src/autoskillit/recipe/_api.py +++ b/src/autoskillit/recipe/_api.py @@ -45,6 +45,11 @@ list_all, validate_from_path, ) +from autoskillit.recipe._binding import bind_recipe +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, @@ -84,8 +89,6 @@ ) from autoskillit.recipe.io import ( RecipeInfo, - _assert_no_raw_placeholders, - _load_recipe_dict, _parse_recipe, builtin_recipes_dir, builtin_sub_recipes_dir, @@ -202,6 +205,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. @@ -270,6 +274,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, @@ -302,7 +307,8 @@ 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) + return cast(LoadRecipeResult, cached_result) t0 = time.perf_counter() @@ -318,8 +324,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 @@ -328,6 +334,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] = [] @@ -339,11 +346,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 +404,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 +416,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 +477,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 +492,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) @@ -614,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, @@ -670,6 +691,8 @@ def load_and_validate( if _deferred_guard_list: result["deferred_guards"] = _deferred_guard_list if active_recipe 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) result["post_prune_routing_edges"] = sorted( @@ -700,4 +723,5 @@ 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) + return cast(LoadRecipeResult, caller_result) diff --git a/src/autoskillit/recipe/_binding.py b/src/autoskillit/recipe/_binding.py new file mode 100644 index 0000000000..746cf58fd0 --- /dev/null +++ b/src/autoskillit/recipe/_binding.py @@ -0,0 +1,980 @@ +"""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, + InvocationTemplate, + RecipeBindingProjection, + ToolParamDef, + ToolWireType, + get_tool_def, + resolve_skill_name, +) +from autoskillit.recipe._contracts_manifest import ( + compute_skill_contract_identity as _compute_skill_contract_identity, +) +from autoskillit.recipe._contracts_manifest import ( + get_callable_contract, + get_skill_contract, + 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", +] + +_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, 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 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 + + +def _origin_for( + declared: BoundScalar, +) -> tuple[ + BoundValueOrigin, + tuple[str, ...], + 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))) + 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): + 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, template_dependencies + + +def _bound_value(name: str, declared: BoundScalar, effective: BoundScalar) -> BoundValue: + ( + 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, + effective_value=effective, + state=BoundValueState.PRESENT, + origin=origin, + context_dependencies=context_dependencies, + input_dependencies=input_dependencies, + template_dependencies=template_dependencies, + ) + + +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, + name: str, + message: str, +) -> BindingFailure: + return BindingFailure(code=code, step_name=step_name, name=name, message=message) + + +def _wire_value_is_valid(value: object, 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) and not isinstance(value, bool) + case ToolWireType.BOOLEAN: + return isinstance(value, bool) + case ToolWireType.SCALAR: + return _is_scalar(value) + 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: + return input_def.accepts(value) + + +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} + 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 + 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_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( + 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 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") + 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 + ) + 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( + _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 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 + 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( + _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) + 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, + 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), + ) + + 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 if manifest is not None else 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) + + +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/recipe/_cmd_rpc_guards.py b/src/autoskillit/recipe/_cmd_rpc_guards.py index e70a808c54..7653c8bc85 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,196 @@ 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 _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(): + return None + cycle_dir = authority_path.parent + try: + authority = AuditCycleVerifier(cycle_dir).load_authority(authority_path) + 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 + + try: + plan_ref_candidates: list[ArtifactRef] = [] + 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, + 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: + 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: + _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" + } + if association["association_digest"] != compute_canonical_hash( + 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) 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_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 + ) + 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( + 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 +415,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/recipe/_contracts_card.py b/src/autoskillit/recipe/_contracts_card.py index 280a0acbd6..d96e4d00c7 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/_contracts_manifest.py b/src/autoskillit/recipe/_contracts_manifest.py index eeb9999225..e2d44eb56b 100644 --- a/src/autoskillit/recipe/_contracts_manifest.py +++ b/src/autoskillit/recipe/_contracts_manifest.py @@ -3,6 +3,8 @@ from __future__ import annotations import hashlib +import json +from collections.abc import Callable, Mapping from pathlib import Path from typing import Any, Literal, assert_never, cast @@ -20,6 +22,7 @@ _CONTEXT_REF_RE, _TEMPLATE_REF_RE, INPUT_REF_RE, + AuditAuthorityPublicationSpec, OutcomeInvariantEntry, ResultFieldSpec, SkillContract, @@ -33,6 +36,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]: @@ -47,7 +51,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 +60,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"], @@ -118,6 +122,31 @@ 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: + 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} + 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, @@ -130,7 +159,54 @@ 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"), + audit_authority_publication=authority_publication, + ) + + +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 (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 + 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, ...]: @@ -184,7 +260,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 +269,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 731e7ba1fa..69a6be4c8a 100644 --- a/src/autoskillit/recipe/_contracts_types.py +++ b/src/autoskillit/recipe/_contracts_types.py @@ -6,13 +6,15 @@ 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"\$\{\{[^}]+\}\}") RESULT_CAPTURE_RE = re.compile(r"\$\{\{\s*result\.([\w-]+)\s*\}\}") -@dataclasses.dataclass +@dataclasses.dataclass(frozen=True, slots=True) class SkillInput: name: str type: str @@ -20,6 +22,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) and not isinstance(value, bool) + if normalized in {"boolean", "bool"}: + return isinstance(value, bool) + return False + @dataclasses.dataclass class SkillOutput: @@ -47,9 +68,15 @@ class SuccessQualifierEntry: qualifier: str +@dataclasses.dataclass(frozen=True, slots=True) +class AuditAuthorityPublicationSpec: + output_field: str + prior_input_field: str + + @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) @@ -60,6 +87,16 @@ 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 + audit_authority_publication: AuditAuthorityPublicationSpec | 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) diff --git a/src/autoskillit/recipe/_io_loading.py b/src/autoskillit/recipe/_io_loading.py new file mode 100644 index 0000000000..8073c498ab --- /dev/null +++ b/src/autoskillit/recipe/_io_loading.py @@ -0,0 +1,130 @@ +"""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.""" + if _SCRIPTS_PLACEHOLDER not in text: + return text + 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/_recipe_ingredients.py b/src/autoskillit/recipe/_recipe_ingredients.py index c9025e8e84..cacec77366 100644 --- a/src/autoskillit/recipe/_recipe_ingredients.py +++ b/src/autoskillit/recipe/_recipe_ingredients.py @@ -5,6 +5,7 @@ from typing import Any, NotRequired, TypedDict from autoskillit.core import ( + RecipeBindingProjection, TerminalColumn, _render_gfm_table, ) @@ -132,6 +133,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/_skill_helpers.py b/src/autoskillit/recipe/_skill_helpers.py index 290307b64a..34e330fde6 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/io.py b/src/autoskillit/recipe/io.py index 7732af7194..805e217f58 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,21 @@ 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 ( + 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, @@ -64,93 +72,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). - """ - 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 - 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") - 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 - - def load_recipe(path: Path, temp_dir_relpath: str = ".autoskillit/temp") -> Recipe: """Parse a YAML recipe file into a Recipe dataclass. @@ -163,8 +84,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 +292,7 @@ def load_campaign_recipes_in_packs( "python", "constant", "with_args", + "declared_with_args", "on_success", "on_failure", "on_context_limit", @@ -491,7 +416,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 +441,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 +559,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 +613,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"), @@ -699,8 +669,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/src/autoskillit/recipe/repository.py b/src/autoskillit/recipe/repository.py index 9a27b7eaa5..b5ab5ab947 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/recipe/rules/AGENTS.md b/src/autoskillit/recipe/rules/AGENTS.md index 8b3dbcc854..fcc7ddb412 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 d62f9fea8a..dfa504f1ce 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_contracts.py b/src/autoskillit/recipe/rules/rules_contracts.py index 87d1612592..130cbfd5f0 100644 --- a/src/autoskillit/recipe/rules/rules_contracts.py +++ b/src/autoskillit/recipe/rules/rules_contracts.py @@ -6,10 +6,10 @@ 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, - resolve_skill_name, ) from autoskillit.recipe.registry import RuleFinding, make_finding, semantic_rule @@ -60,8 +60,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 +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 - 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 +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 - 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 +189,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 +294,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 +379,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 +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 - 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 +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 - 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 +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 - 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 +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 - 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_flake_loop.py b/src/autoskillit/recipe/rules/rules_flake_loop.py index bb282df069..ac8c590490 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_inputs.py b/src/autoskillit/recipe/rules/rules_inputs.py index 621cdd8b76..30c7873482 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_inventory_gate_bilateral.py b/src/autoskillit/recipe/rules/rules_inventory_gate_bilateral.py index 57dd3e77a5..7f50f6273d 100644 --- a/src/autoskillit/recipe/rules/rules_inventory_gate_bilateral.py +++ b/src/autoskillit/recipe/rules/rules_inventory_gate_bilateral.py @@ -1,106 +1,210 @@ -"""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.contracts import resolve_skill_name +from autoskillit.recipe._analysis_bfs import all_paths_cross, bfs_reachable +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 _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 _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 -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. - 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, +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 ) - - 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 @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 bound_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 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 bound_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 bound_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 2c02d80630..bdd53c3751 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 f88138fe2c..7b69bb4f0c 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 9b25258504..f4df2e0809 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 914b6c1461..497c4c705b 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_REGISTRY, TOOL_SUBSET_TAGS, - UNGATED_TOOLS, + BindingFailureCode, Severity, 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] = frozenset(TOOL_REGISTRY) _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 e5d8256f8c..ff054ef853 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 @@ -115,7 +115,10 @@ 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) + # 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] | None = field(default=None, repr=False) on_success: str | None = None on_failure: str | None = None on_context_limit: str | None = None @@ -147,6 +150,32 @@ 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 key == "skill_inputs": + if self.tool != "run_skill" or not isinstance(value, dict): + raise TypeError( + "Only run_skill.with.skill_inputs may declare structured " + "child-skill inputs" + ) + if not all( + isinstance(input_name, str) + and isinstance(input_value, (str, int, 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" + ) + if self.declared_with_args is not None: + if not self.declared_with_args.keys() <= self.with_args.keys(): + raise ValueError( + "RecipeStep declared with mapping cannot contain keys absent " + "from the effective mapping" + ) + 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/src/autoskillit/recipe/skill_contracts.yaml b/src/autoskillit/recipe/skill_contracts.yaml index 78606d18c3..00d1f9de73 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: file_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: @@ -397,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 @@ -416,19 +444,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 +705,9 @@ skills: - name: mode type: string required: false + - name: pr_head_sha + type: string + required: false outputs: - name: verdict type: string @@ -1637,6 +1676,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 +1710,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 +1923,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 +2276,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 +2338,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 +2435,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 +3136,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 +3147,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/recipe/validator.py b/src/autoskillit/recipe/validator.py index dbadb922ac..9c81987567 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/src/autoskillit/recipes/bem-wrapper.json b/src/autoskillit/recipes/bem-wrapper.json index 4ad8b5f2f3..76f35aefc0 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 04ccebbb1e..fa4994c5cb 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 3c9958224f..2c2f3b8365 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 b9d1a0da0d..bd0cc7d041 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 3211f94899..f055fa47a8 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 3f936bed91..358f6b3061 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 9a9251050e..5dc7b156b2 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 18f34ed884..551375b879 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 2869592144..90d1c24786 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 83ba7039df..aba271b10b 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": "CURRENT group file path from ${{ 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: 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", "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": "${{ inputs.dispatch_id }}", + "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": "${{ inputs.dispatch_id }}", + "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": "${{ inputs.dispatch_id }}", + "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": "${{ 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 7a339b7c9f..9bd4339248 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: "CURRENT group file path from ${{ 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 @@ -236,37 +240,37 @@ 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. - 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. + 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". - 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: "${{ 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 @@ -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: "${{ inputs.dispatch_id }}" + 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: "${{ inputs.dispatch_id }}" + 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: "${{ 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 27d923136e..4f3ed5d061 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": "${{ inputs.dispatch_id }}", + "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": "${{ inputs.dispatch_id }}", + "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": "${{ inputs.dispatch_id }}", + "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": "${{ inputs.dispatch_id }}", + "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": "${{ inputs.dispatch_id }}", + "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": "${{ 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 70774addef..2d21bcb0f4 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: "${{ 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 @@ -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: "${{ inputs.dispatch_id }}" + 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: "${{ inputs.dispatch_id }}" + 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: "${{ inputs.dispatch_id }}" + 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: "${{ inputs.dispatch_id }}" + 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: "${{ 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 559c9db1fa..7357ffddad 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": "${{ inputs.dispatch_id }}", + "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": "${{ 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 b65d74f564..7c49d8af63 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: "${{ inputs.dispatch_id }}" + 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: "${{ 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/planner.json b/src/autoskillit/recipes/planner.json index 1357fa5341..852ce77544 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 a7695250e1..feb15d8c94 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 77361800a2..dceb0d5be8 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": "${{ inputs.dispatch_id }}", + "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": "${{ inputs.dispatch_id }}", + "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": "${{ inputs.dispatch_id }}", + "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": "${{ inputs.dispatch_id }}", + "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": "${{ inputs.dispatch_id }}", + "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": "${{ 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 792bf0c583..1441017184 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: "${{ 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 @@ -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: "${{ inputs.dispatch_id }}" + 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: "${{ inputs.dispatch_id }}" + 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: "${{ inputs.dispatch_id }}" + 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: "${{ inputs.dispatch_id }}" + 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: "${{ 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-design.json b/src/autoskillit/recipes/research-design.json index 88620fff21..a7729c1aa1 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 580f42febf..596c93d2a8 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 f93620c494..94b2a84a32 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": "FIRST REMAINING phase file path from ${{ 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. 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" + ] }, "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 790bd53cc9..3860e8ebd5 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,44 +194,50 @@ steps: tool: run_skill model: "" with: - skill_command: "/autoskillit:make-plan ${{ context.group_files }}" + skill_command: "/autoskillit:make-plan" + skill_inputs: + 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 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: > - 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. - 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 06ec2a7fc9..05edacf32f 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 99c08eae2b..2069e996c6 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 16d2a041d1..2794df5c82 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": "${{ context.methodology_tradition }}", + "disambiguation_rule_applied": "${{ context.disambiguation_rule_applied }}", + "experiment_plan_path": "${{ context.experiment_plan }}", + "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 e73bce1c20..ca332997c4 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: "${{ context.methodology_tradition }}" + disambiguation_rule_applied: "${{ context.disambiguation_rule_applied }}" + experiment_plan_path: "${{ context.experiment_plan }}" + 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/server/AGENTS.md b/src/autoskillit/server/AGENTS.md index 9827ee06a1..09186d79b3 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 e8018f52cd..795e4892f1 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 @@ -19,7 +19,9 @@ from autoskillit.core import ( DirectInstall, FleetLock, + InstalledRecipeExecution, PluginSource, + RecipeExecutionSnapshot, SkillExecutionRole, SubprocessRunner, WriteBehaviorSpec, @@ -66,6 +68,10 @@ resolve_input_specs, resolve_skill_name, ) +from autoskillit.server._recipe_execution import ( + DefaultAuditCycleHeadStore, + DefaultInputPreflightResolver, +) from autoskillit.workspace import ( DefaultCloneManager, DefaultSessionSkillManager, @@ -80,6 +86,25 @@ logger = get_logger(__name__) + +def make_recipe_execution( + *, + snapshot: RecipeExecutionSnapshot, + allowed_root: Path, +) -> 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, + ), + ) + + # Optional-override: _UNSET means "not provided, use factory-computed default" (None is valid). _UNSET: Any = object() @@ -390,6 +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.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 2c15dd1c97..752ab45917 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,53 @@ 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 ( # circular-break + build_recipe_execution_snapshot, + ) + + 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) 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", + 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 +831,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 +848,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 +957,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 +971,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, ) @@ -923,21 +983,53 @@ def complete_finalized_recipe_response( *, now_unix: int | None = None, ) -> Any: - """Commit only an exact enforced response; otherwise abort its pending 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 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 finalized.execution_snapshot is not None and finalized.tool_ctx is not None: + try: + from autoskillit.server._recipe_execution import ( # circular-break + prepare_recipe_execution, + ) + + prepared_execution = prepare_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 not None and ( + ledger is None + or not ledger.commit( + handle, + now_unix=int(time.time()) if now_unix is None else now_unix, + ) + ): + 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, + ) + + install_recipe_execution( + finalized.tool_ctx, + prepared_execution=prepared_execution, + ) return enforced - enforced = json.dumps( - {"success": False, "error": "recipe_delivery_receipt_commit_failed"}, - separators=(",", ":"), - ) 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 0000000000..9deeae1fe1 --- /dev/null +++ b/src/autoskillit/server/_recipe_execution.py @@ -0,0 +1,770 @@ +"""Server-owned compiled recipe execution and audit admission state.""" + +from __future__ import annotations + +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 +from uuid import uuid4 + +from autoskillit.core import ( + AdmissionReason, + AdmissionStatus, + AuditCycleAuthority, + AuditCycleHead, + AuditCycleVerificationError, + AuditCycleVerifier, + AuditVerdict, + BindingMode, + BoundScalar, + InstalledRecipeExecution, + InventoryAdmissionDecision, + InvocationTemplate, + PreflightEvidence, + PreflightKind, + RecipeBindingProjection, + RecipeExecutionSnapshot, + VerifiedInputPreflightRequest, + VerifiedInputPreflightResult, + compute_invocation_template_digest, + compute_recipe_execution_snapshot_digest, + compute_tool_contract_identity, + get_logger, + get_tool_def, +) +from autoskillit.recipe import ( + RecipeStep, + RuntimeBindingError, + bind_runtime_skill_invocation, + bind_step_invocation, + compute_skill_contract_identity, + get_skill_contract, + load_bundled_manifest, +) + +if TYPE_CHECKING: + from autoskillit.core import AuditCycleHeadStore, SkillResult + from autoskillit.pipeline import ToolContext + +__all__ = [ + "AuditCycleHeadConflict", + "DefaultAuditCycleHeadStore", + "DefaultInputPreflightResolver", + "RecipeExecutionAdmissionError", + "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", + "prepare_recipe_execution", + "publish_audit_cycle_result", + "publish_reported_audit_cycle", + "publish_verified_audit_cycle", + "record_runtime_binding_digest", + "resolve_attested_input_preflight", +] + + +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.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 + ): + raise AuditCycleHeadConflict("audit-cycle head compare-and-swap failed") + try: + AuditCycleVerifier.verify_successor(authority, current) + except AuditCycleVerificationError 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, + audited_plan_refs=authority.audited_plan_refs, + inventory_ref=authority.inventory_ref, + 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: + get_logger(__name__).error( + "audit-cycle authority verification failed", + exc_info=True, + ) + 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", + ) + ) + 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, + ) + 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=request.expected_plan_set_id, + expected_scope_id=request.expected_scope_id, + expected_part_id=request.expected_part_id, + ) + return self._result(decision) + + +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, + 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 + 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": + 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") + skill_identity = compute_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_identity, + skill_contract_identity=skill_identity, + ) + templates[step_name] = InvocationTemplate( + invocation=invocation, + tool_contract_identity=tool_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, + dynamic_skill_step_names=frozenset(dynamic_skill_step_names), + ) + 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, + dynamic_skill_step_names=frozenset(dynamic_skill_step_names), + ) + + +def get_recipe_execution(tool_ctx: ToolContext) -> InstalledRecipeExecution | None: + with tool_ctx.recipe_execution_lock: + return tool_ctx.active_recipe_execution + + +def prepare_recipe_execution( + tool_ctx: ToolContext, + *, + snapshot: RecipeExecutionSnapshot, +) -> InstalledRecipeExecution: + """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) + + +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 + tool_ctx.active_recipe_execution = installed + if previous is not None and previous is not installed: + try: + previous.audit_cycle_heads.clear_generation(previous.snapshot.execution_id) + except Exception: + get_logger(__name__).warning( + "prior recipe execution cleanup failed", + exc_info=True, + ) + 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 + tool_ctx.active_recipe_execution = None + if previous is not None: + previous.audit_cycle_heads.clear_generation(previous.snapshot.execution_id) + + +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 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", + ) + 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, + ) + except RuntimeBindingError as exc: + raise RecipeExecutionAdmissionError(exc.code, str(exc)) from exc + 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], ...], + 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 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. + """ + if skill_inputs is None: + return skill_command + with_args: dict[str, object] = { + "skill_command": skill_command, + "cwd": cwd, + "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] + raise RecipeExecutionAdmissionError( + f"standalone_{failure.code.value}", + failure.message, + ) + return build_bound_child_prompt( + skill_command, + binding.canonical_child_invocation, + None, + ) + + +def _publish_loaded_audit_cycle( + tool_ctx: ToolContext, + *, + installed: InstalledRecipeExecution, + authority: AuditCycleAuthority, + expected_parent_digest: str | None, + expected_round: int, + authorized_successor_part_id: str | None = None, +) -> 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) + manifest = load_bundled_manifest() + 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, + ) + 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, + 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") + 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, + 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, + ) + + +def publish_audit_cycle_result( + tool_ctx: ToolContext, + 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 is None or installed is None: + return + 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 authority-producing skill result did not declare " + f"a valid {publication.output_field}", + ) + prior_authority_path = dict(bound_inputs).get(publication.prior_input_field) + 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/_execution_helpers.py b/src/autoskillit/server/tools/_execution_helpers.py index 57d77ca4b4..38bae6aa1a 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/_serve_helpers.py b/src/autoskillit/server/tools/_serve_helpers.py index 5dd755782b..687a35acc9 100644 --- a/src/autoskillit/server/tools/_serve_helpers.py +++ b/src/autoskillit/server/tools/_serve_helpers.py @@ -16,12 +16,16 @@ 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 if TYPE_CHECKING: - from autoskillit.core import BackendCapabilities, CodingAgentBackend + from autoskillit.core import ( + BackendCapabilities, + CodingAgentBackend, + ) from autoskillit.pipeline.context import ToolContext @@ -105,7 +109,17 @@ 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.""" + 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]: @@ -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 # circular-break + + 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 8a241ba030..279b4ff49f 100644 --- a/src/autoskillit/server/tools/tools_execution.py +++ b/src/autoskillit/server/tools/tools_execution.py @@ -25,9 +25,11 @@ DISPATCH_ID_ENV_VAR, SKILL_COMMAND_DISPLAY_MAX, WORKTREE_SKILLS, + BoundScalar, ClosureAuthoritySpec, CodingAgentBackend, EffectiveSkillInvocationAuthority, + InvocationTemplate, SkillContractError, SkillExecutionRole, SkillResult, @@ -35,6 +37,7 @@ ValidatedAddDir, WriteBehaviorSpec, closure_authority_spec_from_args, + compute_runtime_binding_digest, execution_marker, extract_skill_name, find_caller_session_id, @@ -72,6 +75,18 @@ 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, + 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, +) from autoskillit.server._subprocess import _run_subprocess_captured from autoskillit.server.tools._backend_compat import ( _check_backend_compat, @@ -150,14 +165,17 @@ ) INGREDIENT_LOCK_DENY_PREFIX = "INGREDIENT LOCK ENFORCED" - - DEPENDENCY_DENY_PREFIX = "DEPENDENCY UNMET" -def _is_absolute_path(path: str) -> bool: - """Return True if path is an absolute filesystem path.""" - return Path(path).is_absolute() +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 _check_ingredient_locks(step_name: str, order_id: str) -> str | None: @@ -588,6 +606,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 +620,7 @@ async def run_skill( closure_base_sha: str = "", closure_diff_sha: str = "", closure_target_sha: str = "", + 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. @@ -640,7 +661,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( ( @@ -660,6 +681,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 +708,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 +746,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 +774,7 @@ 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 +835,132 @@ 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], ...] = () + _invocation_template: InvocationTemplate | None = None + child_skill_command = skill_command + _claims_recipe_execution = bool(recipe_execution_id or invocation_template_digest) + _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", + ( + "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)) + 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, + ) + except RecipeExecutionAdmissionError as exc: + return _recipe_execution_deny(exc.code, str(exc)) + 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, + actual_mcp_kwargs=_actual_mcp_kwargs, + preflight=_preflight_result, + ) + 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: + 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), + ) + with structlog.contextvars.bound_contextvars(tool="run_skill", cwd=cwd): logger.info("run_skill", command=skill_command[:80], cwd=cwd) await _notify( @@ -821,7 +980,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 +988,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 +1059,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 @@ -1026,7 +1185,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 @@ -1442,6 +1601,13 @@ def _observe_contract_session_id(candidate_session_id: str) -> None: contract_lifecycle.apply_retention(skill_result.needs_retry) if skill_result.success: + _publish_audit_result( + tool_ctx, + target_name, + skill_result, + (_installed_execution if _invocation_template is not None else None), + _bound_recipe_inputs, + ) tool_ctx.audit.record_success(skill_command) clear_run_skill_state(tool_ctx.project_dir) if step_name: diff --git a/src/autoskillit/server/tools/tools_kitchen.py b/src/autoskillit/server/tools/tools_kitchen.py index 44b77b69f0..26f8d23222 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: @@ -720,6 +724,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 +753,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 +919,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 +997,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 +1104,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 +1123,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 +1270,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 a75e752b83..ab80a98a24 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 get_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, @@ -389,6 +391,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 +451,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) @@ -603,6 +609,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", @@ -612,6 +619,21 @@ 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 + 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"] = { + "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/apply-review-dimensions/SKILL.md b/src/autoskillit/skills_extended/apply-review-dimensions/SKILL.md index 55208ddeb3..faf23815aa 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/audit-impl/SKILL.md b/src/autoskillit/skills_extended/audit-impl/SKILL.md index f5f55e3c2c..4b4800ee47 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/diagnose-ci/SKILL.md b/src/autoskillit/skills_extended/diagnose-ci/SKILL.md index e95a5e69c1..0388b8b1d8 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/dry-walkthrough/SKILL.md b/src/autoskillit/skills_extended/dry-walkthrough/SKILL.md index cef2c8eaee..0296b3d2ac 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 6a0af4c60a..b7b00fb9f7 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/src/autoskillit/skills_extended/merge-pr/SKILL.md b/src/autoskillit/skills_extended/merge-pr/SKILL.md index 8332ada9f1..241b97f153 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 2e725d0b12..8ffd598695 100644 --- a/src/autoskillit/skills_extended/resolve-design-review/SKILL.md +++ b/src/autoskillit/skills_extended/resolve-design-review/SKILL.md @@ -28,6 +28,11 @@ 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. `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 Called by the research recipe via run_skill when review_design emits verdict=STOP. @@ -38,7 +43,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 +69,9 @@ abandoning the partial triage. ### Step 0: Validate Arguments and Parse Dashboard -1. Create `{{AUTOSKILLIT_TEMP}}/resolve-design-review/` if absent +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 - If file not found: print `"Error: file not found — {missing_path}"`, then emit `resolution=failed`, and return @@ -106,7 +113,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 +158,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 +187,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: @@ -189,15 +196,15 @@ 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 `{{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 path. ``` -{{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 bf8cbc3c56..6d220a9140 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 e21dd5a441..0989ac6041 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/_test_filter.py b/tests/_test_filter.py index faa0b94dc1..18b428c8ce 100644 --- a/tests/_test_filter.py +++ b/tests/_test_filter.py @@ -279,10 +279,15 @@ 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_recipe_binding": frozenset({"core", "recipe", "server"}), + "_type_recipe_execution": frozenset({"core", "pipeline", "recipe", "server"}), "_type_closure_report": frozenset({"core"}), "context_admission": frozenset({"core"}), - "closure_hashing": frozenset({"core"}), - "path_containment": frozenset({"core"}), + "audit_cycle_verifier": frozenset({"core", "recipe", "server"}), + "tool_registry": frozenset({"core", "recipe", "server"}), + "closure_hashing": frozenset({"core", "recipe"}), + "path_containment": frozenset({"core", "recipe"}), "closure_verifier": frozenset({"core", "execution"}), } @@ -486,6 +491,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"}), @@ -790,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", @@ -812,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/arch/test_pyright_suppression_allowlist.py b/tests/arch/test_pyright_suppression_allowlist.py index a934ddfb9b..a05e92ca6c 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", 291): "lazy-registry: RULE_REGISTRY_HASH set by _finalize_registry()", } TEST_ALLOWLIST: dict[tuple[str, int], str] = { diff --git a/tests/arch/test_run_python_path_resolution.py b/tests/arch/test_run_python_path_resolution.py index c6a697ee51..7f2947dd6a 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/arch/test_subpackage_isolation.py b/tests/arch/test_subpackage_isolation.py index 3bd4ea3944..b36b86ae09 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,8 +1014,15 @@ 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 before " + "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; " @@ -1056,10 +1064,12 @@ 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": ( - 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 +1099,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, @@ -1353,6 +1366,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 +1392,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/arch/test_subpackage_structure.py b/tests/arch/test_subpackage_structure.py index ba91a60efc..b414b9afed 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 7ddcbc492b..0164fd1572 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 9bfe7708f1..0000000000 --- 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 fbdbcb07f6..a9b59a3e42 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 beea69d4fe..972da49102 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_prepare_compose_pr_contracts.py b/tests/contracts/test_prepare_compose_pr_contracts.py index 6de9cb70f1..fb21364c97 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 341055439e..6a99a09b45 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,12 @@ 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 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 cmd, ( - f"{recipe_name}.yaml step '{step_name}' calls {skill_name} " - f"without valid_lines_path= in skill_command" + 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" ) diff --git a/tests/contracts/test_skill_contracts.py b/tests/contracts/test_skill_contracts.py index 6caa40425b..bba6e5316b 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/core/AGENTS.md b/tests/core/AGENTS.md index 0e7c6f33a0..e49f40d7dd 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 0000000000..5e89aee9dc --- /dev/null +++ b/tests/core/test_audit_cycle_attacks.py @@ -0,0 +1,254 @@ +"""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, + 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", + 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, + 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) + 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 + + +@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) + 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, + audited_plan_refs=current.audited_plan_refs, + inventory_ref=current.inventory_ref, + 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 0000000000..1c0c54732c --- /dev/null +++ b/tests/core/test_audit_cycle_authority.py @@ -0,0 +1,365 @@ +"""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") + + +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", ["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"), + [ + ("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") + 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()]) + + 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"): + 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, + 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", + ) + + +def test_schema_version_is_public() -> None: + assert AUDIT_CYCLE_SCHEMA_VERSION == 1 diff --git a/tests/core/test_context_admission_state_machine.py b/tests/core/test_context_admission_state_machine.py index 6c247601c9..897b14dec7 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( ( diff --git a/tests/core/test_inventory_admission.py b/tests/core/test_inventory_admission.py new file mode 100644 index 0000000000..15d4a73378 --- /dev/null +++ b/tests/core/test_inventory_admission.py @@ -0,0 +1,527 @@ +"""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, + AuditCycleVerificationError, + AuditCycleVerifier, + AuditVerdict, + InventoryAdmissionDecision, + InventoryAdmissionEvaluator, + PlanDispositionReport, + PlanDispositionRow, +) +from autoskillit.core.closure_hashing import canonical_json_bytes, 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, + audited_plan_refs=authority.audited_plan_refs, + inventory_ref=authority.inventory_ref, + 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, + 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: + 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=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=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( + 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 + + +@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_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) + 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), + ({"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}, + 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_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())) + 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_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, + 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, + audited_plan_refs=authority.audited_plan_refs, + inventory_ref=authority.inventory_ref, + verdict=authority.verdict, + ) + decision = _evaluate(authority, report, head=stale_head) + assert decision.reason is AdmissionReason.AUTHORITY_NOT_CURRENT diff --git a/tests/core/test_io.py b/tests/core/test_io.py index 735671552d..9a6a41b317 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/core/test_path_containment.py b/tests/core/test_path_containment.py index 4cb37a404a..926fd306fa 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,84 @@ 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: + 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, + 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 diff --git a/tests/execution/test_headless_enum_recovery.py b/tests/execution/test_headless_enum_recovery.py index 25c6a6b0e2..2d79a42877 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_headless_path_validation.py b/tests/execution/test_headless_path_validation.py index 5daa627c12..b0bd96ac81 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/execution/test_outcome_invariants.py b/tests/execution/test_outcome_invariants.py index 64b045ac57..fe868d032d 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/infra/test_pretty_output_recipe.py b/tests/infra/test_pretty_output_recipe.py index f92b4209a6..7d056bc866 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_782, "remediation", "all_truthy"), + "open_kitchen": (168_835, "remediation", "all_truthy"), } diff --git a/tests/infra/test_schema_version_convention.py b/tests/infra/test_schema_version_convention.py index 2bb3293cf0..900c88b35c 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", 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), diff --git a/tests/recipe/AGENTS.md b/tests/recipe/AGENTS.md index d499f0a32e..d52ad31e99 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 | @@ -69,6 +70,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 | @@ -240,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_analysis_public_api.py b/tests/recipe/test_analysis_public_api.py index 72535f7638..aa9cf241aa 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_api.py b/tests/recipe/test_api.py index 66edc83063..925fe6bd0e 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 @@ -400,6 +427,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 +548,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 +568,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 0000000000..f97ddefff0 --- /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 f1bddf678c..cb89880c6d 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_dispatch_ready.py b/tests/recipe/test_bundled_recipes_dispatch_ready.py index 4f7b55039e..b99699c7a4 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,44 @@ 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,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, diff --git a/tests/recipe/test_bundled_recipes_pipeline_structure.py b/tests/recipe/test_bundled_recipes_pipeline_structure.py index 218d419d0e..6b599b7228 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 a8b3b03c7a..5fa7604fad 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 3ed1f96b2e..372ccc6918 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 141410ce59..2beafc6814 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 05eb11a8dc..33da2eeef8 100644 --- a/tests/recipe/test_cmd_rpc_verify_plan_artifacts.py +++ b/tests/recipe/test_cmd_rpc_verify_plan_artifacts.py @@ -3,8 +3,26 @@ 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, + 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 +34,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 +193,79 @@ 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"} + + +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 diff --git a/tests/recipe/test_contracts.py b/tests/recipe/test_contracts.py index 417ce66f2f..3a20fe97d7 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 @@ -595,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: @@ -748,14 +763,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 +813,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 +877,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_contracts_manifest.py b/tests/recipe/test_contracts_manifest.py index 5059cf3502..9241d14c20 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) diff --git a/tests/recipe/test_contracts_types.py b/tests/recipe/test_contracts_types.py index 0fec48ee85..e200e6e6b9 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,31 @@ import pytest -from autoskillit.recipe._contracts_types import SkillOutput +from autoskillit.recipe._contracts_types import SkillContract, 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_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( 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 63117fdd89..0000000000 --- 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_implementation.py b/tests/recipe/test_implementation.py index 74f2f4c2b5..02ab7702da 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_io_json_precompile.py b/tests/recipe/test_io_json_precompile.py index 3be57bc046..acd7d61773 100644 --- a/tests/recipe/test_io_json_precompile.py +++ b/tests/recipe/test_io_json_precompile.py @@ -11,9 +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, builtin_recipes_dir, ) @@ -50,11 +50,11 @@ 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, ) - 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" @@ -66,11 +66,11 @@ 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, ) - 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" @@ -88,11 +88,11 @@ 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, ) - 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" @@ -116,9 +116,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,11 +136,11 @@ 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, ) - 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" @@ -153,11 +157,11 @@ 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, ) - 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/recipe/test_issue_url_pipeline.py b/tests/recipe/test_issue_url_pipeline.py index 982233435a..aa935de970 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 87ee0000ea..9b694ffdf0 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_optional_capture_guard_rule.py b/tests/recipe/test_optional_capture_guard_rule.py index a29b570103..be702ebf0c 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_planner_recipe.py b/tests/recipe/test_planner_recipe.py index 84735daed5..203a054138 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_recipe_scripts.py b/tests/recipe/test_recipe_scripts.py index 82597c0882..f499728baf 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"], diff --git a/tests/recipe/test_recipe_temp_substitution.py b/tests/recipe/test_recipe_temp_substitution.py index c158b5db31..eb093956f1 100644 --- a/tests/recipe/test_recipe_temp_substitution.py +++ b/tests/recipe/test_recipe_temp_substitution.py @@ -58,6 +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"' + 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: @@ -155,19 +158,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( @@ -194,36 +197,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, ) diff --git a/tests/recipe/test_remediation_depth_ingredient.py b/tests/recipe/test_remediation_depth_ingredient.py index 24dc5ccf1c..42f8f59f23 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 e4bab32e52..78f417460c 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 dcd15aca5d..61e3450336 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 9101982957..ba55c384d9 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 ef98e2c595..5d0f47ac71 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 22b9061c71..867d8687a4 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 c3f990d078..32c8bb2ebf 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/recipe/test_review_loop_artifact_isolation.py b/tests/recipe/test_review_loop_artifact_isolation.py index 0e584c7796..1096189cb2 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_contracts.py b/tests/recipe/test_rules_contracts.py index f63c09650d..fc091bc5b4 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_dataflow_handoff.py b/tests/recipe/test_rules_dataflow_handoff.py index 0d72bfe6b0..a86dec0665 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_features.py b/tests/recipe/test_rules_features.py index aeeaf0a040..b04dd17509 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"})}, ) diff --git a/tests/recipe/test_rules_inventory_gate_bilateral.py b/tests/recipe/test_rules_inventory_gate_bilateral.py index 9f03741fa5..1f66794734 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,176 @@ 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 {} + 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}, - capture={"remediation_path": _cap("${{ result.remediation_path }}")}, + 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, **dw_with}, + 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 _audit_impl_without_remediation_capture_steps() -> dict[str, RecipeStep]: - """audit_impl without remediation_path capture, remediation loop routes to verify.""" - return { - "audit_impl": RecipeStep( - tool="run_skill", - with_args={"skill_command": _AUDIT_IMPL_CMD}, - 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}, + 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 _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}, +def _findings(steps: dict[str, RecipeStep]) -> list: + return [ + finding + for finding in run_semantic_rules(_make_recipe(steps)) + if finding.rule == _RULE_NAME + ] + + +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"), } - -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] + assert _findings(steps) == [] -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 +201,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 25c3d7f679..7670f3bbe4 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 75ba004f72..ea35a93f31 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: @@ -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) ) @@ -548,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 [], diff --git a/tests/recipe/test_rules_work_dir_misplacement.py b/tests/recipe/test_rules_work_dir_misplacement.py index e44c1680bf..df9c0158ec 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 diff --git a/tests/recipe/test_skill_invocation_binding.py b/tests/recipe/test_skill_invocation_binding.py new file mode 100644 index 0000000000..1d8d980c2c --- /dev/null +++ b/tests/recipe/test_skill_invocation_binding.py @@ -0,0 +1,595 @@ +"""Canonical recipe invocation compiler coverage.""" + +from __future__ import annotations + +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 ( + ABSENT_BOUND_VALUE, + BindingFailureCode, + BindingMode, + BoundStepInvocation, + BoundValue, + 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 | 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", + } + + +@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, + ) + + +@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", + 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 + 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_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": { + "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", + _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 | 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_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 = 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()) + + 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'" + 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} + + +@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", + ), + ( + "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( + 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 + ) + + +@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"] + 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" + assert ambiguous_step.declared_with_args is not None + 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 plan.template_dependencies == ("AUTOSKILLIT_TEMP",) + 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 4e18f8e7dd..d53aafa0f3 100644 --- a/tests/server/AGENTS.md +++ b/tests/server/AGENTS.md @@ -137,6 +137,8 @@ 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_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 0000000000..af15189b37 --- /dev/null +++ b/tests/server/test_audit_cycle_delivery_integration.py @@ -0,0 +1,2061 @@ +"""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 +from unittest.mock import MagicMock + +import pytest +from hypothesis import settings +from hypothesis import strategies as st +from hypothesis.stateful import ( + RuleBasedStateMachine, + initialize, + invariant, + precondition, + 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 ( + AdmissionReason, + AdmissionStatus, + ArtifactRef, + AuditAssessment, + AuditAssessmentRow, + AuditCycleAuthority, + AuditCycleHead, + AuditCycleVerificationError, + AuditCycleVerifier, + AuditVerdict, + BindingMode, + BoundStepInvocation, + BoundValue, + BoundValueOrigin, + BoundValueState, + InputPreflightResolver, + InventoryAdmissionDecision, + InventoryAdmissionEvaluator, + InvocationTemplate, + PlanDispositionReport, + PlanDispositionRow, + PreflightEvidence, + RecipeBindingProjection, + RecipeExecutionSnapshot, + RetryReason, + SkillResult, + VerifiedInputPreflightRequest, + 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, + finalize_recipe_delivery, + load_recipe_artifact, +) +from autoskillit.server._recipe_execution import ( + AuditCycleHeadConflict, + DefaultAuditCycleHeadStore, + DefaultInputPreflightResolver, + RecipeExecutionAdmissionError, + bind_attested_runtime_invocation, + build_bound_child_prompt, + build_recipe_execution_snapshot, + get_recipe_execution, + install_recipe_execution, + publish_audit_cycle_result, + publish_reported_audit_cycle, + publish_verified_audit_cycle, + record_runtime_binding_digest, +) +from autoskillit.server.tools.tools_execution import run_skill +from tests.conftest import _make_result + +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", "/tmp/audit-cycle.json"), + _present("plan_disposition_path", "/tmp/plan-disposition.json"), + ), + ) + 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 _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), + media_type="application/json", + schema_version=1, + byte_size=byte_size, + content_digest=digest, + ) + + +def _authority( + tmp_path: Path, + *, + generation: str, + round_: int, + parent: str | None, + verdict: AuditVerdict, + plan_set_id: str = "plans-1", + 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", + assessment=AuditAssessment.COVERED, + evidence_summary="covered", + ) + return AuditCycleAuthority.create( + execution_generation=generation, + cycle_id="cycle-1", + plan_set_id=plan_set_id, + scope_id="scope-1", + part_id=part_id, + audit_round=round_, + parent_authority_digest=parent, + audited_plan_refs=(plan_ref,), + inventory_ref=inventory_ref, + assessments=(assessment,), + verdict=verdict, + remediation_ref=(remediation_ref if verdict is AuditVerdict.NO_GO else None), + generated_at="2026-07-23T00:00:00Z", + ) + + +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: + _wire_recipe_execution_factory(minimal_ctx) + 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_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", + "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, + "prepare_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_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", + "content_hash": _HASH_A, + "composite_hash": _HASH_B, + "valid": True, + }, + surface="load_recipe", + recipe_name="demo", + tool_ctx=tool_ctx_kitchen_open, + compiled_bindings=_projection(), + ) + assert complete_finalized_recipe_response(finalized, "bounded replacement") == ( + "bounded replacement" + ) + 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( + 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, + ) + assert get_recipe_execution(minimal_ctx) is previous + + +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": "/tmp/audit-cycle.json", + "plan_disposition_path": "/tmp/plan-disposition.json", + } + 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", + "step_name": "dry", + "recipe_execution_id": "execution-1", + "invocation_template_digest": template.template_digest, + }, + ) + 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": "/tmp/audit-cycle.json"}, + {"name": "plan_disposition_path", "value": "/tmp/plan-disposition.json"}, + ] + + +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( + binding_module, + "compute_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_skill_contract_identity_fails_closed_for_stale_contract_shape( + monkeypatch: pytest.MonkeyPatch, +) -> None: + stale_contract = SimpleNamespace( + audit_authority_publication=None, + completion_required=True, + inputs=(), + ) + monkeypatch.setattr( + binding_module, + "get_skill_contract", + lambda *_args, **_kwargs: stale_contract, + ) + + with pytest.raises(AttributeError, match="input_preflight"): + binding_module.compute_skill_contract_identity("stale", manifest={}) + + +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_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_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", + 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_bound_inputs_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( + 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: + 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, + materialize=True, + ) + 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( + 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, + ) + + +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: + 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, + 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) + authority = _authority( + Path(tool_ctx_kitchen_open.temp_dir), + generation="execution-1", + 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) + authority_path.write_bytes(authority.canonical_bytes) + result = SkillResult( + success=True, + result=f"authority_file = {authority_path}", + session_id="audit-session", + subtype="success", + is_error=False, + exit_code=0, + needs_retry=False, + retry_reason=RetryReason.NONE, + stderr="", + 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"}], + }, + "dry-walkthrough": { + "input_preflight": "audit_cycle_inventory", + "inputs": [], + "outputs": [], + }, + } + }, + ) + + publish_audit_cycle_result( + tool_ctx_kitchen_open, + "renamed-auditor", + result, + installed, + (), + ) + + 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, + ) + + +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"), + [ + ("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) + + +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( + "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 " + "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")), +) +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( + binding_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( + 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_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.""" + + 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, + ) + ) + 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, + audited_plan_refs=authority.audited_plan_refs, + inventory_ref=authority.inventory_ref, + 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=successor, + ) + 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) + + @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, + 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 + + +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 + + +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 + + +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, + 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_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, + 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 + 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) + + 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( + 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) + 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 + 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 +async def test_preflight_rejects_before_executor( + tool_ctx_kitchen_open, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> 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, + "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(), + preflight_identities={"dry": ("plans-1", "scope-1", "part-a")}, + ), + ) + 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_explicit_backend_override.py b/tests/server/test_explicit_backend_override.py index a0062239e6..cf9342b972 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 19fd55d03b..9441eb7eb1 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 2602806b38..ab9c921525 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_tool_registry_parity.py b/tests/server/test_tool_registry_parity.py new file mode 100644 index 0000000000..eace05dc65 --- /dev/null +++ b/tests/server/test_tool_registry_parity.py @@ -0,0 +1,143 @@ +"""Canonical IL-0 tool registry parity with live MCP handlers.""" + +from __future__ import annotations + +import ast +from dataclasses import replace +from pathlib import Path + +import pytest + +from autoskillit.core import ( + TOOL_REGISTRY, + ToolParamDef, + ToolWireType, + compute_tool_contract_identity, +) + +pytestmark = [pytest.mark.layer("server"), pytest.mark.small] + + +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)) + 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), + ] + 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 + if argument.arg != "ctx" + ) + 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()) + + +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_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}, + "load_recipe": {"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 + ) + assert tuple(param.name for param in structured) == ("skill_inputs",) + 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( + run_skill, + params=(*run_skill.params, ToolParamDef("future_parameter")), + ) + + assert compute_tool_contract_identity(changed) != compute_tool_contract_identity(run_skill) diff --git a/tests/server/test_tools_kitchen_gate_features.py b/tests/server/test_tools_kitchen_gate_features.py index 15b86d31c3..45d6c6489f 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, @@ -437,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 3952d7ce15..d83a0aa3d8 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 @@ -208,7 +219,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_recipe_pull.py b/tests/server/test_tools_recipe_pull.py index 81c4e76c29..cdafd65ecf 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: 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 3d10895683..0000000000 --- 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() diff --git a/tests/skills/test_planner_skill_contracts.py b/tests/skills/test_planner_skill_contracts.py index 98108d379b..1b25efe3be 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 065cba29bf..d2081695a4 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", diff --git a/tests/test_test_filter_core_cascade.py b/tests/test_test_filter_core_cascade.py index fb913d873a..fed7fb8dcb 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.""" @@ -120,7 +135,12 @@ def test_all_entries_present(self) -> None: "_execution_marker", "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", @@ -129,6 +149,16 @@ 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_recipe_execution_cascade(self) -> None: + assert MODULE_CASCADE_CORE["_type_recipe_execution"] == frozenset( + {"core", "pipeline", "recipe", "server"} + ) + def test_type_resume_cascade(self) -> None: assert MODULE_CASCADE_CORE["_type_resume"] == frozenset( {"core", "cli", "execution", "fleet"}