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
12 changes: 10 additions & 2 deletions src/autoskillit/execution/diff_annotator.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,21 +202,29 @@ def filter_findings(
"""Partition findings into filtered (in-range) and unpostable (out-of-range).

When valid_lines is provided, uses exact set-membership for validation instead
of hunk-span interval checking. When both are absent, all findings pass through.
of hunk-span interval checking. When both are absent, all findings pass through
except those with null/zero line numbers, which route to unpostable.
Sets all_unpostable=True when total findings > 0 and filtered is empty.
"""
if not findings:
return FilterResult()

if not valid_ranges and valid_lines is None:
good = [f for f in findings if f.get("line") and f["line"] != 0]
bad = [f for f in findings if not f.get("line") or f["line"] == 0]
if bad:
return FilterResult(filtered=good, unpostable=bad, all_unpostable=not good)
return FilterResult(filtered=list(findings))

filtered: list[dict] = []
unpostable: list[dict] = []

for finding in findings:
file_path = finding.get("file", "")
line_num = finding.get("line", 0)
line_num = finding.get("line") or 0
if line_num == 0:
unpostable.append(finding)
continue

if valid_lines is not None:
valid_set = set(valid_lines.get(file_path, []))
Expand Down
2 changes: 1 addition & 1 deletion src/autoskillit/recipe/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ Sub-package: rules/ (see rules/AGENTS.md).
| `_analysis_detectors.py` | Dead outputs + ref invalidations + implicit handoffs |
| `_git_helpers.py` | Shared git-remote regex (`_GIT_REMOTE_COMMAND_RE`, `_LITERAL_ORIGIN_RE`) for lint rules |
| `_skill_helpers.py` | Shared helpers for skill-related semantic rules |
| `_skill_placeholder_parser.py` | SKILL.md structural analysis: bash placeholder extraction, step section parsing, section splitting, prose GraphQL detection, git command extraction |
| `_skill_placeholder_parser.py` | SKILL.md structural analysis: bash placeholder extraction, step section parsing, section splitting, prose GraphQL detection, git command extraction, blockquote section extraction |
| `identity.py` | Recipe identity hashing — content and composite fingerprints |
| `schema.py` | `Recipe`, `RecipeStep`, `DataFlowWarning` |
| `staleness_cache.py` | Staleness cache for contract and diagram freshness checks |
Expand Down
69 changes: 69 additions & 0 deletions src/autoskillit/recipe/_skill_placeholder_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,3 +215,72 @@ def extract_validation_rule_block(content: str, rule_label: str) -> str | None:
if m.group(1) == rule_label:
return m.group(0).strip()
return None


_CONTENT_VAR_SIGNAL_RE = re.compile(r"\{[a-z_]+_content\}")


def extract_blockquote_sections(content: str) -> list[tuple[str, str]]:
"""Extract blockquote sections from SKILL.md content that are subagent prompts.

Returns ``(step_context, block_text)`` tuples where ``step_context`` is the
nearest ``### `` heading above the block and ``block_text`` is the joined
blockquote content with the ``> `` prefix stripped.

Inclusion criteria — a contiguous blockquote run is yielded if ANY of:

- It contains at least 3 contiguous ``> `` lines (likely a subagent prompt)
- It contains a ``{*_content}`` placeholder (content signal — even 1-2 line
blocks with these are subagent-prompt-shaped, not stylistic callouts)

Single-line ``>`` callouts without content signals (e.g., ``> **Note:**``)
are excluded — those are stylistic, not subagent prompts.

A trailing blockquote that runs to end of file is flushed (not silently
dropped).
"""
sections: list[tuple[str, str]] = []
current_heading = ""
buf: list[str] = []

def flush() -> None:
if not buf:
return
joined = "\n".join(buf)
has_content_signal = bool(_CONTENT_VAR_SIGNAL_RE.search(joined))
if len(buf) >= 3 or has_content_signal:
sections.append((current_heading, joined))
buf.clear()

for line in content.splitlines():
heading_match = _STEP_RE.match(line)
if heading_match:
flush()
current_heading = heading_match.group(1)
continue
if line.startswith("> "):
buf.append(line[2:])
elif line.strip() == ">":
buf.append("")
elif line.startswith(">"):
# '>' without space (rare) — still blockquote, strip the '>'
buf.append(line[1:].lstrip())
else:
flush()

flush() # trailing blockquote at EOF
return sections


_BANNED_CONTENT_SUFFIX_RE = re.compile(r"\{([a-z_]+_content)\}")


def extract_blockquote_placeholders(blockquote_text: str) -> set[str]:
"""Extract ``{identifier}`` tokens from blockquote text matching ``*_content``.

The naming convention is the key signal: ``*_content`` in a subagent-facing
blockquote is always wrong — should be ``*_path`` with the subagent reading
the file. Other placeholder patterns (``*_path``, ``*_name``, etc.) are
intentionally excluded; those are valid.
"""
return {m.group(1) for m in _BANNED_CONTENT_SUFFIX_RE.finditer(blockquote_text)}
71 changes: 71 additions & 0 deletions src/autoskillit/recipe/rules/rules_skill_content.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
_VRULE_RE,
extract_bash_blocks,
extract_bash_placeholders,
extract_blockquote_placeholders,
extract_blockquote_sections,
extract_declared_ingredients,
extract_graphql_blocks,
extract_python_blocks,
Expand Down Expand Up @@ -960,3 +962,72 @@ def _check_graphql_query_requires_shell_invocation(ctx: ValidationContext) -> li
)

return findings


_BANNED_BLOCKQUOTE_VARS: frozenset[str] = frozenset(
{
"annotated_diff_content",
"diff_content",
"section_diff_content",
}
)


@semantic_rule(
name="inline-content-in-subagent-prompt",
description=(
"A SKILL.md blockquoted subagent prompt references a banned *_content "
"variable. Subagent prompts must use *_path variables and instruct the "
"subagent to Read the file, per the inline-content-in-subagent-prompt "
"architectural rule (PR #3651)."
),
)
def _check_inline_content_in_subagent_prompt(ctx: ValidationContext) -> list[RuleFinding]:
"""Fire WARNING when a run_skill step's SKILL.md uses banned *_content placeholders.

Detection scans blockquote subagent prompts for ``{*_content}`` variables
that are never defined as ingredients. The naming convention is wrong:
subagent prompts must reference content by PATH (``*_path``) with the
subagent reading the file. Inline content placeholders are dangling and
will be silently dropped by the recipe framework, leaving the subagent
prompt incomplete.
"""
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 # unknown-skill-command rule handles missing skills
try:
content = skill_md.read_text(encoding="utf-8")
except OSError:
continue # file deleted or unreadable between resolution and read

for step_context, block_text in extract_blockquote_sections(content):
banned_found = extract_blockquote_placeholders(block_text) & _BANNED_BLOCKQUOTE_VARS
if not banned_found:
continue
for banned_var in sorted(banned_found):
context_label = f"step {step_context!r}" if step_context else "no step heading"
findings.append(
make_finding(
rule_name="inline-content-in-subagent-prompt",
step_name=step_name,
message=(
f"Skill '{skill_name}' blockquote subagent prompt in {context_label} "
f"references banned {{placeholder}} {{{banned_var}}}. "
f"Subagent prompts must use a *_path variable (e.g., "
f"{{{banned_var.removesuffix('_content')}_path}}) and instruct the "
f"subagent to Read the file. Inline content placeholders are "
f"never populated and produce incomplete prompts."
),
)
)
return findings
29 changes: 24 additions & 5 deletions src/autoskillit/skills_extended/audit-claims/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ comments and emits a verdict for recipe routing.
- Generate findings for `experimental` claims — they are self-evidencing by definition
- Run subagents in the background (`run_in_background: true` is prohibited)
- Issue subagent Task calls sequentially — ALL must be in a single parallel message
- Embed diff content inline in subagent prompts — always pass by path and instruct subagents to Read

**ALWAYS:**
- Use the explicit `pr_url` argument instead of re-discovering via `gh pr list`
Expand Down Expand Up @@ -119,6 +120,14 @@ Do not output any prose between subagent dispatches. Immediately proceed to the
Divide the diff by top-level markdown section: `## Executive Summary`, `## Results`,
`## Methodology`, `## Discussion`, `## Limitations`, and any other top-level `##` section.

After dividing the diff by section, write each section's diff chunk to its own file at
`{{AUTOSKILLIT_TEMP}}/audit-claims/section_diff_{section_slug}_{pr_number}.txt` where
`{section_slug}` is the section name lowercased with spaces replaced by underscores (e.g.
`executive_summary`, `results`, `methodology`). Use `jq -n` or the Write tool — do not use
inline Python one-liners or heredoc scripts with `open()` (these are blocked by the sandbox,
per the existing convention at Step 1 of this file). Then bind `{section_diff_path}` to that
section's file path when building each subagent's prompt.

Launch one subagent via `Agent(model="sonnet")` per section containing `+` diff lines.
Each subagent returns a JSON array of extracted claims:

Expand Down Expand Up @@ -154,8 +163,7 @@ Subagent prompt template:
> - comparative: compares to prior work, published results, or baselines
>
> If no claims found in this section, return an empty array [].
> Diff content for section [{section_name}]:
> {section_diff_content}
> Read the section diff from: {section_diff_path}

Aggregate all extracted claims from all subagents. Save to
`{{AUTOSKILLIT_TEMP}}/audit-claims/claims_{pr_number}.json`. Use `jq -n` or the Write tool. If the file already exists from a prior retry, either read it first (to satisfy the Write tool guard) or use a Bash redirect (`jq -n ... > path`).
Expand Down Expand Up @@ -189,6 +197,12 @@ returns findings:
- `comparative` — requires attribution; "comparable to state-of-the-art" without citation
is `critical`

When building each subagent's prompt, bind:
- `{claims_json_path}` to `{{AUTOSKILLIT_TEMP}}/audit-claims/claims_{pr_number}.json`
(the file written in Phase 1 above)
- `{diff_file_path}` to `{{AUTOSKILLIT_TEMP}}/audit-claims/diff_{pr_number}.txt`
(the file written in Step 2 above)

Subagent prompt template:

> You are checking citation evidence for [{claim_type}] claims in a GitHub PR diff.
Expand All @@ -203,13 +217,17 @@ Subagent prompt template:
> remove claim).
>
> Evidence rules for [{claim_type}]:
> {evidence_rules_for_type}
> Apply the rule for your claim type as enumerated under "Evidence rules per claim type"
> earlier in this skill (skipped entirely for `experimental` claims; warning for missing
> `external` citations or for specific numeric comparisons in `external` claims being
> critical; warning for missing `methodological` rationale; critical for unattributed
> `comparative` claims such as "comparable to state-of-the-art" without citation).
>
> If all claims have adequate evidence, return an empty array [].
> Claims to check:
> {claims_json}
> Read the claims file at: {claims_json_path}
> Full PR diff:
> {diff_content}
> Read the diff file at: {diff_file_path}

Save findings to `{{AUTOSKILLIT_TEMP}}/audit-claims/findings_{pr_number}.json`. Use `jq -n` or the Write tool. If the file already exists from a prior retry, either read it first (to satisfy the Write tool guard) or use a Bash redirect (`jq -n ... > path`).

Expand Down Expand Up @@ -410,6 +428,7 @@ Exit 1 only for unrecoverable tool-level errors.
```
{{AUTOSKILLIT_TEMP}}/audit-claims/
├── diff_{pr_number}.txt
├── section_diff_{section_slug}_{pr_number}.txt (Phase 1 intermediate, one per section)
├── claims_{pr_number}.json (Phase 1 output)
├── findings_{pr_number}.json (Phase 2 output)
└── summary_{pr_number}_{ts}.md
Expand Down
14 changes: 10 additions & 4 deletions src/autoskillit/skills_extended/review-pr/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ by the recipe pipeline after `open_pr_step` opens the PR.
- Run subagents in the background (`run_in_background: true` is prohibited)
- Issue subagent Task calls sequentially — ALL must be in a single parallel message
- Specify `subagent_type` when spawning audit subagents — these are ephemeral agents, not registered agent definitions. Use `Agent(model="sonnet")` exactly as shown, with no `subagent_type` parameter.
- Embed diff content inline in subagent prompts — always pass by path and instruct subagents to Read

**ALWAYS:**
- Find the PR by feature branch at invocation time (not from a pre-captured URL)
Expand Down Expand Up @@ -410,8 +411,9 @@ Subagent prompt template (dimensions 1–6):
> `+` or context line's marker in the same hunk.
>
> If no issues found, return an empty array [].
> Annotated diff content (each line prefixed with [LNNN] markers):
> {annotated_diff_content}
> Read the annotated diff file at path: {annotated_diff_path}
> Each line in the file is prefixed with [LNNN] markers indicating the GitHub diff line number.
> Use the [LNNN] number as the `line` value in your findings JSON.
>
> Prior resolved findings (DO NOT RE-RAISE — these have been addressed by resolve-review):
> {json_list_of_prior_resolved_findings or "[]"}
Expand Down Expand Up @@ -442,7 +444,11 @@ Subagent prompt template (dimensions 1–6):
> are repetitive.

Pass `prior_resolved_findings` and `prior_unresolved_findings` (both as JSON arrays) into each
subagent prompt via template substitution, same as `annotated_diff_content`.
subagent prompt via template substitution. The annotated diff is available at `annotated_diff_path`
— instruct subagents to Read it. If `annotated_diff_path` is unset or the file does not exist,
state "No annotated diff is available for this run — evaluate the diff without [LNNN] anchors and
approximate `line` from context" instead of emitting a reference to a nonexistent path. This
mirrors the existing graceful-degradation fallback for `DISPATCH_AGENTS` documented in Step 2.9.

Subagent prompt template (dimension 7 — deletion_regression, only when `deletion_context` is non-null):

Expand All @@ -455,7 +461,7 @@ Subagent prompt template (dimension 7 — deletion_regression, only when `deleti
> Deleted symbols: {deletion_context.deleted_symbols}
>
> PR diff:
> {diff_content}
> Read the raw diff file at path: {diff_file_path}
>
> Instructions:
> - For each deleted file in the deletion context: check if the diff adds or recreates it
Expand Down
12 changes: 9 additions & 3 deletions src/autoskillit/skills_extended/review-research-pr/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,13 @@ a summary verdict. Called by the recipe pipeline after `open_research_pr` opens

## Arguments

`/autoskillit:review-research-pr <worktree-path-or-feature-branch> <base-branch> [hunk_ranges_path=<path>] [valid_lines_path=<path>]`
`/autoskillit:review-research-pr <worktree-path-or-feature-branch> <base-branch> [annotated_diff_path=<path>] [hunk_ranges_path=<path>] [valid_lines_path=<path>]`

- **worktree-path-or-feature-branch** — Either an absolute path to the research worktree
(preferred; skill derives the feature branch from `git rev-parse --abbrev-ref HEAD`)
or the feature branch name directly
- **base-branch** — The base branch the PR targets (e.g., "main")
- **annotated_diff_path** (optional) — absolute path to a pre-computed annotated diff file (produced by `annotate_pr_diff` run_python step). When provided and present, read from file instead of running python3.
- **hunk_ranges_path** (optional) — absolute path to a pre-computed hunk ranges JSON file (produced by `annotate_pr_diff` run_python step). When provided, loaded in Step 2.7 instead of parsing from the diff inline.
- **valid_lines_path** (optional) — absolute path to a pre-computed valid lines JSON file (produced by `annotate_pr_diff` run_python step). Contains exact `{filepath: [line_numbers]}` set. When provided, enables exact set-membership validation in Step 4.

Expand All @@ -48,6 +49,7 @@ a summary verdict. Called by the recipe pipeline after `open_research_pr` opens
results are valid outcomes for research PRs (do not flag them)
- Run subagents in the background (`run_in_background: true` is prohibited)
- Issue subagent Task calls sequentially — ALL must be in a single parallel message
- Embed diff content inline in subagent prompts — always pass by path and instruct subagents to Read

**ALWAYS:**
- Find the PR by feature branch at invocation time (not from a pre-captured URL)
Expand Down Expand Up @@ -226,8 +228,12 @@ Subagent prompt template (all 8 dimensions):
> `+` or context line's marker in the same hunk.
>
> If no issues found, return an empty array [].
> Annotated diff content (each line prefixed with [LNNN] markers):
> {annotated_diff_content}
> Read the annotated diff file at path: {annotated_diff_path}
> Each line is prefixed with [LNNN] markers. Use the [LNNN] number as the `line` value.

If `annotated_diff_path` is unset or the file does not exist, state "No annotated diff is
available for this run — evaluate the diff without [LNNN] anchors and approximate `line` from
context" instead of emitting a reference to a nonexistent path.

### Step 4: Aggregate and Deduplicate Findings

Expand Down
17 changes: 17 additions & 0 deletions tests/arch/test_subpackage_isolation.py
Original file line number Diff line number Diff line change
Expand Up @@ -1091,6 +1091,23 @@ def test_data_directories_are_not_python_packages() -> None:
"; _register_agent_tomls session-config registration for generated roles "
"(+39 net lines)",
),
"rules_skill_content.py": (
1200,
"REQ-CNST-010-E11: SKILL.md content validation rules registry — accumulating "
"semantic rules (undefined-bash-placeholder, hardcoded-origin-remote, "
"blind-git-add, no-interpreter-mediated-writes, no-autoskillit-import, "
"no-posix-char-class, no-grep-bre-alternation, output-section-no-markdown-directive, "
"no-gh-issue-comment, transition-boundary-anti-confirmation, "
"executable-field-content-validity, reviews-post-requires-input-flag, "
"source-attribution-directive, graphql-query-requires-shell-invocation, "
"inline-content-in-subagent-prompt) co-located to keep SKILL.md validation "
"discovery a single import; splitting into sub-modules per rule would fragment "
"the @semantic_rule registration surface and break the test filter cascade."
"; inline-content-in-subagent-prompt rule (#4289 manifestation, #3636 architectural): "
"extract_blockquote_sections + extract_blockquote_placeholders helpers co-located "
"in _skill_placeholder_parser.py and re-used by both rules_skill_content.py "
"and the tests/skills/ contract linters (+~60 net lines)",
),
}


Expand Down
Loading
Loading