Audit changed files for dead code, complexity, duplication, and styling. Returns a pass, warn, or fail verdict based on the severity of issues found. Scoping to changed files keeps signal-to-noise high: by default only issues introduced by the current PR or commit fail the gate; inherited findings in touched files are shown as context.
fallow audit is the PR-time gate. For the initial full-repo cleanup on an existing codebase, start with Adopt Fallow in an existing repo, which walks through fallow, fallow dead-code, fallow dupes, and fallow health, and hands the actual cleanup to an AI agent before turning audit on in CI.
fallow audit
| Flag | Description |
|---|---|
--base <REF> | Git ref to compare against (e.g., main, HEAD~5, a commit SHA). Alias for --changed-since. When omitted, the base is the git merge-base against the branch's upstream or the remote default (origin/main); set FALLOW_AUDIT_BASE to pin it without a flag. |
| Flag | Description |
|---|---|
--diff-file <PATH> | Path to a pre-computed unified diff (e.g. git diff --unified=0 main...HEAD > /tmp/pr.diff) that narrows source-anchored findings to lines inside an added hunk. Pass - for stdin (gh pr diff | fallow audit --diff-file -) or use the --diff-stdin alias. The bundled GitHub Action and GitLab CI template pre-compute the diff and pass it automatically; you only need this flag for non-PR pipelines or local invocations. The filtering rules and the FALLOW_DIFF_FILE fallback are documented under global flags. |
| Flag | Description |
|---|---|
--gate new-only | Default. Compare against the base ref and fail only on findings introduced by the current changeset. Inherited findings are reported in JSON attribution and annotated with introduced: false. |
--gate all | Strict mode. Fail on every finding in changed files, including findings inherited from the base ref. Skips the extra base-snapshot attribution pass for lower latency. |
The gate has three surface forms. They all accept the same values (new-only, all) and the precedence is CLI flag > config > MCP param > default.
fallow audit --gate allUnder --gate new-only, audit normally runs every analysis twice: once on the current tree and once on a temporary worktree at the base ref so it can attribute each finding as introduced or inherited. When every changed file is either a non-behavioral doc (.md, .markdown, .txt, .rst, .adoc) or token-equivalent at the base ref (a comment-only or whitespace-only edit on a .ts/.tsx/.js/.jsx/.mjs/.mts/.cjs/.cts source file), audit reuses the current run's findings as the base snapshot, classifies everything as inherited, and skips the second worktree analysis entirely. Common case: docs-only PRs and formatter sweeps complete in roughly one analysis pass instead of two.
Run fallow audit --base main --gate new-only --performance to see whether the fast path fired: the JSON output includes base_snapshot_skipped: true|false and the human output prints the same on stderr. The optimization activates automatically whenever every entry in the diff qualifies; there is no flag to opt in or out.
Clone-group attribution carries one carve-out under --gate new-only: an introduced clone group none of whose instances overlap an added line from the run's diff is demoted to inherited. This covers changes that remove duplication elsewhere and thereby re-shape a surviving clone group into a new structural key: the group is new to the report, but no duplicated line was written by this change, so it does not fail the gate.
Every demotion is observable. The gate-excluded note on stderr gains a sub-line naming the demoted count and the diff that decided it:
audit gate excluded 3 inherited findings (run with --gate all to enforce)
of which 1 clone group was reclassified as pre-existing: no duplicated line was added by this change (diff source: merge-base worktree diff vs main)
With --explain, the duplication section adds one line per demoted group, capped like the clone listing: the group's dup:<fingerprint> id (matching the listing and fallow dupes --trace), its instance locations, and the deciding rule, plus a closing line naming the diff source. In JSON, each demoted entry in duplication.clone_groups[] carries a demotion_reason field and the attribution block counts demotions in duplication_demoted; see Key fields.
The decision uses the same diff precedence as diff scoping: a diff supplied via --diff-file, --diff-stdin, or FALLOW_DIFF_FILE wins over the merge-base worktree diff. A supplied diff computed against a narrower base than the merge base (for example a last-commit-only diff on a multi-commit branch) can demote duplication the branch genuinely introduced, so derive supplied diffs from the same base the audit compares against. When no diff is available at all, the check is skipped and every introduced clone group keeps gating.
| Flag | Description |
|---|---|
-f, --format <FORMAT> | Output format: human (default), json, sarif, compact, markdown, codeclimate, gitlab-codequality, pr-comment-github, pr-comment-gitlab, review-github, review-gitlab |
-q, --quiet | Suppress progress and status output on stderr |
--explain | Add metric explanations in JSON output (_meta objects with docs links). In human output, also lists each clone group the new-only gate demoted, with the deciding diff source. See New-only duplication demotion. |
| Flag | Description |
|---|---|
--brief | Render the deterministic review brief instead of the gating audit report. Answers "where do I look?" rather than "will CI block this?", runs the same analysis, and always exits 0. Orthogonal to --format. Implied by fallow review. See Review brief and decision surface. |
--max-decisions <N> | Cap on the number of consequential structural decisions surfaced in the brief's decision surface (the working-memory limit). Default 4; clamped to a 3 to 5 band. Only consulted on the brief path. |
--walkthrough-guide | Emit the agent-contract walkthrough guide: the current digest (brief + decision surface), the review direction, the JSON schema the agent must return, and a deterministic graph-snapshot hash pinned into the digest. Implies the brief; always exits 0. See Agent walkthrough loop. |
--walkthrough-file <PATH> | Ingest an agent's judgment JSON and post-validate it against the live graph; see Agent walkthrough loop for the accepted and rejected outcomes. Implies the brief; always exits 0. |
--walkthrough | Render the walkthrough guide as a staged human terminal tour (Stage 1 load-bearing, Stage 2 mechanical), or a paste-into-PR artifact with --format markdown. --format json --walkthrough emits the same agent-contract JSON as --walkthrough-guide. Implies the brief; always exits 0. |
--mark-viewed <FILE> | Record one or more changed files as viewed in the local walkthrough viewed-state ledger, then render the tour. Files already viewed (and still current) collapse into the Cleared panel. Repeatable. Stale marks (the tree moved) are ignored on render but never deleted. Only consulted on the --walkthrough path. |
--show-cleared | Expand the Cleared panel in the human/markdown walkthrough tour: list each de-prioritized and already-viewed file instead of the collapsed one-line summary. Only consulted on the --walkthrough path. |
--show-deprioritized | Expand the de-prioritized units in the brief's weighted focus map ("show me what you de-prioritized"). The deprioritized escape-hatch list is always present in --format json regardless; this flag only re-expands the collapse-by-default human focus render. Only consulted on the brief path. |
| Flag | Description |
|---|---|
-w, --workspace <NAME> | Scope to a single workspace package |
--production | Exclude test/story/dev files (applies to dead-code, health, and dupes) |
--production-dead-code | Production mode for dead-code only. See global flags. |
--production-health | Production mode for health only. |
--production-dupes | Production mode for duplication only. |
--no-css | Disable audit styling analytics entirely. |
--css-deep | Force deep styling analytics on when config disables it. Deep CSS is on by default in audit: project-wide styling reachability is narrowed back to changed anchors, and JSON can include complexity.css_analytics plus first-class complexity.styling_findings. |
--no-css-deep | Keep local styling analytics on, but skip project-wide styling reachability. |
| Flag | Description |
|---|---|
--max-crap <N> | Maximum CRAP score before the audit fails (default: 30.0, configurable via health.maxCrap). Functions meeting or exceeding this score contribute to the fail verdict alongside dead-code and complexity findings. Pair with --coverage for accurate per-function CRAP; without Istanbul data fallow estimates coverage from the module graph. |
--coverage <PATH> | Path to Istanbul-format coverage data (coverage-final.json) for accurate per-function CRAP scores in the health sub-analysis. Also configurable via FALLOW_COVERAGE, then health.coverage (the same chain as fallow health). Same format and semantics as fallow health --coverage; without any of them, fallow auto-detects coverage/coverage-final.json or falls back to the static-estimate model. The resolved map feeds both the head pass and the base attribution pass. A configured file that does not exist exits 2, like fallow health. Relative paths resolve against --root. |
--coverage-root <PATH> | Absolute prefix to strip from file paths in coverage data before prepending the project root. Also configurable via FALLOW_COVERAGE_ROOT, then health.coverageRoot; a relative health.coverageRoot is rejected with exit 2, like fallow health. Use when coverage was generated under a different checkout root in CI or Docker (e.g., /home/runner/work/myapp on GitHub Actions). |
| Flag | Description |
|---|---|
--dead-code-baseline <PATH> | Baseline file produced by fallow dead-code --save-baseline. Dead-code issues present in the baseline are excluded from the verdict. |
--health-baseline <PATH> | Baseline file produced by fallow health --save-baseline. Complexity findings present in the baseline are excluded from the verdict. |
--dupes-baseline <PATH> | Baseline file produced by fallow dupes --save-baseline. Clone groups present in the baseline are excluded from the verdict. |
The global --baseline / --save-baseline flags are rejected on audit (exit 2) because audit runs three analyses with incompatible baseline formats. Use the per-analysis flags above, or configure defaults in .fallowrc.json:
{
"audit": {
"deadCodeBaseline": "fallow-baselines/dead-code.json",
"healthBaseline": "fallow-baselines/health.json",
"dupesBaseline": "fallow-baselines/dupes.json"
}
}
Store committed baselines outside .fallow/, because fallow init adds that directory to .gitignore for machine-local cache.
CLI flags override config. Baselines are a no-op if unset; any subset (e.g. dead-code only) is allowed.
| Verdict | Exit code | When | What to do |
|---|---|---|---|
| pass | 0 | No introduced issues in changed files (new-only) or no issues at all (all) | Ship it. |
| warn | 0 | Issues found, all warn-severity | CI passes, but consider fixing before they become errors. |
| fail | 1 | Error-severity issues found | Fix the reported issues before merging. |
| error | 2 | Runtime error (invalid ref, not a git repo, config error) | Check the error message. In JSON format, emits {"error": true, "message": "...", "exit_code": 2}. |
Dead code issues follow your rules configuration severity (error/warn/off). Complexity findings above configured thresholds are always errors; thresholds are inherited from your fallow health config (defaults: cyclomatic 20, cognitive 15). Duplication is a warning unless a --threshold is configured. With the default new-only gate, inherited error-severity findings can appear in the report while the audit exits 0.
Inline suppression comments (// fallow-ignore-next-line) work in audit. Findings in changed files are suppressed the same way as in fallow dead-code.
Audit emits three counts that look interchangeable but answer different questions. CI integrations and downstream consumers must gate on the right one:
| Field | What it counts | Severity-aware? |
|---|---|---|
verdict | Overall outcome (pass / warn / fail) honoring per-rule severity from .fallowrc.json | Yes |
attribution.dead_code_introduced, attribution.complexity_introduced, attribution.duplication_introduced, attribution.styling_introduced | Findings introduced by the changeset under gate: new-only, regardless of severity | No |
summary.dead_code_issues, summary.complexity_findings, summary.duplication_clone_groups | All findings in changed files (any gate), regardless of severity | No |
Per-finding introduced: true | false | Whether each individual finding was introduced by the changeset | No (severity is per-rule, not per-finding) |
Rule of thumb for CI gating: branch on verdict == "fail" (or check the exit code, which mirrors the verdict). Counting introduced findings re-introduces the bug command: audit was designed to fix: a project with unused-exports: warn would fail CI on every PR that introduces a warn-tier finding, even though the verdict correctly says warn ("do not fail"). The official GitHub Action and GitLab CI template already do this; third-party wrappers should mirror the same contract.
Rule of thumb for AI agents: read verdict first to know whether the run passed or failed, then read attribution for new-vs-inherited counts and walk the per-category finding arrays for actionable details. Use introduced: true to filter to changes the current PR is responsible for.
# Auto-detect base branch
fallow audit
# Explicit base ref
fallow audit --base main
# Audit last 3 commits
fallow audit --base HEAD~3
# Strict mode: fail on inherited findings too
fallow audit --gate allAudit scope: 8 changed files vs main (d4a2f91..HEAD)
✓ No issues in 8 changed files (1.18s)
Audit scope: 8 changed files vs main (d4a2f91..HEAD)
■ dead code 0 · complexity 2 (warn, max cyclomatic: 12) · duplication 0
✓ dead code: 0 issues · complexity: 2 findings (warn) · 8 changed files (1.18s)
The ■ summary line appears only on warn verdicts, before any detail sections. It is suppressed with --quiet.
Audit scope: 12 changed files vs main (d4a2f91..HEAD)
── Dead Code ──────────────────────────────────────
● Unused exports (2)
src/components/Button.tsx (2)
:42 OldButton
:67 deprecatedHelper
Exported symbols not imported by any reachable file: https://docs.fallow.tools/explanations/dead-code#unused-exports
── Duplication ────────────────────────────────────
● Duplicates (1 clone group)
23 lines 2 instances
src/utils/helpers.ts:10-32
src/legacy/compat.ts:5-27
── Complexity ─────────────────────────────────────
● High complexity functions (1)
src/parser.ts
:45 parseExpression
28 cyclomatic 32 cognitive 80 lines
Functions exceeding thresholds: https://docs.fallow.tools/explanations/health#complexity-metrics
✗ dead code: 2 issues · complexity: 1 finding · duplication: 1 clone group · 12 changed files (2.14s)
--base if provided, then FALLOW_AUDIT_BASE, otherwise auto-detects the git merge-base (fork point) against the branch's upstream (@{upstream}) or the remote default (origin/HEAD → origin/main → origin/master). Resolving the merge-base, rather than a bare branch name, means a feature branch compares against where it actually forked rather than a possibly-stale local main. Repositories with no origin remote fall back to the local main / master branch. Hard-errors if no base can be determined.git diff --name-only <base>...HEAD (three-dot diff, showing changes since the merge base).new-only, runs the same analyses at the base ref and compares structural issue keys.new-only gates only introduced findings; all gates every finding in changed files without the extra base-snapshot analysis.{
"schema_version": 10,
"version": "3.23.0",
"command": "audit",
"verdict": "fail",
"changed_files_count": 12,
"base_ref": "main",
"head_sha": "d4a2f91",
"elapsed_ms": 2140,
"summary": {
"dead_code_issues": 2,
"dead_code_has_errors": true,
"complexity_findings": 1,
"max_cyclomatic": 28,
"duplication_clone_groups": 0
},
"attribution": {
"gate": "new-only",
"dead_code_introduced": 2,
"dead_code_inherited": 0,
"complexity_introduced": 1,
"complexity_inherited": 0,
"duplication_introduced": 0,
"duplication_inherited": 0,
"styling_introduced": 0,
"styling_inherited": 0,
"duplication_demoted": 0
},
"dead_code": {
"schema_version": 9,
"total_issues": 2,
"unused_exports": [
{ "path": "src/components/Button.tsx", "export_name": "OldButton", "introduced": true, "actions": [...] }
]
},
"complexity": {
"findings": [...]
},
"duplication": {
"clone_groups": [],
"stats": { "duplication_percentage": 0.0 }
}
}
| Field | Type | Description |
|---|---|---|
verdict | "pass" | "warn" | "fail" | The audit result. Use this for CI gates. |
changed_files_count | integer | Number of files changed between base and HEAD |
base_ref | string | The git ref used for comparison |
summary.dead_code_issues | integer | Total dead code issues in changed files |
summary.dead_code_has_errors | boolean | Whether any dead code issues have error severity |
summary.complexity_findings | integer | Functions exceeding complexity thresholds |
summary.max_cyclomatic | integer | null | Highest cyclomatic complexity found (null if none) |
summary.duplication_clone_groups | integer | Clone groups involving changed files |
attribution.*_introduced | integer | Findings whose structural key did not exist at the base ref. With gate=all, these stay 0 because audit skips the base-snapshot attribution pass. |
attribution.*_inherited | integer | Findings already present at the base ref. Also 0 under gate=all. |
attribution.duplication_demoted | integer | Clone groups the new-only gate demoted from introduced to inherited because no instance overlaps an added line. Always present in audit-family JSON (audit, review brief, MCP audit). Demoted groups stay counted in duplication_inherited, so this never exceeds it. |
dead_code.*[].introduced | boolean | Present in audit JSON sub-results when base attribution is available. true means the finding is new relative to the base ref. |
duplication.clone_groups[].demotion_reason | string | Present only on demoted groups. Kebab-case reason for the demotion, currently no-added-lines; further values may be added, so treat an unknown value as "some demotion reason". Never emitted by fallow dupes --format json. |
complexity.styling_findings[] | array | Present when CSS analysis is active. Contains first-class styling issues with rule severity and actions. Each item carries optional introduced attribution when the base comparison is available. |
complexity.css_analytics | object | Descriptive CSS analytics emitted with CSS analysis: specificity, token usage, duplicate declaration context, and cleanup candidates. Use it as evidence around styling_findings, not as a gate by itself. |
The dead_code, complexity, and duplication sub-objects contain full results in the same format as fallow dead-code, fallow health, and fallow dupes respectively. These are omitted when no files changed. By default, the complexity sub-object can also include the health report's css_analytics and styling_findings blocks. When --runtime-coverage is supplied, it can also include runtime_coverage and additive coverage_intelligence blocks.
The audit MCP tool wraps fallow audit --format json --quiet --explain:
{
"tool": "audit",
"arguments": {
"base": "main",
"gate": "new-only",
"css_deep": true
}
}
When base is omitted it resolves as described under How it works; set FALLOW_AUDIT_BASE in the server env to pin it. Set gate to all for strict mode. Styling findings are included by default; set css_deep to false to skip project-wide styling reachability, or true to force it back on when config disables it. Returns the same JSON envelope as the CLI.
See MCP integration for setup instructions.
Under --gate new-only, audit creates a temporary base snapshot (a git worktree checkout) to compare against the base ref. When the current checkout has node_modules, audit links it into the snapshot so tsconfig extends chains into installed packages and path aliases resolve like the working tree. Snapshots are unregistered from git immediately after materialization, so they never appear in git worktree list, IDE repo views, or .git/worktrees/, even if the process is force-killed. Entries registered by earlier fallow versions are deregistered automatically on the next audit, with their warm caches preserved.
Each project root keeps one root-owned base-snapshot cache, rebuilt in place when the resolved base SHA changes, and the reuse lock is held for the whole audit so cleanup can never remove files under a running audit. Automatic age-based GC runs on every audit: the FALLOW_AUDIT_CACHE_MAX_AGE_DAYS env var (or the audit.cacheMaxAgeDays config field) tunes the 30-day default, and 0 disables the age-based sweep. Two subcommands maintain the caches on demand.
audit-cache removeTo reclaim one project's caches unconditionally (warm or not), use fallow audit-cache remove --root <PATH>:
fallow audit-cache remove --root ./my-project --dry-run # preview what would be removed
fallow audit-cache remove --root ./my-project --yes # remove (required in non-interactive shells)
--dry-run reports what would be removed without touching the filesystem, and non-interactive removal requires --yes. Lock-contended entries (an audit is mid-rebuild) are left in place and reported, and the command exits 2.
audit-cache prunefallow audit-cache prune applies the same GC policy every fallow audit run applies silently (orphaned-sidecar cleanup, age-based reclaim under the resolved threshold, and reclaim of abandoned entries whose recorded owner root no longer exists) and reports every cache entry it considered, with sizes and ages. Entries owned by other live projects are never touched. Reach for it when audit caches from deleted checkouts or long-gone branches are holding disk space and you want the cleanup now, with a visible decision per entry, instead of waiting for the next audit's silent sweep.
fallow audit-cache prune --dry-run # preview every decision without touching the filesystem
fallow audit-cache prune # apply the GC policy now
fallow audit-cache prune --max-age-days 7 # tighter age threshold for this run
| Flag | Description |
|---|---|
--dry-run | Preview decisions without touching the filesystem: nothing is removed and no lock or age-tracking sidecars are created. |
--max-age-days <N> | Age threshold in days for this invocation. Precedence: this flag, then FALLOW_AUDIT_CACHE_MAX_AGE_DAYS, then audit.cacheMaxAgeDays, then the 30-day default; the JSON output reports which source won. 0 disables age-based reclaim but still reclaims orphaned entries and entries whose recorded owner root is gone. |
--root is optional for prune and defaults to the current directory. Reported sizes come from a full recursive walk of each cache entry, which can take a few seconds on large caches.
Human output prints one row per considered entry, sorted by path, with the decision, reason, size, and age. Entries kept because another live project owns them come with a hint naming audit-cache remove as the way to clear that project's own cache, and the closing line totals the reclaimed bytes.
Unlike remove, prune exits 0 whenever it ran, including runs with lock-contention skips (an audit is mid-rebuild) or per-entry failures; on a shared runner, entries owned by other users are reported as kept rather than failing the invocation. Exit code 2 is reserved for validation errors and an unreadable scan root. Machine consumers gate on the complete field of the JSON envelope, not the exit code.
With --format json, prune emits its own maintenance envelope, separate from the audit envelope and a sibling of the audit-cache-remove envelope:
{
"kind": "audit-cache-prune",
"schema_version": 1,
"command": "audit-cache prune",
"root": "/work/my-project",
"scan_root": "/tmp",
"dry_run": false,
"max_age_days": 30,
"max_age_source": "default",
"entries": [
{
"path": "/tmp/fallow-audit-base-cache-1a2b3c4d",
"pass": "foreign",
"disposition": "removed",
"reason": "aged-out",
"age_days": 41,
"size_bytes": 187563008,
"owner_root": "/work/deleted-checkout"
}
],
"found": 1,
"removed": 1,
"kept": 0,
"skipped": 0,
"failed": 0,
"complete": true,
"reclaimed_bytes": 187563008
}
| Field | Type | Description |
|---|---|---|
dry_run | boolean | true when the run only previewed decisions. |
max_age_days, max_age_source | integer, string | The resolved age threshold and which precedence level supplied it (flag, env, config, or default) |
entries[] | array | One item per considered cache entry: path, pass (owned, legacy, or foreign), disposition (removed, kept, skipped, or failed), a short kebab-case reason (e.g. aged-out, fresh, owner-live), age_days, size_bytes, and owner_root (each of the last three is null when not measurable). |
found, removed, kept, skipped, failed | integer | Aggregate counts; removed + kept + skipped + failed always equals found |
complete | boolean | true when nothing was skipped or failed. The field to gate on in scripts; the exit code stays 0 either way. |
reclaimed_bytes | integer | Bytes removed from disk by this run. On a dry run, the bytes the run would have removed. |
To see the same per-entry decisions during a normal audit run (where the GC is silent), set RUST_LOG=fallow=debug: both fallow audit and audit-cache prune then log one diagnostic line per considered entry with its decision, reason, age, and threshold. With RUST_LOG unset, audit output is unchanged.
fallow review is an alias for fallow audit --brief. It produces a deterministic, graph-derived review brief and always exits 0: the verdict is carried informationally, unlike fallow audit, which gates (exits non-zero on a fail verdict). --format is orthogonal to --brief, so the brief renders in human or json like any other audit output.
The brief answers "where do I look?" rather than "will CI block this?". It runs the same analysis as the gating audit and organizes it around four jobs:
| Job | What it does |
|---|---|
| subtract | Dead code, complexity, and duplication for the changed files are reported but kept out of the judgment loop, so the reviewer is not asked to weigh in on mechanical findings. |
| focus | Changed-file units are ranked by a composite attention score, labeled review-here or not-prioritized, plus a full deprioritized list (re-expand the human render with --show-deprioritized). |
| structure | The decision surface: the few consequential structural decisions the change embeds. |
| direct | The walkthrough guide for an agent (see Agent walkthrough loop). |
The decision surface is the ranked, capped (--max-decisions) set of coupling-boundary, public-api-contract, and dependency decisions the change embeds, each a framed judgment question anchored to a signal_id. fallow decision-surface emits it on its own and documents the categories, the fields each decision carries, and suppression.
fallow review --format json carries decisions, focus, deltas, impact_closure, partition, and graph_facts.
deltas also carries the dependency keys the decision surface reads: dependency_added (<manifest>::<name>) and dependency_major_bumped (<manifest>::<name>@<from>-><to>), so the envelope names what changed even when the cap collapses the decision. Both are always present, as empty arrays when nothing changed.
partition carries independent_slices only when there are two or more of them: the connected components of the inter-unit dependency graph, each a sorted list of module directories. Two or more slices mean the change splits along a graph-proven seam into pieces that can be reviewed and merged on their own; a single slice would only repeat order, so the field is absent. An orientation fact, never a demand to split.
The walkthrough guide turns the brief into a strict agent contract. The verifier is the graph, not a second model: every judgment an agent returns is post-validated against the live graph, and the agent's free-text framing is fenced as non-deterministic and never gates.
1. Fetch the guide.
fallow review --base main --walkthrough-guide --format json
Returns digest (brief + decision surface), direction, graph_snapshot_hash, agent_schema, injection_note, emitted_signal_ids, and change_anchors. The digest is built from the graph only (PR prose is never folded in, so it is injection-resistant), and the snapshot hash is pinned into the digest.
Each direction.units[] entry carries test_adjacency: none (no test file imports the changed unit directly), untouched (a test imports it and none of those tests is in the diff), or changed (at least one importing test moved with the change). It is absent when the graph was not retained or the unit is itself a test file. A graph fact about direct importers, not a coverage claim.
agent_schema publishes action_vocabulary (block, address, consider, fyi), the closed set a judgment's action may use, and a recommended concern_vocabulary (the thirteen trade-off lenses: abstraction, coupling, data-model, error-handling, control-flow, performance, dependencies, api-ergonomics, compatibility, state-ownership, extensibility, testability, trust-boundary). concern stays free text on the wire; a value from the list lets a review surface group judgments by lens.
change_anchors is a per-hunk anchor set: one stable, content-addressed chg: id per changed region, derived from the same diff source the run used. Its id is content-addressed (file path + normalized added text, line numbers excluded) so it survives an edit above the hunk and a whitespace-only change, and it is rename-durable via previous_change_anchor. It lets an agent anchor a judgment about a changed REGION that has no graph finding (a trade-off with no signal_id), so the broader trade-off can still be post-validated rather than hallucinated.
2. The agent returns judgment JSON. Each judgment cites a signal_id fallow emitted OR a change_anchor fallow emitted; echo the hash.
{
"graph_snapshot_hash": "<echoed>",
"judgments": [
{
"signal_id": "<one fallow emitted>",
"change_anchor": "<or one fallow emitted chg: id, for a changed region with no finding>",
"framing": "<reasoning>",
"concern": "<optional>",
"action": "<optional: block | address | consider | fyi>"
}
]
}
action tells the receiving author what to do with the judgment: block and address are required actions, consider is optional, fyi needs nothing. It is the reviewer's instruction, fenced with the framing, never a gate.
3. Post-validate the judgment against the live graph.
fallow review --base main --walkthrough-file judgment.json --format json
Each judgment is sorted into one of these outcomes:
| Outcome | Condition | Shape |
|---|---|---|
accepted (signal) | The signal_id is anchored and the snapshot still matches. | Framing fenced deterministic: false; anchor_kind: "signal" (the strong anchor, a graph finding). |
accepted (change) | No signal_id, but the change_anchor is one fallow emitted and the snapshot still matches. | Framing fenced deterministic: false; anchor_kind: "change" (the weaker anchor, a changed region only). |
rejected (unanchored) | The signal_id is not one fallow emitted (a hallucinated id). | reason: "unanchored-signal-id". |
rejected (unknown change anchor) | The change_anchor is not one fallow emitted (a hallucinated region). | reason: "unknown-change-anchor". |
rejected (invalid action) | The anchor resolved but the action is outside the closed vocabulary. A judgment with a hallucinated anchor and a bad label is rejected for the anchor, never as invalid-action. | reason: "invalid-action", invalid_value: "<the offending label>" so the agent can fix it in one round trip. |
rejected (stale) | The echoed graph-snapshot hash no longer matches (the tree moved). | reason: "stale-snapshot", stale: true. Re-fetch the guide. |
An accepted judgment echoes action next to agent_framing when the agent set one.