# Skill conformance  -  reviewing against the criteria the work was built to

> **TLDR**  -  Phase 4 Step 1.78 resolves, deterministically and before any reviewer runs, WHAT the changed code was supposed to honour: declared rule registries scoped to the diff's languages, in-repo module guides, and the toolchains those registries delegate to. The result is `criteria-manifest.json`: a bounded set of rule IDs that becomes the denominator for "was this applied completely". Reviewers answer per rule ID. An ID that is neither checked nor explicitly waived fails the stage.

## Why this exists

Phase 4 used to select its review criteria from `detectedStack`, a string Phase 1 computed before a line of code existed, and Phase 3 never recorded which skills it actually applied. Two consequences:

- The dev side and the review side could disagree about the standard without either noticing. A component built by a plugin skill was reviewed against `clean-code`.
- "Did it do this correctly?" had no fixed denominator, so the only available answer was "it looks fine", and a reviewer that opened nothing produced the same output as a reviewer that checked everything.

A Short run makes this sharper: it has no Phase 1 at all, so the sole input to the old selection logic was absent.

## The four rules that make it work

1. **The denominator is written before the reviewers run.** `criteria-manifest.json` lands on disk in Step 1.78. Reviewers receive the selected rule IDs and must return a verdict per ID. A reviewer cannot narrow the set after seeing the diff, and a no-findings result still has to say what it checked.
2. **The resolver is primary; self-report only corroborates.** Phase 3 appends to `state.telemetry.skillCalls[]`, but coverage is never computed from it. An unrecorded consultation and no consultation are byte-identical in state, so a percentage derived from self-report reads green over an empty set. `ledger.source` therefore defaults to `derived`, and a declared entry the resolver could not bind to a changed file is FLAGGED rather than believed.
3. **Absent, empty and zero are three states.** `selfReport: absent` (no key), `empty` (key is `[]`), `declared` (has entries). Collapsing them makes the completeness claim unfalsifiable.
4. **The pipeline names no stack-specific skill.** Registries opt in; see below.

## Registry discovery is declared, never sniffed

A skill becomes a standards registry by saying so in its own frontmatter:

```yaml
standards-registry: references/rules.yml
```

`skill-conformance.mjs` reads frontmatter across the installed skills trees and loads what declared itself. Nothing in the pipeline names `ios-coding-standard`, `apple-archive-compliance` or any other skill, so a future UIKit, Objective-C, Kotlin or backend registry drops in with zero pipeline change  -  and a registry that is absent produces a declared coverage gap rather than a silent pass.

**The skills root differs per host**, so discovery is a bounded walk over candidate roots (`<install>/skills`, `<install>/multi-agent-refs/skills` for Codex, `<repo>/pipeline/skills`), installed layouts first. `install/copilot.mjs` copies `scripts/` byte-for-byte with no path rewrite, so a hardcoded `~/.claude/skills` is inert on two of the three hosts. That bug has already shipped here once: dynamic skill loading exited 1 on every real install while passing a smoke that ran from the repo. `skillsRootsSearched` is recorded in the manifest so an empty result is attributable to a root rather than to an absence of registries.

## Scope is required, and it is what makes this stack-generic

Every registry declares the languages and paths its rules may be applied to:

```yaml
scope:
  languages: [swift]
  paths: ["**/*.swift"]
  excludePaths: ["**/Generated/**"]
  notCovered:
    objective-c: "no ObjC rules exist here; report as a coverage gap"
```

Per-rule `scope:` narrows this further and never widens it.

Without this the 99 Swift rules of the iOS registry would be applied to an Objective-C or UIKit file, which manufactures findings, buries the real ones, and teaches the reader to distrust the run  -  strictly worse than declaring no coverage. Every dropped rule is counted with its reason in `droppedReasons`, because silently narrowing the rule set is indistinguishable from the code passing it.

A registry that declares no scope loads as `reference-only`: readable context for the reviewers, contributing no rule IDs to the denominator.

## What is deterministic here, and what is deliberately not

**Deterministic (this gate):** which registries apply, which rule IDs are in scope, which changed files no criteria cover, whether the delegated toolchain is wired, and the exception-marker audit.

**Not deterministic, on purpose:** the rules themselves. This gate does NOT execute registry `mechanism` patterns. Across the 99-rule iOS registry the distribution is 47 `judgement` / 41 `lint` / 10 `scan` / 1 `format`, only 43 rules carry `mechanism` at all, and the field is prose with an embedded pattern (`swiftlint file_length, function_body_length`, `custom regex per module`) whose scope is written in English. Perhaps 12-15 have an extractable pattern. Running them unscoped floods a review with doc-comment matches  -  which the registry's own guidance says, requiring each hit be opened and confirmed. The properly engineered version already exists as `references/swiftlint.draft.yml`, 36 regex rules with path scoping.

So instead: **if a registry names a toolchain, check whether the repo wired it, run it when it is there, and report its absence as a finding.** That is stack-generic for free, because swiftlint, ktlint, detekt, eslint and ruff all speak rule IDs. An unwired toolchain ranks above most individual violations it would have caught: those rules are unverified, and a review reporting no violations for them is reporting that nothing was measured.

## The one bespoke scan: exception markers

Cheap, language-agnostic, and it answers "completely" head on  -  an exception is the author's own claim that a rule does not apply here, so an expired or unexplained one is something the reviewers would otherwise take on trust. The marker template is read from the registry's `exception_marker`, never hardcoded, so a registry using a different comment syntax still works.

| Condition | Severity |
|---|---|
| Exception expired (`expiry < today`) | blocking |
| Exception names an ID absent from every registry in scope | blocking |
| Exception with no rule ID | blocking |
| Exception with no expiry date | important |
| Exception with no reason | important |

## Dev-mode substitutes (Phases 1 and 2 never ran)

| Full-pipeline input | Dev-mode substitute |
|---|---|
| `detectedStack` (Phase 1) | language census of the diff by file extension. Describes what the diff CONTAINS, not what the repo is nominally built in, so one Objective-C bridging file in a Swift repo is classified correctly |
| Phase 1 analysis summary (triage scope) | the task description plus the inline task list Phase 3 generated for itself |
| Phase 2 plan | same inline task list |
| `state.evidence.figma[]` (Step 1.8) | recorded as `not-applicable (no Phase 1 evidence)` |
| Step 2.8 visual conformance | recorded as `not-applicable (no Phase 1 evidence)` |

Recording `not-applicable` rather than skipping is the point: "silently skipped" and "out of scope with a reason" must be distinguishable, or the completeness claim cannot be checked.

## Invocation

```bash
node $HOME/.claude/scripts/skill-conformance.mjs \
  --diff "$WORKTREE/.review-diff.txt" \
  --state "$WORKTREE/agent-state.json" \
  --repo "$WORKTREE" \
  --out "$WORKTREE/.pipeline/criteria-manifest.json"
```

Exit codes:

| Code | Meaning |
|---|---|
| `0` | manifest written; may still carry findings or declared coverage gaps |
| `1` | setup error: no diff, unreadable state, or an explicit `--skills-root` that does not exist |
| `2` | **fail closed**: no skills root resolved at all, a declared registry could not be parsed, or a declared registry path escaped its skill directory |

Any non-zero code halts before the reviewers. Continuing on `2` would drop a whole rule set from the denominator; and because `validate-reviewer.mjs` skips the checklist when zero rules were selected, an empty resolution would otherwise render as a clean review over criteria that were never loaded. An empty registry set inside a root that DOES exist is the different, legitimate case: declared coverage gap, exit 0.

`skillsRootsSearched` lists every candidate root that was probed with an `existed` flag, not only the ones found  -  otherwise the field is empty in exactly the case it exists to explain.

Output validates against `$HOME/.claude/schemas/criteria-manifest.schema.json`.

## Handoff to the reviewers

The manifest's selected rule IDs and registry file paths go into the **shared, cacheable prompt prefix** as one `${CRITERIA}` block, byte-identical for every reviewer (Step 1.9 requires an identical leading block; per-reviewer criteria subsets would invalidate the prefix for the whole panel and pay full input rate on the largest block in the phase). Reviewers read the registry YAML natively  -  the pipeline hands over a path, not a parse.

Deterministic findings merge at Step 3.0 alongside the test-integrity set, so triage adjudicates them rather than never seeing them. A finding citing a registry rule ID carries evidence a reviewer opinion does not, and the triage prompt says so: an ID-citing finding is not dismissible as a matter of taste, though it can still be out of scope for this task.

## Preference

`prefs.global.skillConformance.blockOnCoverageGap` (default **false**). A coverage gap on a stack with no registry would otherwise block every non-iOS run from day one. There is deliberately no opt-out for the stage itself or for the exception-expiry check, on the same grounds as Step 1.76: a run that can switch off its own anti-reward-hacking control cannot be trusted to report a pass.
