Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ markers = [
]
filterwarnings = [
"error::SyntaxWarning",
"error::FutureWarning",
"error::DeprecationWarning:autoskillit",
"default:AUTOSKILLIT_HEADLESS=1 without:DeprecationWarning",
]
Expand Down
4 changes: 2 additions & 2 deletions src/autoskillit/recipe/rules/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,9 @@ See each subdirectory's AGENTS.md for details.
| `rules_recipe.py` | Sub-recipe reference validity and `with_args` hygiene |
| `rules_route_gate.py` | Route gate shared-stop detection; fallback and primary path convergence |
| `rules_loop_artifact_scope.py` | Loop artifact isolation: run_skill steps in cycles must use iteration-scoped output_dir |
| `rules_loop_counter.py` | Loop counter scope isolation: cross-path sharing and guard-before-verify detection |
| `rules_loop_counter.py` | Loop counter scope isolation: cross-path sharing, guard-before-verify, and cross-cycle reset enforcement |
| `rules_loop_progress.py` | Loop progress tracking: run_skill steps in cycles must capture declared outputs |
| `rules_skill_content.py` | SKILL.md content validation: undefined bash placeholders, source-attribution directives, output formatting, issue comment prohibition, inline-content-in-subagent-prompt detection |
| `rules_skill_content.py` | SKILL.md content validation: undefined bash placeholders, source-attribution directives, output formatting, issue comment prohibition, inline-content-in-subagent-prompt detection, POSIX character class detection |
| `rules_skill_write_path_alignment.py` | Cross-layer validation: SKILL.md declared write scope must align with recipe step output_dir; fires ERROR when iteration-scoped output_dir is narrower than SKILL.md NEVER block path and the skill doesn't use a dynamic write variable |
| `rules_skills.py` | `skill_command` resolvability rules |
| `rules_stamp_ownership.py` | Exclusive stamp ownership enforcement across skills |
Expand Down
110 changes: 110 additions & 0 deletions src/autoskillit/recipe/rules/rules_loop_counter.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,3 +195,113 @@ def _check_loop_guard_before_verify(ctx: ValidationContext) -> list[RuleFinding]
)

return findings


@semantic_rule(
name="loop-counter-not-reset-on-outer-cycle",
description=(
"An inner check_loop_iteration guard's counter variable is reachable "
"from an outer check_loop_iteration guard's non-max_exceeded route "
"without passing through a step that resets the inner counter"
),
severity=Severity.WARNING,
)
def _check_loop_counter_not_reset_on_outer_cycle(ctx: ValidationContext) -> list[RuleFinding]:
"""Detect temporal counter sharing across outer audit-remediation cycles.

When the audit-remediation outer loop re-enters the implementation
sub-cycle, any inner guard whose counter variable is captured along that
path (e.g. test_fix_loop_count) will accumulate across outer iterations
unless a step resets it via autoskillit.smoke_utils.init_counter.

Scoped to outer guards whose counter variable indicates an audit-
remediation cycle (audit_remediation_count) — other outer/inner guard
relationships (e.g. merge_fix wrapping merge_rebase) have separate
reset mechanisms and are out of scope for this rule.
"""
findings: list[RuleFinding] = []
recipe = ctx.recipe
graph = ctx.step_graph

guard_steps: dict[str, str] = {}
for step_name, step in recipe.steps.items():
if step.tool != "run_python":
continue
if step.with_args.get("callable") != "autoskillit.smoke_utils.check_loop_iteration":
continue
current_iter_expr = step.with_args.get("current_iteration", "")
m = _CTX_VAR_RE.search(current_iter_expr)
if not m:
continue
guard_steps[step_name] = m.group(1)

if len(guard_steps) < 2:
return findings

audit_outer_guards = {
name for name, counter in guard_steps.items() if "audit_remediation" in counter
}
if not audit_outer_guards:
return findings

reverse_graph: dict[str, set[str]] = {}
for src, targets in graph.items():
for tgt in targets:
reverse_graph.setdefault(tgt, set()).add(src)

for inner_name, inner_counter in guard_steps.items():
if inner_name in audit_outer_guards:
continue

for outer_name in audit_outer_guards:
if inner_name == outer_name:
continue

outer_step = recipe.steps[outer_name]
if outer_step.on_result is None:
continue

non_exit_target: str | None = None
for cond in outer_step.on_result.conditions:
if cond.when and "max_exceeded" in cond.when:
continue
non_exit_target = cond.route
break

if non_exit_target is None or non_exit_target not in recipe.steps:
continue

forward_reachable = bfs_reachable(graph, non_exit_target)
if inner_name not in forward_reachable:
continue

backward_reachable = bfs_reachable(reverse_graph, inner_name)
on_path = forward_reachable & backward_reachable
on_path.add(non_exit_target)
on_path.discard(inner_name)

has_reset = False
for sn in on_path:
candidate = recipe.steps.get(sn)
if candidate is not None and inner_counter in candidate.capture:
has_reset = True
break

if not has_reset:
findings.append(
make_finding(
rule_name="loop-counter-not-reset-on-outer-cycle",
step_name=inner_name,
message=(
f"Inner guard '{inner_name}' uses counter '{inner_counter}' "
f"but is reachable from audit-remediation guard "
f"'{outer_name}' via '{non_exit_target}' without a reset "
f"step. Add a step using "
f"'autoskillit.smoke_utils.init_counter' to capture "
f"'{inner_counter}' on this path so each audit-remediation "
f"cycle gets a fresh budget."
),
)
)

return findings
61 changes: 61 additions & 0 deletions src/autoskillit/recipe/rules/rules_skill_content.py
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,67 @@ def _check_no_autoskillit_import(ctx: ValidationContext) -> list[RuleFinding]:
)
_GIT_GREP_BRE_RE: re.Pattern[str] = re.compile(r"--grep=[\"'].*\\\|")

_POSIX_CHAR_CLASS_RE: re.Pattern[str] = re.compile(
r"\[\[:"
r"(?:alpha|digit|alnum|space|upper|lower|print|punct|blank|cntrl|graph|xdigit)"
r":\]\]"
)


@semantic_rule(
name="posix-char-class-in-skill",
severity=Severity.ERROR,
description=(
"A SKILL.md bash block uses a POSIX bracket expression ([[:space:]], "
"[[:alnum:]], etc.). These are valid in grep -E but silently mis-parsed "
"by Python re — [[:space:]] becomes a character class containing "
"[, :, s, p, a, c, e instead of matching whitespace. "
"Fix: replace [[:space:]] with [ \\t] or a Python-compatible equivalent."
),
)
def _check_no_posix_char_class(ctx: ValidationContext) -> list[RuleFinding]:
findings: 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", "")
if not skill_cmd:
continue
skill_name = resolve_skill_name(skill_cmd)
if skill_name is None:
continue
skill_md = _resolve_skill_md(skill_name, resolver=ctx.skill_resolver)
if skill_md is None:
continue
try:
content = skill_md.read_text(encoding="utf-8")
except OSError:
continue
bash_blocks = extract_bash_blocks(content)
if not bash_blocks:
continue
violations: list[str] = []
for block in bash_blocks:
for line in block.splitlines():
if _POSIX_CHAR_CLASS_RE.search(line):
violations.append(line.strip())
if violations:
findings.append(
make_finding(
rule_name="posix-char-class-in-skill",
step_name=step_name,
message=(
f"Skill '{skill_name}' bash block uses POSIX bracket expression "
f"in {len(violations)} line(s). Python re silently mis-parses "
f"these — [[:space:]] becomes a character class containing "
f"[, :, s, p, a, c, e instead of matching whitespace. "
f"Fix: replace [[:space:]] with [ \\t] or a Python-compatible equivalent. "
f"Violations: {violations!r}"
),
)
)
return findings


@semantic_rule(
name="grep-bre-alternation-in-skill",
Expand Down
4 changes: 2 additions & 2 deletions 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:7b1752e3b62d4cff41b935c363aa6655fabd231943791784009db587633a70c0 -->
<!-- autoskillit-recipe-hash: sha256:64c9d08f8daa9e5277d160a1e86119a2e4b3e64a84c70ddae6d1db6fed73ea41 -->
<!-- autoskillit-diagram-format: v7 -->
## implementation-groups
Group-based implementation with per-group plan/implement/test cycles and PR gates.
Expand All @@ -18,7 +18,7 @@ group → plan → review_approach (optional)
│ fix (on failure) → next_or_done
└────┘
|
+-- audit_impl → remediate (optional)
+-- audit_impl → reset_test_fix_counter → remediate (optional)
|
+-- [open-pr] (optional):
| prepare_pr → compose_pr → review_pr → resolve_review
Expand Down
4 changes: 2 additions & 2 deletions src/autoskillit/recipes/diagrams/implementation.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<!-- autoskillit-recipe-hash: sha256:512146091e1b0925b6963280a929d4586ce652d0b498d43a5280ed5b724f459c -->
<!-- autoskillit-recipe-hash: sha256:1cf6514f266b3334d7ae8845e5321f62f4d160e6bc25dbe65e1ed13dfd254404 -->
<!-- autoskillit-diagram-format: v7 -->
# implementation

Expand All @@ -19,7 +19,7 @@
|
+----+
|
+-- audit_impl → remediate (optional)
+-- audit_impl → reset_test_fix_counter → remediate (optional)
|
+-- [open-pr] (optional):
| prepare_pr → compose_pr → review_pr → resolve_review
Expand Down
4 changes: 2 additions & 2 deletions src/autoskillit/recipes/diagrams/remediation.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<!-- autoskillit-recipe-hash: sha256:c0b46f2ed86362d2104bf8689e07345e746a29f01479ecae013e2c2e266a74d3 -->
<!-- autoskillit-recipe-hash: sha256:3c77001f20b467137e2be091e3df4208647f6a4f4c0af9a974a55a350176b8f5 -->
<!-- autoskillit-diagram-format: v7 -->
## remediation
Investigate, rectify, implement, and merge a bug fix with CI and PR gates.
Expand All @@ -14,7 +14,7 @@ dry_walkthrough → implement ↔ [retry_worktree on context limit]
|
test → assess
|
+-- audit_impl → remediate (optional)
+-- audit_impl → reset_test_fix_counter → pre_remediation_merge → remediate (optional)
|
make_plan → commit_guard → merge → push
|
Expand Down
17 changes: 15 additions & 2 deletions src/autoskillit/recipes/implementation-groups.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@
},
"test_fix_max_retries": {
"default": "3",
"description": "Maximum iterations of the pre-merge test → fix → test cycle.",
"description": "Maximum pre-merge test → fix → test budget (allowed fix rounds = value − 1). Set to 3 for 2 fix rounds per remediation cycle.",
"hidden": true
},
"merge_test_fix_max_retries": {
Expand Down Expand Up @@ -522,7 +522,7 @@
"route": "release_issue_failure"
},
{
"route": "remediate"
"route": "reset_test_fix_counter"
}
],
"on_failure": "release_issue_failure",
Expand All @@ -531,6 +531,19 @@
],
"note": "Guards the audit_impl → remediate → plan → implement → test remediation cycle. Separate counter from merge_fix_count.\n"
},
"reset_test_fix_counter": {
"tool": "run_python",
"with": {
"callable": "autoskillit.smoke_utils.init_counter",
"counter_value": ""
},
"capture": {
"test_fix_loop_count": "${{ result.value }}"
},
"on_success": "remediate",
"on_failure": "remediate",
"note": "Resets test_fix_loop_count to 0 so each audit-remediation cycle gets a fresh pre-merge fix budget. The outer audit_remediation_count already bounds total cycles."
},
"commit_guard": {
"tool": "run_python",
"with": {
Expand Down
20 changes: 18 additions & 2 deletions src/autoskillit/recipes/implementation-groups.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,9 @@ ingredients:
default: "3"
test_fix_max_retries:
default: "3"
description: Maximum iterations of the pre-merge test → fix → test cycle.
description: >-
Maximum pre-merge test → fix → test budget (allowed fix rounds = value − 1).
Set to 3 for 2 fix rounds per remediation cycle.
hidden: true
merge_test_fix_max_retries:
default: "3"
Expand Down Expand Up @@ -483,14 +485,28 @@ steps:
on_result:
- when: "${{ result.max_exceeded }} == true"
route: release_issue_failure
- route: remediate
- route: reset_test_fix_counter
on_failure: release_issue_failure
optional_context_refs:
- audit_remediation_count
note: >
Guards the audit_impl → remediate → plan → implement → test
remediation cycle. Separate counter from merge_fix_count.

reset_test_fix_counter:
tool: run_python
with:
callable: "autoskillit.smoke_utils.init_counter"
counter_value: ""
capture:
test_fix_loop_count: "${{ result.value }}"
on_success: remediate
on_failure: remediate
note: >-
Resets test_fix_loop_count to 0 so each audit-remediation cycle gets a
fresh pre-merge fix budget. The outer audit_remediation_count already
bounds total cycles.


commit_guard:
tool: run_python
Expand Down
17 changes: 15 additions & 2 deletions src/autoskillit/recipes/implementation.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@
},
"test_fix_max_retries": {
"default": "3",
"description": "Maximum iterations of the pre-merge test → fix → test cycle.",
"description": "Maximum pre-merge test → fix → test budget (allowed fix rounds = value − 1). Set to 3 for 2 fix rounds per remediation cycle.",
"hidden": true
},
"merge_test_fix_max_retries": {
Expand Down Expand Up @@ -564,7 +564,7 @@
"route": "release_issue_failure"
},
{
"route": "remediate"
"route": "reset_test_fix_counter"
}
],
"on_failure": "release_issue_failure",
Expand All @@ -573,6 +573,19 @@
],
"note": "Guards the audit_impl → remediate → plan → implement → test remediation cycle. Separate counter from merge_fix_count.\n"
},
"reset_test_fix_counter": {
"tool": "run_python",
"with": {
"callable": "autoskillit.smoke_utils.init_counter",
"counter_value": ""
},
"capture": {
"test_fix_loop_count": "${{ result.value }}"
},
"on_success": "remediate",
"on_failure": "remediate",
"note": "Resets test_fix_loop_count to 0 so each audit-remediation cycle gets a fresh pre-merge fix budget. The outer audit_remediation_count already bounds total cycles."
},
"commit_guard": {
"tool": "run_python",
"with": {
Expand Down
Loading
Loading