A policy family is one behavioural rule with a single owning file. Five ship
today: observable-outcome, repository-anchoring, new-spec-step-5a,
the-razor, and cognitive-load.
The registry lives at
.claude/skills/work-loop/references/policy-families.md (or
.agents/skills/work-loop/references/policy-families.md, depending on your
adapter) as one fenced json policy-registry.v1 block. The prose around it is
for you; the block is what the selector reads.
Declaring a family
Section titled “Declaring a family”Add a record to the families array and give it an entry in every selection
key that should teach it. A record carries three fields:
id— a stable identifier. It appears in the delivery record and in arrival verdicts, so renaming one is a contract change, not a tidy-up.tier—preciseoradvisory. See the next section.module— a logical locator, never a path.
The locator matters more than it looks. Write skill:new-spec/assets/spec.md,
not packs/core/.apm/skills/new-spec/assets/spec.md: the registry ships to your
repository, where the catalogue path does not exist and the same rule lives under
.claude/skills/ or .agents/skills/. Two namespaces are recognised —
skill:<name>/<relative-path> for a rule owned by a skill, and seed:<path> for
one owned by a file seeded into your repository root.
Point a locator at the file that owns the rule. Where a generated projection
and its source both exist, the source is the owner — the-razor is
seed:AGENTS.md rather than a projected copy.
A family you add but select nowhere is inert. A selection key you leave empty
is a deliberate answer, not a gap: the human-gate and terminal states select
nothing because no agent is authoring while the loop waits in them.
Classifying a family
Section titled “Classifying a family”Every family is precise or advisory. There is no middle tier.
precise means a parse-level predicate can decide the rule against the
produced artifact. Only a precise family can ever block.
advisory means the rule is taught and reported but never blocks. Choose it
when deciding the rule needs a judgement rather than a parse — including when the
decidable evidence exists but lives outside the artifact, such as a rule about
whether a search was run.
The safe default is advisory. A family shipped precise on an undecidable
predicate produces false blocks, which is worse than teaching a rule you cannot
yet enforce. Two of the five ship precise today; three are advisory.
Splitting helps. A rule that needs a reasoned verdict often divides into a
decidable part and a smaller semantic residue — ship the decidable part as its
own precise family and leave the residue advisory, rather than forcing the
whole rule into one tier.
Troubleshooting a selection
Section titled “Troubleshooting a selection”Run the selector directly. It takes the registry, a resolution root, and a selection key:
python3 .claude/skills/work-loop/scripts/select-policy-families.py \ --registry .claude/skills/work-loop/references/policy-families.md \ --root . \ CODE-IMPLEMENTATIONEvery refusal prints to stderr with a select-policy-families: prefix and exits
non-zero. The record goes to stdout and nothing else does, so piping into a JSON
parser is safe.
| What you see | What it means |
|---|---|
unknown selection key 'X' | X is not an engine-state.json.state value, a stage key, or a declared constant. Selection never infers a phase from prose — check what the engine actually recorded. |
module 'Y' resolves to no file confined to Z | Either the file is not installed under --root, or it exists but is reached from outside the boundary — through .., an absolute path, a symlink that leaves the root, or a hard link to a file outside it. The second cause is the common one in a monorepo: check what the locator resolves to before assuming the file is missing. |
info string ... disagrees with schema_version | The fence’s version token and the block’s schema_version diverged. Both must move together. |
unsupported schema_version | The registry was written for a newer selector than the one installed. |
duplicate family id / selection 'X' repeats a family id | The same family appears twice. Selection is an ordered set. |
family 'Y' has tier ... | Only precise and advisory exist. |
--root is not always where the registry lives. The registry travels with
the skill, while seed: rules live at your repository root. Pointing --root at
a projection output directory makes every seed: family unresolvable — pass the
repository root instead.
Stage keys select the same families as the states they replace. The
supervisor’s status --json reports a stage, and the selector accepts it as
the key:
| Stage key | Engine states it replaces |
|---|---|
PRE-APPROVAL | SPEC-PLAN-DRAFTING, SPEC-PLAN-REVIEW |
admitted | SPEC-PLAN-APPROVED |
implementing | CODE-IMPLEMENTATION |
gating | CODE-VERIFICATION |
reviewing | CODE-REVIEW |
awaiting-human | SPEC-HUMAN-GATE, PLAN-HUMAN-GATE, CODE-HUMAN-GATE |
terminal | DONE |
The light path uses DIRECT-LIGHT, and pre-approval work uses
PRE-APPROVAL. A direct-light change creates no engine state, and the
supervisor has no stage before approval, so neither has a value to key on. Both
are declared constants, and they are the only reserved tokens.
An empty result for a gate or terminal state is correct. If a state that
should teach something returns nothing, the selection map has been thinned.
Compare the map against what you expect that phase to teach; an empty list is a
deliberate answer only for the gate and terminal states, and the admitted, awaiting-human, and terminal stage keys.