# Codebase Audit Comprehensive codebase audit. Produces a findings document and creates GitHub issues. Prompts for configuration when invoked. ## Usage ```bash # Interactive (prompts for configuration) /audit # Asks: scope (read-only vs auto-fix) + execution strategy # Direct flags (skip the prompt) /audit --fix # Audit WITH auto-fixes (uses worktrees, creates PR) /audit --single # Single-session audit (one subagent per selected pack, read-only) /audit --manual # Set up tasks + output launch commands for manual orchestration /audit --worker # Worker mode (run from worktree/clone after --manual setup) /audit --collect # Compile results + create issues (after workers complete) /audit --collect --force # Collect even if some agents haven't completed /audit --max-fixes 10 # Limit number of auto-fixes (only with --fix) # Diff-scoped audit (limit findings to changed files only) /audit --diff # Audit only files changed vs the detected base branch (git diff) /audit --diff main # Audit only files changed vs a specific ref /audit --staged # Audit only files currently staged for commit # Baseline / delta classification (compare to a previous run) /audit --baseline # Classify findings as new/existing vs a baseline run /audit --baseline --new-only # Report only newly introduced findings ``` > **Note on `--diff` and `--staged`**: These flags scope the audit to changed files only. > `--diff` is composable with `--fix` and all execution strategies. `--staged` is always > read-only — `--fix` is ignored with a printed note when combined with `--staged`. > The deterministic spine still runs against the full repo root; its findings are filtered > to the changed-file set before slicing and merging. LLM workers receive the changed-file > list and are instructed to audit only those files (except repo-wide checks whose subject > file is in the changed set). > **Note on `--single` and `--fix`**: `--single` is always read-only. It never applies > fixes even if `--fix` is also passed. If you invoke `/audit --single --fix`, the `--fix` > flag is silently ignored and a note is printed: > `Note: --single is read-only; --fix ignored. Use parallel-worktrees strategy for auto-fix.` > **Note on `--staged` and `--fix`**: `--staged` is always read-only. If you invoke > `/audit --staged --fix`, the `--fix` flag is ignored and a note is printed: > `Note: --staged is read-only; --fix ignored. Staged-only audits do not apply fixes.` ### Interactive Configuration When `/audit` is called without flags, it prompts the user with two questions: 1. **Audit scope** - Read-only (just findings) or analyze + auto-fix (also make safe changes, create PR) 2. **Execution strategy** - Parallel worktrees, single session, multi-clone, or manual setup This ensures the user always knows exactly what the audit will do before it starts. ### Execution Strategies | Strategy | Agents | Isolation | Depth | Speed | |----------|--------|-----------|-------|-------| | **Parallel worktrees** | N workers (one per non-empty assignment) | Git worktrees in `.audit/worktrees/` | Good | Fast | | **Single session** | One Explore subagent per selected pack | None (all read from same dir) | Light | Fastest | | **Multi-clone** | N workers | Sibling clone dirs | Deep | Fast | | **Manual setup** | N full Claude sessions | Worktrees (or clones) | Deepest | Slowest | --- ## CRITICAL: Isolation Rules 1. **Read-only by default** - The audit does NOT modify any files, create branches, or make commits unless the user explicitly chooses "Analyze + auto-fix". 2. **`--single` is always read-only** - It never applies fixes regardless of other flags. 3. **Worktree isolation (recommended)** - When worktrees or auto-fix are used, all work happens in git worktrees under `.audit/worktrees/`. The user's working directory is never touched. 4. **Multi-clone is opt-in only** - Sibling clones are ONLY used when the user explicitly selects "Multi-clone" execution. Before using clones, ALL must be verified as clean (no uncommitted changes, no active feature branches). If any clone has active work, warn the user and suggest worktrees instead. 5. **Always prompt first** - When `/audit` is called without flags, always ask the user to configure scope and execution strategy before doing anything. --- ## Instructions ### Mode Detection & Routing **If flags are passed, use them directly (skip the interactive prompt):** - `--single` -> Single-Session Mode (Phases 1-7), always read-only - `--worker` -> Worker Mode (Phases W1-W5) - `--collect` -> Collector Mode (Phases C1-C4) - `--force` -> sets FORCE_COLLECT=true (only used with --collect) - `--fix` -> sets FIX_MODE=true (ignored silently when combined with --single) - `--max-fixes N` -> sets MAX_FIXES=N (only with --fix) - `--manual` -> Coordinator-Only Mode (Phases M1-M4 + output launch commands) - `--diff [ref]` -> sets DIFF_MODE=true; changed files computed via `git diff -z ...HEAD` (ref defaults to BASE_BRANCH). Composable with `--single`, `--fix`, and all execution strategies. - `--staged` -> sets STAGED_MODE=true; changed files computed via `git diff -z --staged`. Composable with `--single` and all execution strategies. `--fix` is ignored with a printed note when combined with `--staged` — staged-only runs are always read-only. - `--baseline ` -> sets BASELINE_FILE=. After merge-findings writes findings.jsonl, run `scripts/baseline.py` to classify each finding as new/existing and emit resolved records. Composable with `--single`, `--diff`, and all execution strategies. - `--new-only` -> sets NEW_ONLY=true. Only valid with `--baseline`. Filters the report to new findings only (findings not in the baseline). Passed through to `scripts/baseline.py --new-only`. - Remaining argument is the target path (default: entire repo) **If NO flags are passed, prompt the user with `AskUserQuestion` to configure the audit:** Use AskUserQuestion with TWO questions: **Question 1** - header: "Audit scope", question: "What should the audit do?" Options: 1. **Read-only (Recommended)** - description: "Analyze the codebase, produce a findings report, and create GitHub issues. No code changes." 2. **Analyze + auto-fix** - description: "Same as read-only, plus automatically fix high-confidence issues (unused imports, console.logs, formatting). Creates a PR with fixes for review." **Question 2** - header: "Execution", question: "How should the audit run?" Options: 1. **Parallel worktrees (Recommended)** - description: "Agents in isolated git worktrees within this repo. Good balance of depth and speed. Your working directory is never touched." 2. **Single session** - description: "Lightweight read-only subagents in the current session. One subagent per selected pack. Fastest but least thorough." 3. **Multi-clone** - description: "Agents across sibling clone directories. Deepest analysis with full context per agent. WARNING: Requires all clones to be on clean branches with no active work." 4. **Manual setup** - description: "Set up worktrees and task files, then output launch commands so you can run each agent yourself in separate terminals." **Map user choices to configuration:** | Scope | Execution | Result | |-------|-----------|--------| | Read-only | Parallel worktrees | Default autonomous mode (M1-M7, FIX_MODE=false) | | Read-only | Single session | Single-session mode (Phases 1-7, always read-only) | | Read-only | Multi-clone | Clone-based autonomous mode (M1-M7, FIX_MODE=false, USE_CLONES=true) | | Read-only | Manual setup | Manual mode (M1-M4 + launch commands) | | Analyze + auto-fix | Parallel worktrees | Autonomous mode with fixes (M1-M7, FIX_MODE=true) | | Analyze + auto-fix | Single session | Single-session mode (Phases 1-7, read-only; --fix silently ignored) | | Analyze + auto-fix | Multi-clone | Clone-based mode with fixes (M1-M7, FIX_MODE=true, USE_CLONES=true) | | Analyze + auto-fix | Manual setup | Manual mode with fixes (M1-M4 + launch commands, FIX_MODE=true) | **Multi-clone mode additional validation (when selected):** Before proceeding, the coordinator MUST: 1. Discover sibling clones by detecting the repo name from `git remote get-url origin` (basename without `.git`) and listing sibling directories matching `{repo-name}-[0-9]*` or `{repo-name}-repos/{repo-name}-[0-9]*` in the parent directory. 2. Verify ALL clones have clean git state (`git status --porcelain` returns empty) 3. Verify NO clone has active feature branches checked out (all should be on the base branch or `main`) 4. If any clone is dirty or has active work, WARN the user and suggest "Parallel worktrees" instead 5. Only proceed after explicit user confirmation **Derive environment variables:** ```bash # Repo root (the current working directory where /audit is invoked) REPO_DIR=$(git rev-parse --show-toplevel) # Audit coordination directory (inside the repo, gitignored) AUDIT_DIR="$REPO_DIR/.audit" # Today's date AUDIT_DATE=$(date +%Y%m%d) # Base branch: detect from remote HEAD, fall back to "main" BASE_BRANCH=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null \ | sed 's|refs/remotes/origin/||' \ || echo "main") [ -z "$BASE_BRANCH" ] && BASE_BRANCH="main" # Package manager: detect from lockfile present in repo root if [ -f "$REPO_DIR/bun.lockb" ]; then PKG_MANAGER="bun" elif [ -f "$REPO_DIR/pnpm-lock.yaml" ]; then PKG_MANAGER="pnpm" elif [ -f "$REPO_DIR/yarn.lock" ]; then PKG_MANAGER="yarn" elif [ -f "$REPO_DIR/package-lock.json" ]; then PKG_MANAGER="npm" else PKG_MANAGER="npm" # safe fallback: npm is always present in Node projects fi # Skill root (absolute path to the installed skill directory) SKILL_ROOT="$HOME/.claude/skills/audit" ``` --- ## Autonomous Mode (DEFAULT) (Phases M1-M7) Run from any clone. The default is **read-only** - no code changes unless `--fix` is passed. ### Phase M1: Pre-Flight 1. **Verify this is a git repo** and identify the repo root: ```bash REPO_DIR=$(git rev-parse --show-toplevel) ``` 2. **Ensure `.audit` is gitignored**: Check if `.audit` or `.audit/` is in `.gitignore`. If not, add it: ```bash grep -qxF '.audit/' .gitignore 2>/dev/null || echo '.audit/' >> .gitignore ``` Do NOT commit this change - it's a local-only addition. **Then check whether `.audit/` is ALREADY TRACKED (field report #8).** Appending to `.gitignore` is a no-op for files git already tracks — a prior run that committed `.audit/` would keep re-committing coordination state. Detect and warn (offer to stop tracking it without deleting the working copy): ```bash if [ -n "$(git -C "$REPO_DIR" ls-files .audit/ 2>/dev/null)" ]; then echo "WARNING: .audit/ is already tracked by git (a prior run likely committed it)." >&2 echo " .gitignore will NOT untrack it. To stop tracking without deleting files:" >&2 echo " git -C \"$REPO_DIR\" rm -r --cached .audit/ && git commit -m 'stop tracking .audit/'" >&2 fi ``` **The `.gitignore` entry is also load-bearing for the branch-guard hook (field report #9, 2026-07-10 run).** `branch-guard.py` hard-blocks Edit/Write to any file inside a repo checked out on its default branch, but exempts **gitignored** paths (verified via `git check-ignore`; fails closed on git errors). Workers running against a main checkout can only Write their `.audit/current/results/worker-N.json` files because `.audit/` is ignored. A tracked `.audit/` (field report #8) voids the exemption — `check-ignore` never reports tracked files as ignored — so untrack it before launching workers, or every worker's results-file Write will be denied. 3. **Check for existing audit run**: Look for `$AUDIT_DIR/current/config.json`. - If exists, ask the user: ``` An existing audit run was found from [date]. 1. Resume (keep existing task files, only recreate missing ones) 2. Clean start (move .audit/current/ to .audit/archived-YYYYMMDD/ and start fresh) 3. Cancel ``` 4. **Check for open audit PRs** (informational): ```bash gh pr list --search "Audit:" --state open ``` Warn if existing audit PRs are open. 5. **If --fix mode**: Verify clean git state and check for existing worktrees (see Fix Mode Addendum below). ### Phase M2: Create Coordination Directory ```bash mkdir -p "$AUDIT_DIR/current/tasks" mkdir -p "$AUDIT_DIR/current/results" mkdir -p "$AUDIT_DIR/current/spine" mkdir -p "$AUDIT_DIR/history" ``` Write `config.json`: ```json { "audit_date": "YYYYMMDD", "started_at": "ISO-8601", "base_branch": "", "scope": "entire repo", "fix_mode": false, "repo_dir": "", "epic_issue": null } ``` ### Phase M2.1: Diff Scope (--diff / --staged only) **Skip this phase entirely for full-repo audits.** Only execute when `--diff` or `--staged` is set. 1. **Compute the changed-file set once** and write it to the coordination directory. `changed-files.z` is canonical for machine consumers; `changed-files.txt` is a lossy human-readable view for LLM scoping (filenames containing newlines render ambiguously there). ```bash # --diff mode: changed files vs ...HEAD if [ "${DIFF_MODE:-false}" = "true" ]; then DIFF_REF="${DIFF_REF:-$BASE_BRANCH}" git -C "$REPO_DIR" diff --name-only -z "${DIFF_REF}...HEAD" \ > "$AUDIT_DIR/current/changed-files.z" if [ $? -ne 0 ]; then echo "ERROR: git diff failed for ref '${DIFF_REF}' — does it exist?" >&2 echo " Verify with: git rev-parse --verify '${DIFF_REF}'" >&2 exit 1 fi fi # --staged mode: staged files only if [ "${STAGED_MODE:-false}" = "true" ]; then git -C "$REPO_DIR" diff --name-only -z --staged \ > "$AUDIT_DIR/current/changed-files.z" if [ $? -ne 0 ]; then echo "ERROR: git diff --staged failed" >&2 exit 1 fi fi # Generate the human-readable version FROM the -z file using python3. # Never split on shell word boundaries — filenames may contain spaces, # semicolons, or other metacharacters; they must remain inert data. python3 - "$AUDIT_DIR/current/changed-files.z" \ "$AUDIT_DIR/current/changed-files.txt" << 'PYEOF' import sys with open(sys.argv[1], "rb") as fh: data = fh.read() # Null-delimited; strip trailing null before splitting paths = [p.decode("utf-8", errors="surrogateescape") for p in data.rstrip(b"\x00").split(b"\x00") if p] with open(sys.argv[2], "w", encoding="utf-8") as out: for p in paths: out.write(p + "\n") print(f"diff scope: {len(paths)} changed file(s)", file=sys.stderr) PYEOF ``` 2. **Empty-set guard**: if `changed-files.z` is empty (zero bytes or contains only null bytes), clean up the current directory so the next invocation does not hit the resume prompt for a no-op run (config.json would otherwise still claim full-repo scope), then exit cleanly — no spine, no workers, no report: ```bash CHANGED_COUNT=$(python3 -c " import sys data = open(sys.argv[1], 'rb').read() paths = [p for p in data.rstrip(b'\x00').split(b'\x00') if p] print(len(paths)) " "$AUDIT_DIR/current/changed-files.z") if [ "${CHANGED_COUNT:-0}" -eq 0 ]; then echo "no changed files — nothing to audit" rm -rf "$AUDIT_DIR/current/" exit 0 fi ``` 3. **Update config.json** with diff scope metadata: ```json { "scope": "diff", "diff_ref": "", "changed_files": } ``` 4. The rest of the M-phase pipeline runs unchanged. The diff scope is enforced in two ways: - **LLM scoping**: task files written in Phase M4 include the absolute path to `changed-files.txt` and the instruction: "Audit ONLY files listed in changed-files.txt. Checks whose subject is repo-wide (dependency manifests, ToS surfaces) run only if their subject file is in that list." - **Spine post-filter**: after the spine runs (Phase M4), filter its findings before slicing: ```bash python3 - \ "$AUDIT_DIR/current/spine/findings.jsonl" \ "$AUDIT_DIR/current/changed-files.z" \ "$AUDIT_DIR/current/spine/findings-diff-filtered.jsonl" << 'PYEOF' import json, sys spine_file, changed_z, out_file = sys.argv[1], sys.argv[2], sys.argv[3] # Read changed paths null-delimited — no shell interpolation anywhere. with open(changed_z, "rb") as fh: data = fh.read() changed_set = set( p.decode("utf-8", errors="surrogateescape") for p in data.rstrip(b"\x00").split(b"\x00") if p ) kept = 0 skipped = 0 with open(spine_file, encoding="utf-8") as fin, \ open(out_file, "w", encoding="utf-8") as fout: for raw in fin: raw = raw.strip() if not raw: continue try: rec = json.loads(raw) except json.JSONDecodeError: continue # coverage_gap and provenance records always pass through. if "type" in rec: fout.write(raw + "\n") continue # Finding records: keep only if location.path is in the changed set. path = rec.get("location", {}).get("path", "") if path in changed_set: fout.write(raw + "\n") kept += 1 else: skipped += 1 print(f"diff-filter: kept {kept} finding(s), skipped {skipped} (not in changed set)", file=sys.stderr) PYEOF # Replace the spine findings file with the filtered version for downstream phases. mv "$AUDIT_DIR/current/spine/findings-diff-filtered.jsonl" \ "$AUDIT_DIR/current/spine/findings.jsonl" ``` 5. **Report header**: the audit report compiled in Phase M6 includes: > `Diff scope: N changed files (ref: )`. > Deterministic tools scanned the full repository root; findings are scoped to the changed-file set. ### Phase M2.5: Create Epic Issue Create a GitHub epic issue to serve as the parent tracker for this audit run. All downstream findings issues (created during collection) will reference this epic. ```bash gh issue create \ --title "Audit: YYYY-MM-DD - Codebase Audit" \ --label "audit" \ --body "$(cat <<'EOF' ## Codebase Audit - YYYY-MM-DD Tracking issue for the YYYY-MM-DD codebase audit. ### Status - **Started**: YYYY-MM-DD - **Mode**: Read-only audit ### Downstream Issues Pack-specific findings issues will be linked here as they are created. --- *Generated by `/audit` skill* EOF )" ``` Save the epic issue number in `config.json` as `"epic_issue"`. ### Phase M3: Ecosystem Detection + Pack Selection + Pack Assignment This phase replaces the legacy hardcoded 9-category model with the pack registry pipeline. 1. **Run the ecosystem detector:** ```bash bash "$SKILL_ROOT/scripts/detect-ecosystems.sh" "$REPO_DIR" \ > "$AUDIT_DIR/current/detection.json" ``` 2. **Run the pack registry to select applicable packs:** ```bash python3 "$SKILL_ROOT/scripts/registry.py" "$AUDIT_DIR/current/detection.json" \ > "$AUDIT_DIR/current/selected-packs.json" ``` 3. **HALT if zero packs selected** — do NOT silently proceed: ```bash PACK_COUNT=$(python3 -c "import json,sys; print(len(json.load(open(sys.argv[1]))))" \ "$AUDIT_DIR/current/selected-packs.json") if [ "$PACK_COUNT" -eq 0 ]; then echo "ERROR: registry selected zero packs for this repository." >&2 echo " Detection output: $AUDIT_DIR/current/detection.json" >&2 echo " Review the detected ecosystems and project shape, then re-run." >&2 exit 1 fi ``` 4. **Run the pack assignment balancer** (default 4 workers): ```bash python3 "$SKILL_ROOT/scripts/assign-packs.py" \ "$AUDIT_DIR/current/selected-packs.json" \ --workers 4 \ > "$AUDIT_DIR/current/assignment.json" ``` The assignment maps worker ids 0..N-1 to ordered pack-id lists. Workers with empty lists will NOT be launched (see M5). 5. **Do NOT create worktrees/clones here.** Agent-environment preparation is deliberately deferred to Phase M4 **after** the spine runs. The skill creates worktrees *inside the repo it is about to scan*; if they exist while the spine runs, the spine scans every audit worktree (and its symlinked `node_modules`) as if it were source — 4× duplicate findings (field report #1). Run the spine against a clean main checkout first, then create the agent environment. ### Phase M4: Run Spine, Prepare Agent Environment, Write Task Files **Step 1 — Pin the base commit SHA** (field report #5). Capture it now, against the untouched main checkout, BEFORE any worktree/worker work can move HEAD. Thread it into `provenance.py --commit` later so the report records the commit the audit actually ran against, not a SHA a polluting fix-mode worker may have moved: ```bash BASE_SHA=$(git -C "$REPO_DIR" rev-parse HEAD) python3 -c "import json,sys;p=sys.argv[1];d=json.load(open(p));d['base_sha']=sys.argv[2];json.dump(d,open(p,'w'),indent=2)" \ "$AUDIT_DIR/current/config.json" "$BASE_SHA" ``` **Step 2 — Run the deterministic spine** (coordinator responsibility, once per audit run). Runs against the **clean main checkout** — no audit worktrees exist yet (see M3 step 5). The spine excludes vendored/generated dirs (`exclude-dirs.txt`) and vendored/minified files by name (`exclude-file-globs.txt`: `*.min.js`, `*.bundle.js`, `*.map`) per-tool, AND applies a post-filter (`scripts/spine/exclude.py`) that additionally drops findings on `.gitignore`d paths and a looks-minified backstop that drops lint/SAST findings on minified vendored files not caught by name (e.g. `js-dos.js`). gitleaks runs with a generated config that allowlists gitignored files, so a never-committed `.env.local` is never reported as a leaked credential. The spine also reports per-tool timing + finding counts to stderr so a slow tool is visible immediately, not mistaken for a hang: ```bash # Compute union of tools[] across all selected packs SPINE_TOOLS=$(python3 - "$AUDIT_DIR/current/selected-packs.json" << 'PYEOF' import json, sys packs = json.load(open(sys.argv[1])) tools = set() for p in packs: tools.update(p.get("tools", [])) print(",".join(sorted(tools)) if tools else "") PYEOF ) mkdir -p "$AUDIT_DIR/current/spine" if [ -z "$SPINE_TOOLS" ]; then echo "Note: no selected packs declare tools[]; skipping spine run." >&2 touch "$AUDIT_DIR/current/spine/findings.jsonl" else bash "$SKILL_ROOT/scripts/spine/run.sh" \ --repo "$REPO_DIR" \ --tools "$SPINE_TOOLS" \ --output "$AUDIT_DIR/current/spine/findings.jsonl" fi # Diff mode: apply the Phase M2.1 post-filter to the spine findings NOW, before slicing. if [ "${DIFF_MODE:-false}" = "true" ] || [ "${STAGED_MODE:-false}" = "true" ]; then python3 - \ "$AUDIT_DIR/current/spine/findings.jsonl" \ "$AUDIT_DIR/current/changed-files.z" \ "$AUDIT_DIR/current/spine/findings-diff-filtered.jsonl" << 'PYEOF' import json, sys spine_file, changed_z, out_file = sys.argv[1], sys.argv[2], sys.argv[3] with open(changed_z, "rb") as fh: data = fh.read() changed_set = set( p.decode("utf-8", errors="surrogateescape") for p in data.rstrip(b"\x00").split(b"\x00") if p ) kept = 0; skipped = 0 with open(spine_file, encoding="utf-8") as fin, \ open(out_file, "w", encoding="utf-8") as fout: for raw in fin: raw = raw.strip() if not raw: continue try: rec = json.loads(raw) except json.JSONDecodeError: continue if "type" in rec: fout.write(raw + "\n"); continue path = rec.get("location", {}).get("path", "") if path in changed_set: fout.write(raw + "\n"); kept += 1 else: skipped += 1 print(f"diff-filter (M4): kept {kept}, skipped {skipped}", file=sys.stderr) PYEOF mv "$AUDIT_DIR/current/spine/findings-diff-filtered.jsonl" \ "$AUDIT_DIR/current/spine/findings.jsonl" fi ``` **Step 3 — Prepare agent environment** (worktree/clone/none per strategy). This runs **after** the spine so the spine never scans the audit worktrees (field report #1). **Worktree mode:** ```bash git fetch origin mkdir -p "$AUDIT_DIR/worktrees" for i in 0 1 2 3; do git worktree add "$AUDIT_DIR/worktrees/agent-$i" \ -b "audit/agent-$i-$AUDIT_DATE" "origin/$BASE_BRANCH" done ``` If FIX_MODE is true, give each worktree a toolchain. **Prefer symlinking the main checkout's `node_modules` into each worktree** rather than a fresh install (field report #8): the worktree is the same commit, so the dependency tree is identical, and a symlink is instant vs. minutes + ~2 GB for 4× `npm ci`: ```bash if [ "${FIX_MODE:-false}" = "true" ] && [ -d "$REPO_DIR/node_modules" ]; then for i in 0 1 2 3; do ln -s "$REPO_DIR/node_modules" "$AUDIT_DIR/worktrees/agent-$i/node_modules" done fi # Fall back to a real install only when the main checkout has no node_modules. ``` **Multi-clone mode:** ```bash REPOS_DIR=$(dirname "$REPO_DIR") REPO_BASE=$(git remote get-url origin 2>/dev/null | sed 's|.*/||; s|\.git$||') if [ -z "$REPO_BASE" ]; then REPO_BASE=$(basename "$REPO_DIR" | sed -E 's/-[0-9]+$//') fi CLONE_DIRS=() for i in 0 1 2 3; do candidate="$REPOS_DIR/${REPO_BASE}-$i" [ -d "$candidate/.git" ] || [ -f "$candidate/.git" ] && CLONE_DIRS+=("$candidate") done if [ "${#CLONE_DIRS[@]}" -eq 0 ]; then echo "ERROR: Multi-clone discovery found zero sibling clone directories." >&2 echo " Searched: $REPOS_DIR/${REPO_BASE}-{0..3}" >&2 echo " Suggest: use 'Parallel worktrees' mode instead." >&2 exit 1 fi for dir in "${CLONE_DIRS[@]}"; do git -C "$dir" status --porcelain done ``` Verify all clones are clean. Create audit branches in each clone. **Single-session mode:** No preparation needed. **Step 4 — Slice the spine output per pack:** For each selected pack, filter `spine/findings.jsonl` to produce a per-pack slice. A finding belongs in a pack's slice when any of these is true: - The finding's `check_id` namespace (the part before `/`) matches a check-id prefix declared in the pack's `checks` array (e.g. pack has check `"id": "security/leaked-credential"` → the `security` namespace matches findings with `check_id` starting with `security/`). - The finding's `properties.tool` value (where spine normalizers store the tool name — e.g. `parse-gitleaks.py` emits `{"properties": {"tool": "gitleaks"}}`) matches a tool listed in the pack's `tools[]`. There is no top-level `tool` field on findings. - The finding has no `properties.tool` value (un-attributed): assign it to ALL packs that declare at least one tool (broad assignment to avoid silent gaps). ```bash python3 - \ "$AUDIT_DIR/current/spine/findings.jsonl" \ "$AUDIT_DIR/current/selected-packs.json" \ "$AUDIT_DIR/current/spine" << 'PYEOF' import json, os, sys spine_file, packs_file, out_dir = sys.argv[1], sys.argv[2], sys.argv[3] packs = json.load(open(packs_file)) # Build per-pack filtering criteria pack_criteria = {} for p in packs: pack_dir = p["id"].split("/")[-1] # e.g. "ccgm/security" -> "security" namespaces = set() tools = set(p.get("tools", [])) for check in p.get("checks", []): ns = check["id"].split("/")[0] namespaces.add(ns) pack_criteria[pack_dir] = {"namespaces": namespaces, "tools": tools} # Any-tool packs (packs that declare at least one tool) any_tool_packs = {d for d, c in pack_criteria.items() if c["tools"]} lines = [] try: with open(spine_file) as f: for line in f: line = line.strip() if line: lines.append(line) except FileNotFoundError: pass # empty spine for pack_dir, criteria in pack_criteria.items(): slice_path = os.path.join(out_dir, f"{pack_dir}.jsonl") with open(slice_path, "w") as out: for line in lines: try: rec = json.loads(line) except json.JSONDecodeError: continue # Skip non-finding records (provenance, coverage_gap type records) if "type" in rec: continue check_id = rec.get("check_id", "") # Spine normalizers store the tool name in properties.tool # (e.g. parse-gitleaks.py emits {"properties": {"tool": "gitleaks"}}). # Findings have no top-level "tool" field. props_tool = rec.get("properties", {}).get("tool", "") ns = check_id.split("/")[0] if "/" in check_id else "" if ns in criteria["namespaces"]: out.write(line + "\n") elif props_tool and props_tool in criteria["tools"]: out.write(line + "\n") elif not props_tool and any_tool_packs: # Un-attributed: broadcast to all tool-using packs if pack_dir in any_tool_packs: out.write(line + "\n") PYEOF ``` **Write task files:** For each worker with a non-empty pack assignment: ```bash python3 - \ "$AUDIT_DIR/current/assignment.json" \ "$AUDIT_DIR/current/selected-packs.json" \ "$SKILL_ROOT" \ "$AUDIT_DIR" \ "$REPO_DIR" \ "${FIX_MODE:-false}" << 'PYEOF' import json, os, sys assignment_file, packs_file, skill_root, audit_dir, repo_dir, fix_mode_str = sys.argv[1:] FIX_MODE = fix_mode_str.lower() in ("true", "1", "yes") assignment = json.load(open(assignment_file)) all_packs = {p["id"]: p for p in json.load(open(packs_file))} tasks_dir = os.path.join(audit_dir, "current", "tasks") os.makedirs(tasks_dir, exist_ok=True) results_dir = os.path.join(audit_dir, "current", "results") os.makedirs(results_dir, exist_ok=True) rubric_path = os.path.join(skill_root, "schemas", "severity-rubric.json") try: rubric = json.load(open(rubric_path)) # Extract rubric check-ids for this worker's packs except Exception: rubric = {} for worker_id, pack_ids in assignment.items(): if not pack_ids: continue # skip empty workers # Build rubric slice for this worker's packs worker_check_ids = set() for pid in pack_ids: p = all_packs.get(pid, {}) for check in p.get("checks", []): worker_check_ids.add(check["id"]) rubric_slice = {} if isinstance(rubric, dict): rubric_checks = rubric.get("checks", rubric) # unwrap top-level "checks" key for cid, val in rubric_checks.items(): if cid in worker_check_ids: rubric_slice[cid] = val packs_info = [] for pid in pack_ids: p = all_packs.get(pid, {}) pack_dir = pid.split("/")[-1] checks_path = os.path.join(skill_root, "packs", pack_dir, "checks.md") spine_slice = os.path.join(audit_dir, "current", "spine", f"{pack_dir}.jsonl") packs_info.append({ "pack_id": pid, "checks_md_path": checks_path, "spine_slice_path": spine_slice, }) results_path = os.path.join(audit_dir, "current", "results", f"worker-{worker_id}.json") task = { "worker_id": worker_id, "packs": packs_info, "rubric_slice": rubric_slice, "results_file_path": results_path, "repo_dir": repo_dir, "fix_mode": FIX_MODE, # passed in from the coordinator — True when --fix was selected } task_path = os.path.join(tasks_dir, f"worker-{worker_id}.json") with open(task_path, "w") as f: json.dump(task, f, indent=2) print(f"Wrote {task_path}") PYEOF ``` ### Phase M5: Launch Audit Workers **CRITICAL**: Determine which workers have non-empty pack assignments, then launch only those workers — in a SINGLE message with parallel Agent tool calls (`subagent_type: "general-purpose"`, `run_in_background: true`). > **Concurrency — avoid the 429 throttle.** `general-purpose` workers each load a pack's checks plus a spine slice (large context) and, under `FIX_MODE=true`, also commit and push — these are heavy agents. Launch **at most 4 workers at once**. If more than 4 have non-empty assignments, launch the first 4 in this message, wait for them to return (`run_in_background` plus the M6 collect step gives you the join point), then launch the next wave of 4. Bursting more than ~5 heavy workers together trips a server-side rate limit (`Server is temporarily limiting requests · Rate limited`) that fails the whole batch; if you see it mid-run, stop, wait 30–60s, and re-dispatch only the failed workers in waves of ≤4. See `~/.claude/rules/concurrency-and-rate-limits.md`. ```bash # Determine active worker ids ACTIVE_WORKERS=$(python3 -c " import json, sys a = json.load(open(sys.argv[1])) print(' '.join(k for k,v in sorted(a.items()) if v)) " "$AUDIT_DIR/current/assignment.json") ``` **Snapshot the main checkout before launching (field report #2).** Subagents inherit the coordinator's cwd (the main checkout) and the coordinator CANNOT set a subagent's working directory. A worker that runs a bare `git` command (instead of `git -C `) mutates the MAIN checkout — leaving it on a worker branch with uncommitted edits. Record HEAD + status now so M6 can detect and revert any pollution: ```bash MAIN_HEAD_BEFORE=$(git -C "$REPO_DIR" rev-parse HEAD) MAIN_BRANCH_BEFORE=$(git -C "$REPO_DIR" rev-parse --abbrev-ref HEAD) git -C "$REPO_DIR" status --porcelain > "$AUDIT_DIR/current/main-status-before.txt" { echo "$MAIN_HEAD_BEFORE"; echo "$MAIN_BRANCH_BEFORE"; } \ > "$AUDIT_DIR/current/main-head-before.txt" ``` Display progress summary before launching: ``` ## Launching Audit Workers | Worker | Packs | Working Dir | Mode | |--------|-------|------------|------| | 0 | | {agent_dir} | {mode} | ... Running {N} audit workers in parallel... ``` **Prompt template for each agent (read-only mode):** ``` You are audit worker {WORKER_ID} performing a READ-ONLY codebase audit using the pack registry. CODEBASE ROOT: {AGENT_WORKING_DIR} TASK FILE: {AUDIT_DIR}/current/tasks/worker-{WORKER_ID}.json RESULTS FILE: {AUDIT_DIR}/current/results/worker-{WORKER_ID}.json IMPORTANT: This is a READ-ONLY audit. Do NOT modify any source files. Do NOT create branches or make commits. Use ABSOLUTE PATHS for ALL file operations. Your codebase root is {AGENT_WORKING_DIR}. ## Instructions 1. Read your task file to get your assigned pack ids, checks.md paths, spine slice path, and rubric slice. 2. Write an initial results file to signal you've started: Write {"worker_id": "{WORKER_ID}", "status": "in_progress", "started_at": ""} 3. For each assigned pack: a. Read the pack's checks.md from the absolute path in your task file. b. Run each check described in checks.md against the codebase. c. For findings with detection="hybrid" in your spine slice: triage (confirmed/dismissed). A finding should be confirmed if your LLM analysis agrees it is a real issue. Dismissed means you are confident it is a false positive. d. Add any LLM-only findings with source:"llm". e. Source all severity/confidence/fix_confidence from the rubric_slice in your task file. For check_ids not in the rubric, set confidence:"low" and flag for rubric expansion. 4. Write final results to your results file per the worker results-file contract. Each finding MUST match this EXACT shape (repo-relative path, integer line): { "check_id": "/", "rule_id": "", "severity": "high", "confidence": "medium", "detection": "llm", "source": "llm", "message": "", "location": {"path": "src/foo.ts", "line": 42} } Use the key "message" (NOT "title"/"description"), nest path+line under "location" (NOT flat "path"/"line"), set "detection" to "llm" or "hybrid", and emit a REPO-RELATIVE path (never an absolute worktree path) with an INTEGER line. Findings in any other shape risk being dropped at merge. Be thorough. Read entire files when needed. Trace patterns across the codebase. This is a deep audit. ``` **Worker results-file Writes and the branch-guard hook (field report #9).** On the 2026-07-10 run, the branch-guard PreToolUse hook hard-blocked all 3 workers' Writes of their mandated results files (the checkout was on its default branch), and every worker routed around the denial with shell writes — the exact red-flag pattern the branch-guard rule forbids. branch-guard now exempts gitignored paths, so results-file Writes pass as long as `.audit/` is actually gitignored (ensured in M1). If a worker still reports a branch-guard denial on an `.audit/...` path, the cause is tracked `.audit/` state (field report #8) or broken git state in the checkout — fix that and re-dispatch; never instruct a worker to fall back to shell redirection (`echo >`, `tee`, heredocs) to get past a guard. After launching all workers, poll for completion using TaskOutput. Once all active workers complete (or timeout): 1. **Validate each returned worker file (field report #3).** The moment a worker returns, check its `worker-{id}.json` parses and that each finding has `check_id`, `message`, and `location.{path,line}`. Surface the count of malformed findings per file instead of discovering the loss at merge time: ```bash for wf in "$AUDIT_DIR/current/results"/worker-*.json; do python3 - "$wf" << 'PYEOF' import json, sys p = sys.argv[1] try: d = json.load(open(p)) except Exception as e: print(f"WORKER FILE INVALID JSON: {p}: {e}", file=sys.stderr); sys.exit(0) bad = [i for i, f in enumerate(d.get("findings", [])) if not (isinstance(f, dict) and f.get("check_id") and (f.get("message") or f.get("title")) and (f.get("location") or f.get("path")))] if bad: print(f"{p}: {len(bad)} finding(s) in non-canonical shape " f"(merge will normalize/recover where possible)", file=sys.stderr) PYEOF done ``` merge-findings normalizes the common variant shape, but a hard structural failure (unparseable file) means re-dispatch that worker. 2. **Recover crashed/incomplete workers (field report #7).** A worker that died mid-run (e.g. an API stream-idle timeout) leaves its results file at the `"status": "in_progress"` stub — or missing entirely. Detect this after the poll and **auto-re-dispatch a finish-only pass** (analysis only; any fixes it already committed stay on its branch) rather than silently emitting a partial report: ```bash for wid in $ACTIVE_WORKERS; do rf="$AUDIT_DIR/current/results/worker-$wid.json" st=$(python3 -c "import json,sys;print(json.load(open(sys.argv[1])).get('status','missing'))" "$rf" 2>/dev/null || echo "missing") if [ "$st" != "completed" ]; then echo "Worker $wid status=$st — re-dispatching a finish-only analysis pass." >&2 # Re-launch the same worker prompt with: "Your prior run did not finish. # Do NOT redo committed fixes; only complete analysis and write the final # results file (status: completed)." fi done ``` If a worker still cannot complete after one finish-only retry, record it as a **coverage gap** (which packs went un-audited) so the gap is visible in the report rather than hidden. 3. Proceed to Phase M6. ### Phase M6: Merge Findings + Compile Report After all workers complete: 0. **Detect & revert main-checkout pollution (field report #2).** Before merging, confirm no worker mutated the main checkout via a bare `git` command. If HEAD moved or the working tree gained changes that were not there in the M5 snapshot, restore the recorded state and log it — silent pollution of the user's checkout is the dangerous failure mode: ```bash MAIN_HEAD_AFTER=$(git -C "$REPO_DIR" rev-parse HEAD) read -r MAIN_HEAD_BEFORE MAIN_BRANCH_BEFORE < <( tr '\n' ' ' < "$AUDIT_DIR/current/main-head-before.txt" ) if [ "$MAIN_HEAD_AFTER" != "$MAIN_HEAD_BEFORE" ] \ || ! diff -q <(git -C "$REPO_DIR" status --porcelain) \ "$AUDIT_DIR/current/main-status-before.txt" >/dev/null 2>&1; then echo "WARNING: a worker polluted the MAIN checkout; restoring pre-launch state." >&2 echo " (worker fixes live on their own audit branches; nothing is lost)" >&2 git -C "$REPO_DIR" checkout --quiet "$MAIN_BRANCH_BEFORE" 2>/dev/null || true git -C "$REPO_DIR" reset --hard --quiet "$MAIN_HEAD_BEFORE" # Surface in the report's coverage/notes section that pollution occurred. fi ``` 1. **Run the merge pipeline:** ```bash # Collect all worker result files (array — safe for paths with spaces) LLM_ARGS=() for f in "$AUDIT_DIR/current/results"/worker-*.json; do LLM_ARGS+=(--llm "$f") done # Capture stderr: merge-findings prints a loud "dropped N invalid finding(s)" # line when worker findings fail to normalize (field report #3). Surface that # number in the report so a run cannot silently ship missing half its findings. python3 "$SKILL_ROOT/scripts/merge-findings.py" \ --spine "$AUDIT_DIR/current/spine/findings.jsonl" \ "${LLM_ARGS[@]}" \ --rubric "$SKILL_ROOT/schemas/severity-rubric.json" \ --repo "$REPO_DIR" \ --output "$AUDIT_DIR/current/findings.jsonl" \ 2> "$AUDIT_DIR/current/merge-stderr.txt" cat "$AUDIT_DIR/current/merge-stderr.txt" >&2 DROPPED=$(grep -oE 'dropped [0-9]+ invalid finding' \ "$AUDIT_DIR/current/merge-stderr.txt" | grep -oE '[0-9]+' | head -1) [ -n "${DROPPED:-}" ] && [ "$DROPPED" -gt 0 ] && \ echo "NOTE: $DROPPED worker finding(s) were dropped as unrecoverable — see report." >&2 ``` 2. **Compile the audit document** from `$AUDIT_DIR/current/findings.jsonl`. Include a **Dropped findings** note in the Summary when `$DROPPED` > 0, so the headline number reflects any worker findings that could not be recovered: ```markdown # Codebase Audit Report - YYYY-MM-DD ## Summary | Metric | Count | |--------|-------| | Total Findings | X | | Critical | X | | High | X | | Medium | X | | Low | X | ## Findings by Pack | Pack | Critical | High | Medium | Low | Total | |------|----------|------|--------|-----|-------| | security | X | X | X | X | X | | dependencies | X | X | X | X | X | | code-quality | X | X | X | X | X | ... ## Critical & High Severity Findings ### security - **[security/leaked-credential]** (CRITICAL) ... ### code-quality ... ## Medium Severity Findings ... ## Low Severity Findings ... ## Coverage Gaps Tools that were absent, wrappers that were skipped, or checks that could not run: | Tool | Reason | |------|--------| | | | --- *Generated by `/audit` skill on YYYY-MM-DD* ``` 3. **Write** the compiled document to `$AUDIT_DIR/current/audit-report.md`. 4. **Display** the summary table and critical/high findings to the user. ### Baseline / Delta (optional, after merge step) When `--baseline ` is passed, run the baseline classifier immediately after `merge-findings.py` writes `$AUDIT_DIR/current/findings.jsonl`: ```bash BASELINE_ARGS=(--current "$AUDIT_DIR/current/findings.jsonl" --baseline "$BASELINE_FILE") if [[ "${NEW_ONLY:-false}" == "true" ]]; then BASELINE_ARGS+=(--new-only) fi python3 "$SKILL_ROOT/scripts/baseline.py" \ "${BASELINE_ARGS[@]}" \ --output "$AUDIT_DIR/current/findings-delta.jsonl" ``` The script compares current findings against the baseline using `(rule_id, fingerprint)` as the matching key and tags each finding with `properties.baseline_status`: - `"new"` — introduced since the baseline. - `"existing"` — present in both runs (persisting). - Baseline findings absent from current are emitted as `{"type":"resolved", ...}` records. A `{"type":"baseline_summary", "new": N, "existing": N, "resolved": N}` record is always the first line of the delta output. **Match key stability:** Tool/spine findings set `rule_id` deterministically, so they classify stably across runs. LLM findings may not: when a worker omits `rule_id`, `merge-findings` backfills it from `check_id`. If a worker's `rule_id` emission is inconsistent between runs, the SAME logical finding (same location + fingerprint) can carry two different `rule_id` values — producing one phantom **new** and one phantom **resolved** for a finding that did not actually change. To prevent this, ensure LLM workers always emit `rule_id` explicitly. (The key is intentionally composite — dropping `rule_id` would silently merge two different rules that share a fingerprint.) **Report impact:** when `--baseline` is set, add a **Delta vs Baseline** row to the Summary table showing New / Persisting / Fixed counts sourced from the baseline_summary record. When `--new-only` is set, limit the Findings sections to new findings only and note this in the report header. **`--single` mode:** the same `--baseline` / `--new-only` flags and this exact step apply after Phase 5 (Run Merge Inline) in Single-Session Mode. Run `baseline.py` in place of or after `merge-findings.py` in the same inline step; then compile the report (Phase 6) from `findings-delta.jsonl` instead of `findings.jsonl`. **Persisting the baseline:** to save the current run as the next baseline, pass `--save-baseline` to `baseline.py`, or manually copy `findings.jsonl` into a history directory (e.g., `.audit/history/YYYY-MM-DD.jsonl`). The script never auto-writes a baseline; the caller controls when to advance it. ### Provenance & Routing After `merge-findings.py` produces `findings.jsonl` (or `findings-delta.jsonl` in delta mode), run `provenance.py` to prepend an audit-level provenance header and tag findings for routing: ```bash # --commit pins the base SHA captured in Phase M4 (config.json base_sha), so the # header records the commit the audit ran against even if a worker moved HEAD (#5). BASE_SHA=$(python3 -c "import json,sys; print(json.load(open(sys.argv[1])).get('base_sha',''))" \ "$AUDIT_DIR/current/config.json") python3 "$SKILL_ROOT/scripts/provenance.py" \ --findings .audit/current/findings.jsonl \ --repo "$REPO_ROOT" \ --model "$AUDIT_MODEL" \ ${BASE_SHA:+--commit "$BASE_SHA"} \ --output .audit/current/findings-tagged.jsonl ``` Pass `findings-tagged.jsonl` (instead of `findings.jsonl`) to the report compiler and any downstream consumers. In `--single` mode the same invocation applies; use `--single` only when suppression/baseline is not needed, but provenance tagging is always beneficial. **audit_provenance header** — the first record in output has `type: "audit_provenance"` and carries these fields for traceability and report display: | Field | Source | |---|---| | `commit` | `--commit` (base SHA pinned at spine time); falls back to live `git -C rev-parse HEAD` only when `--commit` is absent (#5) | | `rubric_version` | `version` field from `severity-rubric.json` | | `skill_version` | `version` field from `module.json` | | `tool_versions` | `{tool: version_string}` for spine tools present on PATH | | `model` | `--model` arg or `AUDIT_MODEL` env var | | `optional_checks_ran` | list of check IDs passed via `--optional-check` | The report header (Phase 6) should display `commit`, `rubric_version`, and `model` so readers can trace any finding back to its rubric snapshot. When the rubric changes between runs, different `rubric_version` values explain severity shifts (see ADV-007). **CODEOWNERS owner tagging** — if a `CODEOWNERS` file exists in the repo (checked in order: `.github/CODEOWNERS`, `CODEOWNERS`, `docs/CODEOWNERS`), each finding whose `location.path` matches a CODEOWNERS rule gains `properties.owner` set to the owning team or user handle(s). Follows GitHub CODEOWNERS last-match-wins with directory-prefix and glob matching. Owner tags enable per-team issue routing and allow the issue-creation step (Phase M7) to notify the right team. Findings with no matching CODEOWNERS rule receive no `owner` field (omitted, not null). **Per-package monorepo scoping** — for monorepos, `provenance.py` detects package roots from `pnpm-workspace.yaml` or the root `package.json#workspaces` field (pass `--packages` to override). Each finding in a detected package root gains `properties.package` set to the package directory path. After tagging, the script emits one `package_summary` record per package with per-severity counts: ```json {"type":"package_summary","package":"packages/auth","counts":{"critical":0,"high":1,"medium":2,"low":0,"info":3}} ``` Package summaries appear after all finding records in the output and can drive per-package report sections or Slack/issue routing. ### Suppression (optional, after merge step) Suppression lets you acknowledge known findings that cannot be fixed immediately, without hiding them entirely. Suppressed findings **remain in the JSONL** with a `suppression` field; they are never omitted. In the audit report, suppressed CRITICAL and HIGH findings appear tagged `[SUPPRESSED]` so they remain visible. #### .auditignore.yaml format Place a `.auditignore.yaml` file at the repo root (or pass `--auditignore `). The file is a YAML list of mapping blocks. Supported keys per entry: | Key | Required | Description | |-----|----------|-------------| | `id` (or `check_id`) | yes | The `check_id` to suppress. Supports `fnmatch` globs (e.g. `security/*`). Note: Python `fnmatch` `*` crosses `/`, so `security/*` also matches `security/sub/deep` — matching is recursive; there is no separate `**`. | | `paths` | no | Inline list of repo-relative `fnmatch` globs. When absent, the rule matches any path. Note: Python `fnmatch` `*` crosses `/`, so `src/*.js` also matches `src/sub/deep.js` — matching is recursive; there is no separate `**`. | | `reason` | **required** | Human-readable justification. Entries missing `reason` emit a warning and are skipped. | | `expires` | no | ISO date `YYYY-MM-DD`. When present and strictly before today's date, the suppression is **ignored** and a warning is emitted. | Example: ```yaml - id: security/no-console paths: [src/scripts/*.js] reason: console.log intentional in CLI scripts expires: 2027-01-01 ``` #### Inline comment format Add a comment on the same line as a problematic line **or the line immediately before it**: ```python # audit-ignore: code-quality/unused-import kept for re-export import logging ``` ```typescript // audit-ignore: typescript-react/missing-prop-types legacy component ``` Scoping rule: `# audit-ignore: [reason]` on line N suppresses a finding with `location.line == N` (same line) **or** `location.line == N+1` (the line immediately following). Only the named `check-id` is suppressed at that location. Both `#` (Python, shell, YAML) and `//` (JavaScript, TypeScript) comment markers are supported. #### Wiring suppression into the emit/report step Run `suppress.py` **after** `merge-findings.py` writes `findings.jsonl` and (when used) after `baseline.py` writes `findings-delta.jsonl`. The suppressor runs by default whenever `.auditignore.yaml` exists in the repo root. ```bash # After merge-findings.py (or baseline.py) has written its output: SUPPRESS_INPUT="$AUDIT_DIR/current/findings.jsonl" # When .auditignore.yaml exists or --auditignore is passed: if [ -f "$REPO_DIR/.auditignore.yaml" ]; then python3 "$SKILL_ROOT/scripts/suppress.py" \ --findings "$SUPPRESS_INPUT" \ --auditignore "$REPO_DIR/.auditignore.yaml" \ --repo "$REPO_DIR" \ --output "$AUDIT_DIR/current/findings-suppressed.jsonl" SUPPRESS_INPUT="$AUDIT_DIR/current/findings-suppressed.jsonl" fi ``` Use `findings-suppressed.jsonl` (instead of `findings.jsonl`) as the input to the report compiler. In the report: - CRITICAL/HIGH findings with a `suppression` field are shown with `[SUPPRESSED]` after the severity label, e.g. `**[security/leaked-credential]** (CRITICAL) [SUPPRESSED] ...` - The Summary table includes a **Suppressed** row showing the count of suppressed findings. **`--single` mode:** the same suppression step applies after Phase 5 (Run Merge Inline). Run `suppress.py` before compiling the Phase 6 report. **Expiry and missing-reason behaviour:** expired entries and entries missing `reason` are skipped with a warning to stderr; their findings are treated as unsuppressed. ### Phase M7: Issue Creation Ask the user: ``` The audit found {N} findings (Critical: X, High: Y, Medium: Z, Low: W). Full report: .audit/current/audit-report.md Would you like me to create GitHub issues for these? 1. Create issues for Critical + High severity only 2. Create issues for all findings 3. Create issues for Critical + High, plus one umbrella issue for Medium + Low 4. Skip issue creation ``` **If creating issues:** 1. Check for existing audit issues: `gh issue list --label "audit" --state open` 2. Create labels if needed: ```bash gh label create "audit" --color "d4c5f9" 2>/dev/null || true gh label create "needs-human-review" --color "fbca04" 2>/dev/null || true ``` 3. Group findings by pack and create one issue per pack (for selected severity levels). 4. Use the issue template from `reference/output-template.md`. 5. **Link each issue to the epic**: Add `Parent: #` in the issue body. 6. **Update the epic issue** with downstream issue links. 7. **Optionally clean up**: Ask user if they want to keep `.audit/current/` (for reference) or archive it. --- ### Phase M5-manual: Output Launch Instructions (--manual mode only) **This phase only runs when `--manual` flag is provided.** It replaces M5-M7 above. For `--manual` mode, worktrees are always created (even in read-only mode) so each Claude Code session has its own working directory: ```bash for i in 0 1 2 3; do git worktree add "$AUDIT_DIR/worktrees/agent-$i" -b "audit/agent-$i-$AUDIT_DATE" "origin/$BASE_BRANCH" done ``` Display a clear summary and, for each active worker id (those with a non-empty pack assignment), output the exact launch command block so the user can copy-paste into separate terminals: ``` ## Audit Setup Complete — Manual Launch Required Run each of the following commands in a separate Claude Code terminal: --- Worker 0 --- cd {AUDIT_DIR}/worktrees/agent-0 /audit --worker --task {AUDIT_DIR}/current/tasks/worker-0.json # (worktree strategy) No push needed — the coordinator merges local refs. # (multi-clone strategy only) After worker finishes: git push origin audit/agent-0-{AUDIT_DATE} --- Worker 1 --- cd {AUDIT_DIR}/worktrees/agent-1 /audit --worker --task {AUDIT_DIR}/current/tasks/worker-1.json # (worktree strategy) No push needed — the coordinator merges local refs. # (multi-clone strategy only) After worker finishes: git push origin audit/agent-1-{AUDIT_DATE} ... (omit workers with empty pack assignments) After all workers complete, run from the repo root: /audit --collect ``` Notes: - For the **worktree strategy** (default): do NOT push — workers commit to their local audit branch and the coordinator merges local refs directly in M6-fix. - For the **multi-clone strategy**: the `git push origin` step is required — the coordinator merges from origin refs and cannot see the branch without the push. --- ## Worker Mode: `--worker` (Phases W1-W5) Run from a worktree (manual mode) or invoked as an agent (autonomous mode). Reads its pack-based task file and performs the audit of assigned packs. ### Phase W1: Self-ID & Task Load 1. **Derive worker id** from current directory or environment. 2. **Derive AUDIT_DIR** from git common directory: ```bash GIT_COMMON=$(git rev-parse --git-common-dir) REPO_ROOT=$(dirname "$GIT_COMMON") AUDIT_DIR="$REPO_ROOT/.audit" ``` 3. **Read task file**: ```bash cat "$AUDIT_DIR/current/tasks/worker-${WORKER_ID}.json" ``` If task file doesn't exist, error out with a message pointing to `/audit`. ### Phase W2: Init Results File Write initial results file to signal this worker has started: ```json { "worker_id": "", "status": "in_progress", "started_at": "ISO-8601", "completed_at": null, "findings": [], "spine_triage": [] } ``` Write to the absolute path from `task.results_file_path`. ### Phase W3: Pack Audit + Spine Triage For each pack assigned in the task file: 1. **Read the pack's checks.md** from the absolute path `task.packs[N].checks_md_path`. 2. **Read the pack's spine slice** from the absolute path `task.packs[N].spine_slice_path`. 3. **Run the checks** described in checks.md against the codebase (using Grep, Glob, Read with absolute paths). 4. **Triage hybrid candidates**: for every finding in the spine slice with `detection: "hybrid"`, decide `confirmed` or `dismissed` and add a `spine_triage` entry: - `confirmed`: LLM analysis agrees the finding is real. - `dismissed`: LLM is confident it is a false positive. - A finding is only dropped if ALL workers that named its fingerprint voted `dismissed` (the merge step enforces unanimity). 5. **Add LLM-only findings** with `source: "llm"`. 6. **Source all severity/confidence/fix_confidence from `task.rubric_slice`**. For check_ids not in the rubric slice, set `confidence: "low"` and note for rubric expansion. Workers must NOT invent severity from intuition. Use the rubric only. ### Phase W4: Fix Cycle (--fix mode only) **Skip entirely unless FIX_MODE is true (from task file).** See "Fix Mode Addendum" below for the full fix cycle. Auto-fix eligibility requires BOTH: the pack's `auto_fixable: true` flag AND the rubric's `fix_confidence` being `"high"` OR `"medium"`. See the **Auto-Fix Confidence Reference** section for the complete rule. `"high"` checks are auto-applied without extra checks; `"medium"` checks are attempted with extra verification steps before committing. All others (`"low"`) are flagged for human review and never auto-applied. ### Phase W5: Write Final Results Write the results file to the absolute path from `task.results_file_path`: ```json { "worker_id": "", "status": "completed", "started_at": "ISO-8601", "completed_at": "ISO-8601", "findings": [ { "check_id": "/", "severity": "critical|high|medium|low|info", "confidence": "high|medium|low", "detection": "llm|hybrid", "source": "llm", "message": "", "location": {"path": "", "line": 1}, "fix_confidence":"high|medium|low" } ], "spine_triage": [ { "fingerprint": "", "verdict": "confirmed|dismissed", "note": "" } ] } ``` Display completion summary: ``` ## Worker {N} Audit Complete Packs: [list] Findings: X (Critical: X, High: X, Medium: X, Low: X) Spine triage: X confirmed, X dismissed ``` --- ## Collector Mode: `--collect` (Phases C1-C4) Run from the repo root (NOT from a worktree) after all workers complete. Compiles results and creates issues. ### Phase C1: Verify Completion Check all result files listed in `assignment.json`: ```bash python3 -c " import json, sys, os a = json.load(open(sys.argv[1])) audit_dir = sys.argv[2] for wid, packs in sorted(a.items()): if not packs: continue rf = os.path.join(audit_dir, 'current', 'results', f'worker-{wid}.json') try: status = json.load(open(rf)).get('status', 'missing') except Exception: status = 'no file' print(f'Worker {wid}: {status}') " "$AUDIT_DIR/current/assignment.json" "$AUDIT_DIR" ``` - If all active workers show `"completed"`, proceed. - If any show `"in_progress"` or `"missing"`: - Re-dispatch a **finish-only** pass for that worker first (field report #7): re-run its worker prompt with "your prior run did not finish; do not redo committed fixes, only complete analysis and write the final results file". Any fixes it already committed stay on its branch. - Without `--force`: if still incomplete after the finish-only retry, report status and wait. - With `--force`: warn, record the still-incomplete packs as a coverage gap, and proceed. ### Phase C2: Merge + Compile Report Run the merge pipeline (same as Phase M6 above) and compile the pack-grouped audit report with Coverage Gaps section. **Merge conflict handling (--fix only):** If a git merge step produces conflicts: 1. **Capture the conflicted files first** — before aborting, record the evidence: ```bash CONFLICT_REPORT="$AUDIT_DIR/current/merge-conflicts.md" CONFLICTED_FILES=$(git diff --name-only --diff-filter=U) cat >> "$CONFLICT_REPORT" </dev/null || true ``` 3. **HALT** with message: "MERGE CONFLICT on worker branch. See $CONFLICT_REPORT. Resolve manually then re-run --collect." The conflict report is written BEFORE the abort so the file list is preserved. After `git merge --abort` the conflict markers are gone — the report is the only record. ### Phase C3: Issue Creation Same as Phase M7 above — present the report, ask about issue creation. ### Phase C4: Archive & Cleanup 1. **Archive results**: Write `history/YYYYMMDD.json` per the schema in `reference/multi-agent-config.md`. 2. **If worktrees exist**, clean them up: ```bash for i in 0 1 2 3; do git worktree remove "$AUDIT_DIR/worktrees/agent-$i" --force 2>/dev/null || true done git worktree prune ``` 3. **If multi-clone mode was used**, reset clones to base branch. 4. **Delete remote agent branches** (if they were pushed in fix mode). 5. **Ask about cleanup**: Keep or archive `.audit/current/`. --- ## Fix Mode Addendum (--fix) When `--fix` is passed, these additional steps are added to the workflow. `--fix` is silently ignored when combined with `--single`. ### M3-fix: Create Worktrees > This is the fix-mode detail for Phase M4 step 3. Worktrees are still created > **after** the spine runs (Phase M4), never before (field report #1). ```bash git fetch origin for i in 0 1 2 3; do git worktree add "$AUDIT_DIR/worktrees/agent-$i" -b "audit/agent-$i-$AUDIT_DATE" "origin/$BASE_BRANCH" done # Give each worktree a toolchain. PREFER symlinking the main checkout's # node_modules (field report #8): worktrees are the same commit, so the tree is # identical — a symlink is instant vs. minutes + ~2 GB for 4x a fresh install. if [ -d "$REPO_DIR/node_modules" ]; then for i in 0 1 2 3; do ln -s "$REPO_DIR/node_modules" "$AUDIT_DIR/worktrees/agent-$i/node_modules" done else # Fall back to a real install only when the main checkout has none. case "$PKG_MANAGER" in bun) INSTALL_CMD="bun install --frozen-lockfile" ;; pnpm) INSTALL_CMD="pnpm install --frozen-lockfile" ;; yarn) INSTALL_CMD="yarn install --frozen-lockfile" ;; *) INSTALL_CMD="npm ci" ;; esac for i in 0 1 2 3; do (cd "$AUDIT_DIR/worktrees/agent-$i" && $INSTALL_CMD 2>&1 | tail -1) & done wait fi ``` ### M5-fix: Worker Prompts Include Fix Instructions The agent prompts are extended with strategy-specific instructions: **CRITICAL — never run a bare `git` command (field report #2).** A subagent is NOT `cd`'d into its worktree; it inherits the coordinator's cwd (the MAIN checkout). A bare `git checkout -b ...` / `git add` / `git commit` therefore mutates the USER'S main checkout, not the worktree. EVERY git command MUST be prefixed with `git -C "$WORKTREE"` (worktree strategy) or `git -C "$CLONE_DIR"` (multi-clone). The worker prompt sets `WORKTREE`/`CLONE_DIR` as the FIRST line and uses it on every git invocation below — there are no bare `git` examples to copy by accident. **Worktree strategy** (default `--fix` path): ``` WORKING DIRECTORY: {AUDIT_DIR}/worktrees/agent-{N} WORKTREE="{AUDIT_DIR}/worktrees/agent-{N}" NEVER run a bare `git` command — you are NOT inside the worktree; a bare git mutates the user's MAIN checkout. ALWAYS use `git -C "$WORKTREE"`. For auto-fixable findings (rubric fix_confidence=high AND pack check auto_fixable=true): - Implement fixes using Edit tool with absolute paths under "$WORKTREE" - Run verification using commands from the verification_commands field - If verification passes: git -C "$WORKTREE" add && \ git -C "$WORKTREE" commit --no-verify -m "audit(): " - If verification fails: git -C "$WORKTREE" checkout -- . && git -C "$WORKTREE" clean -fd - Record fix success/failure in results Commit with --no-verify: the repo's own pre-commit hook (lint/type-check) may fail on PRE-EXISTING warnings unrelated to your fix and clobber the commit (field report #8). The audit's verification step is the gate; the coordinator runs the real pre-push checks once on the combined branch. IMPORTANT (worktree strategy): When finished, commit your changes to your audit branch. Do NOT push — your branch is a LOCAL ref (audit/agent-{N}-{DATE}). The coordinator merges it directly from local refs. Pushing is not needed and not expected. ``` **Multi-clone strategy** (`--fix` with USE_CLONES=true): ``` WORKING DIRECTORY: {CLONE_DIR} CLONE_DIR="{CLONE_DIR}" NEVER run a bare `git` command — ALWAYS use `git -C "$CLONE_DIR"`. For auto-fixable findings (rubric fix_confidence=high AND pack check auto_fixable=true): - Implement fixes using Edit tool with absolute paths under "$CLONE_DIR" - Run verification using commands from the verification_commands field - If verification passes: git -C "$CLONE_DIR" add <files> && \ git -C "$CLONE_DIR" commit --no-verify -m "audit(<pack>): <title>" - If verification fails: git -C "$CLONE_DIR" checkout -- . && git -C "$CLONE_DIR" clean -fd - Record fix success/failure in results IMPORTANT (multi-clone strategy): When finished, push your branch: git -C "$CLONE_DIR" push origin audit/agent-{N}-{DATE} The coordinator merges origin refs (not local), so the push is required. ``` ### W4-fix: Fix Cycle For each auto-fixable finding (rubric `fix_confidence: "high"` AND pack check `auto_fixable: true`), ordered by fix_confidence (high first, then medium): 1. **Implement the fix** using Edit/Write tools 2. **Run verification** using verification commands from the task file 3. **If verification passes**: Commit 4. **If verification fails**: Revert and continue to next finding 5. Stop if MAX_FIXES reached. ### M6-fix: Merge & Create PR After collecting results, merge the fix branches into a combined branch, verify, push, and create a PR targeting `$BASE_BRANCH`. The merge strategy differs by execution mode: **Worktree strategy** (default): worker branches are LOCAL refs in the main repo's ref namespace (worktrees share refs with the main checkout). Merge them directly — no push required from workers. **Multi-clone strategy**: worker branches live in separate repos and were pushed to origin. Merge from `origin/audit/agent-$i-$AUDIT_DATE`. **Host commit-guard hooks may have reverted a worker's commit (field report #2).** Some hosts run a PostToolUse/commit-guard hook that `reset`s commits made inside subagents (`reflog: reset: moving to HEAD`). If a worker reported a successful commit but its branch HEAD does not contain it, do NOT trust the worktree HEAD blindly: verify each worker branch actually advanced past `origin/$BASE_BRANCH`, and where a worker recorded a fix in its results but its branch is empty, **rebuild the fix by cherry-picking from the worker's reflog / recorded commit** rather than silently dropping it: ```bash for i in 0 1 2 3; do B="audit/agent-$i-$AUDIT_DATE" git rev-parse --verify --quiet "$B" >/dev/null 2>&1 || continue AHEAD=$(git rev-list --count "origin/$BASE_BRANCH..$B" 2>/dev/null || echo 0) if [ "${AHEAD:-0}" -eq 0 ]; then echo "NOTE: $B has no commits ahead of base — a host hook may have reverted them." >&2 echo " Check 'git -C <worktree> reflog' and cherry-pick recorded fix commits." >&2 fi done ``` 1. **Create collector worktree**: ```bash git worktree add "$AUDIT_DIR/worktrees/combined" -b "audit/$AUDIT_DATE" "origin/$BASE_BRANCH" ``` 2. **Merge each worker branch** in order (worker-0 first, ascending). **CRITICAL: merge conflicts HALT the process — do NOT auto-resolve with `--ours`.** ```bash cd "$AUDIT_DIR/worktrees/combined" CONFLICT_REPORT="$AUDIT_DIR/current/merge-conflicts.md" MERGE_OK=true MERGED_COUNT=0 EXPECTED_BRANCHES=() for i in 0 1 2 3; do EXPECTED_BRANCHES+=("audit/agent-$i-$AUDIT_DATE") done if [ "${USE_CLONES:-false}" = "true" ]; then # Multi-clone: branches were pushed to origin — fetch first so the # coordinator sees them (coordinator may not have fetched since workers pushed). git fetch origin # Merge from origin refs for i in 0 1 2 3; do BRANCH="origin/audit/agent-$i-$AUDIT_DATE" git rev-parse --verify --quiet "origin/audit/agent-$i-$AUDIT_DATE" 2>/dev/null || continue if ! git merge "$BRANCH" --no-edit 2>/dev/null; then MERGE_OK=false CONFLICTED_FILES=$(git diff --name-only --diff-filter=U) cat >> "$CONFLICT_REPORT" <<EOF ## Merge conflict: agent-$i branch Conflicted files (captured before abort): $CONFLICTED_FILES Resolution required: Manually review and resolve conflicts between the already-merged branches and audit/agent-$i-$AUDIT_DATE. EOF git merge --abort 2>/dev/null || true echo "MERGE CONFLICT on agent-$i branch. See $CONFLICT_REPORT" echo "STOPPING: resolve conflicts manually then re-run --collect." break fi MERGED_COUNT=$((MERGED_COUNT + 1)) done else # Worktree strategy: branches are local refs — no push needed from workers for i in 0 1 2 3; do BRANCH="audit/agent-$i-$AUDIT_DATE" git rev-parse --verify --quiet "$BRANCH" 2>/dev/null || continue if ! git merge "$BRANCH" --no-edit 2>/dev/null; then MERGE_OK=false CONFLICTED_FILES=$(git diff --name-only --diff-filter=U) cat >> "$CONFLICT_REPORT" <<EOF ## Merge conflict: agent-$i branch Conflicted files (captured before abort): $CONFLICTED_FILES Resolution required: Manually review and resolve conflicts between the already-merged branches and audit/agent-$i-$AUDIT_DATE. EOF git merge --abort 2>/dev/null || true echo "MERGE CONFLICT on agent-$i branch. See $CONFLICT_REPORT" echo "STOPPING: resolve conflicts manually then re-run --collect." break fi MERGED_COUNT=$((MERGED_COUNT + 1)) done fi if [ "$MERGE_OK" != "true" ]; then echo "Merge incomplete. Review $CONFLICT_REPORT before proceeding." exit 1 fi # Zero-merge guard: if fix mode ran but NO branches merged, halt loudly if [ "$MERGED_COUNT" -eq 0 ]; then echo "ERROR: Fix mode ran but ZERO worker branches were merged." >&2 echo " Expected branches:" >&2 for b in "${EXPECTED_BRANCHES[@]}"; do echo " $b" >&2; done echo " No combined branch was pushed and no PR was created." >&2 echo " Possible causes: workers did not commit, wrong AUDIT_DATE, branches" >&2 echo " were removed before M6-fix ran, or (multi-clone) coordinator has not" >&2 echo " fetched since workers pushed (run 'git fetch origin' and retry)." >&2 exit 1 fi ``` If `MERGED_COUNT` is less than the number of active workers, continue but include a note in the PR body listing which branches were merged and which were missing. 3. **Install deps and verify** using the detected package manager: ```bash $INSTALL_CMD ${LINT_CMD:-true} && ${TYPECHECK_CMD:-true} && ${BUILD_CMD:-true} ``` 4. **Push and create PR**: ```bash git push origin "audit/$AUDIT_DATE" gh pr create \ --title "Audit fixes: $AUDIT_DATE" \ --body "Auto-fixes from /audit --fix run on $AUDIT_DATE. Review each commit. Merged $MERGED_COUNT of ${#EXPECTED_BRANCHES[@]} worker branches." \ --base "$BASE_BRANCH" ``` --- ## Single-Session Mode: `--single` (Phases 1-7) **Always read-only.** If invoked with `--fix`, print: ``` Note: --single is read-only; --fix ignored. Use parallel-worktrees strategy for auto-fix. ``` Then proceed as read-only. Dispatches one read-only Explore subagent per **selected pack** (not a fixed 9) after running the detector, registry, spine, and merge scripts inline. ### Phase 1: Pre-Flight Parse arguments. Verify git repo. Set up `REPO_DIR`, `AUDIT_DIR`, `SKILL_ROOT`. ```bash REPO_DIR=$(git rev-parse --show-toplevel) AUDIT_DIR="$REPO_DIR/.audit" SKILL_ROOT="$HOME/.claude/skills/audit" mkdir -p "$AUDIT_DIR/current/spine" mkdir -p "$AUDIT_DIR/current/results" grep -qxF '.audit/' .gitignore 2>/dev/null || echo '.audit/' >> .gitignore ``` ### Phase 1.5: Diff Scope (--diff / --staged only, single-session) **Skip this phase entirely for full-repo audits.** Only execute when `--diff` or `--staged` is set. Same mechanics as Phase M2.1 in Autonomous Mode: compute the changed-file set once, write `$AUDIT_DIR/current/changed-files.z` (null-delimited) and `changed-files.txt` (one path per line, generated by python3 from the -z file — never by shell string-splitting), guard on empty set, and write `.audit/current/config.json` recording the diff scope (create it; single-session mode has none yet). All python3 parsing is null-delimited end-to-end; filenames containing spaces, semicolons, or newlines are inert data and are never interpolated into a shell string. **Bad-ref halt**: if the `git diff` command exits nonzero (nonexistent ref), HALT immediately with the git error — do not fall through to the empty-set path. **Spine post-filter (single-session)**: after the spine runs in Phase 3, apply the same documented python3 filter step against `changed-files.z` before producing per-pack slices. Filter spine findings whose `location.path` is NOT in the changed set; pass through all `coverage_gap` and `provenance` records unfiltered. **LLM scoping**: each pack subagent's prompt in Phase 4 includes the absolute path to `changed-files.txt` and the rule: "Audit ONLY files listed in changed-files.txt. Checks whose subject is repo-wide run only if their subject file is in that list." **Report header**: same as M-phase — note diff scope and changed-file count. ### Phase 2: Ecosystem Detection + Pack Selection Run the detector and registry inline: ```bash bash "$SKILL_ROOT/scripts/detect-ecosystems.sh" "$REPO_DIR" \ > "$AUDIT_DIR/current/detection.json" python3 "$SKILL_ROOT/scripts/registry.py" "$AUDIT_DIR/current/detection.json" \ > "$AUDIT_DIR/current/selected-packs.json" PACK_COUNT=$(python3 -c "import json,sys; print(len(json.load(open(sys.argv[1]))))" \ "$AUDIT_DIR/current/selected-packs.json") if [ "$PACK_COUNT" -eq 0 ]; then echo "ERROR: registry selected zero packs for this repository." >&2 exit 1 fi ``` ### Phase 3: Run the Spine Inline Compute the union of tools from selected packs and run the spine: ```bash SPINE_TOOLS=$(python3 - "$AUDIT_DIR/current/selected-packs.json" << 'PYEOF' import json, sys packs = json.load(open(sys.argv[1])) tools = set() for p in packs: tools.update(p.get("tools", [])) print(",".join(sorted(tools)) if tools else "") PYEOF ) if [ -z "$SPINE_TOOLS" ]; then echo "Note: no selected packs declare tools[]; skipping spine run." >&2 touch "$AUDIT_DIR/current/spine/findings.jsonl" else bash "$SKILL_ROOT/scripts/spine/run.sh" \ --repo "$REPO_DIR" \ --tools "$SPINE_TOOLS" \ --output "$AUDIT_DIR/current/spine/findings.jsonl" fi # Diff mode: apply the Phase 1.5 post-filter to the spine findings NOW, before slicing. if [ "${DIFF_MODE:-false}" = "true" ] || [ "${STAGED_MODE:-false}" = "true" ]; then python3 - \ "$AUDIT_DIR/current/spine/findings.jsonl" \ "$AUDIT_DIR/current/changed-files.z" \ "$AUDIT_DIR/current/spine/findings-diff-filtered.jsonl" << 'PYEOF' import json, sys spine_file, changed_z, out_file = sys.argv[1], sys.argv[2], sys.argv[3] with open(changed_z, "rb") as fh: data = fh.read() changed_set = set( p.decode("utf-8", errors="surrogateescape") for p in data.rstrip(b"\x00").split(b"\x00") if p ) kept = 0; skipped = 0 with open(spine_file, encoding="utf-8") as fin, \ open(out_file, "w", encoding="utf-8") as fout: for raw in fin: raw = raw.strip() if not raw: continue try: rec = json.loads(raw) except json.JSONDecodeError: continue if "type" in rec: fout.write(raw + "\n"); continue path = rec.get("location", {}).get("path", "") if path in changed_set: fout.write(raw + "\n"); kept += 1 else: skipped += 1 print(f"diff-filter (Phase 3): kept {kept}, skipped {skipped}", file=sys.stderr) PYEOF mv "$AUDIT_DIR/current/spine/findings-diff-filtered.jsonl" \ "$AUDIT_DIR/current/spine/findings.jsonl" fi ``` Then produce per-pack spine slices using the same slicing logic as Phase M4. ### Phase 4: Dispatch Read-Only Pack Subagents Launch **one Agent per SELECTED pack** (not a fixed 9) in a SINGLE message with parallel Agent tool calls (`subagent_type: "Explore"`, `run_in_background: true`). > **Concurrency — avoid the 429 throttle.** These are light `Explore` agents, but a full audit can select well over 8 packs — and launching 15–20+ at once trips a server-side rate limit (`Server is temporarily limiting requests · Rate limited`) that fails the whole batch. Launch in **waves of ≤8** (`run_in_background` lets a wave drain while you start the next): dispatch the first 8 packs, then the rest. If you see the throttle mid-run, wait 30–60s and re-dispatch only the failed packs. See `~/.claude/rules/concurrency-and-rate-limits.md`. For each pack, the subagent receives: - The absolute path to the pack's `checks.md` (`$SKILL_ROOT/packs/<pack-dir>/checks.md`) - The rubric slice for this pack's check-ids - The absolute path to the pack's spine slice (`$AUDIT_DIR/current/spine/<pack-dir>.jsonl`) - The absolute results-file path (`$AUDIT_DIR/current/results/single-<pack-dir>.json`) - Instruction to write results per the worker results-file contract **Prompt template for each Explore subagent:** ``` You are a read-only pack auditor for the '{PACK_ID}' pack. CODEBASE ROOT: {REPO_DIR} CHECKS FILE: {CHECKS_MD_PATH} SPINE SLICE: {SPINE_SLICE_PATH} RESULTS FILE: {RESULTS_FILE_PATH} RUBRIC SLICE: {RUBRIC_SLICE_JSON} IMPORTANT: READ-ONLY. Do NOT modify files, create branches, or make commits. 1. Read the checks.md from CHECKS FILE. 2. Read the spine slice from SPINE SLICE. 3. Run each check from checks.md against the codebase using Grep, Glob, Read. 4. For each "hybrid" detection finding in the spine slice: triage as confirmed/dismissed. 5. Add any LLM-only findings with source:"llm". 6. Source severity/confidence/fix_confidence from RUBRIC SLICE only. 7. Write results to RESULTS FILE per the worker results-file contract. ``` ### Phase 5: Run Merge Inline After all subagents complete, run the merge pipeline: ```bash # Collect single-session result files (array — safe for paths with spaces) LLM_ARGS=() for f in "$AUDIT_DIR/current/results"/single-*.json; do [ -f "$f" ] && LLM_ARGS+=(--llm "$f") done python3 "$SKILL_ROOT/scripts/merge-findings.py" \ --spine "$AUDIT_DIR/current/spine/findings.jsonl" \ "${LLM_ARGS[@]}" \ --rubric "$SKILL_ROOT/schemas/severity-rubric.json" \ --repo "$REPO_DIR" \ --output "$AUDIT_DIR/current/findings.jsonl" ``` ### Phase 6: Compile Report + Display Summary Compile the pack-grouped audit report from `$AUDIT_DIR/current/findings.jsonl` — same format as Phase M6, including the **Coverage Gaps** section. Write to `$AUDIT_DIR/current/audit-report.md`. Display the summary table and critical/high findings to the user. ### Phase 7: Optional Issue Creation + Cleanup Ask about issue creation (same as Phase M7, except `--single` never creates an epic issue — it creates standalone issues with no `Parent:` link and no epic-update step). Ask about cleanup. Archive results. --- ## Auto-Fix Confidence Reference Auto-fix eligibility is keyed off both the rubric's `fix_confidence` AND the pack's `auto_fixable` flag on the specific check. A check is eligible for autonomous fix only when: - The pack's check entry has `"auto_fixable": true`, AND - The rubric's `fix_confidence` for that `check_id` is `"high"` or `"medium"`. ### HIGH Confidence (auto-fix with --fix) - `eslint --fix` for fixable rules - `prettier --write` for formatting - `npm audit fix` (non-breaking only) - Remove unused imports/variables - Add missing semicolons ### MEDIUM Confidence (fix with extra care) - Add explicit return types (verify inference is correct) - Replace simple `any` with inferred type - Add React.memo wrapper ### LOW Confidence (human review always) - Refactor long methods - Resolve circular dependencies - Add error boundaries - Write test implementations - Major dependency upgrades --- ## Error Handling | Scenario | Response | |----------|----------| | Zero packs selected | HALT with message: "registry selected zero packs". Do NOT silently proceed. | | Worker crashes mid-audit | Results show `"in_progress"` (or missing). M5 auto-re-dispatches a finish-only pass (#7); if it still fails, record a coverage gap. `--collect --force` collects available. | | Worker polluted the main checkout | M6 step 0 detects moved HEAD / dirty tree vs. the M5 snapshot and restores it; worker fixes stay on their branches (#2). | | Worker findings in non-canonical shape | merge-findings normalizes (title→message, flat path→location, line coercion, worktree-path stripping) and reports a loud `dropped N` count for any unrecoverable ones (#3). | | Fix breaks the build (--fix) | Fix is reverted, recorded in results, worker continues. | | Merge conflict (--fix) | HALT: (1) capture conflicted files to `.audit/current/merge-conflicts.md` FIRST, (2) run `git merge --abort`, (3) stop with message pointing to the report. | | `.audit/current/` already exists | Coordinator asks: clean start, resume, or cancel. | | Worktree already exists | Remove stale worktree first, then create fresh. | | Task file missing | Worker errors with message to run `/audit` first. | | Agent times out | Collect proceeds with completed workers, reports incomplete ones. | | All agents fail | Report failure, suggest `--manual` mode. | | `--single` with `--fix` | Print note: `--single is read-only; --fix ignored`. Proceed read-only. | | `--staged` with `--fix` | Print note: `--staged is read-only; --fix ignored`. Proceed read-only. | --- ## Spine Execution Ownership & Merge Contract **ADV-002. This section is the authoritative spec for Epic 1.7b orchestration.** ### Spine Execution (coordinator responsibility) The **COORDINATOR** runs the deterministic spine **once per audit run** — never per-worker, never inside a worktree: ``` scripts/spine/run.sh \ --repo <ABSOLUTE repo root> \ --tools <comma-list scoped to the union of the selected packs' tools[]> \ --output <ABSOLUTE>/.audit/current/spine/findings.jsonl ``` - The repo path must be **absolute**. The spine must run against the main checkout, not a worktree. - `--tools` must be the union of every `tools[]` array from the packs selected for this run. Do not run spine tools that no pack will consume. - The output file is written to `.audit/current/spine/` (under the **main** checkout's `.audit/`, not under any worktree). ### Per-worker spine slices Workers run in separate git worktrees and **cannot see** a relative `.audit/` under the main checkout. Every path a worker receives must be **absolute**. At task-file-writing time (Phase M3/M4) the coordinator: 1. Filters the spine output by the pack's check-id namespaces and `tools[]` to produce a per-pack slice. 2. Writes the slice to `<ABSOLUTE>/.audit/current/spine/<pack-dir>.jsonl`. 3. Embeds the **absolute** slice path in the worker's task file as `spine_slice_path`. Workers read their slice from that absolute path. They never access `.audit/current/spine/findings.jsonl` directly. ### Worker duties For each pack a worker handles: 1. **Triage hybrid candidates**: for every finding in the spine slice with `detection: "hybrid"`, decide `confirmed` or `dismissed` and record a `spine_triage` entry in the results file. A finding is only dropped if ALL workers that named its fingerprint voted `dismissed`; a single `confirmed` vote from any worker overrides any number of `dismissed` votes. 2. **Add LLM-only findings**: run the pack's LLM/hybrid checks; emit new findings with `source: "llm"`. 3. **Never invent severity**: workers must NOT set severity, confidence, or fix_confidence from intuition. Use `schemas/severity-rubric.json` only. The merge step enforces this mechanically, but workers should follow it proactively to avoid spurious `agentReportedSeverity` entries. ### Worker results-file contract Each worker writes a single JSON file. The ABSOLUTE path to this file (`.audit/current/results/worker-<id>.json`) is embedded in the worker's task file at the same time as `spine_slice_path`, so workers always receive it as an absolute path and never need to derive it from cwd. ```json { "findings": [ { "check_id": "<pack>/<check>", "rule_id": "<rule>", // optional -- defaults to check_id when absent "severity": "critical|high|medium|low|info", "confidence": "high|medium|low", "detection": "tool|llm|hybrid", "source": "llm", "message": "<human-readable description>", "location": {"path": "<repo-relative path>", "line": 1}, "fingerprint": "<optional — kept VERBATIM if present>", "fix_confidence":"high|medium|low", "properties": {} } ], "spine_triage": [ { "fingerprint": "<fingerprint of a spine hybrid candidate>", "verdict": "confirmed|dismissed", "note": "<optional explanation>" } ] } ``` `findings` contains only the worker's **new** LLM-detected findings. Spine findings are not re-emitted here; they are carried through by the merge step. ### `--single` mode In `--single` mode there is no coordinator/worker split. The single session: 1. Runs the spine inline (same invocation as above, before dispatching its read-only subagents). 2. Dispatches read-only subagents per pack (they receive the absolute spine slice path and produce results files). 3. Runs the merge itself after all subagents complete. `--single` is always read-only. `--fix` is silently ignored when combined with `--single`. ### Merge invocation After all workers complete, the coordinator runs: ``` scripts/merge-findings.py \ --spine <ABSOLUTE>/.audit/current/spine/findings.jsonl \ --llm <ABSOLUTE>/.audit/current/results/worker-0.json \ --llm <ABSOLUTE>/.audit/current/results/worker-1.json \ ... --rubric <ABSOLUTE>/schemas/severity-rubric.json \ --repo <ABSOLUTE repo root> \ --output <ABSOLUTE>/.audit/current/findings.jsonl ``` `--repo` must be the same absolute repo root passed to `scripts/spine/run.sh`. It ensures fingerprints computed for location paths are cwd-independent so baseline comparisons remain stable across invocations from different directories. The merger: - Deduplicates findings by fingerprint (tool source wins over llm source when fingerprints collide). - Applies mechanical rubric overwrite: for every `check_id` in the rubric, overwrites `severity`, `confidence`, and `fix_confidence` with the rubric values. When the overwrite changes severity, preserves the original as `properties.agentReportedSeverity` (ADV-007 — calibration disagreements stay visible). - Folds coverage-gap records from the spine through to the output (deduped). - Validates every output finding against `finding.schema.json`. --- ## Severity Sourcing **Agents MUST source severity, confidence, and fix_confidence from `schemas/severity-rubric.json`.** Do NOT invent or guess these values. For every finding whose `check_id` appears in the rubric, copy the rubric's `severity`, `confidence`, and `fix_confidence` verbatim. Preserve the agent-reported value in `properties.agentReportedSeverity` if it differs. For `check_id`s not yet in the rubric, set confidence to `"low"` and flag for rubric expansion. --- ## Notes - **Always prompt the user** when `/audit` is called without flags - let them choose scope and execution strategy - **Default mode is read-only** - no code changes, no branches, no PR - **`--single` is always read-only** - `--fix` is silently ignored when combined with `--single` - Auto-fix is opt-in (user selects "Analyze + auto-fix" or passes `--fix`) - **Three execution strategies**: Parallel worktrees (recommended), single session, or multi-clone - Multi-clone is opt-in and requires explicit verification that all clones are clean - Worktrees are the safest isolation method - they never touch the user's working directory or sibling clones - Pack selection is registry-driven: `scripts/detect-ecosystems.sh` + `scripts/registry.py` + `scripts/assign-packs.py` - The spine runs once per audit run via `scripts/spine/run.sh`; findings are merged via `scripts/merge-findings.py` - The output is always an audit report document + optional GitHub issues grouped by pack - The `reference/multi-agent-config.md` file has full JSON schemas for coordination files