Code Gauntlet
Concern-parallel agents with context-pulling and deterministic verification. When in doubt about whether something is a real issue, err on the side of not reporting it. A review with 5 real issues is far more valuable than one with 5 real issues buried in 20 false positives.
This is a code gauntlet tool built for thoroughness, not speed. The user chose this tool because they want aggressive, high-confidence review. Cost and time concerns do not justify skipping any phase — especially the blind-challenge stage, which requires spawning sub-agents. Every stage exists for a reason; skipping any of them degrades the result.
How v3 runs
The skill layer (this file) does three things: prepare (Phases 1–2 — gate, checkout, git artifacts, args), run (Phase 3 — a single Workflow tool call), and deliver (Phase 8 — read the persisted artifacts and run the delivery gates). The eight review stages themselves — Summarize, Discover, Merge, Verify, Validate, Filter, Challenge, Report — run inside the workflow (workflows/pipeline.js), which orchestrates them through injected agent()/parallel() runtime globals and returns a compact result. The workflow script has no disk, shell, or process.env access, so everything it needs arrives through the args object; legacy and derived writer fallbacks persist artifacts, while the return channel is serialized by the harness.
Verify slices travel to their executor as one quoted, percent-encoded --input-inline
token. The workflow plans the token under VERIFY_INLINE_CHAR_BUDGET (50k), while
RETURN_CHAR_BUDGET (1M) remains the separate limit for harness serialization of Persist
artifacts. The restricted inline alphabet leaves no escape semantics for a model to normalize;
the content proof remains the belt. Persist's separate JSON backslash-respelling experiment is
not a remedy for that wire.
Phase 1: Pre-Flight
Inline checks before any workflow run — no subagent dispatch. Read references/phase1-preflight.md for full templates.
Workflow-tool availability check — MANDATORY, FIRST
Before anything else, confirm the Workflow tool is present in this session's tool definitions. This is passive self-inspection — no tool call verifies it. Never probe with ToolSearch: it searches deferred tools only and returns zero hits for a top-level Workflow, which reads as a false "absent". v3 orchestration is a single Workflow invocation; there is no in-session fallback. If Workflow is absent from your tool definitions, print exactly:
code-gauntlet v3 requires Claude Code >= 2.1.154 with dynamic workflows. Install the pre-rename deep-review v2.x for older CLIs.
and STOP. Do not attempt to reproduce the pipeline inline — the clean break to the workflow runtime is intentional.
Plugin root resolution
Resolve plugin_root from this SKILL.md's path — go up two directories from skills/code-gauntlet/. Never search the filesystem for it. A recorded run (2026-07-30) that resolved it with find / -type d -name code-gauntlet picked version 3.2.3 out of a four-version plugin cache while 3.3.1 was the installed one, and reviewed the PR with stale scripts and a stale bundle. The path you were loaded from is the only correct answer; a find hit is a coin flip between every version ever installed. The plugin registers the workflow as code-gauntlet:code-gauntlet-pipeline; {plugin_root}/workflows/pipeline.js is its source and the identity receipt's authority. Never pass that path to Workflow: the CLI's read gate refuses a scriptPath in the plugin cache during interactive runs. Retained scripts (verify_findings.py, post_review.py) live under {plugin_root}/scripts/. Confirming {plugin_root}/scripts/, {plugin_root}/agents/, and {plugin_root}/workflows/ exist happens inside the Phase 1 composite call below — not as its own round trip.
Shell hygiene — binds here, the first site that needs it. User shells commonly alias
ls/cp/grepto incompatible replacements: anls→eza --iconsalias broke exactly this directory listing on a recorded run, because this reminder previously appeared only in Phase 2, after the damage was already done. In every Bash call in this skill, prefergit ls-files/findfor file enumeration, and prefix coreutils withcommand(command ls,command cp) when you must use them. This sentence is deliberately repeated verbatim at the Phase 2 composite below rather than cross-referenced once — the same duplication doctrine for the false-positive exclusion list and complete-read contract for whichagents/AGENTS.mdsays "Do not refactor them into a shared read." A future refactor that collapses this into a single cross-reference reintroduces the exact failure it fixes.
Resolve review target
Parse the user's input to determine the review target before eligibility checks — the target type affects every subsequent step. Store target_type (pr, mr, or local) and pr_number (if applicable). The ARGUMENTS value is the user's explicit input — a bare number (e.g., 1, 42) is always a PR/MR number. Resolve it via gh pr view before considering any other target type. Do not compare it against the branch name or second-guess it; the branch may track a different upstream PR. See references/phase1-preflight.md for resolution logic, validation, and the PR-not-found template. (One case needs its own round trip before the composite below: "review" with no number/URL, resolved via gh pr view --json number --jq '.number' for the current branch — the composite needs pr_number as an input, so this must run first.)
Phase 1 composite: output dir, plugin confirmation, config, PR state, trivial-check file list
One Bash call gathers every independent Phase-1 input at once: output-directory setup, plugin confirmation, config resolution, PR state, and the changed-file list. None of these depend on each other.
echo "=== output_dir ==="
if ! OUTPUT_DIR=$(python3 "{plugin_root}/scripts/ensure_output_dir.py"); then
echo "output_dir: FAILED"
exit 1
fi
echo "$OUTPUT_DIR"
echo "=== plugin_dirs ==="
command ls "{plugin_root}/scripts" "{plugin_root}/agents" "{plugin_root}/workflows"
echo "=== config ==="
if ! CONFIG_JSON=$(python3 "{plugin_root}/scripts/resolve_config.py" --target {target_type} --plugin-root "{plugin_root}"); then
echo "config: FAILED"
exit 1
fi
echo "$CONFIG_JSON"
echo "=== pr_view ==="
gh pr view {pr_number} --json state,isDraft,title,url
echo "=== changed_files ==="
gh pr diff {pr_number} --name-only
(GitLab: swap pr_view for glab mr view {pr_number} --output json, and changed_files for glab mr diff {pr_number} --name-only. Local/branch targets skip pr_view and changed_files entirely — use git diff --name-only <base>...HEAD / git diff --name-only HEAD for the trivial-check list instead.)
Output-dir hard stops (same severity class — stop before any agent dispatch; script stderr already flowed into this Bash result with disclosures/remedy lines):
| Marker | Script exit | Meaning |
|---|---|---|
output_dir: FAILED |
1 | Cannot establish ignore for an in-repo dir (info/exclude unwritable/unresolvable and not otherwise ignored), or mkdir failed. Empty stdout — do not stamp args.outputDir. Remedy (ignore case): set $CODE_GAUNTLET_OUTPUT_DIR outside the repo and re-run. |
output_dir: FAILED |
2 | Usage: not a git repo, empty/whitespace $CODE_GAUNTLET_OUTPUT_DIR, output dir equals repo root, or git check-ignore error (exit 128). Empty stdout — do not stamp. |
On success, stdout is one absolute path line — store it as {output_dir} / args.outputDir. Ignore establishment (including .git/info/exclude append when needed) is owned by ensure_output_dir.py in this call; Phase 2 does not re-run it.
Store: output_dir from section 1.
Store the plugin-dir confirmation from section 2. If a directory is missing, stop because plugin_root was resolved wrong.
Store the resolver JSON and stderr block from section 3. Retain them as configResult; its stderr block is the Phase 2 gate.
Store the PR state from section 4. It feeds eligibility checks 1 and 2.
Store the changed-file list from section 5. It feeds eligibility check 4; the worked command is in references/phase1-preflight.md.
The resolver owns its configuration inputs.
Do not resolve the head SHA yet — it is computed after PR checkout in Phase 2 so the SHA reflects the actual PR HEAD, not whatever branch was checked out when the session started.
Eligibility checks
Reads from the composite above — no new Bash calls here.
Closed/merged? (
pr_view.state) → Stop.Headless exception (
configResult.mode == "headless"): do not stop — headless reviews closed/merged PRs, proceeding against the pinned head exactly as resolved. Posting safety usesconfigResult.resolved.post_mode, and delivery usesconfigResult.resolved.delivery, not PR state. Seereferences/headless-mode.md.Draft? (
pr_view.isDraft) → Ask user (template inreferences/phase1-preflight.md).Previously reviewed? → Deferred to Phase 2 (after checkout,
phase2-triage.md2b-post step 3) — the gate needs the PR's tree to compare commits. Runsdetect_prior_review.py; gates incremental vs full vs skip onincremental_safe(templates and degradations inreferences/phase1-preflight.md→ "Previously-Reviewed Gate").Trivially simple? (
changed_filesfrom the composite above) → If ONLY lockfile/generated/auto-formatted changes, stop.
Resolve configuration
The Phase 1 composite calls scripts/resolve_config.py. It reads CODE_GAUNTLET_HEADLESS to select headless or interactive mode.
The resolver owns precedence and validation. It renders the configuration block to stderr in the Bash result under === config ===.
That stderr block is the Phase 2 entry gate. Retain the JSON as configResult, including mode, waist, resolved, and identity.
Copy configResult.waist.configEcho verbatim. For later decisions, use only configResult.resolved.<knob>.
The resolver returns its JSON on stdout. A resolver failure stops the composite with config: FAILED.
Phase 2: Target, Triage & Args Preparation
Entry gate — resolver result: proceed only when the
Resolved config:orHeadless config:block appears in the=== config ===Bash result. Retain its JSON asconfigResult.
Identify the review target, gather the git artifacts the workflow consumes, and assemble the args object. This is a fast pass in the main context — the review stages run later, inside the workflow. Read references/phase2-triage.md for the full sub-steps (VCS detection, checkout, risk classification, REVIEW.md parse) and the args-preparation walkthrough.
Phase 2 Composite A — pre-gather (status → checkout → SHA → prior-review gate → stale truncation)
One Bash call, but its sections form the genuine dependency chain — status → checkout → SHA → prior-review gate → stale truncation — each depends on the previous section's output, so unlike Composite B below they cannot be reordered or run separately. {owner}/{repo} resolve inside the call itself, parsed from the PR's own URL — never the origin remote, which is the fork in a fork clone. {platform} is a different kind of thing entirely: a template placeholder, substituted before dispatch (like {pr_number} and {plugin_root}) from what Phase 1 already determined (PR vs. MR), not a value the shell computes from anything fetched inside this composite.
Shell hygiene: user shells commonly alias
ls/cp/grepto incompatible replacements (anls→eza --iconsalias broke a live run's directory listing). In every Bash call, prefergit ls-files/findfor file enumeration, and prefix coreutils withcommand(command ls,command cp) when you must use them. (Same reminder as Phase 1 — see the duplication rationale there.)
echo "=== status ==="
TARGET_SHA=$(gh pr view {pr_number} --json headRefOid --jq '.headRefOid')
CURRENT_SHA=$(git rev-parse HEAD)
echo "target=$TARGET_SHA current=$CURRENT_SHA"
echo "=== checkout ==="
if [ "$TARGET_SHA" = "$CURRENT_SHA" ]; then
echo "already at target, no checkout needed"
elif [ "${CODE_GAUNTLET_HEADLESS:-}" = "1" ]; then
echo "HEADLESS INPUT ERROR: working tree HEAD $CURRENT_SHA != PR head $TARGET_SHA"
exit 1
else
gh pr checkout {pr_number} || { echo "CHECKOUT FAILED: unable to checkout PR {pr_number}"; exit 1; }
fi
echo "=== sha ==="
HEAD_SHA_SHORT=$(git rev-parse --short=8 HEAD)
HEAD_SHA_FULL=$(git rev-parse HEAD)
echo "head_sha_short=$HEAD_SHA_SHORT"
echo "head_sha_full=$HEAD_SHA_FULL"
echo "=== owner_repo ==="
OWNER_REPO=$(gh pr view {pr_number} --json url --jq '.url | split("/") | .[3] + "/" + .[4]')
echo "$OWNER_REPO"
echo "=== prior_review ==="
OWNER="${OWNER_REPO%%/*}"
REPO="${OWNER_REPO##*/}"
PRIOR_JSON=$(python3 "{plugin_root}/scripts/detect_prior_review.py" --platform {platform} --owner "$OWNER" --repo "$REPO" --number {pr_number} --head-sha "$HEAD_SHA_FULL")
echo "$PRIOR_JSON"
echo "=== stale_truncate ==="
echo "$PRIOR_JSON" | python3 -c "
import json, sys, glob, os
j = json.load(sys.stdin)
reviewed_at_current_head = (
j.get('previously_reviewed')
and j.get('sha_resolvable')
and j.get('last_reviewed_sha') == j.get('head_sha')
)
if reviewed_at_current_head:
print('DEFERRED: previously reviewed at the current SHA -- truncation withheld until the Skip/Review-again answer is known (a Skip must preserve these files)')
else:
pattern = os.path.join('{output_dir}', 'code-gauntlet-*-$HEAD_SHA_SHORT.*')
n = 0
for f in glob.glob(pattern):
open(f, 'w').close()
n += 1
print('truncated ' + str(n) + ' file(s)')
"
Headless exception (CODE_GAUNTLET_HEADLESS=1): the checkout section above already handles this branch inline — the elif fires before any gh pr checkout is attempted and exit 1s the whole composite call immediately, so sha/owner_repo/prior_review/stale_truncate never run against the wrong commit. CODE_GAUNTLET_HEADLESS is read directly by the script (not pre-resolved by the model), so this is self-contained regardless of who assembles the call. See references/headless-mode.md.
status/checkout duplicate references/phase2-triage.md 2b — 2b is the owner. 2b's target-type table (PR/MR, branch, local) and its checkout-failure STOP are canonical; this composite is one concrete instantiation of that table (the PR/MR row) plus the headless row. For branch/local targets, apply 2b's table directly: in status, replace TARGET_SHA=$(gh pr view ...) with TARGET_SHA=$(git rev-parse <branch>) (branch comparison) or drop the status/checkout sections entirely and set TARGET_SHA=$CURRENT_SHA (local changes — always a no-op, per 2b step 1); in checkout, replace gh pr checkout {pr_number} with git checkout <branch> (branch comparison) or nothing (local changes). Checkout failure (2b step 4): the checkout section's || clause already exits non-zero on a failed gh pr checkout/git checkout — on that exit, stop and print 2b step 4's message (Unable to checkout [branch/PR]. The review requires the target code to be accessible locally. You can checkout the branch manually and re-run the review.); no fallback.
GitLab MR mode: swap gh pr view/gh pr checkout for glab mr view/glab mr checkout in the status/checkout sections; for owner_repo, use glab mr view {pr_number} --output json | jq -r '.web_url' and take the path segments before /-/merge_requests/ instead of splitting a GitHub API URL.
Local/branch targets: drop the owner_repo/prior_review sections entirely (no PR/MR ⇒ no previously-reviewed signal), and in stale_truncate skip straight to the unconditional truncate loop (the else branch) — there is no prior-review artifact to protect.
Why stale_truncate is conditional, not unconditional: truncating code-gauntlet-*-{head_sha_short}.* is destructive only when the current SHA IS the SHA a prior review already covered — the detector's own last_reviewed_sha == head_sha (with sha_resolvable: true, so the comparison is between two resolved full SHAs, not a short form racing a long one). That is the one case where a "Skip — keep the existing review" answer, asked next from this composite's printed JSON, must be able to leave the on-disk artifacts untouched. head_advanced is the wrong signal to gate on here: it reads false in this same-SHA case, but it also reads false when the recorded SHA is unresolvable and when history was rewritten — two cases where the correct answer is the opposite (truncate now, nothing is protected) — so a gate keyed on head_advanced alone cannot tell them apart. Every stale_truncate gate outcome has a defined resolution:
previously_reviewed: false(no prior review found) → truncate now. Nothing to protect.previously_reviewed: true,sha_resolvable: false(the recorded SHA is not present in this clone — shallow clone, unfetched object, or a pruned force-push target) → truncate now. An object git cannot see locally cannot be the checked-out HEAD, so this SHA never held that prior review's output.previously_reviewed: true,sha_resolvable: true,last_reviewed_sha != head_sha(includes bothsha_is_ancestor: true— head advanced, whichever of Incremental/Full/Skip gets chosen — andsha_is_ancestor: false— rewritten history) → truncate now. Either way this SHA could not already hold a prior review's output, so truncating up front is safe and saves a second round trip.previously_reviewed: true,sha_resolvable: true,last_reviewed_sha == head_sha→DEFERRED. Run the unconditional truncate loop as a follow-up only if the user answers "Yes — review again" to the template inreferences/phase1-preflight.md→ "Previously-Reviewed Gate"; a "No — skip" answer stops the review here with the files intact.
After this call: interpret prior_review's JSON per references/phase1-preflight.md → "Previously-Reviewed Gate" (branch order, question templates, degradations — unchanged). Incremental stores last_reviewed_sha for Composite B's incremental diff branch below. Skip stops the run here.
Stamp reviewScope from this resolved state before assembling the waist.
Local and branch targets stamp { requested: "full", kind: "full", since: null, commits: null, detector: null }.
Headless PR/MR targets omit requested (see the derived waist fields under "Assemble the args object").
Interactive targets stamp the recorded gate answer, or "full" when no prior review existed.
PR/MR targets copy detector values verbatim into detector.
Copy previously_reviewed, sha_resolvable, head_advanced, sha_is_ancestor, and incremental_safe.
Set error to the first prior_review.errors value, or null.
Use kind: "incremental" only for an incremental request with detector.incremental_safe: true.
Set since to the detector's safe last_reviewed_sha only for incremental kind.
Set since to null otherwise.
Otherwise use kind: "full" and retain the detector.
The renderer derives the fallback explanation from retained detector facts.
Headless mode still runs the
prior_reviewsection. Detection is read-only and safe under any resolved post mode. ApplyconfigResult.resolved.reviewed_policyinstead of asking.skipstops the run only whenpreviously_reviewedis true ANDsha_is_ancestoris true. Anincrementalpolicy usesincremental_safe; otherwise it degrades tofulland discloses the reason. Rewritten history hassha_is_ancestorfalse, soskipproceeds as a full review with the degradation disclosed. ADEFERREDtruncation resolves the same way it does interactively. Run the unconditional truncate loop for every policy outcome except askipthat actually stops the run. Seereferences/headless-mode.md.
All workflow-facing files use {output_dir}/code-gauntlet-{purpose}-{head_sha_short}.{ext} naming. The skill writes: context-*.md (shared agent context), diff-*.patch (unified diff), files-*.json (changed-file list), project-rules-*.md (AGENTS.md/QODO.md pointer resolution, scripts/collect_project_rules.py's --out, folded into context-*.md before it is written — see "Write the shared agent context file" below). The run's own artifacts are findings-*.json, report-*.md, post-review-*.json, checkpoint-all-*.json, patches-*.md (Phase 8, report_patches.py), plus persist-plan-*.json on either derived persist path (see "Assemble the args object" below). On the default RETURN channel Phase 8 writes them (materialize_artifacts.py); on the writer paths the workflow's artifact-writer does. The Phase 2 stale-file truncation glob (code-gauntlet-*-{head_sha_short}.*, see stale_truncate above) matches on the * between code-gauntlet- and -{head_sha_short}, so it already covers every purpose name in this list, including persist-plan, without needing an update per new artifact.
Phase 2 Composite B — independent-gather (diff, changed-files, line count, misc)
Once Composite A resolves (and, for PR/MR targets, any previously-reviewed question is answered), one Bash call gathers everything the args waist needs from disk — these four sections are mutually independent of each other on the default (full-diff) path. files runs before diff so that the incremental path below — which must bound the diff to the files list — reads a list that already exists in this same call, rather than one the literal script hasn't produced yet:
echo "=== files ==="
gh pr diff {pr_number} --name-only | python3 -c "import json, sys; print(json.dumps([l.rstrip(chr(10)) for l in sys.stdin if l.strip()]))" > "{output_dir}/code-gauntlet-files-{head_sha_short}.json"
cat "{output_dir}/code-gauntlet-files-{head_sha_short}.json"
echo "=== diff ==="
gh pr diff {pr_number} > "{output_dir}/code-gauntlet-diff-{head_sha_short}.patch"
head -c 200 "{output_dir}/code-gauntlet-diff-{head_sha_short}.patch" # must start with "diff --git"; also confirm file size > 0
echo "=== numstat ==="
python3 -c "
path = '{output_dir}/code-gauntlet-diff-{head_sha_short}.patch'
added = 0
removed = 0
binary = 0
in_hunk = False
with open(path, 'r', errors='replace') as f:
for line in f:
if line.startswith('@@'):
in_hunk = True
continue
if line.startswith('diff --git'):
in_hunk = False
continue
if not in_hunk:
if line.startswith('Binary files ') and line.rstrip(chr(10)).endswith('differ'):
binary += 1
continue
if line.startswith('+'):
added += 1
elif line.startswith('-'):
removed += 1
print('changed_lines=' + str(added + removed))
print('binary_files=' + str(binary))
"
echo "=== misc ==="
git rev-parse --show-toplevel
python3 -c "import datetime; print(datetime.datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ'))"
python3 -c "import secrets; print(secrets.token_hex(8))"
- Files → this path becomes
args.changedFilesPath; keep the same array inline forargs.changedFiles(the Summarize stage reads it by value — the workflow cannot open the file). - Diff → this path becomes
args.diffPath, passed to the verify executor as--diff-file. numstatreads the patch already saved by thediffsection above via a pure text scan — nevergit apply.git apply --numstat | awk '{a+=$1;d+=$2}'was the prior approach and is wrong:git applyrefuses a wide range of inputs that are still perfectly valid, countable diffs (a patch that no longer applies cleanly against the current tree, renames, mode-only changes), and on refusal it exits non-zero with empty stdout — piped intoawk, that prints a false, plausible0instead of failing loud, and0here can flip the trivial/light-scope gate below and the Summarize bucketing threshold without any visible error. The replacement never invokesgit applyat all: it scans the saved patch text directly using hunk-state tracking, the same thinggit diff --numstatitself does per-file — not a bare line-prefix test. A line starting@@enters a hunk (not counted); a line startingdiff --gitleaves it (start of the next file's header block); while NOT in a hunk nothing is counted (that is where---,+++,index,new file mode,similarity index, andrename from/tolive, alongside theBinary files ... differtally); while IN a hunk, a line starting+counts as added and-as removed (context lines and\ No newline at end of fileare ignored either way). A bare line-prefix test is wrong: a REMOVED content line whose own text begins--followed by a space appears in the patch as---followed by a space (one prefix dash plus the two the line already had), and an ADDED line whose text begins++followed by a space appears as+++followed by a space — both are then indistinguishable from a real file header by prefix alone, and markdown front matter, diffs-of-diffs, and comment banners hit this routinely, silently undercountingchanged_lines. Hunk-state tracking has no such blind spot: it never inspects a hunk-body line's prefix to decide "header," only its position relative to the last@@/diff --gitline. Binary files (Binary files a/... and b/... differ, with no countable hunk) are tallied separately asbinary_filesrather than silently folded into a0contribution — if a real diff is textually small but changes only binary content,changed_linescan legitimately read low or0; checkbinary_files > 0before treating that as "no diff." Verified againstgit diff --numstatground truth on a corpus covering content lines beginning--/++followed by a space, markdown front-matter---/+++delimiters, a patch-of-a-patch, a binary file, a rename, a mode-only change, a no-trailing-newline file, CRLF endings, and an empty diff — the scan reproduces git's own non-binary total in every case (the bare-prefix predecessor undercounted the--/++-plus-space and patch-of-a-patch cases). This is what feedschangedLines, and the same rule applies to the 2k AI-generated-code scan (references/phase2-triage.md2k): both derive from this one saved patch, never a fresh diff fetch.miscgivesrepoRoot, ageneratedAtcandidate (re-stamp at args-assembly time if Phase 3 dispatch isn't immediate —generatedAtmust reflect the actual assembly moment), and anoncecandidate matching^[A-Za-z0-9._-]+$.- Risk classification (2e) and AI-generated-code detection (2k) — classify changed files by risk as in
references/phase2-triage.md; this feeds the context file. Neither runs here.
Incremental path (Composite A's prior-review gate resolved Incremental): because files now runs first, its list is already on disk ({output_dir}/code-gauntlet-files-{head_sha_short}.json) by the time the diff section runs — replace the diff section's gh pr diff {pr_number} with the bounded form git diff {last_reviewed_sha}..HEAD -- <the paths read back from that just-written JSON file> (phase2-triage.md 2c branch 4) — the file list itself still comes from the unbounded gh pr diff {pr_number} --name-only written by the files section, never narrowed to incremental-only files; only the diff content is bounded. changedLines is then counted from this bounded diff, never the full-PR diff.
GitLab MR mode: swap gh pr diff {pr_number} for glab mr diff {pr_number} in both the files and diff sections.
Branch/local targets: replace diff with git diff <base>...HEAD / git diff HEAD and files with git diff --name-only. If gh pr diff fails (e.g., 20K-line / 300-file API limit exceeded), the workflow's verify executor falls back to its own git diff chain inside an executor subagent (which has shell) — not in this composite.
Composites A/B never subsume 2d (CLAUDE.md/REVIEW.md discovery), 2g (test discovery), or 2k (AI-marker scan) — those three stay on Glob/Grep exactly as references/phase2-triage.md already mandates for each ("Never use find from Bash..." / "Never use find or grep from Bash..."). Routing them through Bash here is the mistake the recorded run made; keep them as separate Glob/Grep tool calls.
Discover REVIEW.md and stamp its raw text
Discover REVIEW.md files across the repo root + changed-file directories + their ancestors —
the same directory set scripts/collect_project_rules.py walks for AGENTS.md/CLAUDE.md/QODO.md
(references/review-md-spec.md, issue #80). For each REVIEW.md found, in that discovery order
(root first, then increasing directory depth), Read its raw text. Stamp them as:
args.reviewMd—[{ path, text }, ...]in discovery order (pathrepo-relative,textthe file's raw content). An empty array means "discovery ran, no REVIEW.md found" — a legal, authoritative signal in its own right, distinct from omitting the field entirely.args.exclusionsText— the raw text of whatever exclusions source was found (e.g..reviewignore), unchanged from today.
Do not hand-parse or schema-validate REVIEW.md here — pass the raw text through. The workflow's
resolveReviewConfig (workflows/src/args.js) calls buildReviewConfig, preserving a root layer
and depth-ordered subtree layers. Filter selects the root layer or matching subtree layers per
finding.file: thresholds override and ignore entries accumulate from root to the deepest match.
In particular, resolveReviewConfig never pins a numeric default for
confidence_threshold / security_min_confidence when REVIEW.md does not set one — the Filter
stage's own built-in defaults (non-security 55, security 70) apply exactly when absent,
so there is nothing to "get right" by hand here anymore.
Do not stamp both args.reviewMd and args.reviewConfig (or both args.exclusionsText and
args.exclusionPatterns) — the args waist refuses a waist that stamps both the raw and
pre-parsed form for the same axis (single authority).
Write the shared agent context file
Write the shared context to {output_dir}/code-gauntlet-context-{head_sha_short}.md using python3 -c "import json; ...". Contents, concatenated in this order into one content string: (1) REVIEW.md project rules (2d step 2, by value); (2) CLAUDE.md/AGENTS.md/QODO.md project rules, resolved by scripts/collect_project_rules.py (2d step 3) and folded in via open(path).read() on its --out file inside this SAME python3 -c — never retyped by the model (CLAUDE.md's "Artifact persistence" section records the artifact-writer's transcription of a multi-KB payload diverging from its input on 3 of 3 measured runs; hand-copying this block risks the identical failure one stage earlier); (3) risk classification (2e) and AI-generated-code status (2k); (4) the full diff inside <untrusted-code-content> tags. The workflow's discovery, validate, and summarize agents Read this file at {output_dir}/code-gauntlet-context-{head_sha_short}.md — the workflow threads exactly this path to them, so the filename must match. (The change summary is no longer written here — the workflow's Summarize stage produces it internally.)
Do not guard the read in piece (2). open(path).read() on collect_project_rules.py's --out file must be unconditional — no try/except, no os.path.exists() check, no empty-string fallback if it raises. A missing rules file means the collection step (2d step 3) never ran; the write must fail loudly rather than produce a context file without the collector's one-line fact. This is the same unguarded file-handoff pattern already used above for the diff — gh pr diff {pr_number} > "{output_dir}/code-gauntlet-diff-{head_sha_short}.patch" written, then read back in the numstat section with a plain open(path, 'r', errors='replace') and no existence check — not a new pattern invented for this. It is a deliberate asymmetry with contextLines/contextChars, which do degrade to a disclosed gap rather than fail: an unmeasured context file is still usable, so hard-failing there would trade a partial read for a dead run, but collect_project_rules.py always writes a file or raises on a crash, so a missing one is unambiguously "the step didn't run," never a legitimate state. No test forces a model-executed Phase 2 to actually invoke the script — that is a live-execution property a doc-grep test cannot pin without becoming the phrase-count guard CLAUDE.md forbids — so this unconditional open() is the whole guard, not a supplement to one.
Build content in full, in that order, before measuring it. The measurement below (contextLines/contextChars) must run against the same string that already includes piece (2) — the project-rules block is the newest addition and the easiest to bolt on after the fact. Concatenating it in once the count is already taken silently reopens issue #48: contextReadPlan sizes every agent's Read plan from those two numbers alone, and a block the measurement never saw is a block the read plan never covers.
Measure the file in the same command that writes it, and stamp the measurement into args as contextLines / contextChars. This is not bookkeeping — it is the whole read-completeness mechanism. A Read of a file this size returns only part of it and emits no truncation notice; the workflow has no disk and cannot measure the file itself, so this stamp is the only way contextReadPlan can compute the exact Read calls the agent prompts enumerate. Print both from the string you just wrote, so the numbers describe the bytes on disk rather than a re-read:
# ... inside the same python3 -c that writes `content` to the context path:
# lines counts as the Read tool's `cat -n` numbering does — a file with no trailing
# newline still shows its final partial line, so it counts.
lines = content.count("\n") + (0 if content.endswith("\n") else 1)
print(json.dumps({"contextLines": lines, "contextChars": len(content)}))
Stamp both values verbatim. Never estimate them, never carry them over from an earlier run, and never re-derive them from a later wc -l — wc -l counts newline terminators, so it reports one fewer than cat -n numbers for a file with no trailing newline, and an undercount by one silently drops the file's last line from every agent's read plan. If the file came out empty (not content — note the formula above returns 1, not 0, for empty content, so test the content, never the line count), omit both fields rather than stamping {"contextLines": 1, "contextChars": 0}: that pair would tell every agent the shared context is one line long and it would stop after one read. An empty shared context is a Phase 2 bug to fix, not a value to pass on.
Why this exists (issue #48). On run
wf_cef39739-577, all 7 discovery agents' firstReadof a 95,057-byte / 2,028-line context file returned 58,145 chars ending at line 1083, with no truncation notice in any of the 7 tool results. Six agents inferred the cutoff and paginated on;security-reviewerdid not, and reviewed roughly the first half of the diff while returningcomplete: true. No artifact, report, or transcript distinguished that from a clean empty result.
NDJSON emission has been removed from discovery agents (v3). Discovery agents return findings only through structured output (
agent()/parallel()schema) — theprintf-NDJSON emission prose was stripped from all 7.mdbodies and Bash was dropped from their tool grants (it existed solely for emission).references/ndjson-emission-contract.mdandscripts/validate_ndjson.pyremain shipped as retained v2-compat/bench surface, not consumed by discovery agents.
Assemble the args object and record environment overrides
Read CLAUDE_CODE_SUBAGENT_MODEL from the environment into policy.subagentModel (or null). Resolve policy.provider from the environment in the same Bash call — first match wins, and a flag counts as SET only when its value is truthy the way Claude Code itself parses it (1/true/yes/on, case-insensitive — 0/false/empty leave the session first-party): CLAUDE_CODE_USE_BEDROCK → "bedrock", CLAUDE_CODE_USE_VERTEX → "vertex", CLAUDE_CODE_USE_FOUNDRY → "foundry", else "firstParty". ANTHROPIC_BASE_URL alone does NOT change the provider: an LLM gateway proxies the Anthropic API and expects standard Claude model names, so gateway sessions keep the first-party pin (a gateway with non-standard names uses the CLAUDE_CODE_SUBAGENT_MODEL escape hatch). It DOES set policy.gateway, though: stamp true iff ANTHROPIC_BASE_URL is set, after trimming whitespace, to a non-blank value (it is a URL — any non-blank value counts, no truthy-flag parsing like the provider flags above), else false. policy.gateway turns off the pipeline's conditional per-dimension schema construct on the conventions-and-intent dispatch (a gateway forwards input_schema verbatim to whatever backend it fronts, which could be an unmeasured third-party surface even though the session itself reads as firstParty) while leaving the first-party model-ID pin untouched. The workflow cannot read process.env, so this capture is the only path — on firstParty the pipeline pins full first-party model IDs (immune to session-variant cascade); on every other provider it dispatches bare aliases (sonnet/opus), the only spelling the provider's deployment mapping resolves (first-party IDs pass through unchecked on Bedrock/Vertex/Foundry and fail as invalid model identifiers). If CLAUDE_CODE_SUBAGENT_MODEL is set, warn the user and record it in the methodology — it silently overrides the entire per-stage model policy, and the workflow cannot read process.env, so this capture is the only place it is seen. Stamp generatedAt with the current wall-clock time as an ISO8601 string (the workflow never calls new Date() — this injected clock is what makes outputs deterministic). Generate a nonce matching ^[A-Za-z0-9._-]+$ (it is interpolated into the verify executor's argv per slice). For a PR/MR target, also stamp delivery.prIdentity = { owner, repo, pr_number, sha_full, title } — owner/repo/pr_number from the resolved PR, sha_full from git rev-parse HEAD, and title from the gh pr view {pr_number} --json state,isDraft,title,url this phase already runs (SKILL.md:59; GitLab: glab mr view {pr_number} --output json | jq -r '.title'). title is optional — omit it when the fetch produced nothing; the report title then falls back to owner/repo#N. Omit prIdentity entirely for local-diff reviews.
Copy configResult.waist.configEcho verbatim.
The workflow derives these waist fields from the copied configEcho receipt. Do not stamp a derived field; the receipt is its only source.
limits.deliveryCap(headless and interactive runs): fromconfigEcho.pr_comment_cap; digits derive as a JSON number; on interactive runs the receipt spellingnullderives as JSONnull. Stamplimitsand leavedeliveryCapout of it.delivery.tier(headless and interactive runs): fromconfigEcho.delivery_tier. Leavetierout of any stampeddelivery.reviewScope.requested(headless runs, whenreviewScope.detectoris an object): fromconfigEcho.reviewed_policy;skipderives asfull. StampreviewScopeand leaverequestedout of it.scopeAnswer(headless runs, when every changed file is low risk and fewer than 50 lines changed): fromconfigEcho.trivial_scope. Leave it out.
The workflow fills these recei
…(truncated)