/enforce — Make markdown guidance into enforced harness rules
Quick start
/enforce # distill every imperative .md in the project
/enforce AGENTS.md # distill just one file
/enforce .claude/skills/tdd/ # distill every .md under a directory
/enforce mattpocock/skills/tdd # fetch from GitHub (review-mode by default)
/enforce list # show what's distilled, grouped by source
/enforce show <id> # full detail for one rule
/enforce remove --source <group_id> # bulk-remove rules from a source
/enforce disable <id> # keep on disk but don't enforce
/enforce modify <id> --action ask # change a single rule's action/severity
/enforce --review # distill to a review file; nothing activates yet
/enforce --accept # promote a review file to live rules
Manual invocation only. This skill never auto-fires — not on SessionStart, not on a file-watcher event, not from any hook. The user (or an agent acting on the user's explicit request) types /enforce .... If you are wiring this skill into automation, stop — that violates the design.
Where output goes. Live rules: .interlinked/distilled-rules.json (pristine, regenerated each run). User mods: .interlinked/distilled-rules.overrides.json (removals, disables, modifications — survives re-distillation). The harness watches both files via watchFile and reloads automatically within ~2s — there is no interlinked harness reload command. If a daemon is in a degraded state and not picking up changes, interlinked harness restart is the only recourse.
Execution order (distill path; lifecycle verbs jump straight to §14):
- Pre-flight (§11) → 2. Parse args (§1) → 3. Read overrides (§9) → 4. Read prior
distilled-rules.json(§3) → 5. Discover / resolve targets (§2) → 6. Read each file once (§3) → 7. Classify paragraphs (§4) → 8. Lexical ladder (§5) → 9. Triggers (§6) → 10. Build rule object (§7) → 11. Apply user modifications (§9) → 12. Resolve conflicts (§10) → 13. Self-checks (§12) → 14. Write output (§8) → 15. Print summary (§13).
Common workflows
First-time setup on a project
/enforce # walk the tree, distill what's there, print a report
/enforce list # confirm the rule set looks right
# harness reloads automatically within ~2s (watchFile)
Adopt a remote skill (review-first)
/enforce gh:mattpocock/skills/tdd # fetched, distilled to review-mode by default
/enforce list # inspect what would activate
/enforce --accept # promote review → live
A rule is too noisy
/enforce show <id> # see what fired and why
/enforce modify <id> --action ask --severity medium # downgrade
# or, to keep it on disk but stop enforcing entirely:
/enforce disable <id>
Reject a whole source forever
/enforce remove --source gh:someone/skills/qa
Adds the group to removed_groups[]; stays gone across re-distillation. Undo with /enforce add --source <group_id>.
A .md file changed; re-distill
/enforce # unchanged files skipped via hash; user mods preserved
# harness reloads automatically within ~2s
Throw it all away and start fresh
rm .interlinked/distilled-rules.json .interlinked/distilled-rules.overrides.json
/enforce
What this skill does
Agent-instruction markdown files (AGENTS.md, CLAUDE.md, .clinerules/, .windsurf/rules/, GEMINI.md, etc.) are loaded into the model's context window as hopeful prose. Today the model may or may not follow them. This skill walks the source tree (or just the targets the user named), extracts every concrete imperative from those files, and distills them into typed GuardRule entries that the Interlinked harness enforces deterministically at runtime — meaning the agent literally cannot bypass them, regardless of which underlying coding agent is running.
Distillation, not strict compilation. The §5 lexical ladder + §6 trigger inference + §12 validation are deterministic — typed inputs, typed outputs, no LLM calls. But §4 paragraph classification (is this prose a hard imperative, soft preference, hedged statement, descriptive context?) requires LLM judgment because there's no formal grammar for "is this an imperative." So the operation is honest extraction-and-codification with provenance, not parser-style mechanical translation. The earlier name "compile" was a generous shorthand; "distill" reflects what's actually happening. The lexical ladder is the deterministic gate: if a paragraph contains no §5 marker, the classifier MUST drop it. The distiller never invents a rule from unmarked prose, and every rule carries a verbatim source.quote so the entire pipeline is auditable.
The harness fans rules out across every configured runner via src/harness/adapters/. Your job is to produce one canonical artifact at .interlinked/distilled-rules.json plus an overrides file at .interlinked/distilled-rules.overrides.json. The harness handles distribution.
Coupling and scope. This skill produces an artifact whose schema (GuardRule, defined at src/harness/types.ts in interlinked-cli) is Interlinked's. Enforcement at runtime requires a hook engine that understands that schema — Interlinked is the canonical one, and currently the only one expressive enough to encode regex on tool_input fields, the six action types, severity, keyword quick-reject, role scoping, and exception patterns. The skill does NOT ship multi-backend adapters (Claude Code permissions, Cursor .mdc, Codex deny configs) because every alternative is strictly less expressive — lossy translation creates user confusion ("why doesn't my rule work in X?"). However, distillation itself does not require Interlinked installed: the artifact is a standalone JSON file you can audit, version-control, share, or wire into any compatible engine. See §11 for fallback-mode behavior when .interlinked/ is missing.
Invocation patterns (what /enforce <args> means)
| Form | Behavior |
|---|---|
/enforce |
Walk the whole project — discover all .md files per Step 1, extract from imperative-bearing ones |
/enforce AGENTS.md |
Distill only that file |
/enforce AGENTS.md CLAUDE.md |
Distill that exact set |
/enforce .claude/skills/tdd/ |
Distill every .md under the directory |
/enforce mattpocock/skills/tdd |
Treat as gh:mattpocock/skills/tdd — fetch + distill (review-mode) |
/enforce https://raw.githubusercontent.com/.../SKILL.md |
Fetch + distill (review-mode) |
/enforce list |
Lifecycle: print rules grouped by source — see §13 |
/enforce show <id> |
Lifecycle: full detail for one rule |
/enforce remove --source <group_id> |
Lifecycle: bulk-remove from one source |
/enforce remove <id> |
Lifecycle: remove a single rule |
/enforce disable <id> |
Lifecycle: keep but don't enforce |
/enforce enable <id> |
Lifecycle: re-enable |
/enforce modify <id> --action ask --severity medium |
Lifecycle: change action/severity |
/enforce add --source <group_id> |
Lifecycle: undo a removed group; re-distill to add back |
/enforce reset <id> |
Lifecycle: clear all overrides for this rule |
/enforce --review |
Distill-then-pause: write to .interlinked/distilled-rules.review.json, no activation until accepted |
/enforce --accept |
Activate review-mode output |
If the first argument is one of list, show, remove, disable, enable, modify, add, reset, treat it as a lifecycle verb (jump to §14). Otherwise treat arguments as distill targets.
Operating principles (NON-NEGOTIABLE)
- Manual invocation only. This skill runs only when the user (or an agent acting on the user's explicit request) types
/enforce .... Do not wire it into SessionStart, PreCompact, file watchers, hook events, scheduled jobs, or any other auto-trigger. Surprise enforcement — the model suddenly being unable to do something it could yesterday because a doc changed — is the failure mode this rule prevents. - Verbatim source provenance is required. Every distilled rule must carry the source file path, line range, and exact verbatim quote. A rule whose
source.quotedoes not appear insource.fileatsource.linesis hallucination — drop it. - Lexical strength is binding (see §5 ladder). Don't soften, don't escalate.
- Deterministic at evaluation time; semi-deterministic at distill time. The harness must remain deterministic at runtime — never generate rules that require LLM evaluation to check. The distill step itself is partly LLM-driven (paragraph classification in §4 needs a model to decide "is this prose a hard imperative, soft preference, hedged, or descriptive"), but the lexical ladder (§5) is the deterministic gate: if a paragraph contains no §5 marker, the classifier must drop it. The distiller never invents a rule from unmarked prose. Provenance (§7
source.quoteverbatim) is what makes the distill-time step auditable rather than opaque. - Default-skip when uncertain. Far better to skip a borderline imperative than to emit a wrong one.
- Never overwrite hand-curated rules. Output goes only to
.interlinked/distilled-rules.jsonand.interlinked/distilled-rules.overrides.json. The user's hand-curated rules live inguard-rules.jsonandguard-rules.local.json. Never touch those. - Idempotent across runs. Hash inputs; skip unchanged files; preserve user overrides.
- No invention. If a rule isn't in the source verbatim, it does not exist.
Step 1 — Argument parsing
Parse the invocation arguments. The first arg, if it's one of the lifecycle verbs (list, show, remove, disable, enable, modify, add, reset), routes to §14. Otherwise:
For each remaining argument, classify it:
| Form | Detection | Resolved as |
|---|---|---|
Bare name like AGENTS.md |
exists as file relative to CWD | local file |
Path with / like .claude/skills/tdd/SKILL.md |
exists as file | local file |
Directory path like .claude/skills/tdd/ |
exists as dir | walk the dir for .md and .prompt files |
Glob like docs/**/*.md |
contains * or ? |
glob expand |
<owner>/<repo> or <owner>/<repo>/<subpath> |
matches ^[\w.-]+/[\w.-]+(/.+)?$ and not a local path |
github shorthand → fetch from https://raw.githubusercontent.com/<owner>/<repo>/HEAD/<subpath or SKILL.md> |
https://... or http://... |
URL prefix | fetch directly |
--review, --accept, --source <x>, --action <x>, --severity <x> |
flag | parse separately |
Resolution rules:
- For GitHub shorthand: try
<subpath>/SKILL.md, then<subpath>directly, then<subpath>/AGENTS.md, in that order. - For URL or shorthand: only allow hosts in the default allowlist —
github.com,raw.githubusercontent.com,gitlab.com. Refuse any other host with a clear error. - For URL or shorthand: default to
--reviewmode (write todistilled-rules.review.jsoninstead ofdistilled-rules.json); local paths can distill straight through unless--reviewwas passed. - For local paths (file or directory): refuse if the resolved absolute path is outside the current repo / project root. Find the project root by walking CWD upward to the nearest
.git/(or treat CWD itself as the root if there is no git repo). If the target's resolved absolute path doesn't have that root as a prefix, abort with a clear message:"<target> is outside the current project (<root>). cd into that project and re-run /enforce."Reasoning: the artifact + overrides files live under the target's.interlinked/, the harness running in this session only watches the current project's.interlinked/, and writing into a sibling project from this session is the kind of cross-project surprise the skill exists to prevent. - If no targets resolve, fall back to project walk per Step 2.
Print the resolved target list before doing any work. Form: Distilling: AGENTS.md, CLAUDE.md, gh:mattpocock/skills/tdd (fetched sha256:abc…)
Step 2 — Discover sources (no-arg or directory walk)
When no argument is given, walk these locations and record each file's absolute path, kind, and SHA-256 hash.
2a — Project tree (CWD upward to git root)
For each ancestor directory from CWD up to the repo root (the directory containing .git/), look for:
| Filename / pattern | Kind | Notes |
|---|---|---|
AGENTS.override.md |
imperative | Highest project precedence |
*.local.md (e.g. CLAUDE.local.md) |
imperative | Personal, gitignored, beats shared |
AGENTS.md |
imperative | Cross-tool source of truth |
AGENT.md |
imperative | Singular variant (Amp/community) |
CLAUDE.md |
imperative | Claude Code |
GEMINI.md |
imperative | Gemini CLI |
WARP.md |
imperative | Warp legacy |
.github/copilot-instructions.md |
imperative | Repo-wide Copilot |
.github/instructions/*.instructions.md |
imperative | Path-scoped via frontmatter applyTo |
.cursor/rules/*.mdc |
imperative | Cursor; frontmatter globs/description is machine-readable |
.cursorrules |
imperative | Cursor legacy |
.clinerules |
imperative | Cline single-file legacy |
.clinerules/*.md |
imperative | Cline modular |
.windsurfrules |
imperative | Windsurf legacy |
.windsurf/rules/*.md |
imperative | Windsurf modular |
.continue/rules/*.md |
imperative | Continue.dev — frontmatter globs/alwaysApply |
.augment/rules/*.md |
imperative | Augment |
.tabnine/guidelines/*.md |
imperative | Tabnine |
.tabnine/guidelines.md |
imperative | Tabnine single-file |
.kilocoderules |
imperative | Kilo Code |
.claude/skills/*/SKILL.md |
scan-only | See §2c |
.codex/skills/*/SKILL.md |
scan-only | See §2c |
~/.claude/skills/*/SKILL.md |
scan-only | See §2c |
CONVENTIONS.md |
imperative-likely | Aider-style |
code_review.md |
imperative-likely | Often referenced from AGENTS.md |
CONTRIBUTING.md |
mixed (scan only) | Pull only paragraphs with hard imperatives |
SECURITY.md |
mixed (scan only) | Pull only paragraphs with hard imperatives |
STYLEGUIDE.md |
mixed (scan only) | Pull only paragraphs with hard imperatives |
PLANS.md |
scan only | Mostly procedural |
2b — User home (global rules)
Look in ~/.claude/, ~/.codex/, ~/.gemini/, ~/.config/copilot/, ~/.continue/, ~/.windsurf/ for the same patterns. Treat global rules as lower precedence than project rules unless they appear in *.override.md form.
2c — Skills as scan-only sources
SKILL.md files are mostly procedural (capability bundles). They are scan-only: extract paragraphs that hit the §4a/§4b lexical markers (MUST NOT, bans, forbids, never, MUST, always); ignore the rest. Every rule from a skill body has its group_id formed as skill:<skill-name> (extracted from the SKILL.md frontmatter name field) instead of local: or gh:.
Skill rules ARE scope-gated at runtime via active_when.skill. The harness's SessionTrajectory.active_skills map is populated by interlinked skill enter <name> (called from a slash-command preamble) and read by the active-when evaluator. A distilled skill rule with active_when.skill = "<skill-name>" is dormant unless that skill is currently active in the session. This means:
- Distill at full strength — a skill imperative phrased
Never Xdistills toaction: "block"per §5a, not the previous safety-downgrade toask. The runtime scope makes the strength safe. - Composes with §10 conflict resolution — scope-disjoint rules don't conflict; importing N skill rules without scope used to yield O(N²) collisions, with
active_when.skillpopulated they only collide within the same skill or with always-on rules. - Skill-author opt-in cost is one line — the slash-command preamble adds
interlinked skill enter <skill-name>(and a matchingleaveon completion). Skills that don't opt in still distill correctly but their rules are always-on (active_whenomitted), which is the pre-active_when behavior.
See docs/design/harness-active-when-scoping.md for the full design and §7 below for distiller population rules.
The skill: group_id is also used for lifecycle ops (/enforce remove --source skill:migrate-to-shoehorn).
2d — Skip list (DO NOT extract from these — confirm kind, then skip)
| File | Why skipped |
|---|---|
SOUL.md, IDENTITY.md, STYLE.md, USER.md, HERMES.md |
Persona/voice. Not enforceable as hooks. |
MEMORY.md |
Memory index; agent's concern, not the harness's. |
HEARTBEAT.md, BOOTSTRAP.md |
Lifecycle/initialization, not gating. |
ARCHITECTURE.md, DESIGN.md, RUNBOOK.md, TESTING.md, BUILD.md, DEPLOYMENT.md, RELEASE.md, TROUBLESHOOTING.md, CONTEXT.md |
Descriptive context, not imperative. |
PRD.md, SPEC.md, ROADMAP.md, TASKS.md, TODO.md |
Forward-looking. |
README.md |
Human-facing overview. |
TOOLS.md |
Tool inventory. |
.agent.md, .prompt.md, *.prompt, .github/agents/*.agent.md, .github/prompts/*.prompt.md |
Capability bundles — same treatment as SKILL.md per §2c. *.prompt covers bare-extension role/capability prompts (e.g. SwarmForge <role>.prompt). |
Any SKILL.md whose frontmatter name is enforce |
Self-reference. The distiller must not distill its own imperatives — they describe how to distill, not what the agent should do. Drop the file silently regardless of which install path it lives at (skills/, .claude/skills/, .codex/skills/, .interlinked/skills/, .gemini/extensions/, .github/skills/, etc.). |
Any SKILL.md whose frontmatter name is tdd, starts with tdd-, or ends with -tdd; any path matching **/tdd/SKILL.md or **/*-tdd/SKILL.md |
TDD enforcement is owned by the harness's native primitives — tdd_new_file_gate, tdd_cycle_violation, tdd_regression, tdd_commit_gate, tdd_green_confirmation — driven by the deterministic tdd_cycles state machine in SessionTrajectory. Distilling a competing TDD skill (Matt Pocock's, gstack's, anyone else's) would either over-fire (always-on rules duplicating our gates) or shadow (action downgraded to ask). Drop these files silently. If a project legitimately needs a different TDD policy, edit structural_checks.test_first_mode in guard-rules.local.json, not via /enforce. |
After discovery, print the file inventory before any extraction so the user can see the surface.
Step 3 — Read each file once
Use the Read tool with the full path. Cache contents. SHA-256 each file.
If .interlinked/distilled-rules.json already exists, compare each file's hash to the previous run's source_hashes map. Unchanged files: skip extraction; copy their previous distilled rules verbatim into the new output. Only re-extract files whose hash changed or which are new.
Subset distillation preserves other sources. When the user runs /enforce <single-target> (a path, a directory, a remote URL), the distiller MUST merge with the prior distilled-rules.json: rules from sources NOT in the current invocation are copied through unchanged, with their original source_hashes entries preserved. This is the natural extension of the unchanged-files rule: out-of-scope sources are unchanged by definition. Only /enforce (no arg, project walk) is allowed to fully regenerate. If you need a hard rebuild from a subset invocation, the user must rm .interlinked/distilled-rules.json first — the §"Throw it all away and start fresh" workflow.
For files with frontmatter, parse it and use machine-readable fields directly:
- Cursor
.mdc:globs/description - Continue
.continue/rules/*.md:globs/alwaysApply/description - Copilot
.github/instructions/*.instructions.md:applyTo - SKILL.md:
name(becomes part ofgroup_id),description
Frontmatter scope is machine-readable — do not re-extract it from the prose.
Step 4 — Per-paragraph classification
Iterate paragraph-by-paragraph (split on blank lines and heading boundaries). For each paragraph:
| Paragraph kind | Action |
|---|---|
| Hard imperative with concrete trigger | extract |
Soft preference (should, prefer, usually) |
extract as advisory |
Hedged statement (we usually try to, ideally, if possible) |
skip → log to skipped[] |
| Description / context | skip silently (not imperative) |
| Narrative / persona | skip silently (not enforceable) |
| Procedure / step-by-step | skip — agent guidance, not gates. Exception: a single step phrased as you MUST run tests first extracts as one rule. |
| Architecture / dependency-graph fact | skip silently |
| Forbidden tool / command list | extract per item |
| Required tool / command list | extract as block-on-inverse |
Step 5 — Lexical strength → action ladder (BINDING)
Apply mechanically. Do not adjust. If multiple markers appear in one paragraph, use the strongest.
5a — block (severity: critical or high)
Lexical markers (case-insensitive unless explicitly ALL CAPS, which strengthens):
MUST NOT,must never,never,forbidden,prohibited,not allowed,do not ever,may not,shall not,banned,outlawed,under no circumstances,at no time,bans- Headers:
CRITICAL:,BLOCKING:,FATAL:,DO NOT:
Severity: critical if marker is CRITICAL, MUST NOT, never, forbidden, prohibited, shall not, under no circumstances. Otherwise high.
5b — block via positive form (block on inverse trigger)
Positive imperatives with concrete trigger:
must,MUST,required,is required,is mandatory,has to,shall,always,every time,before X you must Y
For these, the trigger fires when the missing precondition is detected. Severity: high.
If you cannot model the precondition (no observable session state), downgrade to ask and note the gap in distilled_action_reason.
5c — ask (severity: medium)
should not,avoid,don't,prefer not to,try not to,discouraged
The ask primitive prompts the user before allowing. It collapses to deny on runners that lack confirmation (Copilot CLI, Codex). The harness handles that translation.
5d — advisory (severity: low; surfaces only under verify --all-checks)
should,prefer,usually,consider,recommend,ideally,try to,encourage,we like to,aim to
Distilled as action: "warn" with enabled: true. The verify pipeline gates these per its own advisory list.
5e — SKIP (do not extract)
we may,we might,we sometimes,possibly,feel free to,if you want,optionally,maybe- Any imperative with no concrete trigger (no tool, no file glob, no command regex, no session-state predicate)
- Aspirational language without an observable signal
- Anything where you cannot construct a verbatim source quote
5f — Action × trigger compatibility (binding)
block and ask are decisions made before the tool call runs — they require trigger: "PreToolUse". The harness has no post-rule evaluation: a PostToolUse rule cannot block, undo, or ask permission for a write that already happened. So:
| Action | Allowed triggers |
|---|---|
block, ask, soft_block, rewrite |
PreToolUse only |
warn |
PreToolUse or PostToolUse |
When the imperative's signal is only observable post-write (e.g., scanning produced file content for a pattern), the rule MUST distill as warn, not ask. Use PostToolUse + warn for advisories about content that just landed; reserve ask for PreToolUse checks where the agent can still back out. The trigger cookbook below routes Don't commit secret Z to source through PostToolUse + content regex precisely because that's the one place ask-vs-warn doesn't matter — content scanning IS warning, not blocking.
Step 6 — Trigger extraction (real GuardRule schema)
For each imperative, produce the trigger fields. If you cannot, downgrade to advisory — never emit a block or ask rule with no observable trigger.
The harness's GuardRule shape (from src/harness/types.ts):
interface GuardRule {
id: string;
enabled: boolean;
trigger: "PreToolUse" | "PostToolUse" | "both";
tool_match: string[]; // tool names; "*" for all
action: "block" | "warn" | "rewrite" | "soft_block" | "ask";
patterns: RulePattern[]; // see semantics below
reason: string; // shown to the agent
suggestion?: string;
severity: "critical" | "high" | "medium" | "low";
category?: string;
applies_to_roles?: AgentRole[];
keywords?: string[]; // PreToolUse quick-reject tokens
}
interface RulePattern {
field: string; // dot-path into tool_input
regex: string;
flags?: string; // default "i"
negate?: boolean; // exception when true
}
Distilled rules ALSO carry a source sidecar field — see §7. The harness ignores unknown fields; the CLI lifecycle ops use them.
Pattern semantics (binding)
The runtime evaluator splits patterns[] by negate:
- Positive patterns (
negateabsent orfalse) — OR. ANY one positive pattern matching makes the rule fire. With zero positive patterns, the OR is vacuously true: the rule fires whenever itstool_matchmatches. - Negated patterns (
negate: true) — exceptions. If ANY negated pattern matches, the rule is suppressed for that call.
The combined contract: a rule fires when (at least one positive matches OR there are no positive patterns) AND no negated pattern matches.
Practical consequence: you cannot AND two positive patterns together. If your imperative needs both "this file path" AND "this content", encode it as one positive pattern (a regex that captures both signals on one field) plus optional negated exceptions. Don't write the rule as two positive patterns and assume they intersect — that gives you a strict OR. The trigger cookbook below picks the smallest field that captures intent; when in doubt, single-pattern rules are easier to reason about than multi-pattern ones.
Trigger inference cookbook
| Imperative shape | Distilled fields |
|---|---|
| "Never run X" / "Don't use X" | trigger: "PreToolUse", tool_match: ["Bash"], patterns: [{ field: "command", regex: "<X>" }], keywords: ["<token>"] |
| "Don't edit files in path/" | trigger: "PreToolUse", tool_match: ["Edit","Write","MultiEdit","apply_patch"], patterns: [{ field: "file_path", regex: "<glob-as-regex>" }] |
| "Always do X before Y" | trigger fires on Y; harness session state required (see ‡) |
| "Use X instead of Y" | tool_match: ["Bash"], patterns: [{ field: "command", regex: "<Y>" }], suggestion: "use X" |
| "Don't commit secret Z to source" | trigger: "PostToolUse", tool_match: ["Edit","Write","MultiEdit","apply_patch"], patterns: [{ field: "content", regex: "<Z>" }] |
| MCP tool prohibition | trigger: "PreToolUse", tool_match: ["<exact-mcp-tool-name>"], patterns: [] (exact tool_match alone fires the rule via the vacuous-OR; field: "*" + .* would fail self-check #3) |
| Tool-class prohibition | tool_match: ["Bash"], keywords: [<token>], patterns: [{ field: "command", regex: "<pattern>" }] |
‡ Sequential preconditions ("Always X before Y") are not directly representable in GuardRule. Emit a Pass 2 policy.md entry (see §15.2) where trigger_signal describes the precondition in natural language; the Tier 2 LLM gate evaluates it from trajectory. Do NOT downgrade to ask in distilled-rules.json — ask is removed per §15.1 (PreToolUse user-prompts created fatigue; the Tier 2 gate handles the same job without interrupting).
Pattern hygiene (mandatory)
- Use
\bword boundaries — never baregit(matchesgitlab,git-credential). - Anchor where it makes sense:
^git\s+push\b. - Case-insensitive for SQL keywords:
flags: "i"on patterns matchingDROP\s+TABLE. - Reject any pattern that matches the empty string (
new RegExp(p).test("")). - Reject catastrophic-backtracking constructs:
(.*)*,(.+)+, nested unbounded quantifiers. - For multi-tool rules, list every tool:
tool_match: ["Edit", "Write", "MultiEdit", "apply_patch"](apply_patchis Codex CLI's edit tool — include it for cross-runner coverage). - Glob → regex translation:
db/migrations/**becomes^db/migrations/.*(anchored at field start).
Step 6.5 — Session predicates and active-when axes the distiller can consume
Most real AGENTS.md content is sequential ("always run tests before commit," "always read before edit") or scoped ("during /ship, never X," "while migrate-to-shoehorn is active, …"). The distiller has two mechanisms for these:
(A) Typed active_when axes (preferred when applicable). Recognized prose patterns map directly to a typed scope axis on the distilled rule. Wired into the harness; deterministic at runtime; composable.
(B) session_predicate escape hatch. For prose patterns that don't match a typed axis, emit a predicate entry inside active_when with the predicate name and args. Predicate-using rules must still set action: "ask" so they degrade to a no-op on harness builds that don't recognize the predicate name (the harness fails-safe — unknown predicates keep the rule dormant, never fire).
| Prose phrase | Distilled as | Reads from | Notes |
|---|---|---|---|
| "during /", "while is active", source is a SKILL.md body | active_when.skill = "<skill-name>" (typed axis) |
SessionTrajectory.active_skills |
Default for every rule extracted from a <name>/SKILL.md body. Skill-author opt-in via interlinked skill enter preamble. |
| "after running /ship", "after the user invoked X" | active_when.after_command = { pattern, window_steps } (typed axis) |
SessionTrajectory.commands_run (last N) |
Default window 10. Pattern is a regex matched against recent command entries. |
| "only when editing files matching X" | active_when.file_scope = "<regex>" (typed axis) |
event.tool_input.file_path |
AND-ed with rule.patterns; an extra path filter beyond tool_match. |
| "only on Codex" / "only on Claude" / model-overlay file home | active_when.agent_source = "<runner>" or active_when.overlay = "<runner>" (typed axes) |
event.agent_source |
Use overlay when the source is a model-overlays/<runner>.md file; use agent_source when prose explicitly names the runner. Both resolve identically in v1. |
| "always run tests before " | predicate: { name: "tests_passed_recently", args: { window_steps: N } } |
test_runs.last_pass.at_step vs tool_call_count |
N defaults to 5. Action MUST be "ask". |
| "after running tests" | predicate: { name: "tests_run_in_session" } |
test_runs.size > 0 |
Action MUST be "ask". |
predicate: { name: "tdd_state", args: { value: "red" } } |
tdd_cycles[file].state |
Harness-internal only — not emitted by the distiller. TDD enforcement is owned by the harness's native primitives (see §2d skip-list entry for TDD skills). The tdd_cycles state machine exists in the runtime, but distilled rules MUST NOT reference it — drop any imperative that would. If you need a TDD policy change, edit structural_checks.test_first_mode, not /enforce. |
|
| "before pushing" | predicate: { name: "last_command_was", args: { pattern: "^git\\s+push\\b" } } |
commands_run (last entry) |
Negate-form: trigger when X happens AND last_command was NOT a push. Action MUST be "ask". |
| "before editing, read the file" | predicate: { name: "file_read_in_session" } |
files_read set |
Trigger Edit when target file isn't in the set. Action MUST be "ask". |
| "after seeing N consecutive failures" | predicate: { name: "consecutive_failures", args: { tool, n } } |
consecutive_tool_failures |
Self-throttling rules. Action MUST be "ask". |
| "during cleanup" / "after compaction" | predicate: { name: "last_event", args: { name: "PreCompact" } } |
tool_sequence |
Phase-scoped. Action MUST be "ask". |
If a predicate isn't in this table, the imperative's "always X before Y" form must route to a Pass 2 policy.md entry per §6 ‡ + §15.1 — and the imperative's "no observable session-state primitive" gap should be recorded in the Pass 2 entry's rationale so future-you can spot which predicates would benefit from being added to the table (so the LLM-side judgment can be replaced with a deterministic predicate later).
Schema addition (§7) when a predicate is used:
"session_predicate": {
"name": "tests_passed_recently",
"args": { "window_steps": 5 }
}
The harness evaluator must short-circuit allow when the predicate is satisfied (the precondition holds) and fall through to the rule's action when it's not. Until the harness side wires this up — track via the implementation tracker — distilled rules carrying session_predicate MUST also carry action: "ask" and the predicate description in reason, so they degrade gracefully on harness builds that don't understand the field.
Step 7 — Build the distilled rule (one entry per imperative)
The distilled rule object is a real GuardRule with a source sidecar field added. The harness ignores source; the CLI uses it for lifecycle operations.
{
"id": "enforce-<group-stem>-<short-kebab-summary>",
"enabled": true,
"trigger": "PreToolUse",
"tool_match": ["Bash"],
"action": "block",
"patterns": [
{ "field": "command", "regex": "^git\\s+push\\b.*\\bmain\\b" }
],
"reason": "BLOCKED by AGENTS.md:42 — \"Never push to main without code review.\"",
"suggestion": "Open a PR and request review, then merge through the standard flow.",
"severity": "critical",
"category": "distilled-from-md",
"keywords": ["git"],
"source": {
"group_id": "local:AGENTS.md",
"group_label": "AGENTS.md",
"file": "AGENTS.md",
"lines": [42, 42],
"quote": "Never push to main without code review.",
"lexical_marker": "Never",
"marker_class": "block-direct"
},
"distilled_action_reason": "lexical 'Never' → action=block per §5a",
"confidence": 0.95
}
ID slug rule: enforce-<group-stem>-<short-kebab-summary>. Group-stem is derived from group_id (drop scheme prefix, replace //./: with -, lowercase). Summary is ≤6 words from the imperative's intent, kebab-case.
group_id schemes:
| Scheme | Format | Example |
|---|---|---|
local: |
local:<path> — repo-relative when a git root exists, otherwise CWD-relative |
local:AGENTS.md, local:.clinerules/style.md |
home: |
home:<home-relative-path> |
home:.claude/CLAUDE.md |
gh: |
gh:<owner>/<repo>/<subpath> |
gh:mattpocock/skills/tdd |
url: |
url:<host><path> |
url:example.com/foo.md |
skill: |
skill:<skill-name> (from SKILL.md frontmatter) |
skill:tdd, skill:grill-me |
Skill-sourced rules ALWAYS use the skill: scheme; they carry their physical install path in source.file but are grouped by skill-name so cross-install moves don't fragment the group.
confidence: 0.95 for clean direct-prohibition. 0.85 for positive-form. 0.7 for cases where the trigger required interpretation. Below 0.7 → downgrade to advisory.
active_when population (binding): every distilled rule with a known scope MUST carry an active_when field per the table below. Rules from project-wide imperative sources (root CLAUDE.md, AGENTS.md, etc.) omit active_when (always-on). Rules from skill bodies, model-overlay files, or path-scoped sources populate it deterministically. Population is purely structural — no LLM judgment.
| Source location | active_when populated as |
|---|---|
<path>/AGENTS.md, <path>/CLAUDE.md, <path>/AGENTS.override.md, *.local.md, .clinerules/, .cursor/rules/, .continue/rules/, .windsurf/rules/, .augment/rules/, .tabnine/, .kilocoderules |
omitted (always-on) — these are the project-wide gates |
<skill-name>/SKILL.md body (any install path) |
{ skill: "<skill-name>" } — skill-name from frontmatter |
model-overlays/<runner>.md (or any sibling persona-by-runner directory) |
{ overlay: "<runner>" } |
.github/instructions/<file>.instructions.md with frontmatter applyTo: "<glob>" |
{ file_scope: "<glob-as-regex>" } |
.cursor/rules/*.mdc with frontmatter globs: [...] |
{ file_scope: "<joined-globs-as-regex>" } |
.continue/rules/*.md with frontmatter alwaysApply: false + globs: [...] |
{ file_scope: "<joined-globs-as-regex>" } |
| Skill body imperative referencing "after running /" or "after the user invoked X" | merge { after_command: { pattern: "^/<X>\\b", window_steps: 20 } } into the existing scope |
| Skill body imperative referencing TDD state | not emitted — TDD is owned by harness primitives; rule is dropped per §2d skip list |
When multiple axes are inferred from one source (e.g., a skill body that also mentions "after /ship"), all axes are AND-ed in active_when. The distiller emits the union; confidence drops by 0.05 per inferred axis beyond the first.
applies_to_roles population (binding). A source file is role-scoped when its basename is <role>.prompt / <role>.agent.md, or its body opens with You are the <role>.. The harness AgentRole type (src/harness/types.ts) is exactly "lead" | "worker" | "subagent" | "unknown":
- If
<role>is one of those four, setapplies_to_roles: ["<role>"]on every rule distilled from that file. - If
<role>is NOT — e.g. a bespoke multi-agent cohort such as SwarmForge'sarchitect/coder/refactorer/reviewer/specifier— the harness has no axis to scope the rule, so a Pass 1 rule from one role's prompt applies to every agent. That is a correctness bug, not noise:refactorer.prompt's "Do not run mutation tests" emitted globally directly contradictsreviewer.prompt's "Run … mutation tests." Such imperatives MUST NOT be written todistilled-rules.json. Route them to Pass 2 (policy.md) with the role named intrigger_signal("…when the acting agent's role isrefactorer"); the Tier-2 gate resolves role from trajectory. If Pass 2 is unavailable (local-only mode), log toskipped.report.mdwith skip classskip-role-unmodellable. §10 conflict resolution does NOT catch this — only the prohibition carries a lexical marker, so the contradicting permission never distills and no conflict pair forms.
Schema attribution. The shape above is the Interlinked harness's GuardRule, defined at src/harness/types.ts in the interlinked-cli package. The four sidecar fields (source, distilled_action_reason, confidence, user_modified) are the only additions this skill makes; the harness ignores them at evaluation. The active_when field is a real GuardRule field (added 2026-04 alongside the active-when scoping work) — the harness reads and evaluates it at runtime via evaluator/active-when.ts. Other hook engines that want to consume the same artifact must implement matching evaluation semantics — regex on tool_input fields, the six action types (block / warn / rewrite / soft_block / ask / advisory), severity, keyword quick-reject, role scoping, exception patterns, AND active_when scope evaluation. The skill targets GuardRule because it's the most expressive open enforcement primitive today; downgrading to a less-expressive runtime (Claude Code's permissions.deny[], Cursor .mdc globs
…(truncated)