Skip to content

Latest commit

 

History

History
316 lines (227 loc) · 25.8 KB

File metadata and controls

316 lines (227 loc) · 25.8 KB

Rule Authoring Reference

Canonical authoring spec for .claude/rules/*.md frontmatter and the rule-loader's conditional-loading contract (Epic #693 FA1, issue #694). For the higher-level wave-boundary injection flow, see skills/_shared/config-reading.md § "Glob-Scoped Rule Injection (#336)" — that doc links here for the deep field reference.

Purpose & Overview

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 one scopePath matches 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).

Frontmatter Field Reference

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-classhostClass, expires-atexpiresAt, learning-keylearningKey, auto-generatedautoGenerated. (tier is already a single lowercase token, so it is surfaced unchanged as tier.)

Gating Semantics

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 intersect scopePaths. globs: [] → matches nothing, never loaded.
  • Mode-gating — when the mode runtime param is non-null and the rule declares a mode that differs, the rule is EXCLUDED. A null mode param disables mode filtering entirely. A rule without a mode key always passes the mode gate.
  • Host-class-gating — identical logic against the hostClass runtime param vs the rule's host-class value. Null param = no filtering; absent rule key = passes.
  • Expiry — a rule with a parseable expires-at strictly before now is EXCLUDED, with a mandatory stderr WARN. A rule without expires-at, or with a malformed expires-at, is not excluded.
  • Tier-gating (#692) — gated on the context runtime param (not a host/mode param). context: 'wave' excludes tier: coordinator-only rules; context: 'coordinator' excludes tier: wave-only rules; context: null (the default) disables tier filtering entirely. A rule without a tier key 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 gating (issue #692)

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 context parameter to loadApplicableRules (and the --context <c> flag on scripts/print-applicable-rules.mjs).
  • context: 'wave' → excludes rules with tier: coordinator-only.
  • context: 'coordinator' → excludes rules with tier: wave-only.
  • context: null (the default) → no tier gating whatsoever; all tiers load regardless of their tier: value. This is the backward-compatible path — every existing caller that does not pass context is 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.

Byte-for-byte always-on guarantee

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.

Fail-open on parse error

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.

Vendored Rules (issue #722 Epic A)

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.

Provenance header + frontmatter coexistence (issue #722)

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.

Vendoring validation (issue #722)

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).

Archetype-scoped manifest tags (issue #722 Epic A Wave 3)

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.

The Never-Always-On Invariant (Auto-Generated Rules)

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:

  1. Carry at least one activation axisglobs, mode, or host-class. It must NOT be always-on.
  2. Carry a learning-key (provenance — which learning produced it).
  3. 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.

Learning Type-Taxonomy, TTL & Provenance Standard (issue #723 B6 / #733)

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.

The type-taxonomy + per-type TTL registry (single source of truth)

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 in PROPOSAL_TYPES (a wave-agent may memory.propose() this type). autonomy-verdict, fragile-pattern, and stagnation-class-frequency are false — these are analyzer-synthesized classes, not agent-observed, so they are never agent-proposable.
  • ruleConvertible — the type may appear in CONVERT_TYPES (the FA2 reconciliation engine may convert a learning of this type into a conditional .claude/rules/*.md proposal). fragile-file, recurring-issue, anti-pattern, convention, architecture-pattern, design-pattern, fragile-pattern, and stagnation-class-frequency are the eight ruleConvertible: true types.

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).

The auto-generated rule ## Provenance section format

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.

Authoring Examples

(a) Hand-authored always-on rule (no frontmatter)

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.

(b) Glob-scoped rule (block-style globs:)

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"].

(b2) Tier-tagged rules (issue #692)

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 Posture

A 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)

(c) Auto-generated conditional rule (full key set)

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.

See Also