Canonical authoring spec for
.claude/rules/*.mdfrontmatter and the rule-loader's conditional-loading contract (Epic #693 FA1, issue #694). For the higher-level wave-boundary injection flow, seeskills/_shared/config-reading.md§ "Glob-Scoped Rule Injection (#336)" — that doc links here for the deep field reference.
Files under .claude/rules/*.md are engineering rules injected into agent prompts. The loader — loadApplicableRules() in scripts/lib/rule-loader.mjs — reads every *.md file in the rules directory, parses its optional YAML frontmatter, and returns the subset applicable to a given wave. The wave-executor calls it at each wave boundary with the wave's allowedPaths (from wave-scope.json) as scopePaths, so a wave that touches only frontend files does not pay the token cost of backend or Swift rules.
Two rule categories existed before FA1:
- Always-on — no frontmatter (or no
globs:key). Loaded for every wave regardless of scope. The cross-cutting baseline (e.g.security.md,development.md,parallel-sessions.md). - Glob-scoped (#336) — a
globs:frontmatter array. Loaded only when at least onescopePathmatches at least one glob (e.g.frontend.md,testing.md,backend.md).
FA1 (issue #694) extends the frontmatter parser to capture additional conditional activation axes on each rule entry and to apply deterministic gating after a successful parse. The new axes are session-mode, host-class, and expiry, plus metadata keys (learning-key, auto-generated, confidence, description, alwaysApply) that future waves (FA2 reconciliation, FA4 validation) consume. The glob-scoping contract is unchanged; the new gates compose with it.
A third category, added by issue #722 Epic A: vendored rules — files sourced from this repo's rules/ library and copied into a consumer repo's .claude/rules/ via /bootstrap --sync-rules. Vendored rules carry a mandatory provenance header (see Provenance header + frontmatter coexistence below) and are validated before every write (see Vendoring validation below).
All keys are optional. Unknown keys are ignored without error — adding a new key never breaks the loader. Frontmatter is the standard ----delimited YAML block at the top of the file.
| Field | Type | Required | Meaning | Example |
|---|---|---|---|---|
globs |
string[] (block or flow style) |
no | Glob patterns relative to repo root. Rule loads only when a scopePath matches at least one. Absent = always-on; [] = matches nothing (disabled). |
globs: then - src/**/*.tsx |
description |
string |
no | Human-readable summary of what the rule covers. Surfaced on the rule entry; used by FA2/FA4 tooling and authors. | description: Tailwind + a11y conventions |
mode |
string (housekeeping | feature | deep) |
no | Session-mode gate. Rule loads only in the named session mode. Absent = passes every mode. | mode: deep |
host-class |
string |
no | Host-class gate. Matched against host_class in .orchestrator/host.json. Rule loads only on matching hosts. Absent = passes every host. |
host-class: macos-arm64-m4pro |
alwaysApply |
boolean |
no | Author intent flag (distinct from the loader's internal alwaysOn "no globs" computation). Used by FA4 validation, not by gating. |
alwaysApply: false |
expires-at |
string (ISO 8601 date) |
no | Expiry gate. After this date the rule is EXCLUDED with a stderr WARN. A malformed value never excludes (fail-open). | expires-at: 2026-12-31 |
learning-key |
string |
no | Links the rule to a learnings.jsonl entry (type/subject key). Required on auto-generated rules. |
learning-key: testing/shard-dont-widen |
auto-generated |
boolean |
no | Marks a rule produced by the FA2 reconciliation engine (not hand-authored). Triggers the never-always-on invariant (see below). | auto-generated: true |
confidence |
number (0..1) |
no | Confidence of the source learning that generated the rule. Mirrors the learnings.jsonl confidence field. |
confidence: 0.85 |
tier |
string (always | coordinator-only | wave-only) |
no | Load-context tier (issue #692). Gates which contexts the rule loads in, via the context param to loadApplicableRules. Absent = no tier gating (backward-compatible). See Tier gating below. |
tier: coordinator-only |
Surfaced names. The loader normalises kebab-case YAML keys to camelCase on the rule entry: host-class → hostClass, expires-at → expiresAt, learning-key → learningKey, auto-generated → autoGenerated. (tier is already a single lowercase token, so it is surfaced unchanged as tier.)
After a rule's frontmatter parses successfully, the loader applies deterministic gates. A rule is included only if it clears ALL active gates (an AND across axes). Gating runs on both always-on and glob-matched candidates.
Per-axis rules:
- Glob axis (#336) —
globs:absent → always-on (passes).globs:present and non-empty → must intersectscopePaths.globs: []→ matches nothing, never loaded. - Mode-gating — when the
moderuntime param is non-null and the rule declares amodethat differs, the rule is EXCLUDED. A nullmodeparam disables mode filtering entirely. A rule without amodekey always passes the mode gate. - Host-class-gating — identical logic against the
hostClassruntime param vs the rule'shost-classvalue. Null param = no filtering; absent rule key = passes. - Expiry — a rule with a parseable
expires-atstrictly beforenowis EXCLUDED, with a mandatory stderr WARN. A rule withoutexpires-at, or with a malformedexpires-at, is not excluded. - Tier-gating (#692) — gated on the
contextruntime param (not a host/mode param).context: 'wave'excludestier: coordinator-onlyrules;context: 'coordinator'excludestier: wave-onlyrules;context: null(the default) disables tier filtering entirely. A rule without atierkey passes the tier gate in every context. See Tier gating below.
The activation logic in one sentence: a null/absent gate parameter performs no filtering on that axis, and a rule that lacks a given gate key passes that gate unconditionally — so a rule with no frontmatter clears every gate and is always-on, byte-for-byte.
tier: is an optional frontmatter scalar that adds a load-context dimension on top of the existing axes. It answers the question the #668 instruction-budget audit raised (Follow-up 2, "Tier rules by load-context"): a rule is no longer only binary always-on / glob-scoped — it can also declare which contexts it belongs in.
Valid values (exactly three; anything else is treated as "no recognised tier" and the gate does not fire):
| Value | Meaning | Examples (this repo) |
|---|---|---|
always |
Behavioural rule needed in every context — both the coordinator and wave implementation agents. Never excluded by tier gating. | ask-via-tool, verification-before-completion, parallel-sessions, security, receiving-review, development, quality-gates-autofix |
coordinator-only |
Operator/coordinator-context rule not needed by wave implementation agents. Excluded when context: 'wave'. |
owner-persona, lsp, mvp-scope, loop-and-monitor |
wave-only |
Path-scoped implementation rule (the files that also carry globs:). Excluded when context: 'coordinator'. |
backend, backend-data, cli-design, frontend, prompt-caching, security-web, swift, testing |
How gating fires (applyGates in rule-loader.mjs):
- The gate is driven by the
contextparameter toloadApplicableRules(and the--context <c>flag onscripts/print-applicable-rules.mjs). context: 'wave'→ excludes rules withtier: coordinator-only.context: 'coordinator'→ excludes rules withtier: wave-only.context: null(the default) → no tier gating whatsoever; all tiers load regardless of theirtier:value. This is the backward-compatible path — every existing caller that does not passcontextis unaffected.- A rule with no
tier:key is never excluded by the tier gate, in any context.
tier: is orthogonal to globs:. A wave-only rule still uses its globs: patterns for path-scoping within a wave; the tier key only adds the coordinator-vs-wave context dimension on top. The two compose: a wave-only rule must clear both the glob axis (its globs intersect the wave's scopePaths) and the tier axis (the context is not coordinator).
Budget-neutral. tier: is advisory metadata for the per-wave rule-injection surface. It does not change the always-on directive count measured by the instruction-budget guard — that guard skips frontmatter, so adding a tier: line never moves the count. tier: is parsed identically to the other scalar activation keys (#694): it lives in the same SCALAR_META_KEYS set, is quote-stripped, and is surfaced on the rule entry as tier.
A rule file with no frontmatter (or no recognised activation keys) is loaded exactly as it is today: full content, every wave, every mode, every host. FA1 adds no behavioural change for the 11 existing always-on rules. The new keys are purely additive.
Degraded loading is always preferable to silently missing a security or architecture constraint:
- Malformed frontmatter → the rule is treated as always-on and a WARN is written to stderr. A rule is never silently dropped.
- Malformed
expires-at→ the expiry gate does not exclude the rule (fail-open); the rule continues to load.
Rules sourced from this repo's rules/ library (rules/always-on/*.md, rules/opt-in-stack/*.md, and rules/opt-in-domain/*.md) and copied into a consumer repo's .claude/rules/ via /bootstrap --sync-rules are a third rule category, alongside hand-authored and FA2 auto-generated rules. The sync pipeline (scripts/lib/rules-sync.mjs) has its own authoring contract, documented here.
Since issue #743, rules/opt-in-stack/{backend,backend-data,frontend,swift,security-web}.md and rules/opt-in-domain/prompt-caching.md are the live worked example of the provenance-header + frontmatter shape described below — they were lifted verbatim (content unchanged) out of this repo's own .claude/rules/, which had been carrying them as dead exemplar content never vendored anywhere.
Vendored rule sources carry a mandatory single-line provenance header before any frontmatter block — rules-sync.mjs uses that header (PLUGIN_HEADER_PREFIX = '<!-- source: session-orchestrator plugin ...') to tell "plugin-owned, safe to overwrite on re-sync" apart from "local override, preserve". The recommended shape for a vendored rule with globs: frontmatter:
<!-- source: session-orchestrator plugin (canonical: rules/opt-in-stack/foo.md) -->
---
globs:
- src/**/*.tsx
---
# Foo Rules (Path-scoped)rule-loader.mjs's frontmatter parser (parseGlobsFrontmatter) tolerates a leading run of blank lines and/or single-line HTML comments before the opening ---, so a vendored rule's provenance header does not defeat its globs: scoping — the header line is skipped, then frontmatter parses exactly as it would without the header. This tolerance is header-agnostic (it accepts any single-line HTML comment, not only the plugin's own), so a hand-authored rule that happens to start with a one-line comment is unaffected.
Before syncRules() writes a source file into a consumer repo's .claude/rules/, it runs a pre-write gate via validateRuleContent() (scripts/lib/validate-vendored-rules.mjs). Five probes:
| Probe | Severity | Rejects / flags |
|---|---|---|
paths-frontmatter |
error | A top-level paths: frontmatter key — rule-loader.mjs only recognises globs:; a paths: key is silently ignored and the rule loads always-on instead of the intended glob-scoped subset. |
provenance-header |
error (opt-in via requireProvenance, default true in syncRules()) |
Missing provenance header on a library source — without it, rules-sync.mjs mis-detects the file as a local override on the next re-sync and can never update it again. |
placeholder |
error | Unfilled placeholder tokens: {{PROJECT_NAME}}-style handlebars, a ## TODO: Customize heading, or a <!-- TODO: comment — skeleton content, not a finished rule. |
zero-match-globs |
warn | A globs: pattern matching 0 files in the target repo's tracked file list (git ls-files, falling back to a directory walk). Legitimately possible in a freshly-scaffolded repo. |
foreign-glob |
warn | A glob segment carrying a PascalCase, product-like token (regex [A-Z][a-z]+[A-Z], e.g. WalkAITalkieTests) — a likely copy-paste leftover from another project's rule scope. |
Error-severity violations skip the write for that file (recorded in syncRules()'s errors[]); warn-severity violations do not block the write and are recorded additively in warnings[]. The standalone CLI (node scripts/lib/validate-vendored-rules.mjs --dir <rulesDir> [--target-root <repo>] [--require-provenance] [--json] [--mode hard|warn]) exits 0 (no errors, or errors under --mode warn), 1 (errors present under --mode hard), or 2 (invocation error).
rules/_index.md entries may carry an optional trailing [archetypes: a, b] tag:
- `opt-in-stack/foo.md` — description [archetypes: nextjs-minimal, node-minimal]Absent tag = universal (vendored to every consumer repo, the default and fully backward compatible). Present tag = scoped — vendored only when the consumer repo's resolved archetype matches one of the listed values (case-insensitive). Archetype resolution precedence: explicit archetype argument (CLI --archetype) > <repoRoot>/.orchestrator/bootstrap.lock's archetype: line > unknown. A mismatch or an unresolvable target archetype records a skipped[] entry with reason archetype-mismatch or archetype-unknown respectively — never a hard error. See rules/_index.md § Entry syntax for the full archetype value list.
Forward-reference to FA4 (issue #697). The CI gate described here lands with FA4 in
scripts/lib/validate/check-rules.mjs; FA1 documents the contract that gate will enforce.
Hand-authored rules are the curated, cross-cutting baseline. Auto-generated rules are extra rules — narrow, learning-derived, and time-boxed. They must never inflate the always-on instruction budget (cross-ref #668 instruction-budget). The brandmauer (firewall) is:
Any rule with auto-generated: true MUST:
- Carry at least one activation axis —
globs,mode, orhost-class. It must NOT be always-on. - Carry a
learning-key(provenance — which learning produced it). - Carry an
expires-at(time-box — auto-generated rules are not permanent).
A rule that sets auto-generated: true but lacks an activation axis, or omits learning-key / expires-at, is a violation. The FA4 CI gate (scripts/lib/validate/check-rules.mjs) will fail the build on such a rule. This keeps every machine-authored rule conditional and self-expiring — the always-on surface stays the hand-curated baseline.
The learning-key field above links a rule to a learning record, but does not by itself define how long that learning (and any rule generated from it) stays alive, or which learning types are even eligible for agent-proposal or rule-conversion. Those two axes — per-type TTL and per-type capability — are governed by a single registry, and the resulting auto-generated ## Provenance section format is a distinct, richer artifact from the single-line vendored-rule provenance header documented above. This section names both as the standard.
LEARNING_TYPE_REGISTRY in scripts/lib/learnings/schema.mjs (~L80–107) is the single source of truth for every learning type's TTL policy and its two cross-module capability flags. Before this registry existed (pre-#733), three modules independently hand-maintained overlapping type lists that drifted out of sync. LEARNING_TTL_DAYS (this file), PROPOSAL_TYPES (scripts/lib/memory-proposals/schema.mjs), and CONVERT_TYPES (scripts/lib/reconcile/eligibility.mjs) are now all derived from this one registry — no hand-maintained duplicate lists remain.
Transcribed verbatim from LEARNING_TYPE_REGISTRY (16 types):
| Type | ttlDays | agentProposable | ruleConvertible |
|---|---|---|---|
mode-selector-accuracy |
30 | true | false |
hardware-pattern |
60 | true | false |
fragile-file |
45 | true | true |
effective-sizing |
45 | true | false |
recurring-issue |
45 | true | true |
workflow-pattern |
90 | true | false |
proven-pattern |
90 | true | false |
anti-pattern |
90 | true | true |
autopilot-effectiveness |
90 | true | false |
autonomy-verdict |
90 | false | false |
domain-regression |
60 | true | false |
convention |
90 | true | true |
architecture-pattern |
90 | true | true |
design-pattern |
90 | true | true |
fragile-pattern |
45 | false | true |
stagnation-class-frequency |
60 | false | true |
Capability axes:
agentProposable— the type may appear inPROPOSAL_TYPES(a wave-agent maymemory.propose()this type).autonomy-verdict,fragile-pattern, andstagnation-class-frequencyarefalse— these are analyzer-synthesized classes, not agent-observed, so they are never agent-proposable.ruleConvertible— the type may appear inCONVERT_TYPES(the FA2 reconciliation engine may convert a learning of this type into a conditional.claude/rules/*.mdproposal).fragile-file,recurring-issue,anti-pattern,convention,architecture-pattern,design-pattern,fragile-pattern, andstagnation-class-frequencyare the eightruleConvertible: truetypes.
LEARNING_TTL_DAYS[type] derives its value from LEARNING_TYPE_REGISTRY[type].ttlDays for every listed type, plus a default: 60 fallback entry for any type not present in the registry (deriveExpiresAt() looks up LEARNING_TTL_DAYS[type] ?? LEARNING_TTL_DAYS.default).
This is a distinct artifact from the single-line HTML-comment provenance header documented above under Provenance header + frontmatter coexistence — that header marks a vendored rule sourced from this repo's rules/ library; the ## Provenance section below marks a rule generated by the FA2 reconciliation engine from a learning record. A rule file can only ever carry one of the two, never both.
The reconciliation engine emits a body section (not frontmatter) with this exact shape, immediately preceded by a do-not-hand-edit HTML comment:
<!-- provenance (auto-generated by the reconciliation engine — do not hand-edit) -->
## Provenance
- learning-key: `<type>/<subject-slug>`
- learning-id: `<learning UUID>`
- source-session: `<source_session slug, e.g. main-2026-07-03-session-1>`
- confidence: <learning confidence, 0..1>
- generated-by: reconciliation-engine (Epic #693 FA2 / #695)
- expires-at: <ISO 8601 date>Field-by-field:
| Field | Source | Notes |
|---|---|---|
learning-key |
the learning's type/subject composite key |
Duplicated from the frontmatter learning-key field — the body section is the human-readable rendering, frontmatter is what rule-loader.mjs and claude-md-drift-check Check 8 parse. |
learning-id |
the learning record's id (UUID v4) |
Uniquely identifies the exact learning record, distinct from the type/subject key which is not guaranteed unique across sessions. |
source-session |
the learning's source_session field |
Session-slug based, not issue-number based — see the callout below. |
confidence |
the learning's confidence field |
Mirrors frontmatter confidence. |
generated-by |
fixed literal | Always reconciliation-engine (Epic #693 FA2 / #695) — identifies the producing subsystem, not a per-rule variable. |
expires-at |
the derived/floored expiry (see reconcile.rule-expiry-days / min-rule-days in docs/session-config-reference.md) |
Mirrors frontmatter expires-at. |
Provenance is session-slug based, not issue-number based. The only session-identity field the schema carries is source_session (a kebab-slug like main-2026-07-03-session-1) — there is currently no issue-number provenance field on a learning record or a generated rule. Adding issue-number provenance (linking a rule back to the GitHub/GitLab issue that motivated the learning) would require a schema addition to scripts/lib/learnings/schema.mjs — out of scope for this documentation pass.
The default for cross-cutting baseline rules. No frontmatter at all — loads every wave.
# Security Rules (Always-on)
Core security principles that apply to ALL code.
## SEC-004: Auth-at-Boundary
- Every server action MUST authenticate first.Loads only on waves whose allowedPaths intersect the patterns. Mirrors the real frontend.md:
---
globs:
- src/**/*.tsx
- src/**/*.css
- src/**/*.module.css
- "**/components/**/*.{ts,tsx}"
---
# Frontend Rules (Path-scoped)
## React & Next.js
- Use Server Components by default.Flow-style is equivalent: globs: ["src/**/*.tsx", "src/**/*.css"].
A coordinator-only rule — informational posture the coordinator needs but wave implementation agents do not (mirrors the real lsp.md):
---
tier: coordinator-only
---
# Language-Server / LSP PostureA wave-only rule pairs tier with globs: — the tier scopes it out of the coordinator context, the globs scope it within a wave (mirrors the real testing.md):
---
globs:
- "**/*.test.*"
- vitest.config.*
tier: wave-only
---
# Testing Rules (Path-scoped)An always rule is behaviour-critical in both contexts and is never excluded by tier gating (mirrors the real verification-before-completion.md):
---
tier: always
---
# Verification Before Completion (Always-on)Produced by the FA2 reconciliation engine from a high-confidence learning. Carries the complete activation + provenance + time-box set, and is explicitly not always-on:
---
auto-generated: true
alwaysApply: false
description: Shard a contention-bound test suite; never widen the global timeout.
globs:
- "**/*.test.*"
- .gitlab-ci.yml
- vitest.config.*
learning-key: testing/shard-dont-widen-timeout
expires-at: 2026-12-31
confidence: 0.85
---
# Auto-generated: shard, don't widen
When a CI test suite times out under runner contention, split it with
`parallel:` + `--shard` rather than raising `testTimeout`. Widening the
timeout masks real perf regressions.skills/_shared/config-reading.md§ "Glob-Scoped Rule Injection (#336)" — wave-boundary injection flow + match algorithmscripts/lib/rule-loader.mjs—loadApplicableRules()implementation (the contract this doc specifies)scripts/lib/validate/check-rules.mjs— FA4 CI validation gate (forward-reference; lands with #697)scripts/print-applicable-rules.mjs—--context wave|coordinatorflag exercises the tier gate (#692)scripts/lib/validate-vendored-rules.mjs— pre-write vendoring validator (issue #722 Epic A Wave 2)scripts/lib/rules-sync.mjs—syncRules()implementation, archetype resolution (issue #722 Epic A)rules/_index.md— canonical manifest,[archetypes: ...]tag syntaxskills/claude-md-drift-check/SKILL.md— Check 9rule-scopingvalidates this frontmatter contract post-vendoringscripts/lib/learnings/schema.mjs—LEARNING_TYPE_REGISTRY/LEARNING_TTL_DAYS(SSOT for the type-taxonomy + TTL table above)scripts/lib/reconcile/eligibility.mjs—CONVERT_TYPES(derived fromLEARNING_TYPE_REGISTRY), rule-conversion eligibility gatesscripts/lib/memory-proposals/schema.mjs—PROPOSAL_TYPES(derived fromLEARNING_TYPE_REGISTRY)docs/session-config-reference.md§ Reconcile —reconcile.rule-expiry-days/min-rule-days/min-insight-charsconfig keys that tune the emittedexpires-atand eligibility gates- Issues: #336 (glob-scoping), #668 (instruction-budget), #692 (tier load-context gating), #693 (Rule Activation epic), #694 (FA1 foundation), #697 (FA4 validation), #722 (vendoring validation + archetype-scoped manifest), #723 B6 / #733 (type-taxonomy + provenance standard)