mirror of
https://github.com/harvard-edge/cs249r_book.git
synced 2026-05-08 18:01:20 -05:00
Adds book/tools/audit/ — a five-stage editorial audit pipeline (SCAN -> PLAN -> FIX -> VERIFY -> REPORT) for the MIT Press round 1 style rules in book-prose-merged.md. Implements Phase A of the plan at /Users/VJ/Desktop/MIT_Press_Feedback/15_audit_loop/PLAN.md. Architecture. Five-stage cycle with hard stopping conditions and verification gating. Five safety checks before any file is written: no null bytes, no leftover sentinels, expected byte delta matches actual, quarto structural delta is zero, no new issues introduced. The byte-delta check alone would have caught the discarded bulk-edit run that prompted Pass 15. Files. protected_contexts.py is the load-bearing safety layer with LineWalker and inline_protected_spans (14 adversarial tests covering every failure mode from the discarded bulk run). ledger.py is the JSON schema. scan.py, fix_script_lane.py, verify.py, loop.py are the stage runners. Seven check modules cover vs_period, compound_prefix, percent_symbol, lowercase_prose_references, acknowledgements_spelling, binary_units, and h3_titlecase. Validation. h3_titlecase finds 609 in vol1 (within 2 of plan's expected 611). vs_period finds 0 in vol1 (matching pass 10b) and 16 in vol2 (real residuals). compound_prefix finds 19 in vol1 (real misses pass 10b's regex did not catch) and 46 in vol2 (never swept).
435 lines
14 KiB
Python
435 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""Pass 15 orchestrator — the five-stage cycle.
|
|
|
|
Runs SCAN -> PLAN -> FIX -> VERIFY -> REPORT. Stops when any of the
|
|
five conditions in Pass 15 plan section 2.5 is met:
|
|
|
|
1. Zero open issues remaining in active categories -> exit success
|
|
2. No progress in last iteration -> exit "stuck", print surviving
|
|
3. Verification failure -> exit "verification failed", do not retry
|
|
4. Time budget exceeded -> exit "budget exceeded"
|
|
5. Category-level abort (> 5 verification failures in one run) ->
|
|
disable that category, continue others
|
|
|
|
Usage:
|
|
# Scout mode - scan only, no fixes
|
|
python3 book/tools/audit/loop.py --scope vol2 --dry-run
|
|
|
|
# Fix + verify, one category at a time
|
|
python3 book/tools/audit/loop.py --scope vol2 \\
|
|
--categories vs-period --apply --verbose
|
|
|
|
# Fix + verify + commit, multiple categories
|
|
python3 book/tools/audit/loop.py --scope vol2 \\
|
|
--categories vs-period,compound-prefix-closeup \\
|
|
--apply --commit-each-iteration --verbose
|
|
|
|
Phase B (subagent dispatch) adds a separate lane but uses the same cycle.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from collections import defaultdict
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
|
|
from audit.fix_script_lane import fix_all as script_lane_fix_all
|
|
from audit.ledger import (
|
|
Issue,
|
|
Ledger,
|
|
STATUS_FAILED_SCRIPT,
|
|
STATUS_FAILED_VERIFY,
|
|
STATUS_FIXED_SCRIPT,
|
|
STATUS_FIXED_SUBAGENT,
|
|
STATUS_OPEN,
|
|
STATUS_VERIFIED,
|
|
)
|
|
from audit.scan import scan, print_summary, REPO_ROOT
|
|
from audit.verify import verify
|
|
|
|
# ── Stage runners ───────────────────────────────────────────────────────────
|
|
|
|
|
|
def stage_scan(scope: str, categories: list[str] | None, verbose: bool) -> Ledger:
|
|
"""Run the SCAN stage."""
|
|
if verbose:
|
|
print(f"\n{'=' * 60}", file=sys.stderr)
|
|
print(f"[1/5] SCAN scope={scope}", file=sys.stderr)
|
|
print(f"{'=' * 60}", file=sys.stderr)
|
|
return scan(scope, categories, verbose=verbose)
|
|
|
|
|
|
def stage_plan(
|
|
ledger: Ledger, verbose: bool
|
|
) -> dict[str, list[Issue]]:
|
|
"""Run the PLAN stage.
|
|
|
|
Group open issues by lane. Returns a dict:
|
|
{
|
|
'script': [...open auto-fixable issues...],
|
|
'subagent': [...open needs-subagent issues...],
|
|
'accepted': [...open protected-context issues...],
|
|
}
|
|
"""
|
|
if verbose:
|
|
print(f"\n{'=' * 60}", file=sys.stderr)
|
|
print(f"[2/5] PLAN", file=sys.stderr)
|
|
print(f"{'=' * 60}", file=sys.stderr)
|
|
|
|
lanes: dict[str, list[Issue]] = {
|
|
"script": [],
|
|
"subagent": [],
|
|
"accepted": [],
|
|
}
|
|
for issue in ledger.issues:
|
|
if issue.status != STATUS_OPEN:
|
|
continue
|
|
if issue.protected_context:
|
|
lanes["accepted"].append(issue)
|
|
elif issue.auto_fixable:
|
|
lanes["script"].append(issue)
|
|
elif issue.needs_subagent:
|
|
lanes["subagent"].append(issue)
|
|
else:
|
|
# Detection-only, e.g. binary-units-in-prose
|
|
lanes["accepted"].append(issue)
|
|
|
|
if verbose:
|
|
for lane, issues in lanes.items():
|
|
print(f" {lane:10s} {len(issues):6d} issues", file=sys.stderr)
|
|
|
|
return lanes
|
|
|
|
|
|
def stage_fix(
|
|
ledger: Ledger,
|
|
lanes: dict[str, list[Issue]],
|
|
categories: list[str] | None,
|
|
dry_run: bool,
|
|
verbose: bool,
|
|
) -> tuple[int, list[Path]]:
|
|
"""Run the FIX stage (script lane only in Phase A).
|
|
|
|
Returns (applied_count, list_of_changed_files).
|
|
"""
|
|
if verbose:
|
|
print(f"\n{'=' * 60}", file=sys.stderr)
|
|
print(
|
|
f"[3/5] FIX {'(dry run)' if dry_run else '(LIVE)'}",
|
|
file=sys.stderr,
|
|
)
|
|
print(f"{'=' * 60}", file=sys.stderr)
|
|
|
|
# Script lane
|
|
script_results = script_lane_fix_all(
|
|
ledger,
|
|
categories=categories,
|
|
dry_run=dry_run,
|
|
verbose=verbose,
|
|
)
|
|
|
|
# Collect changed files (those with successful fixes)
|
|
changed: set[Path] = set()
|
|
applied = 0
|
|
for r in script_results:
|
|
if r.applied > 0:
|
|
changed.add(Path(r.file))
|
|
applied += r.applied
|
|
|
|
return applied, sorted(changed)
|
|
|
|
|
|
def stage_verify(
|
|
ledger: Ledger,
|
|
changed_files: list[Path],
|
|
dry_run: bool,
|
|
run_quarto: bool,
|
|
verbose: bool,
|
|
) -> bool:
|
|
"""Run the VERIFY stage. Returns True if all checks pass."""
|
|
if verbose:
|
|
print(f"\n{'=' * 60}", file=sys.stderr)
|
|
print(f"[4/5] VERIFY", file=sys.stderr)
|
|
print(f"{'=' * 60}", file=sys.stderr)
|
|
|
|
if dry_run:
|
|
if verbose:
|
|
print(" Skipping verification in dry-run mode", file=sys.stderr)
|
|
return True
|
|
|
|
if not changed_files:
|
|
if verbose:
|
|
print(" No changed files to verify", file=sys.stderr)
|
|
return True
|
|
|
|
ok, errors = verify(ledger, changed_files, run_quarto, verbose)
|
|
if not ok:
|
|
print(f"\nVerification failed with {len(errors)} error(s):",
|
|
file=sys.stderr)
|
|
for e in errors[:10]:
|
|
print(f" - {e}", file=sys.stderr)
|
|
|
|
# Roll back fixed issues in the ledger (mark failed-verification).
|
|
# We do NOT roll back file contents — the operator must do that
|
|
# manually with `git checkout --`. That is safer than automated
|
|
# rollback because it gives the human a chance to inspect.
|
|
for issue in ledger.issues:
|
|
if issue.status == STATUS_FIXED_SCRIPT:
|
|
issue.status = STATUS_FAILED_VERIFY
|
|
issue.error = "verification stage rejected"
|
|
else:
|
|
# Promote to verified
|
|
for issue in ledger.issues:
|
|
if issue.status in (STATUS_FIXED_SCRIPT, STATUS_FIXED_SUBAGENT):
|
|
issue.status = STATUS_VERIFIED
|
|
|
|
return ok
|
|
|
|
|
|
def stage_report(
|
|
ledger: Ledger,
|
|
applied: int,
|
|
iteration: int,
|
|
verbose: bool,
|
|
) -> None:
|
|
"""Run the REPORT stage."""
|
|
if verbose:
|
|
print(f"\n{'=' * 60}", file=sys.stderr)
|
|
print(f"[5/5] REPORT (iteration {iteration})", file=sys.stderr)
|
|
print(f"{'=' * 60}", file=sys.stderr)
|
|
print_summary(ledger)
|
|
print(f"\nIteration {iteration}: {applied} fixes applied and verified",
|
|
file=sys.stderr)
|
|
|
|
|
|
def stage_commit(
|
|
changed_files: list[Path],
|
|
message: str,
|
|
verbose: bool,
|
|
) -> bool:
|
|
"""Create a git commit for the changed files."""
|
|
if not changed_files:
|
|
return True
|
|
|
|
rel_files = []
|
|
for f in changed_files:
|
|
try:
|
|
rel_files.append(str(f.relative_to(REPO_ROOT)))
|
|
except ValueError:
|
|
rel_files.append(str(f))
|
|
|
|
try:
|
|
subprocess.run(
|
|
["git", "add", "--"] + rel_files,
|
|
cwd=REPO_ROOT,
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
result = subprocess.run(
|
|
["git", "commit", "-m", message],
|
|
cwd=REPO_ROOT,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if result.returncode != 0:
|
|
print(
|
|
f"Commit failed: {result.stdout}\n{result.stderr}",
|
|
file=sys.stderr,
|
|
)
|
|
return False
|
|
if verbose:
|
|
print(f"Committed: {message}", file=sys.stderr)
|
|
return True
|
|
except subprocess.CalledProcessError as e:
|
|
print(f"git command failed: {e}", file=sys.stderr)
|
|
return False
|
|
|
|
|
|
# ── Orchestrator ────────────────────────────────────────────────────────────
|
|
|
|
|
|
def run_loop(args: argparse.Namespace) -> int:
|
|
"""Run the full five-stage cycle with stopping conditions."""
|
|
start_time = time.time()
|
|
max_wall_seconds = args.time_budget_min * 60
|
|
|
|
categories = (
|
|
[c.strip() for c in args.categories.split(",") if c.strip()]
|
|
if args.categories
|
|
else None
|
|
)
|
|
|
|
last_open_count = -1
|
|
iteration = 0
|
|
total_applied = 0
|
|
|
|
while iteration < args.max_iterations:
|
|
iteration += 1
|
|
|
|
# Check time budget
|
|
elapsed = time.time() - start_time
|
|
if elapsed > max_wall_seconds:
|
|
print(
|
|
f"\n[STOP] Time budget exceeded: {elapsed:.0f}s > "
|
|
f"{max_wall_seconds}s",
|
|
file=sys.stderr,
|
|
)
|
|
return 4
|
|
|
|
# Stage 1: SCAN
|
|
ledger = stage_scan(args.scope, categories, args.verbose)
|
|
ledger_path = args.ledger or Path("audit-ledger.json")
|
|
ledger.save(ledger_path)
|
|
|
|
# Stage 2: PLAN
|
|
lanes = stage_plan(ledger, args.verbose)
|
|
open_count = len(lanes["script"]) + len(lanes["subagent"])
|
|
|
|
# Stopping condition 1: zero open issues
|
|
if open_count == 0:
|
|
print(
|
|
f"\n[STOP] Zero open issues across active categories",
|
|
file=sys.stderr,
|
|
)
|
|
stage_report(ledger, total_applied, iteration, args.verbose)
|
|
return 0
|
|
|
|
# Stopping condition 2: no progress
|
|
if open_count == last_open_count:
|
|
print(
|
|
f"\n[STOP] No progress in iteration {iteration} "
|
|
f"(open count stuck at {open_count})",
|
|
file=sys.stderr,
|
|
)
|
|
stage_report(ledger, total_applied, iteration, args.verbose)
|
|
return 2
|
|
last_open_count = open_count
|
|
|
|
if args.dry_run:
|
|
stage_report(ledger, 0, iteration, args.verbose)
|
|
print("\n[DRY RUN] Exiting after first scan+plan", file=sys.stderr)
|
|
return 0
|
|
|
|
# Stage 3: FIX (script lane only in Phase A)
|
|
applied, changed = stage_fix(
|
|
ledger, lanes, categories, args.dry_run, args.verbose
|
|
)
|
|
total_applied += applied
|
|
ledger.save(ledger_path)
|
|
|
|
if applied == 0:
|
|
# Nothing to fix in script lane; if no subagents configured,
|
|
# we're done with what the orchestrator can do alone.
|
|
if not lanes["subagent"]:
|
|
print(
|
|
f"\n[STOP] No auto-fixable issues remaining "
|
|
f"(subagent lane has {len(lanes['subagent'])} pending)",
|
|
file=sys.stderr,
|
|
)
|
|
stage_report(ledger, total_applied, iteration, args.verbose)
|
|
return 0
|
|
|
|
# Stage 4: VERIFY
|
|
ok = stage_verify(
|
|
ledger, changed, args.dry_run, args.quarto_check, args.verbose
|
|
)
|
|
ledger.save(ledger_path)
|
|
|
|
if not ok:
|
|
print(
|
|
f"\n[STOP] Verification failed in iteration {iteration}. "
|
|
f"The orchestrator does NOT retry failed verification — "
|
|
f"inspect the changed files manually with `git diff` and "
|
|
f"either commit or roll back.",
|
|
file=sys.stderr,
|
|
)
|
|
return 3
|
|
|
|
# Stage 5: REPORT
|
|
stage_report(ledger, applied, iteration, args.verbose)
|
|
|
|
# Commit if requested
|
|
if args.commit_each_iteration and changed:
|
|
if categories:
|
|
cat_summary = ",".join(categories)
|
|
else:
|
|
cat_summary = "all"
|
|
commit_msg = (
|
|
f"pass 15 audit iter {iteration}: "
|
|
f"apply {applied} {cat_summary} fixes to {len(changed)} files"
|
|
)
|
|
if not stage_commit(changed, commit_msg, args.verbose):
|
|
print(
|
|
"\n[STOP] Commit failed. The working tree has staged changes.",
|
|
file=sys.stderr,
|
|
)
|
|
return 5
|
|
|
|
print(
|
|
f"\n[STOP] Max iterations ({args.max_iterations}) reached",
|
|
file=sys.stderr,
|
|
)
|
|
return 4
|
|
|
|
|
|
# ── CLI ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(
|
|
description="Pass 15 audit loop orchestrator",
|
|
)
|
|
parser.add_argument(
|
|
"--scope", required=True,
|
|
help="vol1 | vol2 | both | path",
|
|
)
|
|
parser.add_argument(
|
|
"--categories", default="",
|
|
help="Comma-separated category names (default: all)",
|
|
)
|
|
parser.add_argument(
|
|
"--ledger", type=Path, default=None,
|
|
help="Path to the ledger JSON (default: audit-ledger.json)",
|
|
)
|
|
mutex = parser.add_mutually_exclusive_group(required=True)
|
|
mutex.add_argument(
|
|
"--dry-run", action="store_true",
|
|
help="Run scan + plan only; no fixes, no verify, no commit",
|
|
)
|
|
mutex.add_argument(
|
|
"--apply", action="store_true",
|
|
help="Actually apply the changes",
|
|
)
|
|
parser.add_argument(
|
|
"--commit-each-iteration", action="store_true",
|
|
help="Create a git commit after each successful iteration",
|
|
)
|
|
parser.add_argument(
|
|
"--quarto-check", action="store_true",
|
|
help="Also run `quarto check` in the verify stage (expensive)",
|
|
)
|
|
parser.add_argument(
|
|
"--max-iterations", type=int, default=10,
|
|
help="Maximum number of iterations (default: 10)",
|
|
)
|
|
parser.add_argument(
|
|
"--time-budget-min", type=int, default=30,
|
|
help="Wall-clock time budget in minutes (default: 30)",
|
|
)
|
|
parser.add_argument(
|
|
"--verbose", "-v", action="store_true",
|
|
help="Print per-stage progress",
|
|
)
|
|
args = parser.parse_args()
|
|
return run_loop(args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|