Skip to content

/age

When to invoke: Review a diff, PR, branch, or path across ten orthogonal dimensions (correctness, security, encapsulation, spec, complexity, deslop, assertions, NIH, efficiency, telemetry) and emit a severity-grouped findings report. Use when the user wants a code review — phrases like “review this”, “/age”, “is this safe to merge”, “find bugs”, “spot security issues”, “check for slop”, “review my PR”, “what’s wrong with this code”. Use even when the user only asks for one dimension — the report scopes itself. Do NOT use for applying fixes (route to /cure) or test hardening (route to /press).


Review a diff or scoped path before merging, after /press, or whenever the user wants evidence-backed observations rather than an approval verdict. Do not apply fixes here — /cure owns application.

/age [<ref-or-range>] [--scope <path>] [--comprehensive] [--full] [--safe] [--open-pr] [--auto] [--html]
/age <slug> [--full] [--safe] [--open-pr] [--auto] [--html]

--full un-collapses the ## Low section when 10 or more low-severity findings exist (the default report collapses them to a one-line summary).

--safe re-introduces cure selection. --open-pr propagates through /cure to terminal /plate; a new PR follows /plate’s explicit-choice and review-shape policy.

When called with a <slug>, resolve .cheese/press/<slug>.md (if present) for press context and review the current working diff. When called with a <ref-or-range>, review that range. Default to the current working diff when neither is supplied. If the base branch is unclear, ask or use the repository’s documented default.

--auto is the propagated autonomous-mode flag from /cook --auto. It changes the handoff (see ## Handoff and ## Auto mode for the cap rule and full chain).

--hard propagates through /cure to /plate. Age never fires the gate; /plate gives /hard-cheese the final verified artifact state before publication.

--html emits a static HTML copy alongside .cheese/age/<slug>.md: write the markdown first, then python3 src/age/age-html-report.py --report .cheese/age/<slug>.md --slug <slug> (bundle fallback: age.pyz html-report with the same flags), and print the returned path. It groups findings by severity into the shared HTML shell (shared/scripts/html_report.render_document) — offline, no CDN, no JS.

Portability reference: ../cheese/references/harness-portability.md covers helper resolution, sub-agent dispatch, GitHub operations, and handoff transitions; prefer the bundled or repo-local helper first, treat ${CLAUDE_SKILL_DIR} as optional host-provided fallback. The handoff blocks below are the portable contract; slash commands are host renderings, not the control model.

Dimensions answer what kind of problem. Severity (blocker / high / medium / low) is per-finding, computed from base + location + compounding modifiers (see references/dimensions.md § Severity computation).

Dimension Base range
correctness low → blocker
security low → blocker
encapsulation low → blocker
spec low → blocker
complexity low → high
deslop low → high
assertions low → blocker
nih low → high
efficiency low → blocker
telemetry low → blocker

Per-dimension base-severity tables, location-sensitivity, fix-cost-now / fix-cost-later, and recommendation shapes live in references/dimensions.md — read it before computing any finding’s severity. This reduced workflow intentionally omits the git-history/precedent dimension.

  1. Identify the diff, scope, and relevant spec or issue. Mode check: compute the review range’s review_surface score and risk flags, then call age_route.route(score=..., risk_flags=..., entry="age") (src/fanout/age_route.py). n=1 — steps 2–4 below, unchanged. Any n>1 — read references/fan-out.md first; its lenses list, not a fixed label, sets worker count. Fan-out also requires /age not itself be a sub-agent — stay single-parent when it is. Thread the router’s effort into the reviewer dispatch.

  2. Gather evidence: diff, touched files, tests, callers/imports. If a press report exists for this slug, read it via python3 shared/scripts/read_handoff_slug.py --phase press --slug <slug> (bundle fallback: common.pyz read_handoff_slug --phase press --slug <slug>) and summarise unresolved items in a ## Press findings sub-section — /cure only reads .cheese/age/<slug>.md.

    No press report but a cook handoff exists: record press: skipped (see ## Output) and print the warning at handoff. No cook artifact either: skip the marker and continue.

    If .cheese/glossary/<slug>.md exists, read it so naming drift can be flagged as a deslop finding.

  3. Review every dimension; dimensions with no findings simply omit themselves. Report every defect, however minor — never self-filter on perceived significance; filtering happens downstream in the verifier pass (n>1) or in severity computation (single-parent). Do not raise a finding for a gate failure identical to the diff’s recorded baseline: block — see ../cook/references/quality-gates.md; flag only new or changed failures.

  4. Compute severity per finding (base + location bump + compounding bump, capped at blocker). Group findings by severity (## Blocker → ## High → ## Medium → ## Low); within a severity group, order by file.

  5. Write the report (see ## Output), then python3 shared/scripts/write_handoff_artifact.py --phase age --slug <slug> --status ok --next cure --artifact "" --orientation "<one-line orientation>" --durable-flags "<none | one line per flag>" --body-file "$report_file" (bundle fallback: ${CLAUDE_SKILL_DIR}/scripts/common.pyz write_handoff_artifact with the same flags). Print the path.

  6. Hand off (see ## Handoff below).

Call source-code backends directly according to the shared code-intelligence-routing.md contract. For caller graphs, use the selected semantic backend’s caller query plus tilth_deps when available.

Need Prefer Fallback
Diff inspection delta git diff --unified=3
Caller/dependency impact + curated review context semantic caller search + tilth_deps manual scoping; note the precision loss
Architecture / hotspot framing for large diffs changed-file map + caller/dependency evidence skip and note in confidence
Design rationale for encapsulation/spec dimensions (optional) mcp__hallouminate__list_corpora / mcp__hallouminate__ground on repo:<repo>:wiki, grounding design intent before grading, rendering consulted pages in ## Wiki context skip; omit ## Wiki context; proceed with diff + code evidence only; cap at speculating when rationale is the primary evidence
GitHub/PR context gh local git commands or user-provided PR data
Merge/conflict awareness mergiraf manual conflict checks

Optional MCPs: hallouminate and milknado follow the detect-and-degrade contract in ../cheese/references/optional-plugins.md — state absence once, fall back, reduce confidence only if evidence quality suffers, never block.

/age sizes its own fan-out via the age router (src/fanout/age_route.py), not a size-only threshold, and resolves every dispatched worker through ../cheese/references/agent-resolution.md (read-only, fresh-context; exact specialist, then compatible specialist, then a prompt-constrained general agent with degraded: true). Mechanics: references/fan-out.md, references/packet.md, references/sub-agent-gate.md.

Cross-cutting house style and citation form: ../cheese/references/formatting.md. This section owns the findings-report shape; formatting.md owns the voice rules and the footnote primitive.

Write to .cheese/age/<slug>.md with a minimum handoff slug at the top — status, next, artifact, durable_flags, baseline, one-line orientation. press: skipped is the first body line after the blank separator when a cook artifact exists but no press report does; omit it otherwise.

Body, in order: # Age Report — <slug>; ## Orientation (1-2 sentences); ## Press findings (omit unless a press report exists); ## Wiki context (omit unless hallouminate grounding hit); severity sections (**[dim:sev]** path:line — claim, then location: <tier> · fix-cost-now: <tier> · fix-cost-later: <tier> · confidence: <tier>, then recommendation: <action>); ## Confidence; ## Next step. See references/report-example.md for the full skeleton plus a worked instantiation.

Empty severity sections are omitted entirely. When ten or more low findings exist, collapse the ## Low section to a single line:

## Low
*N low-severity findings suppressed.* Re-run with `--full` (or `/age --full`) to see them.

Per-finding confidence: uses the voice-kernel scale (references/voice.md § Reasoning posture): certain — verified by direct evidence (diff/code read, command output); speculating — inferred from indirect signal. A don't know grading never ships as a finding row — gather the missing evidence or drop the claim. Suppressed lows feed the cure-selection table only when --full is passed.

status: ok when the review completed; status: halt: <reason> when evidence was unreachable. next: cure when any finding meets the medium+ floor; next: done otherwise. durable_flags: mirrors cook’s gate: default none.

Then print Age report: .cheese/age/<slug>.md. When press: skipped is set, also print: Warning: no /press report for <slug> — hardening was skipped. Run /press <slug> first, or continue with /cure. When --html is passed, also print the HTML path returned by html-report (render command under --html in ## Inputs).

Pipeline: culture → mold → cook → press → [age] → cure → plate

Compute the recommended set. Composite all-medium, cheap: the medium floor (blocker+high+medium) unioned with every Low at fix-cost-now: contained.

Decide act vs ask:

  • Empty set — write next: done, print the report path, stop.
  • Reason to ask — a set member has fix-cost-now: sprawling or fix-cost-later: structural, findings conflict, or --safe was passed: read references/handoff-detail.md and render the gate per ../cheese/references/handoff-gate.md, pre-selecting the composite and flagging heavy rows.
  • Otherwise — act: announce the selection and dispatch /cure per references/handoff-detail.md § Dispatch. No gate.

--auto substitutes a severity-floor selection (## Auto mode below); references/handoff-detail.md also covers the no-chain override under /cook’s fan pathway.

When invoked with --auto:

  • Skip the handoff gate.
  • If two cure passes have already completed (cap reached), stop and surface the final report — do not invoke /cure again even if findings remain.
  • Otherwise, if any finding meets the medium+ floor — invoke /cure <slug> --auto --stake medium+ (forward --open-pr when it is in scope) and increment the cure-pass count when it returns.
  • If no finding meets the medium+ floor, stop the chain with a one-line “auto chain clean” note and the report path.

/cook’s fan pathway spawns age as a fresh-context sub-agent and owns the chain itself. Read references/handoff-detail.md § Within cook’s own fan pathway for the no-chain isolation directive before writing the report.

  • Review is not a verdict; explain where to look and why.
  • Do not edit production files; /cure owns application.
  • Do not raise a finding for a gate failure identical to the diff’s recorded baseline: block; flag only new/changed failures per ../cook/references/quality-gates.md.
  • Default to acting: auto-select the recommended set, dispatch /cure without a gate. Ask first only on a genuine reason or --safe. An empty recommended set is a clean stop, not a question.
  • Do not invent evidence; cite files, diffs, commands, or unavailable-source notes.
  • Agree when the diff is fine — an empty dimension is a valid outcome, not a gap to fill.
  • Keep confidence qualitative (certain | speculating | don't know) at both the report and per-finding level; never a numeric score.
  • Findings carry location + recommendation, not JSON sidecars or tag-anchored fix payloads — /cure reads the markdown directly.
  • Apply references/voice.md (output discipline, reasoning posture, confidence vocabulary).
  • references/dimensions.md — before grading any finding: rubrics, location sensitivity, fix-cost tables, recommendation shapes.
  • references/fan-out.md — before any n>1 dispatch: router mechanics, lens partitions, six-seam sequence, verifier pass.
  • references/packet.md — when assembling the Seam-2 shared context packet for a fan-out run.
  • references/sub-agent-gate.md — before any sub-agent dispatch: digest contract, harness-agnostic selection, what the parent never delegates.
  • references/handoff-detail.md — before the selection gate or a /cure dispatch: gate menu, dispatch payload, cook-fan-pathway no-chain override.
  • references/report-example.md — alongside ## Output: worked rendering plus the full placeholder skeleton.
  • references/voice.md — when writing the report: output discipline, reasoning posture, confidence vocabulary.
  • references/deslop-rust.md, references/deslop-typescript.md, references/deslop-python.md, references/deslop-shell.md, references/deslop-go.md — when grading deslop in that language: pattern catalogs with lint-rule mappings.

Resolve every dimension worker and fresh-context review through ../cheese/references/agent-resolution.md.

Work Preferred types Permissions/isolation Minimum power Effort Fallback
Review a diff or one dimension reviewer read-only, fresh-context powerful high compatible reviewer, then general

The canonical age report/handoff carries the shared agent_resolution block.

Per-language evidence for the age deslop dimension. Each pattern is a Go-specific AI tell to look for during review; most map to a staticcheck or golangci-lint rule, giving a citable rule name to attach to a finding. Use alongside dimensions.md’s deslop rubric — this is the “Look for” detail, not a separate severity scale.

Go errors are lowercase, no trailing punctuation, and wrap with %w.

// SLOP
return fmt.Errorf("Failed to open file: %s", err)
return errors.New("User not found.")
// CLEAN
return fmt.Errorf("open file: %w", err)
return errors.New("user not found")

The %w verb wraps the error so callers can use errors.Is/errors.As. Use %v only when you intentionally want to break the error chain.

Lint: staticcheck ST1005 (error-string capitalization/punctuation); errorlint for %w/%v wrapping.

AI loves named returns. They obscure which values are being returned.

// SLOP
func getUser(id int) (user *User, err error) {
user = db.Find(id)
if user == nil {
err = errors.New("not found")
return // Which values? Have to read the whole function
}
return
}
// CLEAN
func getUser(id int) (*User, error) {
user := db.Find(id)
if user == nil {
return nil, errors.New("user not found")
}
return user, nil
}

Named returns are acceptable only in defer recovery patterns.

Lint: nakedret; revive bare-return.

AI scaffolds with context.TODO() and never replaces it.

// SLOP
func handleRequest(w http.ResponseWriter, r *http.Request) {
ctx := context.TODO()
result, err := db.Query(ctx, query)
}
// CLEAN — use the context you already have
func handleRequest(w http.ResponseWriter, r *http.Request) {
result, err := db.Query(r.Context(), query)
}

context.TODO() means “I haven’t decided which context to use yet.” In production code, you should always have decided.

Almost never correct. Interfaces are already reference types.

// SLOP
func NewService(repo *Repository) *Service { ... }
// where Repository is an interface
// CLEAN
func NewService(repo Repository) *Service { ... }

AI spawns goroutines without cancellation paths.

// SLOP — runs forever, no way to stop it
go func() {
for {
doWork()
time.Sleep(time.Second)
}
}()
// CLEAN — respects context cancellation
go func(ctx context.Context) {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
doWork()
}
}
}(ctx)

Lint: go.uber.org/goleak catches leaked goroutines at test time.

6. fmt.Sprintf for string concatenation in loops

Section titled “6. fmt.Sprintf for string concatenation in loops”

O(n²) string building.

// SLOP
var result string
for _, s := range items {
result = fmt.Sprintf("%s%s", result, s)
}
// CLEAN
var b strings.Builder
for _, s := range items {
b.WriteString(s)
}
result := b.String()

Lint: perfsprint.

// SLOP — user.UserService, user.UserModel
package user
type UserService struct{}
type UserModel struct{}
// CLEAN — user.Service, user.Model
package user
type Service struct{}
type Model struct{}

Lint: revive exported (“type name will be used as user.UserService by other packages”).

AI puts complex initialization in init() which can’t return errors and runs at import time with no control.

// SLOP
func init() {
db, err := sql.Open("postgres", os.Getenv("DATABASE_URL"))
if err != nil {
log.Fatal(err) // Kills the process at import time
}
globalDB = db
}
// CLEAN — explicit initialization the caller controls
func NewDB(dsn string) (*sql.DB, error) {
return sql.Open("postgres", dsn)
}

Lint: gochecknoinits flags any init() function.

  • staticcheck docs (staticcheck.dev/docs/checks) — the ST1005 error-string check
  • Go wiki, Code Review Comments (go.dev/wiki/CodeReviewComments) — error strings, naked returns, package-name stutter, contexts
  • Uber Go Style Guide (github.com/uber-go/guide) — goroutine lifetimes, init() avoidance
  • golangci-lint linters index (golangci-lint.run/usage/linters) — nakedret, perfsprint, gochecknoinits, revive rules
  • go.uber.org/goleak — test-time goroutine-leak detection

Per-language evidence for the age deslop dimension. Each pattern is a Python-specific AI tell to look for during review; most map to a Ruff rule code, giving a citable rule name to attach to a finding. Use alongside dimensions.md’s deslop rubric — this is the “Look for” detail, not a separate severity scale.

AI defaults to C-style index loops.

# SLOP
for i in range(len(items)):
print(i, items[i])
# CLEAN
for i, item in enumerate(items):
print(i, item)

When you don’t need the index at all, just iterate directly:

for item in items:
process(item)

2. Manual None checks instead of truthiness

Section titled “2. Manual None checks instead of truthiness”
# SLOP
if user is not None and user.name is not None and len(user.name) > 0:
greet(user.name)
# CLEAN
if user and user.name:
greet(user.name)

AI mixes %, .format(), and f-strings inconsistently.

# SLOP
message = "Hello, %s! You have %d messages." % (name, count)
message = "Hello, {}!".format(name)
# CLEAN — f-strings everywhere (Python 3.6+)
message = f"Hello, {name}! You have {count} messages."

Swallowing exceptions without handling is the #1 debugging time-sink.

# SLOP
try:
risky_operation()
except Exception:
pass # Silent failure — good luck debugging
# CLEAN — either handle it meaningfully or don't catch it
# If you truly need to ignore: except SpecificError as e: logger.debug(...)

AI returns {"id": 1, "name": "Alice"} where a dataclass gives you type safety, IDE support, and self-documenting code.

# SLOP
def get_user():
return {"id": 1, "name": "Alice", "email": "alice@example.com"}
# CLEAN
@dataclass
class User:
id: int
name: str
email: str
# SLOP
f = open("file.txt")
data = f.read()
f.close() # Never reached if f.read() throws
# CLEAN
with open("file.txt") as f:
data = f.read()

7. Overzealous type hints on obvious locals

Section titled “7. Overzealous type hints on obvious locals”
# SLOP
name: str = "Alice"
count: int = 0
items: list[str] = []
active: bool = True
# CLEAN — type hints on function signatures, not obvious assignments
name = "Alice"
count = 0
items: list[str] = [] # Empty collection annotation is fine (inference can't know the element type)
active = True

8. List comprehension where a generator suffices

Section titled “8. List comprehension where a generator suffices”
# SLOP — builds entire list in memory just to iterate
total = sum([x * x for x in range(1_000_000)])
# CLEAN — generator expression, lazy evaluation
total = sum(x * x for x in range(1_000_000))

def f(x=[]) shares one list across every call.

# SLOP
def append_item(item, items=[]):
items.append(item)
return items
# CLEAN
def append_item(item, items=None):
if items is None:
items = []
items.append(item)
return items

Ruff: B006.

requests/httpx calls with no timeout= hang forever when the server does.

# SLOP
response = requests.get(url)
# CLEAN
response = requests.get(url, timeout=10)

Ruff: S113.

Oversized try blocks with logging noise — the tryceratops family.

# SLOP — log-and-raise duplicates the traceback up the stack
try:
process(item)
except ValueError as e:
logger.error(f"failed: {e}") # TRY400: use logger.exception
raise
# SLOP — raise inside try, caught by its own except (TRY301);
# success path buried inside try (TRY300)
try:
value = compute()
if value < 0:
raise ValueError("negative")
return transform(value)
except ValueError:
...
# CLEAN — narrow try, raise outside it, else for the success path
value = compute()
if value < 0:
raise ValueError("negative")
try:
data = load(value)
except OSError:
logger.exception("load failed")
raise
else:
return transform(data)

Ruff: TRY300, TRY301, TRY400, TRY401.

Models trained on pre-3.9 code emit typing.List/Optional/Union.

# SLOP
from typing import Dict, List, Optional, Union
def find(ids: List[int]) -> Optional[Dict[str, Union[int, str]]]: ...
# CLEAN — builtin generics (3.9+) and | unions (3.10+)
def find(ids: list[int]) -> dict[str, int | str] | None: ...

Ruff: UP006, UP007, UP045.

os.path.join, os.path.exists, and Path interleaved in the same module.

# SLOP
path = os.path.join(base, "config.yaml")
if os.path.exists(path): ...
# CLEAN
path = Path(base) / "config.yaml"
if path.exists(): ...

Ruff: PTH family. Caveat: open(path) on a Path is fine — PTH123 (force Path.open()) is contested among core devs; don’t “fix” it.

# SLOP
print(f"processing {item}")
# CLEAN — logging, or delete if the code is self-evident
logger.debug("processing %s", item)
  • Ruff rule docs (docs.astral.sh/ruff/rules) — every rule code above is verifiable there
  • charlax/professional-programming, error-handling anti-patterns — before/after exception examples
  • Greg-style caveat: PTH123 dispute thread (discuss.python.org/t/106904) — calibration for the pathlib rule

Per-language evidence for the age deslop dimension. Each pattern is a Rust-specific AI tell to look for during review; most map to a clippy lint, giving a citable rule name to attach to a finding. Use alongside dimensions.md’s deslop rubric — this is the “Look for” detail, not a separate severity scale.

1. Excessive .clone() to silence the borrow checker

Section titled “1. Excessive .clone() to silence the borrow checker”

LLMs reach for .clone() as a universal fix for ownership errors.

Fix:

  • Use borrowing (& and &mut) instead
  • Take &str instead of String in function parameters
  • Use .as_ref() on Option/Result instead of cloning to unwrap
  • Rule: .clone() is banned unless you can explain why you need owned data
// SLOP
fn greet(name: String) { println!("Hello, {name}"); }
let msg = my_string.clone();
greet(msg);
// CLEAN
fn greet(name: &str) { println!("Hello, {name}"); }
greet(&my_string);

Creates runtime panics scattered throughout the codebase.

Fix:

  • Use ? operator for error propagation
  • Use anyhow or thiserror for structured errors
  • Use if let Some(x) or match for Option types
  • .unwrap() only for compile-time guarantees (hardcoded regex, constants)
// SLOP
let file = File::open("config.toml").unwrap();
let config: Config = toml::from_str(&contents).unwrap();
// CLEAN
let file = File::open("config.toml")?;
let config: Config = toml::from_str(&contents)?;

Losing type safety and adding unnecessary allocations.

Fix:

  • Accept &str or impl AsRef<str> in function parameters
  • Use Cow<'_, str> when sometimes owned, sometimes borrowed
  • Create newtypes for domain concepts: struct UserId(String)
// SLOP
fn find_user(id: String, name: String) -> String { ... }
// CLEAN
fn find_user(id: &UserId, name: &str) -> Result<User> { ... }

C-style for i in 0..vec.len() misses safety and optimization.

Fix:

  • Use .iter(), .map(), .filter(), .enumerate(), .collect()
  • Use slice patterns: match vec.as_slice() { [first, ..] => ... }
// SLOP
for i in 0..items.len() {
process(i, &items[i]);
}
// CLEAN
for (i, item) in items.iter().enumerate() {
process(i, item);
}

When ownership gets complex, AI reaches for interior mutability or unsafe.

Fix:

  • Reduce borrow lifetimes so they don’t overlap
  • Design structs to own their data
  • Pass short-lived borrows as method parameters
  • Restructure to avoid holding long-lived references

6. Weak assertions — the #1 AI test smell

Section titled “6. Weak assertions — the #1 AI test smell”

assert!(result.is_ok()) and assert!(result.is_err()) swallow the actual error/value on failure, printing only false.

Fix:

  • Propagate with .expect("context") or ? to see the real error
  • Check actual values, not just existence
  • For errors, verify the specific variant with matches! or check the message
  • Every assert_eq!/assert! with non-obvious operands needs a failure message
// SLOP
assert!(result.is_ok());
assert!(result.is_err());
assert_eq!(count, 3); // no context on failure
// CLEAN — propagate the real error
let value = result.expect("scan_worktree should succeed");
assert_eq!(value.label, "Ready");
// CLEAN — check specific error variant
assert!(matches!(result, Err(MyError::NotFound { .. })));
// or check the message
let err = result.unwrap_err();
assert!(err.to_string().contains("not found"), "expected NotFound, got: {err}");
// CLEAN — failure message for non-obvious operands
assert_eq!(count, 3, "expected 3 active workers after spawn");

7. is_none() / is_some() without value context

Section titled “7. is_none() / is_some() without value context”

assert!(x.is_none()) prints assertion failed: false. assert_eq! shows what was actually there.

Fix:

  • Use assert_eq!(x, None) for better failure messages
  • For is_some(), extract and check the inner value
// SLOP
assert!(x.is_none());
assert!(ping["result"]["host_type"].as_str().is_some());
// CLEAN
assert_eq!(x, None);
assert_eq!(ping["result"]["host_type"].as_str(), Some("daemon"));

Raw tokio::time::sleep before assertions is fragile — passes on fast machines, flakes in CI.

Fix:

  • Use a wait_until_async polling pattern with timeout
  • Sleep-then-assert is only acceptable for testing actual timing behavior
// SLOP
tokio::time::sleep(Duration::from_millis(500)).await;
assert_eq!(state.status(), "ready");
// CLEAN — poll with timeout
wait_until_async(Duration::from_secs(2), || async {
state.status() == "ready"
}).await.expect("status should reach ready");

A bare #[should_panic] passes on any panic — including unrelated ones from refactoring. Always pin the expected message.

Fix:

  • Add expected = "substring" to match the intended panic message
// SLOP
#[test]
#[should_panic]
fn rejects_empty_input() {
parse("");
}
// CLEAN
#[test]
#[should_panic(expected = "input must not be empty")]
fn rejects_empty_input() {
parse("");
}

Tests with zero assertions only prove the code doesn’t panic — not that it works.

Fix:

  • Add assertions on return values or side effects
  • If intentionally testing “no panic”, add an explicit comment documenting why
// SLOP
#[test]
fn stamp_activity_nonexistent_is_noop() {
tracker.stamp_activity("ghost-id");
}
// CLEAN — document the intent
#[test]
fn stamp_activity_nonexistent_is_noop() {
// No assertion needed: verifying no panic on missing ID
tracker.stamp_activity("ghost-id");
}

11. Lint suppression as band-aid (#[allow(...)])

Section titled “11. Lint suppression as band-aid (#[allow(...)])”

AI sprinkles #[allow(...)] to silence warnings instead of fixing root causes. The compiler is telling you something — listen, don’t muzzle it.

Crate-level nuclear options (instant fail)

Section titled “Crate-level nuclear options (instant fail)”

These suppress warnings globally and are never legitimate in production code:

// SLOP — the nuclear option
#![allow(warnings)]
#![allow(clippy::all)]
// SLOP — the scaffold dump (3+ together = AI signature)
#![allow(dead_code)]
#![allow(unused_imports)]
#![allow(unused_variables)]

Fix: Delete the allows and fix each warning individually. If there are too many warnings, the code has bigger problems than lint noise.

These five attributes appearing together are the highest-confidence AI signal:

Attribute AI excuse Real fix
allow(dead_code) “I’ll wire it up later” Delete unconnected code
allow(unused_imports) Copied from examples Remove unused use statements
allow(unused_variables) Bound “just in case” Prefix with _ or remove
allow(unused_mut) Added mut preemptively Remove unnecessary mut
allow(unused_assignments) Assign then overwrite Remove dead assignment

Fix: Each has a specific fix — the allow hides which one is needed. Remove the allow, read the warning, apply the real fix.

Red Flag (almost always slop — these suppress restrictions for a reason):

// SLOP — hiding panic risks
#[allow(clippy::unwrap_used)]
#[allow(clippy::expect_used)]
#[allow(clippy::indexing_slicing)]
#[allow(clippy::panic)]
// CLEAN — handle the error
fn get_item(items: &[Item], idx: usize) -> Option<&Item> {
items.get(idx)
}
// SLOP — incomplete code in CI
#[allow(clippy::todo)]
#[allow(clippy::unimplemented)]
#[allow(clippy::dbg_macro)] // debug macros left in source
// CLEAN — ship nothing with these lints suppressed
// SLOP — logging-aware code ignored
#[allow(clippy::print_stdout)]
#[allow(clippy::print_stderr)]
// CLEAN — use a logging framework (tracing, log, slog)
tracing::info!("event happened");
// SLOP — weak error handling
#[allow(clippy::result_unit_err)] // Result<T, ()> is useless for error context
// CLEAN — use a real error type
fn parse_config(s: &str) -> Result<Config, ConfigError> { ... }

Yellow Flag (often slop, but context-dependent — check before removing):

// SLOP (often) — hiding complexity debt
#[allow(clippy::too_many_arguments)]
#[allow(clippy::too_many_lines)]
// CLEAN — decompose the function
// SLOP (often) — legitimate in some contexts (async move blocks, trait impls)
#[allow(clippy::needless_pass_by_value)]
#[allow(clippy::cognitive_complexity)]
// Check: does the suppression hide a real refactoring opportunity?

Blue Flag (style preference, not necessarily slop):

// Acceptable — pedantic lints are opt-in for a reason
#[allow(clippy::cast_possible_truncation)]
#[allow(clippy::cast_sign_loss)]
#[allow(clippy::module_name_repetitions)]
#[allow(clippy::wildcard_imports)]
// These are in "pedantic" (not "restriction"), so suppressing them
// is more defensible. Still check the reason.

Three together = author came from Python/Java, not Rust:

// SLOP
#![allow(non_snake_case)]
#![allow(non_camel_case_types)]
#![allow(non_upper_case_globals)]
// CLEAN — use Rust conventions
// snake_case for functions, CamelCase for types, SCREAMING for constants

Exception: FFI modules wrapping C libraries may legitimately need non_snake_case or non_camel_case_types to match the C API.

Three patterns that almost certainly indicate hastily generated code:

// SLOP — debug macro left in source
fn process(data: &[u8]) {
#[allow(clippy::dbg_macro)]
dbg!(data); // this went to production
// ...
}
// CLEAN — remove the debug macro entirely
fn process(data: &[u8]) {
tracing::debug!(?data); // use structured logging
// ...
}
// SLOP — println instead of logging
#[allow(clippy::print_stdout)]
println!("Processing file: {}", path);
// CLEAN — use a logging framework
tracing::info!(file = %path, "Processing file");
// SLOP — placeholder error type
#[allow(clippy::result_unit_err)]
fn load_config(path: &str) -> Result<Config, ()> {
// caller has no idea what went wrong
}
// CLEAN — define a real error type
#[derive(Debug)]
pub enum ConfigError {
NotFound(String),
InvalidFormat { line: usize, reason: String },
}
fn load_config(path: &str) -> Result<Config, ConfigError> {
// caller can now handle specific errors
}

An allow that duplicates what the language already provides:

// SLOP — `_name` already suppresses unused_variables
#[allow(unused_variables)]
fn process(_name: &str, _config: &Config) { ... }
// SLOP — pub items can't be dead code (compiler perspective)
#[allow(dead_code)]
pub fn my_function() { ... }
// CLEAN — just use the underscore prefix
fn process(_name: &str, _config: &Config) { ... }

The further an allow reaches, the worse it smells:

Scope Severity Example
Crate-level #![allow(...)] High Suppresses across entire crate
Module-level #[allow(...)] on mod Medium Blanket suppression for module
Function-level Low Targeted, possibly legitimate
Statement-level Lowest Precise suppression with clear reason

Rule: If you must allow, scope it to the narrowest possible target and add a comment explaining why.

// Acceptable — narrow scope, clear reason
#[allow(clippy::too_many_arguments)] // mirrors the C FFI signature exactly
fn ffi_create_window(x: i32, y: i32, w: i32, h: i32, flags: u32) -> *mut Window { ... }

Clippy groups lints into categories. Use these groupings as a heuristic when judging whether suppression is legitimate:

Category Philosophy Example Suppression OK?
restriction “Don’t do this” unwrap_used, panic, todo, print_stdout 🔴 Almost never
correctness “This is likely wrong” Most logic bugs 🔴 Almost never
complexity “This is confusing” too_many_arguments, type_complexity 🟡 With justification
perf “This is slow” clone_on_copy, inefficient_to_string 🟡 Document why
style “Use X instead” let_and_return, wildcard_imports 🟡 Preference
pedantic “Extra strict” cast_possible_truncation, module_name_repetitions 🟢 Usually OK

Rule: Never suppress restriction lints casually. Pedantic lints are opt-in, so suppressing them is more defensible. Complexity lints need justification.

Test code:

  • #[allow(dead_code)] on test utility functions
  • #[allow(unused)] in mod tests blocks
  • #[allow(clippy::unwrap_used)] in test assertions (idiomatic)

Framework integration:

  • #[allow(unused)] on trait impls required by framework (async frameworks often have dead-looking methods)
  • #[allow(clippy::must_use_candidate)] when the framework signature doesn’t support #[must_use]

Intentional design:

  • #[allow(clippy::pedantic)] at crate level (pedantic lints are opt-in)
  • #[allow(clippy::cognitive_complexity)] on state machines or DSLs (legitimately complex, not a bug)

FFI/interop:

  • #[allow(non_snake_case)] / non_camel_case_types matching C signatures
  • #[allow(unsafe_code)] when wrapping C libraries

Generated code:

  • build.rs output
  • Protobuf/gRPC generated files
  • Macro-generated code inside the macro itself
  • #[cfg_attr(feature = "generated", allow(...))] for optional generated modules

Red flag check: If the allow targets a restriction lint (unwrap, panic, todo, print) in these contexts, it’s still slop. Only pedantic/style/perf lints are genuinely legitimate here.

12. Hallucinated APIs and deprecated syntax

Section titled “12. Hallucinated APIs and deprecated syntax”

AI generates functions that don’t exist or uses outdated API patterns (e.g., clap App::new instead of derive macros).

Fix:

  • Run cargo check immediately after generating code
  • Pin specific crate versions
  • Use Clippy: cargo clippy -- -W clippy::all
  • When in doubt, check docs against the current crate version

Implementing Deref on a wrapper so it “inherits” the inner type’s methods — simulating the OO inheritance Rust deliberately doesn’t have.

// SLOP
struct AppConfig { base: Config }
impl Deref for AppConfig {
type Target = Config;
fn deref(&self) -> &Config { &self.base }
}
// CLEAN — delegate explicitly, or implement the shared trait
impl AppConfig {
fn timeout(&self) -> Duration { self.base.timeout() }
}

Deref is for smart pointers. No clippy lint catches this — review by hand. (rust-unofficial/patterns, anti-patterns chapter.)

Box/Arc where plain ownership or a borrow works — indirection reached for to make the borrow checker go away.

// SLOP
fn process(data: &Box<MyStruct>) { ... } // borrowed_box
struct Registry { items: Vec<Box<String>> } // vec_box
let cfg: Box<Config> = Box::new(Default::default()); // box_default
// CLEAN
fn process(data: &MyStruct) { ... }
struct Registry { items: Vec<String> }
let cfg = Config::default();

clippy: borrowed_box, vec_box, box_collection, box_default. Inverse case: a very large enum variant SHOULD be boxed (large_enum_variant) — boxing isn’t wrong, unmotivated boxing is.

Functions marked async by habit. An async fn that never awaits forces every caller into the async machinery for nothing.

// SLOP
async fn config_path() -> PathBuf {
dirs::config_dir().expect("config dir").join("app")
}
// CLEAN
fn config_path() -> PathBuf { ... }

clippy: unused_async (has false-negative gaps — also check by hand).

Turns every future compiler warning into a build break — the crate stops compiling on a new toolchain that added a lint.

// SLOP
#![deny(warnings)]
// CLEAN — enforce in CI instead:
// RUSTFLAGS="-D warnings" cargo build

(rust-unofficial/patterns, anti-patterns chapter.)

17. anyhow::Error in a library’s public API

Section titled “17. anyhow::Error in a library’s public API”

anyhow is for applications. A library returning anyhow::Error gives callers nothing to match on.

// SLOP (in a lib crate)
pub fn parse(s: &str) -> anyhow::Result<Config> { ... }
// CLEAN — concrete error type; thiserror for the boilerplate
#[derive(Debug, thiserror::Error)]
pub enum ParseError {
#[error("invalid syntax at line {0}")]
Syntax(usize),
}
pub fn parse(s: &str) -> Result<Config, ParseError> { ... }

Convention, not a lint — check whether the crate is a lib or a bin before flagging. anyhow in binaries and tests is fine.

Agent-speed pressure produces unsafe as a borrow-checker escape hatch. Bun’s audit of its AI-assisted Zig→Rust port found 13,365 unsafe call sites needing review. For every unsafe block ask: is there a safe alternative, is the invariant documented, is it tested?

// SLOP — no SAFETY comment, no bounds reasoning
let val = unsafe { *ptr.add(i) };
// CLEAN — safe alternative existed all along
let val = slice.get(i).copied().ok_or(Error::OutOfBounds)?;
// If unsafe is genuinely required:
// SAFETY: i < self.len is checked by the caller contract above.
  • rust-unofficial/patterns (Rust Design Patterns book) — the official anti-pattern chapter
  • clippy lint list (rust-lang.github.io/rust-clippy/master) — ground truth for every lint named above
  • Bun unsafe audit (bun.com/bun-unsafe-audit) — quantified case study of AI-agent Rust output

Per-language evidence for the age deslop dimension. Each pattern is a shell-specific AI tell to look for during review; most map to a ShellCheck code, giving a citable rule name to attach to a finding. Use alongside dimensions.md’s deslop rubric — this is the “Look for” detail, not a separate severity scale.

The #1 shell bug. Breaks on spaces, globs, and empty values.

Terminal window
# SLOP
for file in $files; do
rm $file
done
# CLEAN
for file in "${files[@]}"; do
rm -- "$file"
done

Quote every variable expansion: "$var", "${array[@]}", "$(command)". The -- stops option parsing (protects against filenames starting with -).

2. Missing or incomplete set -euo pipefail

Section titled “2. Missing or incomplete set -euo pipefail”

AI scripts either omit strict mode entirely or use partial set -e without -u and -o pipefail. Both are dangerous.

# SLOP — no strict mode
#!/bin/bash
cd /some/directory # Might fail silently
rm -rf build/ # Now you're deleting in the wrong place
# SLOP — partial strict mode (common AI output)
#!/bin/bash
set -e
yq '.items[]' file.yaml | while read -r item; do # yq failure silently ignored
process "$item"
done
# CLEAN
#!/bin/bash
set -euo pipefail
cd /some/directory
rm -rf build/
  • -e: Exit on error
  • -u: Error on undefined variables (catches typos like $UESR instead of $USER)
  • -o pipefail: Pipeline fails if any command fails (not just the last)

All three flags together. set -e alone is a half-measure — especially dangerous in scripts that pipe through jq/yq/grep where a failure on the left side is silently swallowed.

set -e has sharp edges (BashFAQ/105) — don’t assume it catches everything:

Terminal window
# MASKED — `local`'s own success hides the command's failure
local output=$(failing_cmd) # -e does NOT fire
# CLEAN — declare and assign in two steps
local output
output=$(failing_cmd) # -e fires here
# MASKED — -e is disabled inside a function used as a conditional
if my_func; then ... # failures inside my_func won't exit

ls output is not machine-readable. Filenames with spaces, newlines, or special characters break everything.

Terminal window
# SLOP
for file in $(ls *.txt); do
process "$file"
done
# CLEAN — glob directly
for file in *.txt; do
[[ -f "$file" ]] && process "$file"
done
# CLEAN — fd for complex searches
fd -e txt -x process {}
Terminal window
# SLOP
cat file.txt | grep "pattern"
cat file.txt | wc -l
# CLEAN
grep "pattern" file.txt
wc -l < file.txt

Backticks don’t nest and are harder to read.

Terminal window
# SLOP
result=`command`
nested=`echo \`date\``
# CLEAN
result=$(command)
nested=$(echo "$(date)")

[[ ]] is safer: no word splitting, supports regex, no quoting surprises.

Terminal window
# SLOP
if [ $var = "value" ]; then
if [ -z $maybe_empty ]; then
# CLEAN
if [[ "$var" == "value" ]]; then
if [[ -z "${maybe_empty:-}" ]]; then

AI writes absolute paths or assumes CWD.

Terminal window
# SLOP
source /home/user/project/lib/utils.sh
config_file=./config.yaml
# CLEAN
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/lib/utils.sh"
config_file="${SCRIPT_DIR}/config.yaml"
Terminal window
# SLOP
MAX_RETRIES=3
BASE_URL="https://api.example.com"
# CLEAN
readonly MAX_RETRIES=3
readonly BASE_URL="https://api.example.com"

Errors go to stderr, not stdout.

Terminal window
# SLOP
echo "Error: file not found"
exit 1
# CLEAN
echo >&2 "Error: file not found"
exit 1
# Or with a helper
die() { echo >&2 "$@"; exit 1; }
die "file not found"
Terminal window
# SLOP
some_command
if [ $? -eq 0 ]; then
echo "ok"
fi
# CLEAN
if some_command; then
echo "ok"
fi
# CLEAN — error path
if ! some_command; then
die "some_command failed"
fi

ShellCheck: SC2181.

The highest-consequence tell: a failed cd (typo, permissions) lets every following command — including rm -rf — run in the wrong directory.

Terminal window
# SLOP
cd "$build_dir"
rm -rf ./*
# CLEAN
cd "$build_dir" || exit 1
rm -rf ./*

ShellCheck: SC2164.

for x in $(cmd) splits on whitespace, not lines — breaks on spaces and globs.

Terminal window
# SLOP
for f in $(find . -name '*.log'); do
process "$f"
done
# CLEAN — NUL-delimited for filenames
find . -name '*.log' -print0 | while IFS= read -r -d '' f; do
process "$f"
done
# CLEAN — line-oriented command output
readarray -t lines < <(cmd)

ShellCheck: SC2044 (find loops), SC2046 (unquoted $(...) generally).

13. Piping into while read and losing variables

Section titled “13. Piping into while read and losing variables”

Each side of a pipe runs in a subshell — assignments inside the loop vanish.

Terminal window
# SLOP — prints 0
count=0
cat file | while read -r line; do
count=$((count + 1))
done
echo "$count"
# CLEAN — redirect (or process-substitute); no subshell
count=0
while read -r line; do
count=$((count + 1))
done < file

echo’s flag behavior differs between the bash builtin and /bin/echo and isn’t POSIX-portable.

Terminal window
# SLOP
echo -e "line1\nline2"
echo -n "no newline"
# CLEAN
printf '%s\n' "line1" "line2"
printf '%s' "no newline"

External processes and deprecated syntax for what the shell does natively.

Terminal window
# SLOP
i=$(expr $i + 1)
let i=i+1
result=$[ a + b ]
# CLEAN
(( i += 1 ))
result=$(( a + b ))

Google Shell Style Guide: always (( )) / $(( )).

Unquoted, both split on internal spaces and drop empty arguments.

Terminal window
# SLOP
my_func $@
# CLEAN
my_func "$@"
  • ShellCheck wiki (shellcheck.net/wiki/SCxxxx) — canonical slop→fix rationale per code
  • Greg’s Wiki: BashPitfalls + BashFAQ/105 — the set -e calibration source
  • Google Shell Style Guide — arithmetic, quoting, loop idioms, when not to use bash at all

Per-language evidence for the age deslop dimension. Each pattern is a TS/JS-specific AI tell to look for during review; most map to a typescript-eslint rule, giving a citable rule name to attach to a finding. Use alongside dimensions.md’s deslop rubric — this is the “Look for” detail, not a separate severity scale.

When types get complex, AI gives up and uses any, throwing away everything TypeScript provides.

// SLOP
function processData(data: any): any {
return data.value;
}
// CLEAN
function processData<T extends { value: unknown }>(data: T): T['value'] {
return data.value;
}
// Or if you genuinely don't know the shape:
function processData(data: unknown): unknown {
if (hasValue(data)) return data.value;
throw new Error("missing value field");
}

AI mixes paradigms or uses promise chains where async/await is cleaner.

// SLOP
function fetchUser(id: string) {
return fetch(`/api/users/${id}`)
.then(res => res.json())
.then(data => data.user)
.catch(err => console.error(err));
}
// CLEAN
async function fetchUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`);
return (await res.json()).user;
// Let errors propagate — the caller should decide what to do
}

AI adds debug logging that never gets removed.

// SLOP
console.log("Fetching user...");
const user = await fetchUser(id);
console.log("User fetched:", user);
console.log("Processing...");

Fix: Delete all console.log debug statements. Use a proper logger if observability is needed, or remove entirely if the code is self-evident.

forEach doesn’t await — async callbacks fire and are silently dropped.

// SLOP — these await calls do nothing useful
items.forEach(async (item) => {
await processItem(item); // Runs concurrently, forEach doesn't wait
});
// CLEAN — sequential
for (const item of items) {
await processItem(item);
}
// CLEAN — concurrent with control
await Promise.all(items.map(item => processItem(item)));

5. Redundant null checks TypeScript already handles

Section titled “5. Redundant null checks TypeScript already handles”

With strictNullChecks, the compiler enforces null safety.

// SLOP — name can't be undefined here, the type says string | null
function greet(name: string | null): string {
if (name === null || name === undefined) {
return "Hello, stranger";
}
return `Hello, ${name}`;
}
// CLEAN
function greet(name: string | null): string {
return name ? `Hello, ${name}` : "Hello, stranger";
}

Lint: @typescript-eslint/no-unnecessary-condition — catches provably true/false conditions in general, not just null checks.

6. JSON.parse(JSON.stringify()) for deep cloning

Section titled “6. JSON.parse(JSON.stringify()) for deep cloning”
// SLOP
const cloned = JSON.parse(JSON.stringify(user));
// CLEAN — structuredClone (available in all modern runtimes)
const cloned = structuredClone(user);

7. Redundant type annotations on initialized variables

Section titled “7. Redundant type annotations on initialized variables”
// SLOP
const count: number = 0;
const name: string = user.name;
const isActive: boolean = true;
const users: User[] = getUsers();
// CLEAN — inference handles these
const count = 0;
const name = user.name;
const isActive = true;
const users = getUsers(); // Return type already typed
// Keep annotations on empty collections or ambiguous initializers
const users: User[] = [];
// SLOP — grabs everything, bloats bundle
import { UserService, UserModel, UserDTO, UserMapper, UserValidator } from "./users";
// CLEAN — import only what you use
import { UserService } from "./users";

9. Non-null assertion as narrowing substitute

Section titled “9. Non-null assertion as narrowing substitute”

! tells the compiler to shut up; a guard tells it something true. AI-authored PRs use ! and as at a much higher rate than human PRs (arXiv 2602.17955).

// SLOP
const user = users.find(u => u.id === id)!;
processUser(user);
// CLEAN
const user = users.find(u => u.id === id);
if (!user) throw new Error(`unknown user: ${id}`);
processUser(user);

Lint: @typescript-eslint/no-non-null-assertion.

as unknown as T makes any value claim any type — it erases the type system at exactly the spot most likely to be wrong.

// SLOP
const config = JSON.parse(raw) as unknown as Config;
// CLEAN — validate at the boundary
const config = configSchema.parse(JSON.parse(raw)); // zod or similar

11. @ts-ignore instead of @ts-expect-error

Section titled “11. @ts-ignore instead of @ts-expect-error”

@ts-ignore silences forever — when the underlying error is fixed, the stale directive stays. @ts-expect-error fails when it no longer suppresses anything.

// SLOP
// @ts-ignore
legacyCall(data);
// CLEAN
// @ts-expect-error — legacy API typed wrong upstream (issue #123)
legacyCall(data);

Lint: @typescript-eslint/ban-ts-comment (set minimumDescriptionLength).

Fire-and-forget async calls — rejections vanish, ordering is accidental.

// SLOP
saveUser(user); // not awaited — errors disappear
items.map(async i => await process(i)); // array of dropped promises
// CLEAN
await saveUser(user);
await Promise.all(items.map(i => process(i)));
// Intentionally fire-and-forget? Say so:
void saveUser(user);

Lint: @typescript-eslint/no-floating-promises, @typescript-eslint/no-misused-promises.

Enums imported from other languages’ habits. Literal unions are erasable, serializable, and need no runtime object.

// SLOP
enum Status { Active = "active", Inactive = "inactive" }
// CLEAN
type Status = "active" | "inactive";
// When you need the values at runtime:
const STATUSES = ["active", "inactive"] as const;
type Status = (typeof STATUSES)[number];

catch (e: any) plus log-and-rethrow: the error is logged at every level and handled at none.

// SLOP
try {
await handler(req);
} catch (e: any) {
console.error(e);
throw e;
}
// CLEAN — catch only where you add value; the error is unknown, not any
try {
await handler(req);
} catch (e) {
if (e instanceof ValidationError) return res.status(400).json(e.detail);
throw e; // the boundary logger handles the rest
}

Lint: @typescript-eslint/only-throw-error, @typescript-eslint/use-unknown-in-catch-callback-variable.

Effects reached for to compute values or chain fetches.

// SLOP — derived state via effect
const [fullName, setFullName] = useState("");
useEffect(() => { setFullName(`${first} ${last}`); }, [first, last]);
// CLEAN — derive during render
const fullName = `${first} ${last}`;

No lint catches this (react-hooks/exhaustive-deps doesn’t) — review by hand. See react.dev “You Might Not Need an Effect”.

  • typescript-eslint strict-type-checked config + rule docs — ground truth for every rule named above
  • Effective TypeScript, 2nd ed. (Vanderkam, 2024) — ch. 5 on narrowing any’s scope
  • arXiv 2602.17955 — empirical AI-vs-human PR comparison (!/as overuse)

Each dimension has its own rubric.

Dimensions answer what kind of problem. Severity answers how bad this one is. The two stay orthogonal.

Four tiers, in order:

blocker > high > medium > low
Tier Meaning
blocker Do not merge — contract broken, exposure open, or data at risk
high Fix before merge — risk of incident or rework
medium Real defect — fix before next release
low Annoyance — safe to merge, fix at leisure

Each finding’s severity is computed, not declared. Three independent contributors, max-merged, capped at blocker:

  1. Base — from the dimension’s per-tier rubric (see § Per-dimension rubrics below).
  2. Location bump+1 tier if location = contract and the dimension is location-sensitive (see § Location sensitivity).
  3. Compounding bump+1 tier if fix-cost-later = structural.

Do not compute the formula in-head — invoke shared/scripts/severity.py compute:

Terminal window
python3 shared/scripts/severity.py compute \
--dimension <dim> --base <low|medium|high|blocker> \
--location <class|module|cross-module|contract> \
--fix-cost-later <contained|spreading|structural>
# -> blocker | high | medium | low

Mental shortcut: a class-private encapsulation leak lands low; the same leak at a slice’s index re-export lands blocker (base high → contract bump → structural fix-cost bump, capped).

Every finding carries these fields:

Field Values Source
dimension correctness, security, encapsulation, spec, complexity, deslop, assertions, nih, efficiency, telemetry reviewer-tagged
severity blocker / high / medium / low computed (formula above)
location class / module / cross-module / contract reviewer-classified
fix-cost-now contained / moderate / sprawling bucketed from blast-radius count
fix-cost-later contained / spreading / structural reviewer-classified
confidence certain / speculating reviewer-assigned per the voice-kernel scale (voice.md); don't know findings are never emitted
recommendation one-line action reviewer
Tier Definition
class Within a single class / type / file’s private scope. Caller graph stays inside the file.
module Within a single module / slice. Crosses files but stays inside the slice’s internal namespace.
cross-module Reaches into another module’s internals (bypasses the public index/crust).
contract Crosses an ingress/egress boundary: slice’s public index re-exports, HTTP/RPC handler signature, DB schema, language-FFI boundary, plugin extension point, published library API.

Language-agnostic note: in projects without an explicit public-index layer (flat scripts, no crust / __init__ re-export surface, as in this repo’s src/melt, src/affinage, src/mold, src/fanout), treat a direct import of another file’s internal function across a directory boundary as cross-module, and CLI argv / stdin ingress as contract.

The contract bump only applies to dimensions where boundary position genuinely changes how bad a finding is:

Dimension Contract bump? Why
correctness yes A bug at the contract leaks into every consumer; internal bugs stay contained
security yes Tainted input crossing a trust boundary is the canonical case
encapsulation yes The whole dimension is about boundary integrity
spec yes Spec drift at the API surface contradicts the published contract
complexity no Complexity grades by function/file shape, not boundary position
deslop no Dead code is dead code wherever it lives
assertions no Test quality doesn’t change by where the SUT lives
nih yes Reinventing primitives that cross the boundary is worse than internal helpers
efficiency yes Hot path on a public handler is the typical blocker shape
telemetry yes Silent failure on an outbound call (boundary) is the canonical blocker

“How hard would it be to fix this right now?”

Bucket the blast-radius file count for the proposed fix. Do not bucket in-head — pipe the raw file/module counts through shared/scripts/severity.py bucket:

Terminal window
python3 shared/scripts/severity.py bucket --files <N> [--modules <M>]
# -> contained | moderate | sprawling

Source priority for the raw count:

  1. tilth_deps — primary. Returns the file set that would need to change.
  2. LSP find-references / find-callers — fallback when tilth is unavailable.

Worked recipe. Given a finding at path:line, run tilth_deps on the containing file. Count the distinct files in the imported-by set as --files — use the <N> dependents header count, since the Used by list emits one entry per call site and several entries can map to one file (counting raw entries overcounts). Count the distinct slice/module roots among them as --modules — the directory immediately under src/ (so src/melt and src/affinage are two modules, not the shared src parent). Then run python3 shared/scripts/severity.py bucket --files <N> --modules <M>. Falling back to LSP callers, count the same way (distinct touched files, distinct module dirs) so buckets stay comparable across tools.

Fix-cost-now is reported, not bumped. Severity decides what to fix; fix-cost-now explains effort and lets triage schedule.

“How much harder does this get if we leave it?”

Tier Meaning
contained Cost stays roughly fixed. A typo in a docstring is the same fix in six months.
spreading Cost grows linearly. New code piles onto the bad pattern; each new caller adds one unit of fix work.
structural Cost grows non-linearly. Consumers harden against the current shape — types get re-exported, mocks calcify, downstream APIs build on the leak. Public-API leaks, DB-schema mistakes, and ingress-contract violations live here.

Decision. structural if the changed symbol is re-exported by any consumer or fixing it requires touching a file outside the diff. spreading if the fix is local but the diff adds new callers of the bad shape, or the pattern is being copy-pasted. contained otherwise. When two tiers apply, take the higher.

Per-dimension structural anchors:

Dimension structural looks like
correctness A race or lost write at a public API boundary; consumers harden retry/mock logic around the broken atomicity.
security A taint path through a published signature; every consumer must re-validate once the contract leaks the unsafe shape.
encapsulation A leaked internal type re-exported from a slice index; downstream slices build on it.
spec A dropped requirement now baked into downstream behaviour that later code depends on.
complexity A god module new code keeps landing in; each addition compounds the untangling cost.
deslop A duplicated block forked across modules; each copy diverges and multiplies the eventual merge.
assertions A mocked SUT or weak harness other tests copy as the pattern to follow.
nih A reinvented primitive other modules import; replacing it later means migrating every caller.
efficiency An unbounded structure on a long-running path; retained references accumulate as callers grow.
telemetry A hand-rolled logging shape new code standardises on; migrating to the real logger later touches every call site.

structural triggers the compounding +1 bump in the formula. The point of carrying this tag is to surface “fix now or pay exponentially later” to the user without dressing it up as severity.

Each dimension’s base-severity table — severity-by-violation-shape, before modifiers. Modifiers (location, compounding) layer on top per the formula.

Look for: off-by-one, ordering, null/empty edge cases, silent failures, races, contradictory branches, lost writes.

Base Trigger
blocker Data loss, data corruption, race in shared concurrent state, lost write, irreversible side effect on wrong input
high Wrong data returned, ordering bug, silent failure with no recovery path
medium Edge case in a flow with a recovery path; null/empty handling that misbehaves on rare input
low Cosmetic edge case in well-bounded leaf code

Inherited shape: a race, lost write, or contradictory branch already present in the caller graph that the diff’s new path now exercises. Expand callers one level before grading clean.

Boundaries: telemetry (no-log failure to here), security (access-control to security), deslop (silent-failure claim to here), efficiency (TOCTOU wrong-data to here), spec (contract commitment to spec, runtime risk to here; emit both). Full rules in § Dimension boundaries.

Recommendation shape: “Add a guard for X” / “Return early when Y” / “Replace catch (_) with explicit handling”.

Look for: authN/authZ holes, injection, secrets in source/logs/URLs, tainted inputs reaching dangerous sinks, crypto missteps.

Base Trigger
blocker Injection (SQL/shell/template/deser), authn bypass, secret in source, RCE, plaintext secret on the wire
high Unvalidated input reaches dangerous sink; broken authz on internal route; weak crypto on durable data
medium Tainted input reaches limited surface with secondary validation; missing rate-limit on auth-adjacent route
low Missing defense-in-depth on already-validated input

Inherited shape: a tainted-input path or missing authz that predates the diff, where the change only adds a new caller of the unsafe sink. Trace the input to its boundary before grading clean.

Definitions: a dangerous sink is any call that executes, queries, renders, deserialises, or persists its argument (SQL/shell exec, template render, eval / pickle / yaml.load, file-path open, request to an internal service). Secondary validation is an independent check downstream of the sink’s entry that constrains the value (a schema parse, an allowlist, a parameterised query) so the tainted value cannot reach the sink unconstrained.

Boundaries: telemetry (secrets-in-logs to security), correctness (access-control to security), nih (reinvented crypto/sanitizer to security). Full rules in § Dimension boundaries.

Recommendation shape: “Validate at the boundary” / “Use the project’s existing <helper>” / “Move secret to env or vault”.

Look for: cross-module reach into internals, public APIs leaking implementation types, parameters that take more context than needed, new exports without a use case, the inverse — a domain invariant lifted out of the producer and enforced above it by every caller — and error, default, and configuration decisions the producer could absorb but exports to every caller instead.

Base Trigger
blocker Ingress/egress contract violation — public API leaks ORM model, infra adapter, framework type, or storage internal across the slice boundary; slice’s index re-exports an internal type
high Cross-module reach into another slice’s internals, bypassing crust/index
high Caller-shadowed domain invariant — a guard/validation that all callers must invoke (or redundantly do) lives outside the producer; the domain doesn’t enforce its own invariant, so a caller can skip it. Especially when a symbol is made public solely to be called from above the domain layer.
high Exported special case — an error, empty/boundary case, or configuration decision every caller must handle identically, where the producer has the information to absorb it (return an empty result instead of raising; apply the safe default instead of demanding one)
medium Module-internal leak (cross-file inside one slice exposes private detail)
low Class-level — one class touches another’s private member within the same file

Detection signals for the caller-shadowed invariant: a guard defined inside a slice but never called by that slice (only by external entrypoints); N callers each repeating the same check before/after one producer; a public/exported guard whose only consumers sit above the domain layer; asymmetry where some callers apply the check and others skip it. Beware the false-clean trap — this often reads as good Sliced-Bread hygiene (a private helper promoted to public + crust-exported), so a diff-scoped pass grades it clean. The violation is usually inherited, not introduced by the diff under review.

Detection signals for the exported special case: N callers wrapping the same call in the same try/except; the same literal passed for the same parameter at every call site; each caller re-deriving the same default. The test is whether the producer has the information to decide — if callers legitimately differ, the parameter is doing real work. The limiting case is a configuration knob the caller cannot set correctly (a “voodoo constant” the module should compute itself); a knob expressing genuine caller-specific policy is not a finding.

Boundaries: deslop (misplaced-invariant dup to encapsulation), complexity (boundary-leaking param to encapsulation). Full rules in § Dimension boundaries.

Note: base tier is the location tier here, so the contract bump tends to redundantly raise an already-blocker finding (capped).

Recommendation shape: “Import from <slice>/index instead of <slice>/internal/foo” / “Narrow the public surface to <minimal-type>” / “Move the <invariant> into <producer> so every caller inherits it; drop the external guard and narrow the public surface” / “Return an empty <result> instead of raising on the boundary case” / “Absorb <default> into <producer> and drop the parameter” / “Compute <constant> inside the module instead of asking every caller for it”.

Look for: behaviour in the spec but not in the diff, behaviour in the diff but not in the spec, renamed concepts or relocated boundaries, missing acceptance criteria.

Base Trigger
blocker Silent drift on a security/data/correctness requirement the spec explicitly nailed down
high Behavior contradicts spec
medium Acceptance criterion partially implemented
low Naming/style drift the user can re-align in 30s

Inherited shape: a requirement dropped in an earlier commit that the current diff neither restores nor violates outright. Compare against the spec, not only the diff.

Spec resolution: locate the spec before grading. Search order: the durable spec corpus via python3 shared/scripts/paths.py artifact_path specs <slug> (or mold.pyz artifact-path specs <slug>), falling back to the legacy literal .cheese/specs/<slug>.md only when the resolver is unavailable (see ../../cheese/references/formatting.md § Corpus location; never hardcode .cheese/specs/); then unresolved items in .cheese/press/<slug>.md, then the PR body or linked issue (gh pr view), then a commit-message ticket ref. If none resolves, record “no spec located; searched [list]” and grade spec findings don't know rather than clean.

Boundaries: correctness (contract commitment to spec, runtime risk to correctness; emit both). Full rules in § Dimension boundaries.

Recommendation shape: “Restore the X requirement” / “Confirm with the user that Y is intentional” / “Update the spec to reflect Z”.

Look for: functions over budget (40 lines / 4 params / 3 nesting), files over 300 lines that grew, speculative abstractions, redundant state, parameter sprawl, stringly-typed code, explanatory-renaming comments, and the inverse failure — abstractions whose interface costs more than they hide: pass-through methods, pass-through variables, adjacent layers restating the same abstraction, wrapper types that forward every call.

Base Trigger
high God function (3× budget), param sprawl threading through 3+ layers (intermediate layers read or transform it), new god module created in this diff
high Shallow layer — a new module, class, or layer whose public interface is nearly as large as the functionality it hides; callers must still know the internals to use it correctly
medium 2× budget, generic helper with one user, redundant cached state
medium Pass-through method — forwards to another method with an unchanged signature and adds no functionality; or a pass-through variable threaded through 3+ layers to reach one consumer, with no intermediate layer reading it; or adjacent layers whose abstractions are the same
low Few lines over budget, mildly speculative abstraction

Detection signals for a shallow abstraction: a method body that is one delegating call with an unchanged or near-unchanged signature; a parameter that exists in a function only to be handed to the next call; two adjacent layers whose method names map 1:1; a class whose public method count approaches its count of non-delegating statements. Dispatchers are the deliberate exception — a method that routes to different implementations by type or key is doing real work, not passing through.

Inherited shape: a god function or param-sprawl the diff extends by a few lines rather than introduces. Grade the function as it now stands, not only the added lines.

Boundaries: encapsulation (boundary-leaking param to encapsulation, exported-decision param to encapsulation), deslop (pass-through and same-abstraction layers to complexity, fake-modularity file sprawl to deslop), efficiency (cache decision to complexity, runtime cost to efficiency). Full rules in § Dimension boundaries.

No default blocker row, but complexity still reaches blocker: a base high finding with fix-cost-later: structural takes the +1 compounding bump to blocker. “No blocker row” means no base blocker, not that complexity caps at high. (Once criticality returns, the floor may push complexity findings up on critical-tier paths.)

The budget is a smell trigger, not a target. Splitting a coherent function into shallow pieces to get under 40 lines is itself a complexity finding — grade the resulting call-depth and interface cost, not the line count. When a function is over budget but has no clean decomposition, grade it clean and record why; the budget rows fire only when a decomposition exists that would leave each piece independently understandable.

Recommendation shape: “Extract <sub-function>” / “Inline <one-call helper>” / “Derive <value> instead of caching” / “Replace <string> with <enum>” / “Replace <vague-name> with <concrete-name>” / “Inline <pass-through> into its caller” / “Collapse <layer-a> and <layer-b> — same abstraction twice” / “Pass <context-object> instead of threading <param> through 3 layers” / “Keep <function> whole — the split to meet budget fragments one abstraction”.

Look for: dead code, AI tells (generic catches, useless docstrings, “// TODO: implement”, placeholder/apology comments like “// in a real implementation”), duplicated logic, copy-paste-over-reuse, vague or container-typed names (user_data_dictionary), convention blindness (reimplementing an existing repo utility from scratch), fake modularity (single-function utils file, God class spread thin), lint-suppression band-aids (# noqa / @ts-ignore / #[allow(...)] / //nolint) masking the real fix, phantom edge-case handling for inputs nobody can name, cargo-cult boilerplate, over-abstraction for one consumer, test bloat (shallow near-duplicate tests), partial shell strict mode (set -e without -uo pipefail).

Per-language pattern catalogs with lint-rule mappings live in deslop-rust.md / deslop-typescript.md / deslop-python.md / deslop-shell.md / deslop-go.md (same directory).

Base Trigger
high Large duplicated logic with diverging behavior; AI residue actively misshapes flow
medium Dead branch left “for reference”, duplicated small block, “// TODO: implement” committed
low Vague name; single weak copy-paste

Inherited shape: duplicated logic or a dead branch the diff copies or leaves untouched beside its change. Read the surrounding block, not only the hunk.

Boundaries: correctness (AI-residue claim to deslop, silent-failure to correctness), nih (existing helper to nih), assertions (generic catch in tests to assertions), encapsulation (misplaced-invariant dup to encapsulation), complexity (fake-modularity file sprawl to deslop, pass-through and same-abstraction layers to complexity). Full rules in § Dimension boundaries.

No default blocker row.

Recommendation shape: “Delete dead branch at ” / “Reuse <existing-helper>” / “Extract shared <helper> from the two near-duplicate blocks” / “Rename data to <noun>” / “Remove <allow/noqa/ts-ignore> and fix the underlying <lint-rule>” / “Delete the placeholder comment at and implement the real branch”.

Look for: existence assertions instead of equality, catch-any-error, no-crash-as-success, mocked SUT, time/random/external coupling.

Base Trigger
blocker SUT itself is mocked; test asserts the bug as correct behavior
high Test passes when the implementation is wrong (no-crash-as-success)
medium Catches generic Exception; depends on time/random without bounding
low toBeDefined where equality is one line away (assert x is not None where assert x == <expected> is one line away)

Inherited shape: a weak assertion in a touched-but-unmodified test that the diff’s behaviour change now leaves under-covering. Read the touched test bodies, not only the diff hunks.

Boundaries: deslop (generic catch in production to deslop, correctness, or telemetry per the claim), telemetry (asserting on log strings to telemetry). Full rules in § Dimension boundaries.

Recommendation shape: “Replace toBeTruthy with toEqual(<expected>)” / “Catch <specific-error> not Exception” / “Replace assert result with assert result == <expected>” / “Catch <SpecificError>, not bare except: / except Exception”.

Look for: hand-rolled retry/validation/UUID/debounce/date-parse/argparse/deep-equality/sanitizer when an import exists; in-project utility duplication.

Base Trigger
high Reinvented logging/telemetry/concurrency primitives the project already wires; reinvented crypto
medium Reinvented retry, debounce, validation, UUID
low Reinvented small util the stdlib already has

Inherited shape: an in-project helper or dependency already does this; the diff re-implements it because the existing one was not visible from the changed file. Check imports and the helper set before grading clean.

Boundaries: deslop (diff-internal dup to deslop), security (crypto/sanitizer to security), telemetry (custom logger to telemetry), efficiency (algorithm choice to efficiency). Full rules in § Dimension boundaries.

No default blocker row.

Recommendation shape: “Replace with <existing-dep>.<fn>” / “Use the stdlib <fn> instead of the local helper” / “Call the existing <project-helper> instead of re-implementing”.

Look for: unnecessary work, missed concurrency, hot-path bloat, no-op updates, TOCTOU pre-checks, memory leaks, overly broad reads.

Base Trigger
blocker Unbounded cache/queue, listener/timer leak, retained references after teardown — anything that grows without bound in a long-running process
high Blocking work on per-request / startup / per-render path; N+1 on a high-traffic endpoint
medium N+1 on a moderate endpoint; redundant compute in a non-hot loop
low Redundant compute outside hot paths

Inherited shape: an N+1, unbounded structure, or hot-path cost already present that the diff’s new call site now triggers. Check whether the changed path runs hot or long-running before grading clean.

Boundaries: nih (import exists to nih), correctness (TOCTOU wrong-data to correctness), complexity (cache decision to complexity). Full rules in § Dimension boundaries.

Recommendation shape: “Hoist <call> out of the loop” / “Run <a> and <b> in parallel with Promise.all (or equivalent)” / “Guard the store write on a value change” / “Drop the existence pre-check; handle the error from <op> instead” / “Bound <structure> or add cleanup on <teardown>” / “Read only the needed range/columns”.

Covers logging, metrics, and tracing hygiene — both presence (is the path instrumented at all?) and shape (structure / levels / context / cardinality). Non-interactive paths need real telemetry (servers, daemons, workers, outbound calls); interactive paths where the operator watches stdout do not need backend-shipped telemetry on the happy path. Secrets-in-logs stays under security; hot-path log-volume cost stays under efficiency; exceptions swallowed with no handling at all stay under correctness.

Base Trigger
blocker Silent failure on critical infra (payments, auth, irreversible side effects) where the operator has nothing to grep
high Silent error branches on outbound calls to external services; un-instrumented new handler on a non-interactive path
medium Silent catch on a non-critical worker; un-instrumented new background loop
low Missing one structured field; wrong level on dev path

Inherited shape: a silent catch or un-instrumented loop in a touched module that the diff extends rather than introduces. Check the surrounding handler, not only the changed branch.

Boundaries: correctness (no-handling silent failure to correctness), security (secrets-in-logs to security), nih (custom logger to telemetry primary), assertions (log-string asserts to telemetry). Full rules in § Dimension boundaries.

Look for: silent error branches on non-interactive paths; outbound calls without observability; silent daemons/workers/schedulers; missing request/response instrumentation; hand-rolled logging infrastructure; missing operational hygiene (rotation/retention) on new file logging; unstructured/string-concat log messages; wrong log levels; double-logging; errors logged without context; missing correlation/trace ids; high-cardinality metric labels or span names; logs-as-metrics; print()/console.log left in production; tests asserting on log strings; unbounded list/object dumps into logs.

Recommendation shape: “Emit a structured error log (and a failure counter) in this catch block before re-raising” / “Wrap the outbound <call> in a span and add a failure-counter metric” / “Add startup + per-iteration logs to the <worker> loop with the failing item id on error” / “Add entry/exit log + latency metric to the new <handler>” / “Use the project’s existing logger / standard <stdlib-or-ecosystem-library> instead of the hand-rolled <class>” / “Configure rotation (size + age cap, retention policy) on the new file handler” / “Read log path / level from project config instead of hardcoding” / “Replace string-concat log with structured fields” / “Demote to DEBUG (or drop)” / “Log once at the boundary, not at every catch” / “Add exc_info=True (or equivalent) to capture the stack” / “Thread trace_id through the log context at the request boundary” / “Move <high-cardinality-attr> from metric label to span attribute” / “Emit a counter instead of grepping logs” / “Replace print() with the project logger” / “Assert on behavior, not on log text”.

When two dimensions could tag the same path:line, this table decides the primary. The per-dimension Boundaries: lines point here. The grader dedups by file:line when writing the report, keeping the higher-base finding and noting the secondary dimension.

Pair Tiebreaker
correctness / telemetry Silent failure with no logging is correctness; telemetry owns it once the failure is caught and the gap is observability.
security / telemetry Secrets in logs or URLs are security regardless of surrounding code.
security / correctness A behavioural bug with an access-control consequence tags security; correctness only when there is no security consequence.
security / nih Reinvented crypto or a security sanitizer tags security (higher base wins); leave nih off to avoid downgrading a blocker through nih’s missing blocker row.
deslop / correctness Tag by the claim: deslop when the primary claim is AI residue, correctness when it is silent failure.
deslop / nih nih when a pre-existing helper or import already does it; deslop when the duplication is internal to the diff with no existing helper.
deslop / assertions Generic catches in test files are assertions; in production code they are deslop, correctness, or telemetry per the claim.
nih / telemetry Custom loggers tag telemetry primary (richer rubric); note the nih angle in the recommendation; do not double-tag.
efficiency / nih nih when an import or library exists for the primitive; efficiency when it is an algorithm or concurrency choice with no available import.
efficiency / correctness TOCTOU as wasted work tags efficiency; TOCTOU that can produce wrong data under a race tags correctness. Split by failure mode.
encapsulation / deslop Duplication caused by a misplaced invariant tags encapsulation (root cause is ownership), not deslop.
encapsulation / complexity A parameter that leaks context or type across a boundary tags encapsulation; raw param count or threading with no boundary concern tags complexity.
complexity / encapsulation (exported special case) Extends the encapsulation / complexity row above. When a threaded parameter also carries a decision the producer has the information to make (a voodoo constant), encapsulation wins — misplaced ownership is the root cause. Structural cost alone, with no misplaced decision, stays complexity.
complexity / deslop Pass-through methods and same-abstraction layers tag complexity; a single-function utils file or one-consumer over-abstraction tags deslop. Both read as fake modularity — split by whether the harm is call-depth or file sprawl.
spec / correctness Emit both with a cross-reference: spec records the broken contract commitment, correctness records the runtime risk. They are orthogonal.
assertions / telemetry Tests asserting on log strings are telemetry-owned.
complexity / efficiency Complexity owns the structural decision to cache; efficiency owns the runtime cost of redundant work.

A fourth severity contributor — a criticality floor keyed off the file’s path/import/structural fingerprint — is bookshelved for v1. When wired, the formula extends with:

sev = max(sev, criticality_floor(file)) # inserted before the cap

Bookshelved material lives in:

  • .cheese/research/severity-rubric/rubric-draft.md § Deferred: criticality inference — full inference ladder (critical / high / standard / low), four-tier vocabulary, two consumers (severity floor + weighted fix-cost-now), .cheese/criticality.toml override schema.
  • .cheese/research/critical-pathways/critical-pathways.md — 35+ detection rules across six signal classes (taint sources/sinks, compliance libraries, framework convention markers, production-pathway layout, graph-structural signals, empirical Pareto from Walkinshaw 2018 ESEM).

v1 does not mine the catalogs, build the override file, or compute the floor. v1 ships without any criticality awareness; the deferred material is read-only context for the v2 ticket.

Read this before any n>1 dispatch from SKILL.md § Flow step 1 / § Sub-agent fan-out.

Compute the review range’s review_surface score via the review-surface CLI (source: src/fanout/review_surface_cli.py, wrapping src/fanout/review_surface.py::score()). Run it through the .pyz bundle — the direct script imports cli/git_utils from shared/scripts/, which are only co-staged flat inside the bundle, so running it directly (python3 src/fanout/review_surface_cli.py ...) fails with ModuleNotFoundError: No module named 'cli': python3 ${CLAUDE_SKILL_DIR}/scripts/age.pyz review-surface --repo . <base>...HEAD — the range must be the diff under review (<base>...HEAD for an already-committed branch, the bare working diff otherwise); never rely on the CLI’s bare default, which scores the working tree against HEAD and silently zeroes an already-committed branch. Grep the diff’s added lines outside skills/** and .hallouminate/** for age_route.OVERRIDE_FLAGS tokens to populate risk_flags — scoped so a diff that merely documents the override vocabulary does not trip its own tokens; a token the grep misses means no promoted lens, not a missing security lens, so treat a hit as a hint, not a guarantee. Then call:

from src.fanout.age_route import route
route(score=<float>, risk_flags=[...], entry="age")

If the host only ships the bundle, echo '{"score": <float>, "risk_flags": [...], "entry": "age"}' | python3 ${CLAUDE_SKILL_DIR}/scripts/age.pyz age-route is the fallback (JSON on stdin, route JSON on stdout).

The returned n is the fan-out mode. The base ladder is n ∈ {1, 2, 5} from score alone (<60 → 1, 60–250 → 2, >250 → 5), with n=1 reviewed single-parent (no fan-out) via SKILL.md § Flow steps 2–4, unchanged; effort (low/medium/high) dials the reviewer dispatch; overrides_hit names any risk-override token matched (auth/secrets/crypto, tenant isolation, payments/ledgers, concurrency/idempotency/ordering/retries, schema/migration/protocol/public-API change, production-destructive ops, weak integration coverage — see age_route.OVERRIDE_FLAGS for the exact grep tokens). An override no longer forces the top tier: it promotes its mapped dimension out of whichever base-ladder group the score placed it in, into its own solo lens — the group’s remaining members stay grouped as one lens. n climbs by however many dimensions were promoted, uncapped, with a natural maximum of 9 when all four override categories hit simultaneously at the top score tier. effort is high whenever any override hits or score exceeds 900; low only at n=1 (unreachable with an override, since an override always forces high); medium otherwise.

Independently of the router’s n, /age may still fork a read-only review-context sub-agent — preferably the explorer phase-agent — purely for evidence-gathering (not fan-out) when caller/dependency graph expansion from tilth_deps or the selected semantic caller search crosses multiple subsystems, especially for --comprehensive reviews. This is a separate, orthogonal dispatch from the n-way fan-out below.

For the digest contract, harness-agnostic selection rules, and what the parent never delegates, see sub-agent-gate.md.

Activates on the router predicate above (n>1 and /age not itself a sub-agent). One worker per lens in the router’s returned lenses list — not per single dimension — dispatch exactly len(lenses) workers. The base ladder’s lens partitions at n>1, before any override promotion:

  • n=2[correctness, spec, assertions, security, telemetry] / [encapsulation, complexity, deslop, nih, efficiency].
  • n=5 — the five cohesion-grouped lenses: [correctness, spec, assertions]; [security, telemetry]; [encapsulation, complexity]; [deslop, nih]; [efficiency].

An override promotion (mechanics above) pulls its mapped dimension out of whichever group the base tier placed it in and gives it its own solo lens; the group’s remaining members survive as one lens together. Every grouping above is chosen for thematic cohesion — encapsulation never shares a lens with efficiency or telemetry at n=5.

The seam sequence below is identical for every n>1 — only worker count and each worker’s assigned dimension set vary with n:

Seam 1 — Predicate. As defined at the section opener above.

Seam 2 — Shared context packet. The orchestrator assembles the packet once, writes it to .cheese/age/<slug>-packet.md, and each worker reads it. Eight components, and the reuse of the review-context digester as the orientation block, are documented in packet.md.

Seam 3 — Worker contract. One worker per lens. Resolve the reviewer role through ../../cheese/references/agent-resolution.md at the router’s effort dial; require read-only permissions and fresh context, a prompt-constrained general fallback allowed only with degraded: true. Each worker:

  • Reviews every dimension in its assigned lens (a solo-lens worker reviews just that one dimension; a multi-dimension lens worker reviews all dimensions in its group, e.g. the [correctness, spec, assertions] worker reviews all three).
  • Computes full per-finding severity for every dimension in its lens (base + location bump + compounding bump).
  • Tags each finding with its dimension and an also-relevant-to: [<dim>, ...] field when cross-dimension overlap is suspected (including overlap with a dimension owned by a different lens).
  • Reports every defect it notices, however minor — no severity-conservative self-filtering; the verifier pass (Seam 6) and orchestrator reconciliation (Seam 4) do the filtering.
  • Returns full per-finding rows in the SKILL.md § Output finding format (**[dim:sev]** path:line — claim + location / fix-cost-now / fix-cost-later / confidence + recommendation). Not an orientation digest — the § Digest contract size ceiling does not apply.
  • Does not dedup, apply boundary tiebreakers, reconcile severity across dimensions or lenses, or write the report.

After all workers return, continue at Seam 4 (reconciliation) below.

Seam 4 — Orchestrator reconciliation. After all workers return, apply the ## Dimension boundaries table (dimensions.md § Dimension boundaries) verbatim to any line meeting EITHER condition: (1) flagged by two or more workers at the same file:line; (2) tagged also-relevant-to: [d] by any worker — the orchestrator re-evaluates dimension d against that line and applies the tiebreaker (keep the higher-base finding / suppress / emit-both-with-cross-reference per the 15 rules). This consumes the also-relevant-to signal and provides the cross-dimension coverage single-parent gets for free. Lines neither flagged by ≥2 workers nor tagged also-relevant-to need no reconciliation. Group by severity. The parent owns the canonical artifact. After reconciliation, continue at Seam 6 (verifier pass), then step 5 (write + print the report path) and SKILL.md § Handoff exactly as the single-parent path does.

Seam 5 — Shared impact evidence. The packet carries the caller/dependency notes assembled through tilth_deps and the selected semantic caller search. Workers use that packet instead of rebuilding impact context independently.

Seam 6 — Verifier pass. After Seam 4 reconciliation produces the candidate findings list, a cheap verifier role (haiku/tiny tier, effort: low per the Roles x tiers table) checks each reconciled finding against the evidence slice cited in its recommendation/location fields — one verifier call per finding, schema-constrained to “verify exactly one claim.” Three outcomes:

  • Confirm — the cited evidence supports the claimed severity; the finding ships unchanged.
  • Downgrade/drop — the evidence does not support the claimed severity (or the claim itself); the verifier lowers the severity tier or drops the finding, and the orchestrator records the original claim and the verifier’s reasoning in the report’s confidence trail.
  • Escalate — the evidence given cannot settle the claim either way (cross-cutting contract 1: “a claim no evidence can settle returns escalate, never a guessed pass or fail”). The finding is kept at its original severity with an escalate flag — never silently dropped or silently passed through unflagged.

This is the “cheap severity-filter leg” referenced in the Roles x tiers table; it applies whenever n>1. It does not run at n=1 — the single-parent path has no reconciliation step to filter, and the reviewer’s own severity computation is the only grading pass.

Output shape invariant. The findings report (.cheese/age/<slug>.md) has the same dedup, severity grouping, and finding format in the single-parent path and every lens fan-out width. Resolution provenance may expose the selected role and topology.

Handoff detail: selection gate, dispatch, auto mode

Section titled “Handoff detail: selection gate, dispatch, auto mode”

Read this before rendering the selection gate (a reason to ask, or --safe) or dispatching /cure.

Selection gate (--safe, or a reason to ask)

Section titled “Selection gate (--safe, or a reason to ask)”

Use the shared handoff gate in ../../cheese/references/handoff-gate.md. Age’s finding selection is the core decision; the tail (Plate it, Checkpoint & stop, Stop) follows.

  1. Render the numbered selection table:

    python3 shared/scripts/findings_cli.py render-table --report .cheese/age/<slug>.md

    If the host only ships the bundle, python3 ${CLAUDE_SKILL_DIR}/scripts/common.pyz findings_cli render-table --report .cheese/age/<slug>.md is the fallback. Mark any sprawling/structural-fix row as heavy.

  2. Ask which findings to cure. Lead each option with the verb (what the user wants to do next); the underlying selection verb is the backing detail. Lead with the recommended composite, then present the same four severity-floor options below it, in the same most-inclusive-to-least order, so the gate is predictable across every run:

    • Fix mediums-and-above plus cheap lows (recommended) — equivalent to all-medium, cheap (the composite floor defined at Compute the recommended set under SKILL.md § Handoff). The cheap lows are the small valid nits that are cheaper to fix than to defer; sprawling/structural lows are left out.
    • Fix everything — equivalent to all (every finding regardless of severity).
    • Fix medium-severity and above — equivalent to all-medium (the medium severity-floor from Compute the recommended set, without the cheap-lows union; add cheap to also union the contained-fix lows, i.e. the recommended composite above).
    • Fix high-severity and blockers — equivalent to all-high (floor at high, includes blockers).
    • Fix blockers only (strict; land only the must-fix blockers and defer the rest to a follow-up) — equivalent to all-blocker.

    Then offer the non-floor and standard-tail options last:

    • Pick findings to fix — accept a free-text reply using the verbs from ../../cure/references/selection.md; expand the verb to finding ids:

      python3 shared/scripts/findings_cli.py parse-selection --report .cheese/age/<slug>.md --selection "<verb>"

      If the host only ships the bundle, python3 ${CLAUDE_SKILL_DIR}/scripts/common.pyz findings_cli parse-selection ... is the fallback.

    • Plate it — apply the recommended composite via /cure <slug> --auto --open-pr --stake medium+; terminal /plate resolves topology and publishes. Carry --hard.

    • Checkpoint & stop/wheypoint: write a resumable handoff and pause instead of curing now.

    • Stop — leave the report for later — equivalent to none.

    Present all four severity options on every run even when a severity band is empty (e.g. no blockers): a floor that resolves to an empty set is a valid, predictable no-op — do not drop or reorder options based on which bands happen to be populated. If the user selects a floor (or the recommended composite) that resolves to an empty set, treat the selection as none: report that no findings match and do not dispatch /cure with empty resolved_ids (the non-empty-selection contract in Dispatch still holds).

On a non-empty selection — whether auto-selected by default or chosen at the gate — immediately dispatch /cure <slug> [--safe] [--open-pr] [--hard] with the selection locked in via context, not a CLI flag:

handoff_context:
source_skill: /age
source_report: .cheese/age/<slug>.md
selection: "<recognized verb or explicit ids>"
resolved_ids: [<expanded ids>]

/cure skips its own selection prompt when this context is present, re-confirms the cited ids still exist, then owns the apply / validate / push loop. Always emit resolved_ids alongside selection — expand the verb yourself rather than leaving the field empty; /cure re-confirms against the report regardless. Propagate --safe, --open-pr, and --hard to /cure when they are in scope.

On none / Stop (only reachable via the gate), exit cleanly with the report path.

--auto substitutes a severity-floor selection and its own chain — see SKILL.md § Auto mode.

/cook’s fan pathway (its retired-/ultracook mechanics, now self-hosted — see ../../cook/SKILL.md § Fan pathway) spawns age as a fresh-context sub-agent and owns the chain itself. Honour the no-chain isolation directive:

  • Write .cheese/age/<slug>.md (with the handoff slug at the top) and stop. Do not invoke /cure <slug> --auto --stake medium+ from inside the sub-agent.
  • Set next: from what you observe on this run, not from any guess about chain position. next: cure when at least one finding meets the medium+ floor; next: done when none do.
  • The two-cure-pass cap is enforced by the fan pathway’s fixed chain length, not by age counting passes. The terminal age is publishable only with next: done; next: cure or a missing next halts without publishing. Parallel curds and post-merge review dispatch age as a top-level fresh-context reviewer, never as nested inline self-review.

The orchestrator assembles this packet once per fan-out run and writes it to .cheese/age/<slug>-packet.md. Each lens worker reads the full packet — a worker owns a lenses[i] entry, which may bundle up to three dimensions. There is no persistent cross-run cache — every run rebuilds (staleness risk + YAGNI).

  1. Located spec — path + content, resolved via the spec-resolution order in SKILL.md § Inputs.
  2. Dependency manifest contentspackage.json, Cargo.toml, pyproject.toml, or equivalent for the project under review.
  3. Project-helper index — one tilth_search for sanitize / validate / escape / safe / retry / debounce / logger across src/ and shared/, so workers can flag NIH or missing helper usage.
  4. Path-context map — which entrypoints are non-interactive or hot (servers, daemons, CLI handlers, outbound callers); workers need this for location classification and telemetry coverage.
  5. Per-lens rubric slice + shared formula sections — the worker’s assigned lens’s rubric slice, the union of the dimension rubrics for every dimension in that lenses[i] list, from dimensions.md, plus § Location sensitivity, § Fix-cost-now, and § Fix-cost-later so each worker can compute severity independently. Extract each dimension’s ## <Dimension name> heading to the next ## heading and concatenate.
  6. The severity machinery — the full § Severity computation section.
  7. Output contract — the per-finding fields table and finding format from SKILL.md § Output, plus the also-relevant-to: [<dim>, ...] field from Seam 3 — it is the cross-dimension signal Seam 4 reconciliation consumes and is not part of § Output’s base format, so a worker following § Output alone would omit it. Workers emit full per-finding rows (format fields + also-relevant-to) the orchestrator can parse and reconcile unambiguously.
  8. Dedup-ownership statement — explicit: workers do NOT dedup, apply boundary tiebreakers, reconcile severity across dimensions or lenses, or write the report. The orchestrator owns those steps (Seam 4), covering reconciliation across lenses as well as dimensions.

The existing review-context sub-agent (SKILL.md § Sub-agent context gate) is reused as the packet’s orientation + citations block. Its digest is embedded in the packet — not duplicated or re-generated.

  • Written to .cheese/age/<slug>-packet.md at the start of each fan-out run.
  • Read-only for workers; never written or modified by workers.
  • Not persisted across runs — no cross-run cache. Leave to normal .cheese/ cleanup.

Read this alongside SKILL.md § Output for a concrete rendering of the report skeleton.

## Blocker
- **[encapsulation:blocker]** `src/users/index.ts:42``index` re-exports `SqlPgUser` (infra ORM type) across slice boundary. 3 consumer slices already import it.
- location: contract · fix-cost-now: sprawling · fix-cost-later: structural · confidence: certain
- recommendation: define `User` in the slice's public types, map at the boundary, deprecate the leaked export.
## High
- **[security:high]** `src/api/admin/users.ts:55` — admin route accepts user-supplied filter without validation.
- location: contract · fix-cost-now: contained · fix-cost-later: contained · confidence: certain
- recommendation: validate against `AdminFilter` schema at boundary.
## Medium
- **[complexity:medium]** `src/utils/format.ts:200-240` — 60-line function, 5 params.
- location: module · fix-cost-now: contained · fix-cost-later: contained · confidence: speculating
- recommendation: extract `formatHeader` / `formatBody`.
## Low
- **[deslop:low]** `src/utils/format.ts:18` — variable `data` shadows outer `data`.
- location: class · fix-cost-now: contained · fix-cost-later: contained · confidence: certain
- recommendation: rename to `lineItems`.
## Confidence
<`certain` | `speculating` | `don't know`> — <one-line justification including which evidence sources were unavailable>
## Next step
<when press was skipped, lead with>: Hardening was skipped for this diff — run `/press <slug>` before curing, or continue reviewing as-is.
Auto-fixing the recommended set via `/cure` (or the selection prompt on a reason to ask / `--safe`).

The worked instantiation above renders only the severity sections. This is the complete report shape, handoff slug through ## Next step, with every placeholder in context:

status: ok | halt: <one-line reason>
next: cure | done
artifact: <path-to-press-report-or-prior-cure-if-any>
durable_flags: none | <one line per flag: what durable knowledge changed -> target wiki page>
baseline: none | <recorded baseline block copied from the upstream handoff see ../cook/references/quality-gates.md>
<one-line orientation: what the diff does>
press: skipped
<!-- `press: skipped` is the first body line after the blank separator. Omit it entirely when a press report exists for this slug or no cook artifact does. -->
# Age Report — <slug>
## Orientation
<one or two factual sentences about what the diff does>
## Press findings
<omit this section when `.cheese/press/<slug>.md` does not exist. When it does, summarise unresolved press items in one or two bullets so `/cure` (which never reads the press report directly) sees them. When it does not exist but `.cheese/cook/<slug>.md` does, omit this section but add `press: skipped` as the first body line after the handoff slug (see above) instead.>
## Wiki context
<omit this section when hallouminate is absent or grounding returned no hits. When wiki pages informed the review context, list one bullet per consulted page `<wiki page path>:<line>` — <one-line why it informed the review> — so the user can see, and challenge, what grounded the review.>
## Blocker
- **[encapsulation:blocker]** `src/users/index.ts:42` — `index` re-exports `SqlPgUser` (infra ORM type) across slice boundary. 3 consumer slices already import it.
- location: contract · fix-cost-now: sprawling · fix-cost-later: structural · confidence: certain
- recommendation: define `User` in the slice's public types, map at the boundary, deprecate the leaked export.
## High
- **[security:high]** `src/api/admin/users.ts:55` — admin route accepts user-supplied filter without validation.
- location: contract · fix-cost-now: contained · fix-cost-later: contained · confidence: certain
- recommendation: validate against `AdminFilter` schema at boundary.
## Medium
- **[complexity:medium]** `src/utils/format.ts:200-240` — 60-line function, 5 params.
- location: module · fix-cost-now: contained · fix-cost-later: contained · confidence: speculating
- recommendation: extract `formatHeader` / `formatBody`.
## Low
- **[deslop:low]** `src/utils/format.ts:18` — variable `data` shadows outer `data`.
- location: class · fix-cost-now: contained · fix-cost-later: contained · confidence: certain
- recommendation: rename to `lineItems`.
## Confidence
<`certain` | `speculating` | `don't know`> — <one-line justification including which evidence sources were unavailable>
## Next step
<when press was skipped, lead with>: Hardening was skipped for this diff — run `/press <slug>` before curing, or continue reviewing as-is.
Auto-fixing the recommended set via `/cure` (or the selection prompt on a reason to ask / `--safe`).

Cross-skill rules for forking work to a sub-agent. Adopted from Anthropic’s effective-context-engineering guidance, Tavily’s published skills, and the cross-harness norms used by LangChain DeepAgents and the Skills standard.

Each skill names its own triggers. This file is the single source of truth for the things every skill agrees on.

The sub-agent returns roughly 2 KB or less: structured summary, citations, gaps. No raw bodies, no full file dumps, no copy-paste of fetched content. Skills name what the digest contains — claim table, orientation paragraph, root-cause summary, etc. — but never relax the size ceiling.

Resolve every worker through ../../cheese/references/agent-resolution.md. The calling skill supplies the work, permission/isolation floor, minimum power, effort, and fallback; this context kernel only governs digest boundaries.

  • Severity grading, final verdicts, approval gates. Exception (scope-limited): a skill may delegate single-dimension grading to a per-dimension worker iff the parent retains final cross-dimension reconciliation and the verdict — the verdict and the cross-cutting grade stay central. This exception does not loosen the default for any other case.
  • Dialogue, contradictions, handshakes, user-facing decisions.
  • Writing the canonical artifact (report, spec, claim table) — the sub-agent supplies the digest; the parent writes the doc.
  • Bulk fetches, extracts, crawls, multi-source research.
  • Many-file reads, dependency / caller graph traversals. For code navigation, start with kind:symbol to find the definition, then kind:callers for call sites. Fall to content/regex only when you don’t have a symbol name.
  • Anything yielding mostly raw bodies that the parent will not read line by line — about 5 K tokens of raw output is the industry rule of thumb for “fork it”.

When two or more heavy units of work are independent, spawn one small sub-agent per unit in parallel and merge their digests in the parent. One sub-agent doing five things sequentially is the wrong shape.

/age (and /affinage through it) no longer use a size-only threshold to decide fan-out — skills/age/SKILL.md § Sub-agent context gate calls src/fanout/age_route.py’s route(score=...) and forks per its n (1 / 2 / 5). This file’s digest contract, selection rules, and delegation boundaries still apply unchanged to every worker the router spawns.

Shared output discipline, reasoning posture, and depth-vs-question scoping. Skills cross-reference this file rather than restate it; when a skill omits a rule, treat the omission as opt-out.

  • Lead with the answer in written reports — the first line of a .cheese/* artifact, written summary, or end-of-task wrap-up is the result, not the lead-up. Skip preamble (“Let me look at…”), restatement (“So you want…”), and trailing sign-offs (“Hope this helps”, “Let me know if…”). Brief conversational scaffolding earns its place in interactive dialogue when the user is exploring or aligning — the rule targets reports, not natural turn-taking.
  • Match shape to content. Headers and bullets are for content that is genuinely list-shaped. A two-sentence answer stays as two sentences.
  • In .cheese/* artifacts, write prose-first Markdown — Markdown headers, bullets, and tables are fine when content is list-shaped, but skip JSON/robotic schemas and ceremonial layout. US spelling, Oxford commas. Skip AI cadence — repeated em-dashed asides as decoration, “consider edge cases” filler, “robust and scalable” boilerplate, “great question” or “you’re absolutely right” openers.
  • Correct false premises before engaging. If a request rests on a wrong assumption, name the assumption and answer the better question instead of working the wrong angle.
  • Name loaded assumptions. When a question presupposes a contested choice, surface it before answering.
  • Flag confidence on each critical claim. Use the three-way scale:
    • certain — direct evidence in front of you (file content, command output, primary doc, test result).
    • speculating — inferred from indirect signal; name the inference path so the user can audit it.
    • don't know — say it. Never launder a guess as analysis.
  • Steelman the rejected option. When proposing one approach, state the strongest case for the alternative before dismissing it. Applies to design choices, library picks, and review recommendations.
  • Track contradictions across the dialogue. If turn N contradicts turn N-3, flag it and resolve before moving on. The model is responsible for noticing — the user should not have to be the consistency check.
  • Agree when agreement is warranted. Do not manufacture counterpoints to seem balanced. A spec the user already got right does not need re-litigation.
  • Prefer satisfying a valid critique over arguing it. When a review comment or self-review nit is correct and the fix is cheap — a contained change, roughly a few lines or a localized refactor — make the change rather than draft a defense for leaving it. Push back only when the critique is wrong (the code is already correct, or the claim is ungrounded) or when satisfying it costs far more than it returns (a sprawling or structural change beyond the current scope). A justified push-back usually costs more than a small valid fix.
  • Name the exact step that breaks when reasoning is invalid — not “this seems off”, but “the X assumption fails when Y because Z”.

These scope to different axes — which decisions to ask about, how to phrase a question, and how much to contribute — not one dial to trade off.

  • What you ask about — the decisions that are the user’s to make. Consequential or preference forks (scope, naming, trade-offs with no single right answer) are the user’s call; ask them rather than deciding silently and presenting the result as settled. On an owned decision, asking is the primary move, not a last resort when you’re stuck.
  • How you phrase a question — one clear thing at a time. Preserve working memory and surface the real ambiguity instead of burying it in a multi-part barrage. This governs phrasing, never whether to ask.
  • What you contribute — maximum useful depth. Full pseudocode signatures over hand-waving, named edge cases over “consider edge cases”, concrete file:line evidence over vague pointers, the actual rejected-option case over “there are trade-offs”. When the model is the one talking, lean toward more, not less.

The failure mode to watch: treating a low question count as a virtue. Tight phrasing is the goal, not few questions — don’t skip a decision that’s the user’s because you’d rather contribute than ask. And don’t ask a thin question as a substitute for thinking: if you have nothing substantive to add yet, add it first.

  • Punctuation aesthetics (em dashes, emojis). The repo’s tone allows them in skill prose; voice rules govern reasoning, not typography.
  • Audience-shaping (“write for an executive”). Skills serve the user in front of them, not a generic audience.
  • A ban on Markdown structure in .cheese/* artifacts. Headers, bullets, and tables are fine when content is genuinely list-shaped; the rule targets JSON-schema-style layout and AI cadence, not Markdown itself.