diff --git a/docker/runners/runner-image.lock.json b/docker/runners/runner-image.lock.json index 4620797e11..c02ddc9978 100644 --- a/docker/runners/runner-image.lock.json +++ b/docker/runners/runner-image.lock.json @@ -1,12 +1,12 @@ { "base_image_digest": "sha256:3ba65aa20f86a0fad9df2b2c259c613df006b2e6d0bfcc8a146afb8c525a9751", "gh_version": "2.67.0", - "identity_digest": "9bce87358ac31006180ffdc8eaa1d370", + "identity_digest": "fdc6bd801d129fb2dfaf5a262fe44bbd", "image_version": 5, "kubectl_version": "1.32.1", "python_version": "3.12", "runner_version": "2.334.0", - "shared_env_digest": "0f0276f1dab9c86d39fef288", + "shared_env_digest": "8f706195c95ee9613b413457", "shared_env_install_args": "--frozen --all-extras --all-groups --no-install-project", "uv_version": "0.6.14" } diff --git a/pyproject.toml b/pyproject.toml index 294ad45579..c15b9abb2c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -125,6 +125,7 @@ verify-container-manifest = "omnibase_infra.scripts.verify_container_manifest:ma kafka = "omnibase_infra.cli.cli_kafka:kafka" node = "omnibase_infra.cli.cli_node:run_node_by_name" run = "omnibase_infra.cli.cli_node:run_node_by_name" +skill = "omnibase_infra.cli.cli_skill:run_skill_by_name" [project.entry-points."onex.backends"] event_bus_kafka = "omnibase_infra.event_bus.event_bus_kafka:EventBusKafka" diff --git a/src/omnibase_infra/cli/cli_skill.py b/src/omnibase_infra/cli/cli_skill.py new file mode 100644 index 0000000000..a7f8b629fd --- /dev/null +++ b/src/omnibase_infra/cli/cli_skill.py @@ -0,0 +1,279 @@ +# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT + +"""``onex skill [args]`` — single-command dispatch surface (OMN-13097). + +Phase 4a of the skill-output-suppression slice +(``docs/plans/2026-06-12-skill-output-suppression-plan.md`` Phase 4 item 1). + +A dispatch skill IS one CLI call (user directive 2026-06-12). This command +replaces the 24 hand-written dispatch shims: it resolves the skill via the +declarative ``skill_mapping.yaml`` registry, builds the backing node's input +payload from the skill's CLI arguments, and dispatches through the proven +receipt-mode path (``run_receipt_mode``, OMN-13094). stdout carries exactly +one typed :class:`~omnibase_core.models.dispatch.model_skill_result.ModelSkillResult` +JSON with the FULL handler result; runtime logs go to the capture file. + +The skill→node mapping is DECLARATIVE DATA in ``skill_mapping.yaml`` — never +hardcoded Python branching here (ticket deliverable 2). Adding a skill is a +YAML edit plus a fixture, not a code change. + +.. versionadded:: OMN-13097 +""" + +from __future__ import annotations + +import json +import logging +import uuid +from functools import lru_cache +from pathlib import Path + +import click +import yaml +from pydantic import JsonValue, ValidationError + +from omnibase_infra.cli.cli_node import _resolve_packaged_contract +from omnibase_infra.cli.enum_skill_arg_type import EnumSkillArgType +from omnibase_infra.cli.model_skill_arg_spec import ModelSkillArgSpec +from omnibase_infra.cli.model_skill_mapping import ModelSkillMapping +from omnibase_infra.cli.model_skill_mapping_registry import ModelSkillMappingRegistry +from omnibase_infra.cli.receipt_mode import ( + default_emit_socket_path, + run_receipt_mode, +) + +__all__ = ["MAPPING_FILENAME", "load_skill_registry", "run_skill_by_name"] + +logger = logging.getLogger(__name__) + +# Declarative skill→node mapping ships beside this module. +MAPPING_FILENAME = "skill_mapping.yaml" + +# Internal scratch for the constructed input payload lives under the state +# root, NEVER /tmp (feedback_no_tmp_use_workspace.md). run_id-suffixed names +# avoid collisions between parallel dispatches. +_TMP_DIR_NAME = "tmp" + + +@lru_cache(maxsize=1) +def load_skill_registry() -> ModelSkillMappingRegistry: + """Load and validate the declarative skill→node mapping registry. + + Raises: + click.ClickException: when the mapping file is missing or invalid. + """ + mapping_path = Path(__file__).parent / MAPPING_FILENAME + if not mapping_path.is_file(): + raise click.ClickException( + f"Skill mapping registry not found at {mapping_path}. " + "This file is required for 'onex skill'." + ) + raw = yaml.safe_load(mapping_path.read_text(encoding="utf-8")) + try: + return ModelSkillMappingRegistry.model_validate(raw) + except ValidationError as exc: + raise click.ClickException( + f"Invalid skill mapping registry at {mapping_path}: {exc}" + ) from exc + + +def _coerce_value(spec: ModelSkillArgSpec, raw: str) -> JsonValue: + """Coerce a raw CLI string to the spec's declared type. Fail fast.""" + if spec.arg_type is EnumSkillArgType.STRING: + return raw + if spec.arg_type is EnumSkillArgType.INTEGER: + try: + return int(raw) + except ValueError as exc: + raise click.ClickException( + f"--{spec.name} expects an integer, got {raw!r}" + ) from exc + if spec.arg_type is EnumSkillArgType.STRING_LIST: + return [item.strip() for item in raw.split(",") if item.strip()] + # BOOLEAN is a presence flag and never reaches value coercion. + raise click.ClickException( + f"--{spec.name}: value coercion not supported for {spec.arg_type}" + ) + + +def _parse_skill_args( + mapping: ModelSkillMapping, extra: tuple[str, ...] +) -> dict[str, JsonValue]: + """Parse trailing ``onex skill`` tokens into a node-input payload. + + Boolean specs are presence flags (``--dry-run``); typed specs take the + next token (``--repos a,b``). A single positional spec collects all + non-flag trailing tokens, joined with spaces. + + Raises: + click.ClickException: on unknown flags, missing values, missing + required args, or coercion failure. + """ + by_flag = {spec.name: spec for spec in mapping.args if not spec.positional} + positional_spec = next((s for s in mapping.args if s.positional), None) + + payload: dict[str, JsonValue] = dict(mapping.static_payload) + positional_tokens: list[str] = [] + seen: set[str] = set() + + tokens = list(extra) + i = 0 + while i < len(tokens): + token = tokens[i] + if token.startswith("--"): + flag = token[2:] + spec = by_flag.get(flag) + if spec is None: + raise click.ClickException( + f"Unknown argument '--{flag}' for skill '{mapping.skill_name}'." + ) + if spec.arg_type is EnumSkillArgType.BOOLEAN: + payload[spec.payload_field] = True + seen.add(spec.name) + i += 1 + continue + if i + 1 >= len(tokens): + raise click.ClickException(f"--{flag} requires a value.") + payload[spec.payload_field] = _coerce_value(spec, tokens[i + 1]) + seen.add(spec.name) + i += 2 + continue + positional_tokens.append(token) + i += 1 + + if positional_tokens: + if positional_spec is None: + raise click.ClickException( + f"Skill '{mapping.skill_name}' takes no positional arguments; " + f"got {positional_tokens!r}." + ) + payload[positional_spec.payload_field] = " ".join(positional_tokens) + seen.add(positional_spec.name) + + # Apply defaults for unset, non-required args; enforce required args. + for spec in mapping.args: + if spec.name in seen: + continue + if spec.required: + label = "positional argument" if spec.positional else f"--{spec.name}" + raise click.ClickException( + f"Skill '{mapping.skill_name}' requires {label}." + ) + if spec.arg_type is EnumSkillArgType.BOOLEAN: + payload.setdefault(spec.payload_field, bool(spec.default)) + elif spec.default is not None: + payload[spec.payload_field] = spec.default + + return payload + + +def _apply_classifiers( + mapping: ModelSkillMapping, payload: dict[str, JsonValue] +) -> None: + """Assign classifier target fields that remain unset after arg parsing.""" + for classifier in mapping.classifiers: + if payload.get(classifier.target_field) is not None: + continue + source = payload.get(classifier.source_field) + source_text = str(source).lower() if source is not None else "" + assigned = classifier.fallback + for keywords, value in classifier.rules: + if any(keyword.lower() in source_text for keyword in keywords): + assigned = value + break + payload[classifier.target_field] = assigned + + +def _write_payload( + state_root: Path, skill_name: str, payload: dict[str, JsonValue] +) -> Path: + """Write the input payload to a run_id-suffixed file under the state root.""" + tmp_dir = state_root / _TMP_DIR_NAME + tmp_dir.mkdir(parents=True, exist_ok=True) + payload_path = tmp_dir / f"{skill_name}-{uuid.uuid4()}.json" + payload_path.write_text(json.dumps(payload), encoding="utf-8") + return payload_path + + +@click.command( + "skill", + context_settings={"ignore_unknown_options": True, "allow_extra_args": True}, +) +@click.argument("skill_name") +@click.option( + "--state-root", + type=click.Path(path_type=Path), + default=".onex_state", + show_default=True, + help="Root directory for disk state and capture files.", +) +@click.option( + "--timeout", + type=int, + default=None, + help="Override the skill's default dispatch timeout (seconds).", +) +@click.option( + "--verbose", + "-v", + is_flag=True, + default=False, + help="Capture DEBUG-level runtime logging (still never on stdout).", +) +@click.option( + "--emit-socket", + "emit_socket", + type=click.Path(path_type=Path), + default=None, + help="Unix socket of the emit daemon (default: ~/.claude/emit.sock).", +) +@click.argument("skill_args", nargs=-1, type=click.UNPROCESSED) +def run_skill_by_name( + skill_name: str, + state_root: Path, + timeout: int | None, + verbose: bool, + emit_socket: Path | None, + skill_args: tuple[str, ...], +) -> None: + """Dispatch a skill to its backing node and print one typed result. + + \b + A dispatch skill is exactly one CLI call: this resolves the declarative + skill→node mapping, builds the node-input payload from SKILL_ARGS, and + runs the dispatch in receipt mode — stdout is exactly one + ModelSkillResult JSON carrying the FULL handler result; runtime logs go + to the capture file under the state root. + + \b + Examples: + onex skill compliance_sweep --repos omnibase_core,omnibase_infra + onex skill dod_verify OMN-1234 + onex skill delegate "summarize this paragraph" --task-type document + """ + registry = load_skill_registry() + mapping = registry.get(skill_name) + if mapping is None: + known = ", ".join(sorted(s.skill_name for s in registry.skills)) or "(none)" + raise click.ClickException( + f"Unknown skill '{skill_name}'. Known skills: {known}" + ) + + payload = _parse_skill_args(mapping, skill_args) + _apply_classifiers(mapping, payload) + + contract_path = _resolve_packaged_contract(mapping.node_name) + payload_path = _write_payload(state_root, skill_name, payload) + + exit_code = run_receipt_mode( + node_name=mapping.node_name, + contract_path=contract_path, + input_path=payload_path, + state_root=state_root, + backend_overrides={"event_bus": mapping.event_bus}, + timeout=timeout if timeout is not None else mapping.timeout, + verbose=verbose, + emit_socket=emit_socket or default_emit_socket_path(), + ) + raise SystemExit(exit_code) diff --git a/src/omnibase_infra/cli/enum_skill_arg_type.py b/src/omnibase_infra/cli/enum_skill_arg_type.py new file mode 100644 index 0000000000..3be5c4e278 --- /dev/null +++ b/src/omnibase_infra/cli/enum_skill_arg_type.py @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT + +"""Argument value types for the declarative ``onex skill`` mapping (OMN-13097). + +Each ``onex skill`` argument declares the type its raw CLI string is coerced +to before it lands in the node-input payload. Keeping this an explicit enum +(never a free-form string) means the mapping loader fails fast on an unknown +type rather than silently passing a string through. +""" + +from __future__ import annotations + +from enum import StrEnum + +__all__ = ["EnumSkillArgType"] + + +class EnumSkillArgType(StrEnum): + """Coercion target for a declarative ``onex skill`` argument.""" + + STRING = "string" + INTEGER = "integer" + BOOLEAN = "boolean" + STRING_LIST = "string_list" diff --git a/src/omnibase_infra/cli/model_skill_arg_spec.py b/src/omnibase_infra/cli/model_skill_arg_spec.py new file mode 100644 index 0000000000..03ed897d6a --- /dev/null +++ b/src/omnibase_infra/cli/model_skill_arg_spec.py @@ -0,0 +1,69 @@ +# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT + +"""Declarative ``onex skill`` CLI-argument spec (OMN-13097). + +One arg of an ``onex skill`` invocation, mapping a ``--flag`` (or positional) +onto a field of the backing node's contract input model. +""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field, JsonValue, model_validator + +from omnibase_infra.cli.enum_skill_arg_type import EnumSkillArgType + +__all__ = ["ModelSkillArgSpec"] + + +class ModelSkillArgSpec(BaseModel): + """One CLI argument of an ``onex skill`` invocation. + + ``boolean`` args are presence flags (``--dry-run``); all others take a + value (``--repos a,b,c``). + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + name: str = Field( + ..., + min_length=1, + description="CLI flag name without leading dashes (e.g. 'repos', 'dry-run').", + ) + payload_field: str = Field( + ..., + min_length=1, + description="Field name in the backing node's input-model payload.", + ) + arg_type: EnumSkillArgType = Field( + ..., + description="Coercion target for the raw CLI string value.", + ) + required: bool = Field( + default=False, + description="Whether the argument must be supplied (no default applies).", + ) + default: JsonValue = Field( + default=None, + description=( + "Default payload value when the arg is omitted. Ignored when " + "'required' is True. None means the field is omitted entirely " + "(the node-input model supplies its own default)." + ), + ) + positional: bool = Field( + default=False, + description=( + "When True the arg is read from trailing positional tokens " + "(joined with spaces) rather than a --flag. At most one " + "positional arg per skill." + ), + ) + + @model_validator(mode="after") + def _validate_required_has_no_default(self) -> ModelSkillArgSpec: + if self.required and self.default is not None: + raise ValueError( + f"arg '{self.name}': required args must not declare a default" + ) + return self diff --git a/src/omnibase_infra/cli/model_skill_classifier.py b/src/omnibase_infra/cli/model_skill_classifier.py new file mode 100644 index 0000000000..789e2a7d32 --- /dev/null +++ b/src/omnibase_infra/cli/model_skill_classifier.py @@ -0,0 +1,45 @@ +# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT + +"""Declarative ``onex skill`` keyword classifier (OMN-13097). + +Models the delegate skill's ``task_type`` auto-classification as DATA: if the +classified target field is still unset after arg parsing, the source field's +text is scanned (case-insensitive) for the first matching keyword group and +the corresponding value is assigned. +""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + +__all__ = ["ModelSkillClassifier"] + + +class ModelSkillClassifier(BaseModel): + """Keyword → payload-value classification for an unset argument.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + target_field: str = Field( + ..., + min_length=1, + description="Payload field this classifier assigns when unset.", + ) + source_field: str = Field( + ..., + min_length=1, + description="Payload field whose text is scanned for keywords.", + ) + rules: tuple[tuple[tuple[str, ...], str], ...] = Field( + ..., + description=( + "Ordered ((keyword, ...), value) pairs. First group with any " + "keyword present in the source text wins." + ), + ) + fallback: str = Field( + ..., + min_length=1, + description="Value assigned when no keyword group matches.", + ) diff --git a/src/omnibase_infra/cli/model_skill_mapping.py b/src/omnibase_infra/cli/model_skill_mapping.py new file mode 100644 index 0000000000..1e332bfa2d --- /dev/null +++ b/src/omnibase_infra/cli/model_skill_mapping.py @@ -0,0 +1,92 @@ +# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT + +"""Declarative ``onex skill`` → backing-node mapping model (OMN-13097). + +Phase 4a of the skill-output-suppression slice +(``docs/plans/2026-06-12-skill-output-suppression-plan.md`` Phase 4 item 1). + +``onex skill [args]`` is the single-command dispatch surface that +replaces the 24 hand-written dispatch shims. The skill→node mapping is +DECLARATIVE DATA — it lives in ``skill_mapping.yaml`` beside this module and +is loaded into these frozen, typed models; it is NEVER hardcoded as Python +branching in the command (ticket deliverable 2). Each entry declares: + +- the backing ONEX node (resolved via the ``onex.nodes`` entry-point group, + exactly like ``onex node``), +- how the skill's CLI arguments map onto fields of the node's contract input + model (name, type, default, required), +- optional keyword classifiers (the delegate skill's ``task_type`` + auto-classification, expressed as data rather than inline logic), +- static payload fields the node requires regardless of args. + +.. versionadded:: OMN-13097 +""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field, JsonValue, model_validator + +from omnibase_infra.cli.model_skill_arg_spec import ModelSkillArgSpec +from omnibase_infra.cli.model_skill_classifier import ModelSkillClassifier + +__all__ = ["ModelSkillMapping"] + + +class ModelSkillMapping(BaseModel): + """One ``onex skill `` → backing node mapping.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + skill_name: str = Field( + ..., + min_length=1, + description="Skill name as invoked: 'onex skill '.", + ) + node_name: str = Field( + ..., + min_length=1, + description=( + "Backing node registered under the 'onex.nodes' entry-point group." + ), + ) + result_model: str = Field( + ..., + min_length=1, + description=( + "Fully qualified name of the backing node's typed handler result " + "model — schema identity surfaced in the receipt (ticket " + "deliverable 3)." + ), + ) + event_bus: str = Field( + default="inmemory", + description="event_bus backend override for the dispatch.", + ) + timeout: int = Field( + default=300, + gt=0, + description="Max dispatch execution time in seconds.", + ) + args: tuple[ModelSkillArgSpec, ...] = Field( + default=(), + description="Declarative CLI-arg → payload-field specs.", + ) + static_payload: dict[str, JsonValue] = Field( + default_factory=dict, + description="Payload fields injected regardless of supplied args.", + ) + classifiers: tuple[ModelSkillClassifier, ...] = Field( + default=(), + description="Keyword classifiers applied to still-unset fields.", + ) + + @model_validator(mode="after") + def _validate_single_positional(self) -> ModelSkillMapping: + positionals = [a for a in self.args if a.positional] + if len(positionals) > 1: + raise ValueError( + f"skill '{self.skill_name}': at most one positional arg allowed, " + f"got {len(positionals)}" + ) + return self diff --git a/src/omnibase_infra/cli/model_skill_mapping_registry.py b/src/omnibase_infra/cli/model_skill_mapping_registry.py new file mode 100644 index 0000000000..7d76745a64 --- /dev/null +++ b/src/omnibase_infra/cli/model_skill_mapping_registry.py @@ -0,0 +1,43 @@ +# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT + +"""Declarative ``onex skill`` mapping registry (OMN-13097). + +The full skill→node registry loaded from ``skill_mapping.yaml``. +""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from omnibase_infra.cli.model_skill_mapping import ModelSkillMapping + +__all__ = ["ModelSkillMappingRegistry"] + + +class ModelSkillMappingRegistry(BaseModel): + """The full declarative skill→node registry loaded from YAML.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + skills: tuple[ModelSkillMapping, ...] = Field( + ..., + description="All declared skill mappings.", + ) + + @model_validator(mode="after") + def _validate_unique_names(self) -> ModelSkillMappingRegistry: + names = [s.skill_name for s in self.skills] + duplicates = sorted({n for n in names if names.count(n) > 1}) + if duplicates: + raise ValueError( + f"duplicate skill_name(s) in mapping registry: {', '.join(duplicates)}" + ) + return self + + def get(self, skill_name: str) -> ModelSkillMapping | None: + """Return the mapping for ``skill_name`` or None when absent.""" + for mapping in self.skills: + if mapping.skill_name == skill_name: + return mapping + return None diff --git a/src/omnibase_infra/cli/skill_mapping.yaml b/src/omnibase_infra/cli/skill_mapping.yaml new file mode 100644 index 0000000000..a1b9abeab8 --- /dev/null +++ b/src/omnibase_infra/cli/skill_mapping.yaml @@ -0,0 +1,255 @@ +# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT +# +# Declarative `onex skill ` -> backing-node mapping (OMN-13097). +# +# Phase 4a of the skill-output-suppression slice +# (docs/plans/2026-06-12-skill-output-suppression-plan.md Phase 4 item 1). +# +# This is DATA, not code: `onex skill` loads it into ModelSkillMappingRegistry +# and uses it to build each backing node's input payload from the skill's CLI +# args, then dispatches through the proven receipt-mode path. Adding a skill is +# a YAML edit + a fixture, never a CLI code change (ticket deliverable 2). +# +# Per entry: +# skill_name – name as invoked: `onex skill ` +# node_name – backing node (onex.nodes entry-point group) +# result_model – FQN of the node's typed handler result (receipt schema +# identity; ticket deliverable 3) — verified against the +# live handler `handle()` return type on origin/dev. +# event_bus – backend override (default inmemory) +# timeout – dispatch timeout seconds (default 300) +# args – CLI arg -> payload field specs +# static_payload – payload fields injected regardless of args +# classifiers – keyword classification for unset fields (delegate) +# +# arg_type: string | integer | boolean | string_list +# boolean args are presence flags (--dry-run); string_list splits on commas. +skills: + - skill_name: aislop_sweep + node_name: node_aislop_sweep + result_model: omnimarket.nodes.node_aislop_sweep.handlers.handler_aislop_sweep.AislopSweepResult + args: + - {name: target-dirs, payload_field: target_dirs, arg_type: string_list} + - {name: checks, payload_field: checks, arg_type: string_list} + - {name: severity-threshold, payload_field: severity_threshold, arg_type: string} + - {name: dry-run, payload_field: dry_run, arg_type: boolean, default: false} + - skill_name: data_flow_sweep + node_name: node_data_flow_sweep + result_model: omnimarket.nodes.node_data_flow_sweep.handlers.handler_data_flow_sweep.DataFlowSweepResult + args: + - {name: flows, payload_field: flows, arg_type: string_list} + - {name: collect, payload_field: collect, arg_type: boolean, default: false} + - {name: dry-run, payload_field: dry_run, arg_type: boolean, default: false} + - skill_name: auto_merge + node_name: node_auto_merge_effect + result_model: omnimarket.nodes.node_auto_merge_effect.models.model_auto_merge_result.ModelAutoMergeResult + args: + - {name: pr-number, payload_field: pr_number, arg_type: integer, required: true} + - {name: repo, payload_field: repo, arg_type: string, required: true} + - {name: strategy, payload_field: strategy, arg_type: string} + - {name: delete-branch, payload_field: delete_branch, arg_type: boolean, default: false} + - {name: ticket-id, payload_field: ticket_id, arg_type: string} + - {name: gate-timeout-hours, payload_field: gate_timeout_hours, arg_type: integer} + - skill_name: build_loop + node_name: node_build_loop + result_model: omnimarket.nodes.node_build_loop.models.model_loop_state.ModelLoopState + args: + - {name: max-cycles, payload_field: max_cycles, arg_type: integer} + - {name: skip-closeout, payload_field: skip_closeout, arg_type: boolean, default: false} + - {name: dry-run, payload_field: dry_run, arg_type: boolean, default: false} + - skill_name: coderabbit_triage + node_name: node_coderabbit_triage + result_model: omnimarket.nodes.node_coderabbit_triage.handlers.handler_coderabbit_triage.ModelCoderabbitTriageResult + args: + - {name: repo, payload_field: repo, arg_type: string, required: true} + - {name: pr-number, payload_field: pr_number, arg_type: integer, required: true} + - {name: dry-run, payload_field: dry_run, arg_type: boolean, default: false} + - skill_name: compliance_sweep + node_name: node_compliance_sweep + result_model: omnimarket.nodes.node_compliance_sweep.handlers.handler_compliance_sweep.ComplianceSweepResult + args: + - {name: repos, payload_field: repos, arg_type: string_list} + - {name: checks, payload_field: checks, arg_type: string_list} + - {name: dry-run, payload_field: dry_run, arg_type: boolean, default: false} + - skill_name: coverage_sweep + node_name: node_coverage_sweep + result_model: omnimarket.nodes.node_coverage_sweep.handlers.handler_coverage_sweep.CoverageSweepResult + args: + - {name: repos, payload_field: repos, arg_type: string_list} + - {name: target-pct, payload_field: target_pct, arg_type: integer} + - {name: dry-run, payload_field: dry_run, arg_type: boolean, default: false} + - skill_name: create_ticket + node_name: node_create_ticket + result_model: omnimarket.nodes.node_create_ticket.handlers.handler_create_ticket.ModelCreateTicketResult + args: + - {name: title, payload_field: title, arg_type: string} + - {name: from-contract, payload_field: from_contract, arg_type: string} + - {name: from-plan, payload_field: from_plan, arg_type: string} + - {name: milestone, payload_field: milestone, arg_type: string} + - {name: repo, payload_field: repo, arg_type: string} + - {name: parent, payload_field: parent, arg_type: string} + - {name: blocked-by, payload_field: blocked_by, arg_type: string_list} + - {name: project, payload_field: project, arg_type: string} + - {name: team, payload_field: team, arg_type: string} + - {name: allow-arch-violation, payload_field: allow_arch_violation, arg_type: boolean, default: false} + - skill_name: database_sweep + node_name: node_database_sweep + result_model: omnimarket.nodes.node_database_sweep.handlers.handler_database_sweep.DatabaseSweepResult + args: + - {name: omni-home, payload_field: omni_home, arg_type: string} + - {name: table, payload_field: table, arg_type: string} + - {name: staleness-threshold-hours, payload_field: staleness_threshold_hours, arg_type: integer} + - {name: dry-run, payload_field: dry_run, arg_type: boolean, default: false} + - skill_name: design_to_plan + node_name: node_design_to_plan + result_model: omnimarket.nodes.node_design_to_plan.models.model_design_to_plan_phase3_launch.ModelDesignToPlanPhase3LaunchResult + args: + - {name: phase, payload_field: phase, arg_type: string} + - {name: topic, payload_field: topic, arg_type: string} + - {name: plan-path, payload_field: plan_path, arg_type: string} + - {name: no-launch, payload_field: no_launch, arg_type: boolean, default: false} + - {name: plan-only, payload_field: plan_only, arg_type: boolean, default: false} + - {name: dry-run, payload_field: dry_run, arg_type: boolean, default: false} + - skill_name: doc_freshness_sweep + node_name: node_doc_freshness_sweep + result_model: omnimarket.nodes.node_doc_freshness_sweep.handlers.handler_doc_freshness_sweep.DocFreshnessSweepResult + args: + - {name: omni-home, payload_field: omni_home, arg_type: string} + - {name: repos, payload_field: repos, arg_type: string_list} + - {name: claude-md-only, payload_field: claude_md_only, arg_type: boolean, default: false} + - {name: broken-only, payload_field: broken_only, arg_type: boolean, default: false} + - {name: dry-run, payload_field: dry_run, arg_type: boolean, default: false} + - skill_name: dod_verify + node_name: node_dod_verify + result_model: omnimarket.nodes.node_dod_verify.models.model_dod_verify_state.ModelDodVerifyState + args: + - {name: ticket-id, payload_field: ticket_id, arg_type: string, positional: true, required: true} + - {name: contract-path, payload_field: contract_path, arg_type: string} + - {name: dry-run, payload_field: dry_run, arg_type: boolean, default: false} + - skill_name: duplication_sweep + node_name: node_duplication_sweep + result_model: omnimarket.nodes.node_duplication_sweep.handlers.handler_duplication_sweep.DuplicationSweepResult + args: + - {name: omni-home, payload_field: omni_home, arg_type: string} + - {name: checks, payload_field: checks, arg_type: string_list} + - skill_name: hostile_reviewer + node_name: node_hostile_reviewer + result_model: omnimarket.nodes.node_hostile_reviewer.models.model_hostile_reviewer_completed_event.ModelHostileReviewerCompletedEvent + args: + - {name: pr-number, payload_field: pr_number, arg_type: integer} + - {name: repo, payload_field: repo, arg_type: string} + - {name: file-path, payload_field: file_path, arg_type: string} + - {name: models, payload_field: models, arg_type: string_list} + - {name: dry-run, payload_field: dry_run, arg_type: boolean, default: false} + - skill_name: linear_housekeeping + node_name: node_linear_triage + result_model: omnimarket.nodes.node_linear_triage.models.model_linear_triage_state.ModelLinearTriageResult + args: + - {name: threshold-days, payload_field: threshold_days, arg_type: integer} + - {name: flag-only, payload_field: flag_only, arg_type: boolean, default: false} + - {name: team, payload_field: team, arg_type: string} + - {name: dry-run, payload_field: dry_run, arg_type: boolean, default: false} + - skill_name: merge_sweep + node_name: node_pr_lifecycle_orchestrator + result_model: omnimarket.nodes.node_pr_lifecycle_orchestrator.handlers.handler_pr_lifecycle_orchestrator.ModelPrLifecycleResult + args: + - {name: repos, payload_field: repos, arg_type: string_list} + - {name: dry-run, payload_field: dry_run, arg_type: boolean, default: false} + - {name: inventory-only, payload_field: inventory_only, arg_type: boolean, default: false} + - {name: fix-only, payload_field: fix_only, arg_type: boolean, default: false} + - {name: merge-only, payload_field: merge_only, arg_type: boolean, default: false} + - {name: max-parallel-polish, payload_field: max_parallel_polish, arg_type: integer} + - {name: admin-fallback-threshold-minutes, payload_field: admin_fallback_threshold_minutes, arg_type: integer} + - {name: verify, payload_field: verify, arg_type: boolean, default: false} + - {name: verify-timeout-seconds, payload_field: verify_timeout_seconds, arg_type: integer} + - skill_name: plan_to_tickets + node_name: node_plan_to_tickets + result_model: omnimarket.nodes.node_plan_to_tickets.handlers.handler_plan_to_tickets.ModelPlanToTicketsResult + args: + - {name: plan-path, payload_field: plan_path, arg_type: string, positional: true, required: true} + - {name: project, payload_field: project, arg_type: string} + - {name: epic-title, payload_field: epic_title, arg_type: string} + - {name: no-create-epic, payload_field: no_create_epic, arg_type: boolean, default: false} + - {name: skip-existing, payload_field: skip_existing, arg_type: boolean, default: false} + - {name: team, payload_field: team, arg_type: string} + - {name: repo, payload_field: repo, arg_type: string} + - {name: allow-arch-violation, payload_field: allow_arch_violation, arg_type: boolean, default: false} + - {name: dry-run, payload_field: dry_run, arg_type: boolean, default: false} + - skill_name: platform_readiness + node_name: node_platform_readiness + result_model: omnimarket.nodes.node_platform_readiness.handlers.handler_platform_readiness.ModelPlatformReadinessResult + args: [] + - skill_name: pr_polish + node_name: node_pr_polish + result_model: omnimarket.nodes.node_pr_polish.models.model_pr_polish_completed_event.ModelPrPolishCompletedEvent + args: + - {name: repo, payload_field: repo, arg_type: string, required: true} + - {name: pr-number, payload_field: pr_number, arg_type: integer, required: true} + - {name: ticket-id, payload_field: ticket_id, arg_type: string} + - {name: required-clean-runs, payload_field: required_clean_runs, arg_type: integer} + - {name: max-iterations, payload_field: max_iterations, arg_type: integer} + - {name: skip-conflicts, payload_field: skip_conflicts, arg_type: boolean, default: false} + - {name: skip-pr-review, payload_field: skip_pr_review, arg_type: boolean, default: false} + - {name: skip-local-review, payload_field: skip_local_review, arg_type: boolean, default: false} + - {name: no-ci, payload_field: no_ci, arg_type: boolean, default: false} + - {name: no-push, payload_field: no_push, arg_type: boolean, default: false} + - {name: no-automerge, payload_field: no_automerge, arg_type: boolean, default: false} + - {name: dry-run, payload_field: dry_run, arg_type: boolean, default: false} + - skill_name: pr_review + node_name: node_pr_review_bot + result_model: omnimarket.nodes.node_pr_review_bot.models.models.ReviewVerdict + args: + - {name: pr-number, payload_field: pr_number, arg_type: integer, required: true} + - {name: repo, payload_field: repo, arg_type: string, required: true} + - {name: reviewer-models, payload_field: reviewer_models, arg_type: string_list} + - {name: judge-model, payload_field: judge_model, arg_type: string} + - {name: severity-threshold, payload_field: severity_threshold, arg_type: string} + - {name: max-findings-per-pr, payload_field: max_findings_per_pr, arg_type: integer} + - {name: dry-run, payload_field: dry_run, arg_type: boolean, default: false} + - skill_name: pr_review_bot + node_name: node_pr_review_bot + result_model: omnimarket.nodes.node_pr_review_bot.models.models.ReviewVerdict + args: + - {name: pr-number, payload_field: pr_number, arg_type: integer, required: true} + - {name: repo, payload_field: repo, arg_type: string, required: true} + - {name: reviewer-models, payload_field: reviewer_models, arg_type: string_list} + - {name: judge-model, payload_field: judge_model, arg_type: string} + - {name: severity-threshold, payload_field: severity_threshold, arg_type: string} + - {name: max-findings-per-pr, payload_field: max_findings_per_pr, arg_type: integer} + - {name: dry-run, payload_field: dry_run, arg_type: boolean, default: false} + - skill_name: session + node_name: node_session_orchestrator + result_model: omnimarket.nodes.node_session_orchestrator.handlers.handler_session_orchestrator.ModelSessionOrchestratorResult + args: + - {name: mode, payload_field: mode, arg_type: string} + - {name: phase, payload_field: phase, arg_type: string} + - {name: skip-health, payload_field: skip_health, arg_type: boolean, default: false} + - {name: dry-run, payload_field: dry_run, arg_type: boolean, default: false} + - skill_name: shim_audit + node_name: node_shim_scanner + result_model: omnimarket.nodes.node_shim_scanner.models.model_shim_scan_result.ModelShimScanResult + args: + - {name: paths, payload_field: paths, arg_type: string_list} + - {name: reference-date, payload_field: reference_date, arg_type: string} + - {name: warn-days-before-expiry, payload_field: warn_days_before_expiry, arg_type: integer} + - skill_name: delegate + node_name: node_delegate_skill_orchestrator + result_model: omnimarket.models.delegation.wire.model_delegate_skill_response.ModelDelegateSkillResponse + static_payload: + source: claude-code + args: + - {name: prompt, payload_field: prompt, arg_type: string, positional: true, required: true} + - {name: task-type, payload_field: task_type, arg_type: string} + - {name: max-tokens, payload_field: max_tokens, arg_type: integer, default: 2048} + classifiers: + - target_field: task_type + source_field: prompt + fallback: research + rules: + - [["test", "pytest", "unit test", "assert"], test] + - [["document", "docstring", "readme", "explain how"], document] + - [["write", "create", "implement", "build", "generate"], code_generation] + - [["refactor", "cleanup", "simplify"], refactor] + - [["review", "audit", "check"], review] + - [["reason", "think through", "decide", "compare"], reasoning] diff --git a/src/omnibase_infra/validation/validation_exemptions.yaml b/src/omnibase_infra/validation/validation_exemptions.yaml index 5b38e61254..e1642d476f 100644 --- a/src/omnibase_infra/validation/validation_exemptions.yaml +++ b/src/omnibase_infra/validation/validation_exemptions.yaml @@ -325,6 +325,42 @@ pattern_exemptions: - OMN-11546 concrete-node audit ticket: OMN-11570 # ========================================================================== + # onex skill subcommand Exemptions (OMN-13097) + # ========================================================================== + # Same Click-callback pattern as run_node_by_name above: Click injects the + # skill name plus dispatch options directly into the command callback, which + # immediately delegates to run_receipt_mode. An intermediate options model + # would obscure the Click contract without reducing complexity. + - file_pattern: 'cli_skill\.py' + method_pattern: "Function 'run_skill_by_name'" + violation_pattern: 'has \d+ parameters' + reason: > + Click injects command arguments and options directly into the command callback (identical pattern to cli_node.py run_node_by_name). The body normalizes them and delegates to run_receipt_mode; an intermediate model would obscure the Click contract without reducing complexity. + + documentation: + - docs/plans/2026-06-12-skill-output-suppression-plan.md (Phase 4) + ticket: OMN-13097 + # skill_name / node_name in the declarative skill->node mapping are literal + # identifiers (the skill name as invoked, the onex.nodes entry-point name), + # not foreign-key references to entities with separate ID + display_name. No + # such entity exists in this domain — the mapping IS the registry. + - file_pattern: 'model_skill_mapping\.py' + violation_pattern: "Field 'skill_name' might reference an entity" + reason: > + skill_name is the literal skill identifier as invoked ('onex skill '), not a foreign key to a skill entity. It is the registry key itself; no separate skill entity with ID + display_name exists. + + documentation: + - docs/plans/2026-06-12-skill-output-suppression-plan.md (Phase 4) + ticket: OMN-13097 + - file_pattern: 'model_skill_mapping\.py' + violation_pattern: "Field 'node_name' might reference an entity" + reason: > + node_name is the onex.nodes entry-point identifier resolved exactly like 'onex node ', used to locate the packaged contract. It is a routing identifier, not a foreign key to a node entity with ID + display_name. + + documentation: + - docs/plans/2026-06-12-skill-output-suppression-plan.md (Phase 4) + ticket: OMN-13097 + # ========================================================================== # KafkaContractCache Exemptions (OMN-1989) # ========================================================================== # Thread-safe cache with cohesive CRUD and query operations for contract diff --git a/tests/integration/infra/test_omn_12765_release_backmerge_identity.py b/tests/integration/infra/test_omn_12765_release_backmerge_identity.py index bc42153723..f7f83dfba0 100644 --- a/tests/integration/infra/test_omn_12765_release_backmerge_identity.py +++ b/tests/integration/infra/test_omn_12765_release_backmerge_identity.py @@ -30,7 +30,8 @@ def test_release_backmerge_preserves_runner_identity_lock() -> None: (ROOT / "docker/runners/runner-image.lock.json").read_text(encoding="utf-8") ) - # OMN-13094: identity regenerated for the advanced core pin (both digests - # bind pyproject.toml + uv.lock). - assert lock["identity_digest"] == "9bce87358ac31006180ffdc8eaa1d370" - assert lock["shared_env_digest"] == "0f0276f1dab9c86d39fef288" + # OMN-13097: identity regenerated for the `onex skill` entry-point added to + # pyproject.toml (the lock binds pyproject.toml + uv.lock). Same mechanical + # regeneration as OMN-13094 did for the advanced core pin. + assert lock["identity_digest"] == "fdc6bd801d129fb2dfaf5a262fe44bbd" + assert lock["shared_env_digest"] == "8f706195c95ee9613b413457" diff --git a/tests/unit/cli/test_cli_skill.py b/tests/unit/cli/test_cli_skill.py new file mode 100644 index 0000000000..c806b9285d --- /dev/null +++ b/tests/unit/cli/test_cli_skill.py @@ -0,0 +1,391 @@ +# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT + +"""Tests for ``onex skill `` (OMN-13097). + +The skill subcommand resolves the declarative skill→node mapping +(``skill_mapping.yaml``), builds the backing node's input payload from the +skill's CLI args, and dispatches through the proven receipt-mode path. These +tests assert the DECLARATIVE LAYER (registry validity, arg parsing, payload +construction, classifiers) directly, plus the command wiring through a stubbed +``run_receipt_mode`` so we verify the constructed payload without standing up +a live runtime. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from omnibase_infra.cli import cli_skill +from omnibase_infra.cli.cli_skill import ( + _apply_classifiers, + _parse_skill_args, + load_skill_registry, + run_skill_by_name, +) +from omnibase_infra.cli.enum_skill_arg_type import EnumSkillArgType +from omnibase_infra.cli.model_skill_arg_spec import ModelSkillArgSpec +from omnibase_infra.cli.model_skill_classifier import ModelSkillClassifier +from omnibase_infra.cli.model_skill_mapping import ModelSkillMapping +from omnibase_infra.cli.model_skill_mapping_registry import ModelSkillMappingRegistry + +pytestmark = pytest.mark.unit + +# The 24 dispatch shims this ticket migrates (ticket deliverable 1). +_EXPECTED_SKILLS = frozenset( + { + "aislop_sweep", + "data_flow_sweep", + "auto_merge", + "build_loop", + "coderabbit_triage", + "compliance_sweep", + "coverage_sweep", + "create_ticket", + "database_sweep", + "design_to_plan", + "doc_freshness_sweep", + "dod_verify", + "duplication_sweep", + "hostile_reviewer", + "linear_housekeeping", + "merge_sweep", + "plan_to_tickets", + "platform_readiness", + "pr_polish", + "pr_review", + "pr_review_bot", + "session", + "shim_audit", + "delegate", + } +) + + +@pytest.fixture(autouse=True) +def _clear_registry_cache() -> None: + """The registry loader is lru_cached; clear it per test for isolation.""" + load_skill_registry.cache_clear() + + +def test_registry_loads_and_validates() -> None: + registry = load_skill_registry() + assert isinstance(registry, ModelSkillMappingRegistry) + assert len(registry.skills) >= len(_EXPECTED_SKILLS) + + +def test_registry_covers_all_24_dispatch_shims() -> None: + registry = load_skill_registry() + declared = {s.skill_name for s in registry.skills} + missing = _EXPECTED_SKILLS - declared + assert not missing, f"mapping is missing skills: {sorted(missing)}" + + +def test_registry_result_models_are_fully_qualified() -> None: + registry = load_skill_registry() + for skill in registry.skills: + assert "." in skill.result_model, ( + f"{skill.skill_name}: result_model must be a fully qualified name, " + f"got {skill.result_model!r}" + ) + assert skill.node_name.startswith("node_"), skill.node_name + + +def test_registry_rejects_duplicate_skill_names() -> None: + with pytest.raises(ValueError, match="duplicate skill_name"): + ModelSkillMappingRegistry( + skills=( + ModelSkillMapping( + skill_name="dup", + node_name="node_x", + result_model="a.B", + ), + ModelSkillMapping( + skill_name="dup", + node_name="node_y", + result_model="a.C", + ), + ) + ) + + +def test_mapping_rejects_multiple_positionals() -> None: + with pytest.raises(ValueError, match="at most one positional"): + ModelSkillMapping( + skill_name="s", + node_name="node_x", + result_model="a.B", + args=( + ModelSkillArgSpec( + name="a", + payload_field="a", + arg_type=EnumSkillArgType.STRING, + positional=True, + ), + ModelSkillArgSpec( + name="b", + payload_field="b", + arg_type=EnumSkillArgType.STRING, + positional=True, + ), + ), + ) + + +def test_arg_spec_required_forbids_default() -> None: + with pytest.raises(ValueError, match="required args must not declare a default"): + ModelSkillArgSpec( + name="x", + payload_field="x", + arg_type=EnumSkillArgType.STRING, + required=True, + default="oops", + ) + + +def _mapping_with(*args: ModelSkillArgSpec, **kw: object) -> ModelSkillMapping: + return ModelSkillMapping( + skill_name="t", + node_name="node_t", + result_model="a.B", + args=tuple(args), + **kw, # type: ignore[arg-type] + ) + + +def test_parse_string_list_and_boolean() -> None: + mapping = _mapping_with( + ModelSkillArgSpec( + name="repos", + payload_field="repos", + arg_type=EnumSkillArgType.STRING_LIST, + ), + ModelSkillArgSpec( + name="dry-run", + payload_field="dry_run", + arg_type=EnumSkillArgType.BOOLEAN, + default=False, + ), + ) + payload = _parse_skill_args(mapping, ("--repos", "a, b ,c", "--dry-run")) + assert payload == {"repos": ["a", "b", "c"], "dry_run": True} + + +def test_parse_applies_boolean_default_when_omitted() -> None: + mapping = _mapping_with( + ModelSkillArgSpec( + name="dry-run", + payload_field="dry_run", + arg_type=EnumSkillArgType.BOOLEAN, + default=False, + ), + ) + assert _parse_skill_args(mapping, ()) == {"dry_run": False} + + +def test_parse_integer_coercion_and_failure() -> None: + mapping = _mapping_with( + ModelSkillArgSpec( + name="pr-number", + payload_field="pr_number", + arg_type=EnumSkillArgType.INTEGER, + ), + ) + assert _parse_skill_args(mapping, ("--pr-number", "42")) == {"pr_number": 42} + from click import ClickException + + with pytest.raises(ClickException, match="expects an integer"): + _parse_skill_args(mapping, ("--pr-number", "notanint")) + + +def test_parse_positional_joins_tokens() -> None: + mapping = _mapping_with( + ModelSkillArgSpec( + name="prompt", + payload_field="prompt", + arg_type=EnumSkillArgType.STRING, + positional=True, + required=True, + ), + ) + assert _parse_skill_args(mapping, ("summarize", "this", "text")) == { + "prompt": "summarize this text" + } + + +def test_parse_missing_required_fails() -> None: + from click import ClickException + + mapping = _mapping_with( + ModelSkillArgSpec( + name="repo", + payload_field="repo", + arg_type=EnumSkillArgType.STRING, + required=True, + ), + ) + with pytest.raises(ClickException, match="requires --repo"): + _parse_skill_args(mapping, ()) + + +def test_parse_unknown_flag_fails() -> None: + from click import ClickException + + mapping = _mapping_with() + with pytest.raises(ClickException, match="Unknown argument '--nope'"): + _parse_skill_args(mapping, ("--nope", "x")) + + +def test_static_payload_merged() -> None: + mapping = _mapping_with( + ModelSkillArgSpec( + name="prompt", + payload_field="prompt", + arg_type=EnumSkillArgType.STRING, + positional=True, + required=True, + ), + static_payload={"source": "claude-code"}, + ) + payload = _parse_skill_args(mapping, ("hello",)) + assert payload == {"source": "claude-code", "prompt": "hello"} + + +def test_classifier_assigns_first_match() -> None: + classifier = ModelSkillClassifier( + target_field="task_type", + source_field="prompt", + rules=( + (("test", "pytest"), "test"), + (("write", "implement"), "code_generation"), + ), + fallback="research", + ) + mapping = _mapping_with(classifiers=(classifier,)) + payload: dict[str, object] = {"prompt": "please write a pytest"} + _apply_classifiers(mapping, payload) # type: ignore[arg-type] + # "test"/"pytest" group comes first → wins over "write". + assert payload["task_type"] == "test" + + +def test_classifier_fallback_when_no_match() -> None: + classifier = ModelSkillClassifier( + target_field="task_type", + source_field="prompt", + rules=((("write",), "code_generation"),), + fallback="research", + ) + mapping = _mapping_with(classifiers=(classifier,)) + payload: dict[str, object] = {"prompt": "tell me about the weather"} + _apply_classifiers(mapping, payload) # type: ignore[arg-type] + assert payload["task_type"] == "research" + + +def test_classifier_does_not_override_explicit_value() -> None: + classifier = ModelSkillClassifier( + target_field="task_type", + source_field="prompt", + rules=((("write",), "code_generation"),), + fallback="research", + ) + mapping = _mapping_with(classifiers=(classifier,)) + payload: dict[str, object] = {"prompt": "write code", "task_type": "document"} + _apply_classifiers(mapping, payload) # type: ignore[arg-type] + assert payload["task_type"] == "document" + + +def test_delegate_mapping_classifies_and_builds_payload() -> None: + """End-to-end declarative-layer check for the worst-case skill.""" + registry = load_skill_registry() + delegate = registry.get("delegate") + assert delegate is not None + payload = _parse_skill_args( + delegate, ("write", "a", "unit", "test", "for", "the", "parser") + ) + _apply_classifiers(delegate, payload) + assert payload["prompt"] == "write a unit test for the parser" + assert payload["source"] == "claude-code" + assert payload["max_tokens"] == 2048 + # "unit test" / "test" keyword group wins. + assert payload["task_type"] == "test" + + +def test_command_unknown_skill_fails() -> None: + runner = CliRunner() + result = runner.invoke(run_skill_by_name, ["does_not_exist"]) + assert result.exit_code != 0 + assert "Unknown skill" in result.output + + +def test_command_dispatches_via_receipt_mode( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """The command builds the payload and hands it to run_receipt_mode.""" + captured: dict[str, object] = {} + + def _fake_receipt_mode(**kwargs: object) -> int: + captured.update(kwargs) + # Read back the payload the command wrote. + input_path = kwargs["input_path"] + assert isinstance(input_path, Path) + captured["payload"] = json.loads(input_path.read_text(encoding="utf-8")) + return 0 + + def _fake_resolve(node_name: str) -> Path: + return tmp_path / f"{node_name}-contract.yaml" + + monkeypatch.setattr(cli_skill, "run_receipt_mode", _fake_receipt_mode) + monkeypatch.setattr(cli_skill, "_resolve_packaged_contract", _fake_resolve) + + runner = CliRunner() + result = runner.invoke( + run_skill_by_name, + [ + "compliance_sweep", + "--state-root", + str(tmp_path / "state"), + "--repos", + "omnibase_core,omnibase_infra", + "--dry-run", + ], + ) + assert result.exit_code == 0, result.output + assert captured["node_name"] == "node_compliance_sweep" + payload = captured["payload"] + assert isinstance(payload, dict) + assert payload["repos"] == ["omnibase_core", "omnibase_infra"] + assert payload["dry_run"] is True + assert captured["backend_overrides"] == {"event_bus": "inmemory"} + + +def test_command_writes_payload_under_state_root_not_tmp( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + seen_paths: list[Path] = [] + + def _fake_receipt_mode(**kwargs: object) -> int: + input_path = kwargs["input_path"] + assert isinstance(input_path, Path) + seen_paths.append(input_path) + return 0 + + monkeypatch.setattr(cli_skill, "run_receipt_mode", _fake_receipt_mode) + monkeypatch.setattr( + cli_skill, "_resolve_packaged_contract", lambda n: tmp_path / "c.yaml" + ) + + state_root = tmp_path / "state" + runner = CliRunner() + result = runner.invoke( + run_skill_by_name, + ["platform_readiness", "--state-root", str(state_root)], + ) + assert result.exit_code == 0, result.output + assert len(seen_paths) == 1 + # Payload scratch lives under the state root, never /tmp. + assert state_root in seen_paths[0].parents + assert "tmp" in seen_paths[0].parts