CascadingDev/automation/runner.py

148 lines
5.6 KiB
Python

"""
automation.runner
=================
Entry point invoked by the pre-commit hook to evaluate `.ai-rules.yml`
instructions. The runner is intentionally thin: it inspects staged files,
looks up the matching rule/output definitions, merges instruction strings and
delegates execution to `automation.patcher.generate_output`, which handles the
heavy lifting (prompt composition, AI invocation, patch application).
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from typing import Dict, Iterable
from automation.config import RulesConfig
from automation.patcher import ModelConfig, generate_output, run
def get_staged_files(repo_root: Path) -> list[Path]:
"""
Return staged (added/modified) paths relative to the repository root.
"""
# We only care about what is in the index; the working tree may include
# experiments the developer does not intend to commit. `--diff-filter=AM`
# narrows the list to new or modified files.
result = run(
["git", "diff", "--cached", "--name-only", "--diff-filter=AM"],
cwd=repo_root,
check=False,
)
return [Path(line.strip()) for line in result.stdout.splitlines() if line.strip()]
def merge_instructions(source_instr: str, output_instr: str, append_instr: str) -> str:
"""
Combine source-level, output-level, and append instructions into a single prompt.
"""
final = output_instr.strip() if output_instr else source_instr.strip()
if not final:
final = source_instr.strip()
append_instr = append_instr.strip()
if append_instr:
prefix = (final + "\n\n") if final else ""
final = f"{prefix}Additional requirements for this output location:\n{append_instr}"
return final.strip() # Final, human-readable instruction block handed to the AI
def process(repo_root: Path, rules: RulesConfig, model: ModelConfig) -> int:
"""
Walk staged files, resolve matching outputs, and invoke the patcher for each.
"""
# 1) Gather the staged file list (Git index only).
staged_files = get_staged_files(repo_root)
if not staged_files:
return 0
# 2) For each staged file, look up the matching rule and iterate outputs.
for src_rel in staged_files:
# Find the most specific rule (nearest .ai-rules.yml wins).
rule_name = rules.get_rule_name(src_rel)
if not rule_name:
continue
rule_config = rules.cascade_for(src_rel, rule_name)
outputs: Dict[str, Dict] = rule_config.get("outputs") or {}
source_instruction = rule_config.get("instruction", "")
for output_name, output_cfg in outputs.items():
if not isinstance(output_cfg, dict):
continue
if str(output_cfg.get("enabled", "true")).lower() == "false":
continue
path_template = output_cfg.get("path")
if not path_template:
continue
rendered_path = rules.resolve_template(path_template, src_rel)
try:
output_rel = rules.normalize_repo_rel(rendered_path)
except ValueError:
print(f"[runner] skipping {output_name}: unsafe path {rendered_path}", file=sys.stderr)
continue
# Build the instruction set for this output. Output-specific text
# overrides the rule-level text, and we keep the source version as a
# fallback.
instruction = output_cfg.get("instruction", "") or source_instruction
append = output_cfg.get("instruction_append", "")
model_hint = rule_config.get("model_hint", "")
output_type = output_cfg.get("output_type")
if output_type:
extra = rules.cascade_for(output_rel, output_type)
instruction = extra.get("instruction", instruction)
append = extra.get("instruction_append", append)
# Output type can also override model hint
if "model_hint" in extra:
model_hint = extra["model_hint"]
final_instruction = merge_instructions(source_instruction, instruction, append)
# 3) Ask the patcher to build a diff with the assembled instruction.
try:
print(f"[runner] generating {output_rel.as_posix()} from {src_rel.as_posix()}")
generate_output(
repo_root=repo_root,
rules=rules,
model=model,
source_rel=src_rel,
output_rel=output_rel,
instruction=final_instruction,
model_hint=model_hint,
)
except Exception as exc: # pragma: no cover - defensive
print(f"[runner] error generating {output_rel}: {exc}", file=sys.stderr)
return 0
def main(argv: list[str] | None = None) -> int:
"""
CLI entry point used by the pre-commit hook.
"""
# Parse command-line options (only --model override today).
parser = argparse.ArgumentParser(description="CascadingDev AI runner")
parser.add_argument("--model", help="Override AI command (default from env)")
args = parser.parse_args(argv)
# Load the nearest .ai-rules.yml (fail quietly if missing).
repo_root = Path.cwd().resolve()
try:
rules = RulesConfig.load(repo_root)
except FileNotFoundError:
print("[runner] .ai-rules.yml not found; skipping")
return 0
# Instantiate the model config and delegate to the processing pipeline.
model = ModelConfig.from_sources(repo_root, args.model)
return process(repo_root, rules, model)
if __name__ == "__main__":
sys.exit(main())