---
description: "Maps each finding in one review findings file to the cheapest deterministic check that could catch it (editorconfig severity, analyzer rule, custom analyzer, Semgrep rule, architecture test, action-guard, or automated-review) and writes one proposal stub per finding; it proposes and never implements. Use when: 'which of these findings could a linter or analyzer catch', 'audit enforceability', 'promote findings to a deterministic check', 'what rung catches this', 'can we stop re-finding this'."
argument-hint: "<findings-file>"
user-invocable: true
disable-model-invocation: false
allowed-tools: ["Bash(${CLAUDE_SKILL_DIR}/scripts/emit-stubs.sh:*)", "Bash(\"${CLAUDE_SKILL_DIR}/scripts/emit-stubs.sh\":*)", "Bash(node \"${CLAUDE_PLUGIN_ROOT}/skills/setup/scripts/setup-apply.mjs\" --check:*)", "Bash(node ${CLAUDE_PLUGIN_ROOT}/skills/setup/scripts/setup-apply.mjs --check:*)", "Bash(git:*)", "Bash(grep:*)", "Bash(head:*)"]
shell: bash
metadata:
  workflow-stage: review
  summary: Propose the cheapest deterministic rung for each review finding
---

## Repository context. Gather first

Collect these with **individual** Bash calls, one command per call, never combined into a single
invocation:

- Current branch, `git branch --show-current`

Treat a failure (not a repository, git unavailable) as an unknown value and carry on. A
worktree-isolated session refuses a compound command that contains git, which is why these stay
separate calls rather than one pre-compute line.

## Purpose

A recurring review finding is re-derived by a model on every run. This skill asks, per finding,
whether a cheaper deterministic check could have caught it, and routes the answer to whoever owns
that check. It reads one findings file, classifies each row, applies
[`context/crosswalk.md`](context/crosswalk.md), and writes one proposal stub per row.

**It writes on bare invocation.** The stubs are the report: memory-tier, self-ignoring, and
consumed by nothing. Two obligations come with that, both taken from the read-only audit posture
this copies: announce the resolved stub path in the report, and when no branch identity resolves,
write nothing and say so.

## 1. Input gate

`$ARGUMENTS` names **exactly one file**. Never glob a findings directory and never pick "the
newest": the operator chose this file, and a second file is a second invocation.

- Empty or missing path, or a path that does not exist: print
  `usage: /review:audit-enforceability <findings-file>` and STOP.
- The file's frontmatter must declare `type: review-findings` and its `## Findings` table must
  parse. Otherwise STOP with a one-line diagnostic naming which half failed. The shape is owned by
  [`${CLAUDE_PLUGIN_ROOT}/reference/findings-file-shape.md`](../../reference/findings-file-shape.md).
- A `branch:` that differs from the current branch is **not** a refusal. The operator named the
  file, and a stub is a proposal, not an applied change. Record that `branch:` value in every stub
  and use it for the branch slug.
- No `branch:` in the file **and** no resolvable current branch: write nothing. Report the
  classification table only, and say that no branch identity resolved.

## 2. Class derivation ladder

Per row, first hit wins. **Never drop a row**: the last step always produces a class.

1. The exact qualified rule id (`<plugin>/<skill>/rule-<slug>`) leading the `Finding` cell matches
   a rule-id row in the crosswalk. Class from that row, `class-basis: rule-id`.
2. The id's `<plugin>/<skill>/` prefix matches a rule-family row. Class from that row,
   `class-basis: rule-family`.
3. The row's `## By dimension` heading, in a file that has that section, matches a dimension row.
   Class from that row, `class-basis: dimension`. An unlisted dimension falls through.
4. Your own judgment over the `Finding` and `Action` text, into the crosswalk's class vocabulary.
   `class-basis: judgment`.
5. Nothing resolved: class `unclassified`, rung `automated-review`, `class-basis: unresolved`.

This step is done when every rank in the table carries exactly one class and one basis, and the
count of classified rows equals the count of rows the table carried.

## 3. Crosswalk lookup

Class to rung to owner or pointer, from [`context/crosswalk.md`](context/crosswalk.md). The rung
list and this skill's selection rule come from the enforcement ladder,
[`${CLAUDE_PLUGIN_ROOT}/context/enforcement-ladder.md`](../../context/enforcement-ladder.md): take
the cheapest rung whose check can actually assert the finding, not the cheapest rung imaginable.
An `invalid-state` finding is offered `make-impossible` first. The crosswalk writes the plugin's
root directory as `<plugin-root>`, which is `${CLAUDE_PLUGIN_ROOT}`; a file read through the Read
tool gets no substitution, so put that path in place of the placeholder.

## 4. Resolve two homes

Both sit under the memory root, `<memory_dir>`: `.work/` unless the project's instructions declare
another root.

- **The stub home**: `<memory_dir>/enforceability/<branch-slug>/`.

  `<branch-slug>` comes from the findings file's own `branch:` value, which the operator chose
  and which nothing authenticates. **The raw value is never a path segment.** Apply the branch-slug
  rule first: lowercase it, then replace `/` and every other character outside
  `[a-z0-9._-]` with `-`. That is what turns a value like `../../etc` into `..-..-etc`, one inert
  segment. A slug that is empty after that rule is not a home; report the classification table and
  write nothing. Pass the memory root you composed the home from as `--memory-root`, so the writer
  refuses a home that escaped it.
- **The fix action's reviews location** for that same branch: `<memory_dir>/reviews/<branch-slug>/`.
  This is what the writer is fenced against.

**Announce the resolved stub path** before writing anything:
*"Read-only pass; the only files written are the proposal stubs at `<resolved path>`."* With no
branch identity: *"Read-only pass; no branch identity resolved, so no stub is written."*

On the session's first memory-tier write, verify the memory root contains a `.gitignore` with `*`,
creating it (announced) when absent; never edit the consumer's root `.gitignore`. Refuse the write
itself when the memory root is the repository root.

## 5. Write the stubs

**Resolve `ratchet_offer` first.** It decides whether a stub on a counting rung carries the
`## Ratchet offer` section ([`context/stub-shape.md`](context/stub-shape.md)). Lowest layer first;
the later layer wins:

1. The default, `true`.
2. The user's option, `${user_config.ratchet_offer}`. A literal, unexpanded placeholder means
   unset; `true` reads as the default.
3. The `ratchet_offer` key of the repository's `docs/conventions/review.yaml` (schema:
   [`${CLAUDE_PLUGIN_ROOT}/schemas/review.schema.json`](../../schemas/review.schema.json)). Read
   it only when the repository root (`CLAUDE_PROJECT_DIR`, else `git rev-parse --show-toplevel`)
   is inside a git working tree and is neither `$HOME` nor an ancestor of it; otherwise skip this
   layer and say so. Read it with
   `node "${CLAUDE_PLUGIN_ROOT}/skills/setup/scripts/setup-apply.mjs" --check --root "<root>"`,
   which validates the whole file against the schema and writes nothing; `--check` comes first, so
   the granted command cannot write. It tells an absent key from a present but invalid one:
   - exit 0 with `INFO ... absent`, or `PASS ratchet_offer: (unset)`: this layer is unset.
   - exit 0 with `PASS ratchet_offer: true` or `false`: this layer sets that value.
   - exit 1 with a `PASS ratchet_offer:` line: a WARN sits on another key only. Name it, and read
     this layer's `ratchet_offer` from the PASS line as above.
   - exit 1 with no `PASS ratchet_offer:` line: `ratchet_offer` or the whole file is invalid.
     Each `WARN` line, or the one refusal line, names the file, the
     key and the value (an empty value, `null`, a quoted `"false"`, a list such as `[]`, a key set
     twice, an unknown key set twice or holding a map or list, a parse error). The layer is
     present and invalid: drop it and resolve
     `true` from the default, never from userConfig.
   - exit 2, or node is not installed: the layer cannot be read; skip it and say so.

A value other than `true` or `false` in either layer, or an invalid repository file, is named with
its file or option, the key and the value, and that layer is dropped: a valid higher layer still
wins, otherwise the default `true`, never a lower layer's value. The run continues. Report one line
before writing, naming the value and its layer, for example
`ratchet_offer: false (docs/conventions/review.yaml)`, `ratchet_offer: false (userConfig)` or
`ratchet_offer: true (default)`. Pass `--ratchet-offer on` for `true` and `--ratchet-offer off`
for `false`. The resolution is done when that line is printed with one value and one layer. The
settings page is
[`${CLAUDE_PLUGIN_ROOT}/reference/config.md`](../../reference/config.md).

Compose the classification TSV and pass it on stdin inside the same granted command, one line per
rank, six fields separated by ONE literal tab each: `rank`, `class`, `basis`, `rung`, `owner`,
`error text`. The error text is the one line the rung's check would print when it fires, and it
names the fix (what to change, not only what is wrong). Write it as one line with no tab; when it
cannot be written that way, leave the sixth field empty and the stub records `none proposed`. The
writer derives the stub's `earliest-stage` from the rung itself. Each `<TAB>` below is a single
tab character, not that text:

```bash
"${CLAUDE_SKILL_DIR}/scripts/emit-stubs.sh" \
  --findings <findings-file> \
  --classes - \
  --out <resolved-stub-home> \
  --scan-dir <resolved-reviews-home> \
  --memory-root <the memory root the home was composed from> \
  --ratchet-offer <on|off> <<'TSV'
1<TAB>style<TAB>judgment<TAB>editorconfig-severity<TAB>in-repo .editorconfig<TAB>IDE0011: add braces to this if statement (set by .editorconfig)
2<TAB>defined-diagnostic<TAB>rule-id<TAB>analyzer-pack-rule<TAB>keep the detector<TAB>
TSV
```

The heredoc rides inside the one granted command, so no separate file-write grant is needed and no
temporary file is left behind. This step is done when the writer has exited 0 and printed its
`N findings → N stubs` line with N equal to the number of rows the table carried.

The writer is deterministic and owns the shape ([`context/stub-shape.md`](context/stub-shape.md)):
it anchors on `## Findings`, unescapes `\|`, never overwrites, refuses a stub home inside either
fenced directory, refuses a home carrying a `..` segment or one that escapes `--memory-root`,
refuses a home whose last path segment sits outside `[a-z0-9._-]` (that segment is the branch
slug, composed from operator-supplied frontmatter; a charset miss is an unsanitized value
reaching the path), and re-reads every stub it wrote, removing all of them if any carries a
findings-file marker. It refuses the whole TSV, writing nothing, when a line has other than five
or six fields, its first field is not a rank in the table, or its rung is outside the closed set. Its exit codes are `2` usage or a
non-conforming input (findings file or TSV), `3` a refused home,
`4` a forbidden marker. Surface a non-zero exit verbatim; never retry it into a different
directory.

## 6. Report

The `ratchet_offer` line from step 5 first. Then a table with one row per finding: rank, class,
class basis, rung, earliest stage, owner, stub path. Then the
writer's own line, `N findings → N stubs in <home>`. Then, grouped per rung, the next step, each
with its gate and its fallback. With the offer on, a counting rung's next step ends with the
offer: once the rule exists, a count above zero goes to `/toolchain:ratchet`, and a zero count lands
the rule.

- **`make-impossible`**: name the type, data structure or API whose shape lets the invalid state
  exist, and the change that would remove it. Hand that proposal to `/architecture:improve` when
  that skill is available; otherwise to `/planning:design`. Nothing is redesigned here.
- **`editorconfig-severity`**: name the setting and the file; no plugin is involved.
- **`analyzer-pack-rule`**: name the diagnostic id and the config file that raises its severity.
  Where the producing detector already is the deterministic check, the next step is to keep it.
- **`custom-analyzer`**: point at Microsoft Learn, "Tutorial: Write your first analyzer and code
  fix" (<https://learn.microsoft.com/en-us/dotnet/csharp/roslyn-sdk/tutorials/how-to-write-csharp-analyzer-code-fix>).
  No installable upstream skill owns this rung, so the pointer is the whole of it. This rung is
  .NET-only; the same invariant elsewhere belongs on the Semgrep rung.
- **`semgrep-rule`**: invoke `/semgrep-rule-creator:semgrep-rule-creator` (if the
  `semgrep-rule-creator` plugin is installed); otherwise the stub points at Semgrep's rule-writing
  documentation (<https://docs.semgrep.dev/writing-rules/overview>) and stops.
- **`architecture-test`**: point at ArchUnitNET (<https://archunitnet.readthedocs.io/>) for .NET
  and the dependency-cruiser repository docs (<https://github.com/sverweij/dependency-cruiser>)
  for JS/TS.
- **`action-guard`**: run `/harness-config:audit-automation-gaps hooks` (if the `harness-config` plugin is
  enabled) and present the stub as a candidate in its candidate-list step, which is
  self-generated and takes no findings input; otherwise the stub records the candidate with the
  evidence that skill's gates ask for (frequency, incident history, the enforcement level already
  covering it) and stops.
- **`automated-review`**: say plainly that no check catches this one and it stays a review-time judgment.

## 7. Boundary

- **Not the detector-findings or autonomy sense of "promotion".** There, promotion moves a
  candidate detector's guardrail class, or flips a matrix cell's human-ratified knob across the
  C1 to C5 work classes. Enforcement promotion here moves a finding class to a cheaper
  deterministic rung. Same word, different meaning, and neither implies the other.
- **Not the enforceability of a convention.** Whether a convention is machine-checkable, and at
  which tier, is a question about a rule. This skill asks whether ONE finding could have been
  caught deterministically.
- **Not the automation-landscape audit.** `audit-automation-gaps` in the `harness-config` plugin
  walks a repository and self-generates its own candidates. This skill reads one findings file and
  walks nothing.
- **Not the fix action.** `/review:fanout fix` applies findings to the working tree. This skill
  mutates no reviewed file, and its stubs are fenced out of the directory that action scans.
- **It never implements a rung.** No analyzer, rule, architecture test, or hook is written here.
  Asked to "just write the analyzer", decline and hand over the rung's pointer.

## Next

`/review:fanout fix`. The persisted findings are its input.

## Gotchas

- **`## By dimension` repeats every row.** The section re-renders the same findings under
  per-dimension headings with rank numbers unchanged, so reading every table row in the file
  counts each finding twice. The table under `## Findings` is the only source of rows. The writer
  enforces this; do not re-count rows yourself from the whole file.
- **Unescape `\|` before classifying.** Cells escape a literal pipe, and reviewer text carries
  pipes routinely (type unions, shell pipelines). A cell read without unescaping splits into
  phantom columns.
- **An omitted `Confidence` is not low confidence.** A minimally conforming producer may leave
  cells thin. Absent is absent; never read it as a value.
- **A missing `## By dimension` section is normal.** Only files the fan-out review wrote carry it.
  An adopter-produced file has none, which simply means the ladder's dimension step never fires.
- **The dimension enum is open.** New categories appear without notice, so an unlisted heading
  falls through to judgment instead of being forced onto a rung.
- **A `> DEGRADED:` blockquote above `## Findings` is a coverage notice, not a row.** Carry its
  first line into the report so the coverage gap stays visible.
