rule-architect
Generate an advanced, runtime-neutral rule set: one slim AI_RULES.md index + on-demand docs/*.md rule files. Runtime-specific entry files both resolve to the same neutral index.
Output Language
Generated rule files follow the project, not this document:
- Existing
AI_RULES.md/ legacy rule index /docs/*.md/READMEin the project → match their language. - No existing docs → match the language the user is writing in.
- Still ambiguous → English.
Apply one language consistently across AI_RULES.md, portable entry pointers, and every docs/*.md in the same run —
a half-English, half-Korean rule set is a defect. Structural tokens stay fixed regardless of language:
MUST / NEVER / PREFER, why:, the ❌/✅ markers, UPPERCASE file names, and the
<!-- generated by rule-architect --> marker. Report to the user in their language.
Core principle: rules record only what code cannot tell you. An agent can run ls and read requirements.txt by itself; it cannot guess that a model must be registered in two places, or that env must load before config import. Always-loaded context (AI_RULES.md) stays minimal; detail lives in docs/ and loads only when the topic comes up.
Output Contract
The deliverable is EXACTLY this shape. Not one big file. Not unlinked docs.
AI_RULES.md (≤60 lines) — runtime-neutral index
In order:
- Project one-liner (what it is, 1–2 lines).
- Core Rules — ≤10 bullets. Only top non-derivable constraints (see §Non-Derivable Filter).
- Run commands — entrypoints, test, migration, in one fenced block.
- Routing table —
| Trigger (situation/task) | File |rows linking every generateddocs/*.md. Every row carries a concrete trigger, e.g.| editing under Controller/ | docs/CONTROLLER_RULES.md |,| adding a new API | docs/tasks/ADD_API.md |. Topic-only rows without a trigger are forbidden. - File Layout — where tests/scripts/docs go;
UPPERCASE.md= AI/team rules,lowercase.md= analysis/reference.
Runtime entry files — one neutral source
Preferred mode on Linux/WSL/POSIX projects whose Git checkout preserves symlinks:
CLAUDE.md -> AI_RULES.md
AGENTS.md -> AI_RULES.md
Both must be relative symlinks in the project root. Never make AI_RULES.md itself a symlink.
The neutral index body must not name or privilege a runtime; filenames appear only at the loader boundary.
Portable fallback when the repository must work on Windows without symlink support, Git reports
core.symlinks=false, or the user explicitly requests regular files: create BOTH entry files as
short pointers (≤15 lines) to AI_RULES.md. They must not contain rule bodies or point at each other:
# Project AI Rules
Read [AI_RULES.md](AI_RULES.md) first, then load only the relevant `docs/` file it links.
Do not mix modes. A symlink plus a regular pointer is drift-prone and fails verification.
AI_RULES.md is the single source of truth in both modes.
Projects with an intentional custom loader name may keep that loader name, but not a custom neutral
index name. For example, a personal Claude entry may use CLAUDE.local.md -> AI_RULES.md alongside
AGENTS.md -> AI_RULES.md. A custom docs_local/ directory does not justify AI_RULES.local.md;
the neutral source remains AI_RULES.md.
docs/*.md (each ≤150 lines) — on-demand rule docs
Always generate:
| File | Content |
|---|---|
ARCHITECTURE.md |
directory map, stack list, data flow, subsystem roles |
CODING_RULES.md |
style, naming, comment language, required patterns — each with a real code example |
PITFALLS.md |
symptom→cause→fix entries. Symptom: quotes the error message/behavior verbatim (greppable), Cause: one line, Fix: links a rule or playbook step. Covers dual naming, ongoing migrations, runtime drift, version-pinning policies |
Conditional — generate only when the scan detects the observable signal:
| Signal detected (observable predicate) | File |
|---|---|
| A dedicated controller/handler layer directory exists | CONTROLLER_RULES.md |
| ≥3 enum/status-code value sets scattered across files (aggregation pays off) | ENUM_CODES.md |
| An API layer with response-shape conventions (key naming, envelope, pagination) | RESPONSE_KEYS.md |
| ORM models or a migrations/ directory | DB_RULES.md |
| deploy scripts, systemd units, or Dockerfiles | DEPLOY.md |
| git log shows the same file set modified together ≥3 times (recurring task) | tasks/ADD_<TASK>.md |
Conditional file names are defaults — rename to fit the project (e.g. API_RULES.md instead of RESPONSE_KEYS.md) as long as the name stays UPPERCASE and appears in the Routing table. Borderline signal → skip the doc; a rule can live in CODING_RULES.md until it earns its own file.
docs/tasks/*.md (each ≤80 lines) — task playbooks
Numbered step-by-step procedure for ONE recurring task. Every step cites file:line evidence. Cross-file couplings ("model registered in BOTH models/__init__.py AND migrations/env.py") live as playbook steps, not standalone rules. Name pattern: docs/tasks/ADD_<TASK>.md (UPPERCASE).
Cross-link invariant: every file AI_RULES.md links exists, and every generated docs/**/UPPERCASE.md appears in the Routing table.
Generation marker: the last line of a generated AI_RULES.md is <!-- generated by rule-architect -->. Its presence is how §Update Mode is detected on a later run.
Bundled Scripts
All are stdlib-only Python and live in scripts/. Resolve them relative to this
SKILL.md; do not assume the current working directory or a runtime-specific home.
| Script | Role |
|---|---|
scan.py <root> |
measures the observable signals and prints a JSON manifest — the reproducible half of step 1 |
harvest.py <root> |
mines past session transcripts for the user's own corrections — the rules a cold scan cannot see |
manifest.py record[--replace]|check <root> |
records file hashes plus symlink targets; check refuses to overwrite hand edits or changed entry types. --replace re-baselines files changed outside rule-architect — requires explicit user confirmation, see §Update Mode |
verify_rules.py <root> |
the form gate. Strict by default; --lenient demotes target overruns to warnings |
quiz.py scaffold|grade <root> |
builds the isolation prompt and grades a run. It never executes a model |
hookgen.py emit|check <root> |
compiles machine-checkable rules into a working PreToolUse guard (opt-in, see §Hook Promotion) |
rule_guard.py |
not run directly — the generic guard hookgen.py copies into a project |
Procedure
- Scan and harvest — run
python3 scripts/scan.py <root>FIRST and keep its JSON manifest. It is the evidence for every conditional-doc decision, and itstruncatedflags say whether the scan was complete. Then runpython3 scripts/harvest.py <root>and keep that report too (see §Harvested Rules). Then dispatch parallel explore agents for what a script cannot measure:- (a) naming/comment/style conventions, (b) infra patterns (config, DB, logging, DI),
(c)
git logfor migrations/renames/drift, (d) cross-file couplings and required ordering. brokenRuleLinksis non-empty → stop and report the broken entry files before any write.existingRuleFilesin the manifest is non-empty → runmanifest.py checkand switch to §Update Mode. Persona/fluff-only files → ignore, note in report.
- (a) naming/comment/style conventions, (b) infra patterns (config, DB, logging, DI),
(c)
- Select doc set — take the
decisionsarray from the scan manifest. A doc whosemetis false is not generated. Overriding a decision is allowed but must be stated in the report with the reason; silently adding a doc the signals do not support is a defect. Select shared symlink mode unless the repository has a portable-fallback condition from §Runtime entry files, and report the selected mode. - Write docs/*.md — each rule cites real project evidence. Default form: a
file.py:42reference. Use an inline snippet only when a reference alone cannot show the pattern (ordering, multi-file coupling, before/after). This keeps docs inside the 150-line budget. Apply §Rule Format and §Non-Derivable Filter. - Write the neutral index and entries — on
--update, normalize any legacy runtime-owned index toAI_RULES.mdfirst as required by §Update Mode. WriteAI_RULES.mdafter docs exist, then create both runtime entry files in the selected mode. Never overwrite a conflicting entry. - Record —
python3 scripts/manifest.py record <root> AI_RULES.md CLAUDE.md AGENTS.md docs/...for every file this run wrote. It merges into the existing manifest, so recording two files on an update run does not erase the other four; pass--replaceonly when the list IS the complete generated set. Skipping this step makes the next update run unable to tell your output from a human's edits. - Verify — run the script gate, then the quiz gate (§Verify). Fix and re-run until both pass.
Rule Format
Every rule in a docs file follows this shape:
- **[MUST|NEVER|PREFER]** <rule, one line>
- why: <one line>
- ❌ <real violation from THIS project — file:line or snippet>
- ✅ <correct form — real project code>
Conflict priority: NEVER > MUST > PREFER. A rule without a why, or without the ❌/✅ pair, is unfinished. If no real violation exists in the project, fill the ✅ with real code and drop the ❌ line — never invent a fake violation.
Non-Derivable Filter
Before recording a fact, ask: "would one ls, one file read, or one grep reveal this?"
- Reveals it → belongs in
docs/ARCHITECTURE.mdat most. NEVER in AI_RULES.md Core Rules. - Doesn't reveal it → record it. High-value categories:
- cross-file couplings ("new model must be added to
models/__init__.pyANDmigrations/env.py") - required ordering ("call
load_project_env()BEFORE importing config") - naming prefixes/conventions with the WHY ("tables use
AIT_prefix") - tempting-but-forbidden acts ("never
create_async_enginedirectly — reuseget_engine()") - ongoing migrations / dual naming ("
Game↔Projectrename in progress; check which is canonical") - external/runtime facts (server drift, API alias policy, ops-only env files)
- cross-file couplings ("new model must be added to
Harvested Rules
A cold scan knows what the project contains. It cannot know what the agent kept getting wrong here.
harvest.py reads past session transcripts for this project and prints the user's own corrections —
recency-ordered, credential-redacted, with a repeatedTerms table of the words that recur across
different corrections. Corrections are the highest-yield rule source there is: someone already paid
for each one.
The script measures; the promotion decision is yours. A candidate becomes a rule only when ALL of these hold:
- repeats — ≥2 separate corrections point the same way (
repeatedTermsis the first place to look). One frustrated message is not a rule. - recent — an old correction may describe code that no longer exists. Prefer the newest.
- this project — the report is already scoped to the project path; do not import a rule from elsewhere.
- agrees with the source — verify against the current code before writing it down. A correction the code has since absorbed is not a rule, it is history.
Then it passes §Non-Derivable Filter like any other rule, cites file:line evidence like any other
rule, and lands in the doc its scope belongs to. No harvest.py output goes into a rule file verbatim —
the correction is the lead, the code is the evidence. If nothing clears the bar, say so in the report;
an empty harvest is a normal outcome for a young project.
Verify
- Script —
python3 scripts/verify_rules.py <project-root>. When the project uses a different index, entry, or rule directory, pass them — e.g. a personal rule set:verify_rules.py <root> --index AI_RULES.md --docs-dir docs_local --entries CLAUDE.local.md,AGENTS.md. Without those flags the script looks forAI_RULES.md+ both default entries +docs/and stops when one is missing. Strict by default: exceeding a target budget fails, not warns.--lenientdemotes target overruns to warnings; hard limits fail in both modes. Never reach for--lenientto get a green run — it exists for a project that has consciously accepted a bigger budget, and the report must say so. Enforces:- link integrity both directions, and the required docs (
ARCHITECTURE.md,CODING_RULES.md,PITFALLS.md) exist and are linked - AI_RULES.md ≤60 lines (hard fail >80), docs ≤150 (hard >190), playbooks ≤80 (hard >100)
- Core Rules ≤10 bullets; routing rows carry a trigger that is neither empty nor a restatement of the file name
- every graded rule (
MUST/NEVER/PREFER) carries awhy:line and a ✅ example. A rule that delegates to another doc by link instead of restating it is exempt — demandingwhy:/✅ there would recreate the duplication you just removed - no
TBD/TODO/placeholder; UPPERCASE naming in docs links - evidence freshness — every backticked citation must resolve: a path with a slash, an
explicit
:line, a bare build file (Dockerfile,Makefile), or a dotfile. A:42cite also fails when the file is shorter than 42 lines, which is how a rule that survived a refactor gets caught. Bare naming patterns (UPPERCASE.md) are not citations and are left alone, and neither are git refs (origin/main,HEAD), home paths (~/.ssh), or globs (docs/*.md) — they contain a slash but are not project files, and flagging them buries the real findings. - Exit 0 = pass.
--jsonprints the same verdict as a machine-readable object.
- link integrity both directions, and the required docs (
- Quiz test — the only content-quality gate, and the one that must leave a record.
python3 scripts/quiz.py scaffold <root> --lang <ko|en> --run-id <id>prints the isolation prompt, the required question mix, the exact rule files the subagent may see, and the result schema. For a custom layout, also pass--index <name> --docs-dir <name>.- You dispatch the fresh subagent with ONLY those files and no source access — the script cannot spawn one, and pretending it can would turn the gate into theatre. Ask the questions in the language the rules are written in.
- Fixed 5-question mix: 3 recall ("Where must a new DB model be registered?"), 1 judgment ("I want to do X — is approach Y allowed?", which measures application rather than recall), and 1 negative — something the rules do NOT cover, which passes only if the agent answers "not in the rules, check the source" instead of inventing one.
- Fill
expectedandcorrectper question, thenpython3 scripts/quiz.py grade <root> --run-id <id> --results <file>. It enforces the mix and the pass rule (≥4/5 correct AND the negative passed), rejects a results file that omitsrunIdorlang, names another run, or has questions with noexpectedanswer, and archives the run under.rule-architect/quiz/<id>.json. Use a fresh id per attempt — it refuses to overwrite a recorded run rather than quietly replacing the evidence. - A failed question names a missing rule: add it, re-verify. The rule set is not done until both gates pass, and "the quiz passed" without an archived run is not a pass.
Update Mode
When AI_RULES.md carries the <!-- generated by rule-architect --> marker, or the user passes
--update, run python3 scripts/manifest.py check <root> BEFORE touching anything and branch on
its exit code. An explicit --update request, including a natural-language request to update or
refresh the project rules, authorizes the structural migration from a legacy runtime-owned index to
AI_RULES.md. It never authorizes overwriting a detected conflict or choosing between divergent
rule bodies.
| Exit | Meaning | What you may do |
|---|---|---|
| 0 | every generated file hash and symlink target/type matches its record | regenerate freely |
| 1 | at least one file was edited by a human, or a recorded file is gone | stop and report the conflict. Never overwrite a modified file. Offer a diff, or edit only the sections the user names. If the user explicitly confirms the edits were intentional — a hand revision, or a rewrite by another tool (e.g. a compression skill that replaced whole files) — re-baseline with manifest.py record <root> <files> (merges) — or, only when passing the complete |
tracked set, --replace. **--replace with a partial list silently drops every file you did not |
||
list**, so a one-file re-baseline with it leaves the rest unverified. Then proceed. Re-baselining without that explicit confirmation is forbidden: it silently voids the very protection check exists for, and the next run would read someone's hand edits as generated output |
||
| 2 | legacy project — no manifest, so nothing can be proven generated | preserve the existing rule body byte-for-byte while migrating it to AI_RULES.md; treat its content as hand-written until the migrated set is recorded. Stop when no single source body can be identified |
Beyond that gate:
- Re-scan, then diff-edit only changed sections. NEVER regenerate whole files — that destroys hand-tuned rules added after generation.
- A
--updaterun MUST finish in the current output contract: realAI_RULES.mdsource plus two same-mode runtime entries that point directly to it. Do not merely report a legacy layout and leave it. - New detected signal → add the conditional doc + its Routing table row.
- Re-run
manifest.py recordfor every file you rewrote, or the next run reads your own output as a human edit. - Report per file: rules added / removed / changed.
Legacy layout migration during update
- Identify one source index body from the clean manifest first, then from generated markers and existing pointer targets. A runtime entry containing the full index is a source candidate.
- Multiple byte-identical candidates are one source. Multiple different bodies, a broken pointer, or an unclear source is a conflict: stop and show the candidates instead of merging or picking one.
- Create a normal-file
AI_RULES.mdwith the source body before changing runtime entries. When an existingAI_RULES.mddiffers from the source, stop. - Replace both runtime entries with relative symlinks or portable pointers to
AI_RULES.mdin the selected mode. Keep an intentional custom loader name such asCLAUDE.local.md, but never rename the neutral index. - Record the complete migrated set with
manifest.py record --replaceonly after enumerating every generated rule file. Re-runmanifest.py check, then continue the ordinary diff update.
The migration changes ownership and entry shape, not rule meaning. Preserve the index body and linked docs before applying separately justified rule additions, removals, or edits.
Hook Promotion
Rules a machine can enforce (forbidden calls, forbidden imports, banned paths) do NOT go into docs — prose relies on the agent having loaded and honoured it, a hook does not. Always list them in the final report as "hook promotion candidates", each as a spec entry:
{"id": "no-direct-engine", "glob": "src/**/*.py", "forbid": "create_async_engine\\(",
"message": "reuse get_engine() — docs/DB_RULES.md", "evidence": "src/db.py:12"}
forbid is a Python regex matched against the text a tool call would WRITE, and glob is matched
against the project-relative path (**/ crosses directories, * does not).
A rule can also deny a path outright — no regex, and not only for writes. Use it for an area the project keeps off-limits: another team's directory, generated output, a file whose existence is the defect:
{"id": "no-team-docs", "glob": "docs/**", "tools": ["Read", "Grep", "Glob"], "deny": true,
"message": "docs/ is team-owned — the rules live in docs_local/"}
tools defaults to the write tools, so a deny rule without it blocks writes only. One limit worth
stating in the report: a repo-wide Grep with no path carries no directory to attribute, so it is
not blocked — path-scoped reads are.
Emitting is opt-in and stays that way. Only when the user explicitly asks:
python3 scripts/hookgen.py emit <root> --rules <spec.json> # writes the spec + the guard, prints the settings entry
python3 scripts/hookgen.py emit <root> --rules <spec.json> --write # also merges it into <root>/.claude/settings.json
emit installs .rule-architect/hooks.json (the rules) and .claude/hooks/rule_guard.py (a generic
guard that reads them, so it never needs regenerating). --write edits the project's harness config,
so ask before passing it. Use hookgen.py check <root> on an update run to see what is already
installed. The guard blocks the write and tells the agent why; it fails open on any internal error,
because a broken guard must never wedge a session.
Only promote a rule whose violation a regex can actually name. A rule needing type information, call graphs, or human judgement stays prose — a hook that misfires is worse than a paragraph nobody read. Do not also write the promoted rule into a docs file: pick one home per rule, and for these it is the guard. rule-architect never writes lint config, and never installs a hook the user did not ask for.
Finishing
If the md-en-kr skill is installed in the active runtime or project, offer once,
in the user's language ("Apply md-en-kr English compression?" / "md-en-kr로 영어 압축 적용할까?").
Apply only on explicit yes. Not installed → skip silently, no mention.
Common Mistakes
| Mistake | Fix |
|---|---|
| Everything in one AI_RULES.md | Split: ≤60-line index + docs/. That IS the deliverable shape |
| Stack list / directory map in AI_RULES.md | Move to ARCHITECTURE.md; index keeps rules and links only |
| Runtime entry contains another runtime's name | Point both entries directly at neutral AI_RULES.md |
| One symlink and one regular pointer | Pick shared or portable mode for both; never mix |
| Rules without code evidence | Every rule cites file:line or a real snippet from THIS project |
| Generated doc missing from Routing table | Cross-link invariant; the verify script fails it |
| Routing table rows without a trigger (file names only) | Every row: situation/task → file |
| Recall-only quiz | Fixed mix: 3 recall + 1 judgment + 1 negative |
| Recording lint-enforceable rules in docs | Report as a hook promotion candidate; emit with hookgen.py when the user asks |
| Generating from the repo scan alone | Run harvest.py too — past corrections are the highest-yield rule source |
| Promoting a single correction into a rule | Needs ≥2 corrections, recency, and agreement with the current code |
| Update regenerates from scratch | Diff-edit only; whole-file rewrite loses manual additions |
--update leaves rules inside CLAUDE.md or CLAUDE.local.md |
Move the body to AI_RULES.md and point both runtime entries to it in one mode |
| Skipping the quiz test | Quiz is the only check that measures whether rules actually answer agent questions |
| Recording generic best practices ("write tests", "use meaningful names") | Rules must be project-specific; generic advice wastes always-loaded tokens |