Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/autoskillit/core/types/_type_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,8 @@ class BackendCapabilities:
has_unguarded_filesystem_access: bool = field(default=False)
# True when backend's git metadata directories (.git/worktrees/) are writable
git_metadata_writable: bool = field(default=True)
# True when the backend can make outbound GitHub API write calls without sandbox restriction
github_api_callable: bool = field(default=False)
# Native skill invocation prefix character used by this backend's model/CLI.
# Claude Code uses "/" (slash-commands via the Skill tool).
# Codex uses "$" (dollar-mention via extract_tool_mentions).
Expand Down Expand Up @@ -262,6 +264,7 @@ def model_class(model: str) -> str:
supports_context_window_suffix=True,
has_unguarded_filesystem_access=False,
git_metadata_writable=True,
github_api_callable=True,
skill_sigil="/",
session_dir_persistent=False,
supports_model_invocation_gating=True,
Expand Down Expand Up @@ -313,6 +316,7 @@ class SkillSessionConfig:
resume_message: str | None = None
sandbox_mode: str = "workspace-write"
backend_override: str | None = None
network_access: bool = False


@dataclass(frozen=True, slots=True)
Expand Down
16 changes: 15 additions & 1 deletion src/autoskillit/core/types/_type_constants_registries.py
Original file line number Diff line number Diff line change
Expand Up @@ -302,10 +302,14 @@ class SkillCapabilityDef:

description: str
codex_status: Literal["works-as-is", "degraded", "fix-required", "not-applicable"]
required_sandbox_overrides: frozenset[str] = frozenset()
worker_routable: bool = False

@property
def required_backends(self) -> frozenset[str]:
if self.codex_status == "not-applicable":
# worker_routable=True → reroute (REROUTE), not reject: required_backends must be empty
# so the compat gate doesn't fire for routable capabilities.
if self.codex_status == "not-applicable" and not self.worker_routable:
return frozenset({AGENT_BACKEND_CLAUDE_CODE})
return frozenset()

Expand Down Expand Up @@ -353,6 +357,16 @@ def required_backends(self) -> frozenset[str]:
"git worktree add, git checkout -b)"
),
codex_status="not-applicable",
worker_routable=True,
),
"github_api_write": SkillCapabilityDef(
description=(
"Skill makes outbound GitHub API write calls (gh pr review, gh api --method POST, "
"gh pr create, gh pr merge, gh issue create/edit/close, etc.) that require network "
"access. On Codex workers, enables network_access=true in the workspace-write sandbox."
),
codex_status="fix-required",
required_sandbox_overrides=frozenset({"sandbox_workspace_write.network_access=true"}),
),
}

Expand Down
1 change: 1 addition & 0 deletions src/autoskillit/core/types/_type_protocols_execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ async def run(
caller_session_id: str | None = None,
inspector_eligible: bool = False,
inspector_model: str = "",
network_access: bool = False,
) -> SkillResult: ...

async def dispatch_food_truck(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ def _apply_config(self, config: SkillSessionConfig) -> dict[str, Any]:
"resume_checkpoint": config.resume_checkpoint,
"resume_message": config.resume_message,
"sandbox_mode": config.sandbox_mode,
"network_access": config.network_access,
}


Expand Down
7 changes: 7 additions & 0 deletions src/autoskillit/execution/backends/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,7 @@ def capabilities(self) -> BackendCapabilities:
# field; rules_backend_compat.py emits ERROR for
# git_metadata_write skills on backends where this is False.
git_metadata_writable=False, # sandbox excludes .git metadata path
github_api_callable=False,
skill_sigil="$",
session_dir_persistent=True,
supports_model_invocation_gating=False,
Expand Down Expand Up @@ -692,6 +693,7 @@ def build_skill_session_cmd(
resume_checkpoint: SessionCheckpoint | None = None,
resume_message: str | None = None,
sandbox_mode: str = "workspace-write",
network_access: bool = False,
) -> CmdSpec:
if config is not None:
cfg = self._apply_config(config)
Expand All @@ -712,6 +714,7 @@ def build_skill_session_cmd(
resume_checkpoint = cfg["resume_checkpoint"]
resume_message = cfg["resume_message"]
sandbox_mode = cfg["sandbox_mode"]
network_access = cfg.get("network_access", False)
if plugin_source is not None:
logger.warning("codex_plugin_source_discarded", plugin_source=str(plugin_source))
if output_format != OutputFormat.JSON:
Expand Down Expand Up @@ -790,9 +793,13 @@ def build_skill_session_cmd(
required=SKILL_SESSION_REQUIRED_ENV | {MCP_CLIENT_BACKEND_ENV_VAR},
)

_net_overrides: list[str] = []
if network_access:
_net_overrides.append("sandbox_workspace_write.network_access=true")
cmd = _codex_exec_base(
sandbox=sandbox_mode,
bypass_hook_trust=self.capabilities.mcp_config_capable,
extra_overrides=_net_overrides,
)
if model:
cmd += [CodexFlags.MODEL, self.translate_model(model)]
Expand Down
4 changes: 4 additions & 0 deletions src/autoskillit/execution/headless/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ async def run_headless_core(
caller_session_id: str | None = None,
inspector_eligible: bool = False,
inspector_model: str = "",
network_access: bool = False,
) -> SkillResult:
"""Shared headless runner used by run_skill.

Expand Down Expand Up @@ -193,6 +194,7 @@ async def run_headless_core(
sandbox_mode="read-only"
if readonly_skill
else ctx.backend.capabilities.default_skill_sandbox_mode,
network_access=network_access,
)
step_backend: CodingAgentBackend | None = None
if backend_override is not None:
Expand Down Expand Up @@ -303,6 +305,7 @@ async def run(
caller_session_id: str | None = None,
inspector_eligible: bool = False,
inspector_model: str = "",
network_access: bool = False,
) -> SkillResult:
cfg = self._ctx.config.run_skill
effective_timeout = timeout if timeout is not None else cfg.timeout
Expand Down Expand Up @@ -344,6 +347,7 @@ async def run(
caller_session_id=caller_session_id,
inspector_eligible=inspector_eligible,
inspector_model=inspector_model,
network_access=network_access,
)

async def dispatch_food_truck(
Expand Down
11 changes: 8 additions & 3 deletions src/autoskillit/recipe/_analysis_bfs.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,12 +99,16 @@ def _build_success_step_graph(recipe: Recipe) -> dict[str, set[str]]:
def bfs_reachable_without_barrier(
recipe: Recipe,
start: str,
barrier: str,
barrier: str | frozenset[str],
) -> set[str]:
"""BFS from ``start`` through success-path routing edges, stopping at ``barrier``.

Returns all step names reachable from ``start`` without crossing ``barrier``.
The barrier step itself is not included in the returned set.
The barrier step itself is included in the returned set — it is visited but
not expanded beyond.

``barrier`` may be a single step name (str) or a frozenset of step names.
When a frozenset is provided, any of the named steps acts as a barrier.

Only follows on_result conditions and on_success edges — error paths
(on_failure, on_context_limit) are excluded because they are not
Expand All @@ -113,8 +117,9 @@ def bfs_reachable_without_barrier(
This is the canonical implementation of the BFS-barrier pattern previously
duplicated inline in ``push-before-audit`` and ``merge-base-unpublished``.
"""
barriers: set[str] = {barrier} if isinstance(barrier, str) else set(barrier)
graph = _build_success_step_graph(recipe)
return _bfs_capped(graph, {start}, {barrier})
return _bfs_capped(graph, {start}, barriers)


def _bfs_capped(
Expand Down
55 changes: 55 additions & 0 deletions src/autoskillit/recipe/rules/graph/rules_graph_review.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,61 @@ def _check_run_skill_missing_context_limit(ctx: ValidationContext) -> list[RuleF
return findings


_REVIEW_EFFECT_ADVANCE_GATES = frozenset({"check_review_loop", "derive_batch_ci_event"})


def _get_review_pr_steps(ctx: ValidationContext) -> list[str]:
"""Return step IDs that dispatch the review-pr skill (either prefix form).

Exposed as a module-level function (not inlined in the rule body) so that
silence tests can call it directly and assert the rule is triggered on the
recipe under test before asserting zero findings.
"""
return [
step_id
for step_id, step in ctx.recipe.steps.items()
if step.tool == "run_skill" and step.skill_name == "review-pr"
]


@semantic_rule(
name="review-effect-verification-waypoint",
severity=Severity.ERROR,
description=(
"No advance gate (check_review_loop or derive_batch_ci_event) must be reachable "
"from any review-pr skill step on the success path without first crossing "
"check_review_posted. Applies to all steps dispatching the review-pr skill "
"regardless of step id (covers review_pr_integration in merge-prs.yaml)."
),
)
def _review_effect_verification_waypoint(ctx: ValidationContext) -> list[RuleFinding]:
effect_steps = _get_review_pr_steps(ctx)
if not effect_steps:
return []
findings = []
for start in effect_steps:
reachable = bfs_reachable_without_barrier(
ctx.recipe,
start=start,
barrier=frozenset({"check_review_posted"}),
)
for advance_gate in _REVIEW_EFFECT_ADVANCE_GATES:
if advance_gate in reachable:
findings.append(
make_finding(
rule_name="review-effect-verification-waypoint",
step_name=start,
message=(
f"Step '{start}' (review-pr skill) can reach '{advance_gate}' "
f"without crossing check_review_posted. Insert a check_review_posted "
f"run_python step on every success path from '{start}' to "
f"'{advance_gate}'."
),
)
)
return findings


@semantic_rule(
name="review-mode-reentry-waypoint-guard",
description=(
Expand Down
11 changes: 11 additions & 0 deletions src/autoskillit/recipe/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
CaptureEntrySpec,
DispatchGateType,
RecipeSource,
resolve_skill_name,
)

AUTOSKILLIT_VERSION_KEY: Final = "autoskillit_version"
Expand Down Expand Up @@ -155,6 +156,16 @@ def __post_init__(self) -> None:
f"the list, producing duplicates. Set retries: 0."
)

@property
def skill_name(self) -> str | None:
"""Canonical skill name, resolving /name and /autoskillit:name forms.

Returns None if with_args has no skill_command or if resolve_skill_name
cannot extract a name (template-only, unrecognized format, etc.).
Use this in rule predicates instead of raw string operations on with_args.
"""
return resolve_skill_name(str((self.with_args or {}).get("skill_command", "")))


@dataclass(frozen=True, slots=True)
class RecipeBlock:
Expand Down
29 changes: 29 additions & 0 deletions src/autoskillit/recipe/skill_contracts.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2574,6 +2574,35 @@ callable_contracts:
type: str
- name: had_blocking
type: str
autoskillit.smoke_utils.check_review_posted:
description: >
Verifies that batch_review_response_{pr_number}.json exists in output_dir
(github mode) or always passes (local mode). No subprocess calls.
output_dir must be an absolute path.
inputs:
- name: pr_number
type: int
required: true
- name: output_dir
type: str
required: true
- name: mode
type: str
required: true
allowed_values:
- local
- github
outputs:
- name: reviews_posted
type: str
allowed_values:
- "true"
- "false"
- name: sentinel
type: str
allowed_values:
- no_reviews_posted
- ""
autoskillit.smoke_utils.check_bug_report_non_empty:
inputs:
- name: workspace
Expand Down
2 changes: 1 addition & 1 deletion src/autoskillit/recipes/diagrams/implementation-groups.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<!-- autoskillit-recipe-hash: sha256:da2c052aa5ec001f093b241b6608a88343d341cc9d576e89ae1c20e6d8bfedf8 -->
<!-- autoskillit-recipe-hash: sha256:e78fe272d401e61b90dd3cc427b7f55031e424f5ab88cc344f9ce93a0a71aa11 -->
<!-- autoskillit-diagram-format: v7 -->
## implementation-groups
Group-based implementation with per-group plan/implement/test cycles and PR gates.
Expand Down
2 changes: 1 addition & 1 deletion src/autoskillit/recipes/diagrams/implementation.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<!-- autoskillit-recipe-hash: sha256:59adb040b882c08afe266b36f46d96a954feca4b0ca86746d7ed1b41f06779b9 -->
<!-- autoskillit-recipe-hash: sha256:5b432470db9a335ec8bb20036f9c090bff0d6b5101a28e7682d391bafe574169 -->
<!-- autoskillit-diagram-format: v7 -->
# implementation

Expand Down
2 changes: 1 addition & 1 deletion src/autoskillit/recipes/diagrams/merge-prs.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<!-- autoskillit-recipe-hash: sha256:6e4a54cb48efe524194a19916db628a1556a7c2a18c5e384f9f1c4e8c70b4f43 -->
<!-- autoskillit-recipe-hash: sha256:11d68dee72633660cb4cb07c25313277aa4dbacca8c336e9802f60d34eaffd73 -->
<!-- autoskillit-diagram-format: v7 -->
## merge-prs
Merge multiple PRs into an integration branch with conflict resolution and CI gates.
Expand Down
2 changes: 1 addition & 1 deletion src/autoskillit/recipes/diagrams/remediation.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<!-- autoskillit-recipe-hash: sha256:057a181a0b203eb19fff416f34fa9b8e29675c46fc3fe09637f26d177a83b5be -->
<!-- autoskillit-recipe-hash: sha256:b46326a0d43bc9c41fa2615979dfbe9df18d296cd2e171be01348976a34d4b1a -->
<!-- autoskillit-diagram-format: v7 -->
## remediation
Investigate, rectify, implement, and merge a bug fix with CI and PR gates.
Expand Down
39 changes: 33 additions & 6 deletions src/autoskillit/recipes/implementation-groups.json
Original file line number Diff line number Diff line change
Expand Up @@ -1123,14 +1123,14 @@
},
{
"when": "${{ result.verdict }} == needs_human",
"route": "check_review_loop"
"route": "check_review_posted"
},
{
"when": "${{ result.verdict }} == approved",
"route": "check_review_loop"
"route": "check_review_posted"
},
{
"route": "check_review_loop"
"route": "check_review_posted"
}
],
"on_failure": "check_repo_ci_event",
Expand All @@ -1141,7 +1141,7 @@
"review_loop_count",
"review_mode"
],
"note": "Runs after compose_pr creates the PR. Invokes the review-pr skill as a headless session to leave inline review comments on the PR. The skill finds the open PR by feature branch name (context.merge_target). Captures verdict as review_verdict and routes via on_result: changes_requested/approved_with_comments → resolve_review; all other verdicts (including approved, needs_human) → check_review_loop (mandatory waypoint to increment review_loop_count). On tool-level failure (MCP error, gh unavailable), routes to check_repo_ci_event (no review to resolve). Skipped when open_pr=false.\n"
"note": "Runs after compose_pr creates the PR. Invokes the review-pr skill as a headless session to leave inline review comments on the PR. The skill finds the open PR by feature branch name (context.merge_target). Captures verdict as review_verdict and routes via on_result: changes_requested/approved_with_comments → resolve_review; all other verdicts (including approved, needs_human) → check_review_posted (effect verification gate) → check_review_loop (mandatory waypoint to increment review_loop_count). On tool-level failure (MCP error, gh unavailable), routes to check_repo_ci_event (no review to resolve). Skipped when open_pr=false.\n"
},
"resolve_review": {
"tool": "run_skill",
Expand Down Expand Up @@ -1243,9 +1243,36 @@
"branch": "${{ context.merge_target }}",
"force": "true"
},
"on_success": "check_review_loop",
"on_success": "check_review_posted",
"on_failure": "release_issue_failure",
"note": "Pushes the review-fixed feature branch back to the remote after resolve_review succeeds. Routes to check_review_loop to determine if another review-resolve cycle is needed (bounded by review_max_retries). On push failure, escalates via release_issue_failure.\n"
"note": "Pushes the review-fixed feature branch back to the remote after resolve_review succeeds. Routes to check_review_posted (effect verification gate) before advancing via check_review_loop. On push failure, escalates via release_issue_failure.\n"
},
"check_review_posted": {
"tool": "run_python",
"with": {
"callable": "autoskillit.smoke_utils.check_review_posted",
"pr_number": "${{ context.pr_number }}",
"output_dir": "{{AUTOSKILLIT_TEMP}}/review-pr/iter_${{ context.review_loop_count }}",
"mode": "${{ context.review_mode }}"
},
"on_result": [
{
"when": "${{ result.reviews_posted }} == 'false'",
"route": "release_issue_failure"
},
{
"when": "${{ result.reviews_posted }} == 'true'",
"route": "check_review_loop"
},
{
"route": "release_issue_failure"
}
],
"on_failure": "release_issue_failure",
"optional_context_refs": [
"review_loop_count",
"review_mode"
]
},
"check_review_loop": {
"tool": "run_python",
Expand Down
Loading
Loading