feat: add --changed fast mode to maintenance agents restricting scope to git-modified files under devplacepy/ and tests/
Implement a new `--changed` flag for the maintenance agent fleet that limits checking and fixing to only files git reports as modified or new (untracked) under `devplacepy/` and `tests/`. The change introduces `agents/changed.py` with `changed_paths()` parsing `git status --porcelain`, adds `_WRITE_ALLOWLIST` guard logic in `agents/agent.py` (`set_write_allowlist`, `clear_write_allowlist`, `_allowlist_guard`) wired into `_mutation_guard` and `create_file`, threads the file list through `orchestrator.run_fleet` -> `agent.run` -> `_execute` -> `task_prompt` with a dedicated "CHANGED-FILES RUN" prompt branch, and exposes `make maintenance` (read-only) and `make maintenance-fix` (fix mode) targets in the Makefile. Documentation is updated in `AGENTS.md`, `CLAUDE.md`, and `README.md`.
This commit is contained in:
+25
-2
@@ -1547,6 +1547,7 @@ _PROTECTED_TREES: set[str] = set()
|
||||
_RESTRICT_SHELL = False
|
||||
_WRITE_BUDGET: Optional[int] = None
|
||||
_WRITTEN: set[str] = set()
|
||||
_WRITE_ALLOWLIST: Optional[set[str]] = None
|
||||
|
||||
|
||||
def protect_tree(path: Path) -> None:
|
||||
@@ -1578,6 +1579,28 @@ def clear_write_budget() -> None:
|
||||
_WRITTEN.clear()
|
||||
|
||||
|
||||
def set_write_allowlist(paths: Optional[Iterable[str]]) -> None:
|
||||
global _WRITE_ALLOWLIST
|
||||
_WRITE_ALLOWLIST = None if paths is None else {str(Path(p).resolve()) for p in paths}
|
||||
|
||||
|
||||
def clear_write_allowlist() -> None:
|
||||
global _WRITE_ALLOWLIST
|
||||
_WRITE_ALLOWLIST = None
|
||||
|
||||
|
||||
def _allowlist_guard(path: Path) -> Optional[str]:
|
||||
if _WRITE_ALLOWLIST is None:
|
||||
return None
|
||||
if str(Path(path).resolve()) in _WRITE_ALLOWLIST:
|
||||
return None
|
||||
return (
|
||||
f"Out of scope: '{path}' is not in this changed-files run's allowed set "
|
||||
"(the git-modified or new files under devplacepy/ and tests/). Read it for context if you must, "
|
||||
"but do not modify or create it; record any cross-file issue as a finding (fixed=false) instead."
|
||||
)
|
||||
|
||||
|
||||
def _protected_guard(path: Path) -> Optional[str]:
|
||||
if not _PROTECTED_TREES:
|
||||
return None
|
||||
@@ -1607,7 +1630,7 @@ def _budget_guard(path: Path) -> Optional[str]:
|
||||
|
||||
|
||||
def _mutation_guard(path: Path) -> Optional[str]:
|
||||
blocked = _protected_guard(path) or _budget_guard(path)
|
||||
blocked = _protected_guard(path) or _allowlist_guard(path) or _budget_guard(path)
|
||||
if blocked:
|
||||
return blocked
|
||||
if not path.exists():
|
||||
@@ -1684,7 +1707,7 @@ async def create_file(path: str, content: str):
|
||||
"""
|
||||
def _do() -> str:
|
||||
p = Path(path)
|
||||
blocked = _protected_guard(p) or _budget_guard(p)
|
||||
blocked = _protected_guard(p) or _allowlist_guard(p) or _budget_guard(p)
|
||||
if blocked:
|
||||
return json.dumps({"status": "error", "error": blocked})
|
||||
if p.exists():
|
||||
|
||||
+51
-7
@@ -15,6 +15,7 @@ from .agent import (
|
||||
MarkdownRenderer,
|
||||
begin_run_cost,
|
||||
clear_protected_trees,
|
||||
clear_write_allowlist,
|
||||
clear_write_budget,
|
||||
close_http_client,
|
||||
cost_session_total,
|
||||
@@ -27,10 +28,12 @@ from .agent import (
|
||||
reset_tool_scope,
|
||||
set_shell_restricted,
|
||||
set_tool_scope,
|
||||
set_write_allowlist,
|
||||
set_write_budget,
|
||||
usd_str,
|
||||
_with_datetime,
|
||||
)
|
||||
from .changed import changed_paths
|
||||
|
||||
DEFAULT_MAX_ITER = 180
|
||||
WRITE_BUDGET = 20
|
||||
@@ -99,7 +102,31 @@ class MaintenanceAgent:
|
||||
rules = CHECK_RULES if mode == "check" else FIX_RULES
|
||||
return MAINT_HEADER.format(name=self.name, mode_rules=rules, mandate=self.mandate())
|
||||
|
||||
def task_prompt(self, mode: str, scope: Optional[str], seed_findings: Optional[list[dict]] = None) -> str:
|
||||
def task_prompt(
|
||||
self,
|
||||
mode: str,
|
||||
scope: Optional[str],
|
||||
seed_findings: Optional[list[dict]] = None,
|
||||
files: Optional[list[str]] = None,
|
||||
) -> str:
|
||||
if files:
|
||||
listed = "\n".join(f"- {path}" for path in files)
|
||||
action = (
|
||||
"record each confirmed issue"
|
||||
if mode == "check"
|
||||
else "for each issue confirm it, impact-check its consumers, fix it at the root, re-verify, then record it"
|
||||
)
|
||||
return (
|
||||
f"Dimension: {self.description}\n\n"
|
||||
f"CHANGED-FILES RUN ({mode} mode). ONLY these {len(files)} files (git-modified or new, under "
|
||||
f"devplacepy/ and tests/) are in scope. Apply YOUR dimension mandate to ONLY these files and "
|
||||
f"{action} via report_finding. You MAY read other files for cross-reference and impact analysis "
|
||||
f"(Doctrine C), but you MUST NOT report on or modify anything outside this list - record any "
|
||||
f"cross-file issue as a finding (fixed=false) instead. The engine hard-blocks writes outside this "
|
||||
f"set. Apply the Accuracy and Safety Doctrine to every candidate. Do not re-read a file you already "
|
||||
f"read or repeat a grep. Exclude the `agents/` directory entirely.\n\n{listed}\n\n"
|
||||
f"Finish with a single line: 'N findings (E errors, W warnings), M fixed.'"
|
||||
)
|
||||
if mode == "fix" and seed_findings:
|
||||
listed = "\n".join(
|
||||
f"- {f.get('file','')}:{f.get('line') or '?'} [{f.get('rule') or f.get('dimension','')}] {f.get('message','')}"
|
||||
@@ -146,11 +173,12 @@ class MaintenanceAgent:
|
||||
max_iter: int,
|
||||
renderer: Optional[MarkdownRenderer],
|
||||
seed_findings: Optional[list[dict]] = None,
|
||||
files: Optional[list[str]] = None,
|
||||
) -> dict:
|
||||
if mode == "check":
|
||||
return await self._execute(mode, scope, max_iter, renderer, seed_findings)
|
||||
return await self._execute(mode, scope, max_iter, renderer, seed_findings, files)
|
||||
async with _RUN_LOCK:
|
||||
return await self._execute(mode, scope, max_iter, renderer, seed_findings)
|
||||
return await self._execute(mode, scope, max_iter, renderer, seed_findings, files)
|
||||
|
||||
async def _execute(
|
||||
self,
|
||||
@@ -159,6 +187,7 @@ class MaintenanceAgent:
|
||||
max_iter: int,
|
||||
renderer: Optional[MarkdownRenderer],
|
||||
seed_findings: Optional[list[dict]],
|
||||
files: Optional[list[str]] = None,
|
||||
) -> dict:
|
||||
writes_enabled = mode != "check"
|
||||
core.reset_findings()
|
||||
@@ -166,7 +195,10 @@ class MaintenanceAgent:
|
||||
if writes_enabled:
|
||||
protect_agents()
|
||||
set_shell_restricted(True)
|
||||
if seed_findings:
|
||||
if files:
|
||||
set_write_allowlist(files)
|
||||
budget = max(WRITE_BUDGET, len(files) + 2)
|
||||
elif seed_findings:
|
||||
distinct = {f.get("file") for f in seed_findings if f.get("file")}
|
||||
budget = max(WRITE_BUDGET, len(distinct) + 2)
|
||||
set_write_budget(budget)
|
||||
@@ -177,7 +209,11 @@ class MaintenanceAgent:
|
||||
if renderer is not None:
|
||||
icon = AGENT_ICONS.get(self.name, "\U0001F916")
|
||||
scope_note = f" (scope: {scope})" if scope else ""
|
||||
if mode == "fix" and seed_findings:
|
||||
if files and mode == "fix":
|
||||
plan_line = f"fix issues in {len(files)} changed file(s) and verify the build"
|
||||
elif files:
|
||||
plan_line = f"scan {len(files)} changed file(s) read-only and report each issue (no files changed)"
|
||||
elif mode == "fix" and seed_findings:
|
||||
plan_line = f"fix {len(seed_findings)} confirmed finding(s) from the last check, then verify the build"
|
||||
elif mode == "fix":
|
||||
plan_line = f"scan{scope_note}, fix each issue, and verify the build (up to {budget} files this run)"
|
||||
@@ -191,7 +227,7 @@ class MaintenanceAgent:
|
||||
)
|
||||
messages = [
|
||||
{"role": "system", "content": _with_datetime(self.system_prompt(mode))},
|
||||
{"role": "user", "content": self.task_prompt(mode, scope, seed_findings)},
|
||||
{"role": "user", "content": self.task_prompt(mode, scope, seed_findings, files)},
|
||||
]
|
||||
state = AgentState()
|
||||
try:
|
||||
@@ -209,6 +245,7 @@ class MaintenanceAgent:
|
||||
if writes_enabled:
|
||||
clear_protected_trees()
|
||||
clear_write_budget()
|
||||
clear_write_allowlist()
|
||||
set_shell_restricted(False)
|
||||
finished = datetime.now()
|
||||
incomplete = final is None or state.iteration >= max_iter
|
||||
@@ -252,6 +289,7 @@ def build_parser(name: str, description: str) -> argparse.ArgumentParser:
|
||||
mode.add_argument("--check", dest="mode", action="store_const", const="check", help="Report only; non-zero exit on findings")
|
||||
parser.set_defaults(mode="fix")
|
||||
parser.add_argument("--scope", default=None, help="Restrict to one scope unit label")
|
||||
parser.add_argument("--changed", action="store_true", help="Restrict to git-changed files under devplacepy/ and tests/")
|
||||
parser.add_argument("--max-iter", type=int, default=DEFAULT_MAX_ITER, help="Maximum agent iterations")
|
||||
parser.add_argument("--no-color", action="store_true", help="Disable ANSI colour output")
|
||||
parser.add_argument("-v", "--verbose", action="store_true", help="Enable debug logging")
|
||||
@@ -264,8 +302,14 @@ async def _amain(agent: MaintenanceAgent, argv: Optional[list[str]] = None) -> i
|
||||
if args.verbose:
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
renderer = MarkdownRenderer(use_color=not args.no_color)
|
||||
files: Optional[list[str]] = None
|
||||
if args.changed:
|
||||
files = changed_paths()
|
||||
if not files:
|
||||
renderer.print("No changed files under devplacepy/ or tests/; nothing to do.")
|
||||
return 0
|
||||
try:
|
||||
result = await agent.run(args.mode, args.scope, args.max_iter, renderer)
|
||||
result = await agent.run(args.mode, args.scope, args.max_iter, renderer, files=files)
|
||||
return result["exit_code"]
|
||||
finally:
|
||||
await close_http_client()
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Sequence
|
||||
|
||||
DEFAULT_ROOTS: tuple[str, ...] = ("devplacepy", "tests")
|
||||
AGENTS_DIR = "agents"
|
||||
|
||||
|
||||
def _repo_root() -> Path:
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return Path(out.stdout.strip())
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
return Path.cwd()
|
||||
|
||||
|
||||
def _unquote(path: str) -> str:
|
||||
if len(path) >= 2 and path[0] == '"' and path[-1] == '"':
|
||||
return path[1:-1].encode("utf-8").decode("unicode_escape")
|
||||
return path
|
||||
|
||||
|
||||
def _entry_path(line: str) -> str:
|
||||
body = line[3:]
|
||||
if " -> " in body:
|
||||
body = body.split(" -> ", 1)[1]
|
||||
return _unquote(body)
|
||||
|
||||
|
||||
def _first_segment(path: str) -> str:
|
||||
return path.split("/", 1)[0]
|
||||
|
||||
|
||||
def changed_paths(roots: Sequence[str] = DEFAULT_ROOTS) -> list[str]:
|
||||
root = _repo_root()
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["git", "status", "--porcelain"],
|
||||
cwd=root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
return []
|
||||
allowed = set(roots)
|
||||
found: set[str] = set()
|
||||
for line in out.stdout.splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
rel = _entry_path(line)
|
||||
if not rel:
|
||||
continue
|
||||
segment = _first_segment(rel)
|
||||
if segment not in allowed or segment == AGENTS_DIR:
|
||||
continue
|
||||
absolute = root / rel
|
||||
if not absolute.is_file():
|
||||
continue
|
||||
found.add(str(absolute))
|
||||
return sorted(found)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
for path in changed_paths():
|
||||
print(path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+13
-2
@@ -12,6 +12,7 @@ from typing import Callable, Optional
|
||||
|
||||
from . import core
|
||||
from .agent import MarkdownRenderer, close_http_client, cost_session_total, format_usd, get_http_client, install_timestamps, usd_str
|
||||
from .changed import changed_paths
|
||||
from .fleet import REGISTRY, ordered_agents
|
||||
|
||||
|
||||
@@ -22,6 +23,7 @@ def _parser() -> argparse.ArgumentParser:
|
||||
mode.add_argument("--check", dest="mode", action="store_const", const="check")
|
||||
parser.set_defaults(mode="fix")
|
||||
parser.add_argument("--only", default=None, help="Comma-separated subset of agent names")
|
||||
parser.add_argument("--changed", action="store_true", help="Restrict to git-changed files under devplacepy/ and tests/")
|
||||
parser.add_argument("--max-iter", type=int, default=120)
|
||||
parser.add_argument("--no-color", action="store_true")
|
||||
parser.add_argument("-v", "--verbose", action="store_true")
|
||||
@@ -34,6 +36,7 @@ async def run_fleet(
|
||||
max_iter: int,
|
||||
renderer: Optional[MarkdownRenderer],
|
||||
on_result: Optional[Callable[[str, dict], None]] = None,
|
||||
files: Optional[list[str]] = None,
|
||||
) -> int:
|
||||
names = ordered_agents(only.split(",") if only else None)
|
||||
started = datetime.now()
|
||||
@@ -43,7 +46,7 @@ async def run_fleet(
|
||||
agent = REGISTRY[name]()
|
||||
if renderer is not None:
|
||||
renderer.print(f"\n## {name}")
|
||||
result = await agent.run(mode, None, max_iter, renderer)
|
||||
result = await agent.run(mode, None, max_iter, renderer, files=files)
|
||||
if on_result is not None:
|
||||
on_result(name, result)
|
||||
return {
|
||||
@@ -54,6 +57,8 @@ async def run_fleet(
|
||||
"json": result["json"],
|
||||
}
|
||||
|
||||
if renderer is not None and files is not None:
|
||||
renderer.print(f"\n# Fleet scoped to {len(files)} changed file(s) under devplacepy/ and tests/")
|
||||
if mode == "check":
|
||||
if renderer is not None:
|
||||
renderer.print(f"\n# Fleet [check] running {len(names)} agents concurrently (read-only)")
|
||||
@@ -107,8 +112,14 @@ async def _amain(argv: Optional[list[str]] = None) -> int:
|
||||
if args.verbose:
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
renderer = MarkdownRenderer(use_color=not args.no_color)
|
||||
files: Optional[list[str]] = None
|
||||
if args.changed:
|
||||
files = changed_paths()
|
||||
if not files:
|
||||
renderer.print("No changed files under devplacepy/ or tests/; nothing to do.")
|
||||
return 0
|
||||
try:
|
||||
return await run_fleet(args.mode, args.only, args.max_iter, renderer)
|
||||
return await run_fleet(args.mode, args.only, args.max_iter, renderer, files=files)
|
||||
finally:
|
||||
await close_http_client()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user