pi-rules-md
Pi extension that discovers markdown rules and injects conditionally matched ones into the system prompt. Functional core, imperative shell.
Package details
Install pi-rules-md from npm and Pi will load the resources declared by the package manifest.
$ pi install npm:pi-rules-md- Package
pi-rules-md- Version
0.2.0- Published
- Oct 22, 2026
- Downloads
- 568/mo · 22/wk
- Author
- metalbolicx
- License
- MIT
- Types
- extension
- Size
- 1.6 MB
- Dependencies
- 3 dependencies · 1 peer
Pi manifest JSON
{
"extensions": [
"./dist/extension.js"
]
}Security note
Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.
README
pi-rules-md
Pi extension that discovers markdown rule files and injects conditionally matched ones into the system prompt. Rules can activate when the agent touches files matching their globs, matching files exist in the project, a selected tool, model, or git branch matches, prompt-derived intent satisfies its glob condition, or the rule is always active.
Functional core / imperative shell; pure functions and data, no classes.
How it works
- Discovery — every turn, rule files are discovered from
<project>/.pi/rules/**/*.md, from every ancestor.pi/rulesup to the enclosing git root, from every nested.pi/rulesbelow the working directory, and — read in place, never copied — from Copilot's.github/instructionsand Claude's.claude/rules(all of those only when the project is trusted — see Monorepo rule discovery and Importers), and from~/.pi/agent/rules/**/*.md(always). Native rule files are parsed against a strict schema, imported files leniently, and every file is cached by mtime. - Matching — each rule's activation is evaluated against the live
session: touched paths, existing files, selected tools, current model, git
branch, and prompt-derived intent. The
existsfile scan is cached per working directory and glob set, and re-run only after awrite,edit, orbashtool result or a glob-set change — see Exists-scan cache. - Selection — pinned rule bodies are content-deduped, budgeted, and ordered for presentation. On-demand rules are listed by path instead of having their bodies injected.
- Injection — the selected rules and on-demand index are rendered into a
single
pi-rules-mdsystem-prompt section viasystemPromptOptions.sectionsinbefore_agent_start. The section is re-applied every turn while rules are active; identical content produces no prompt diff, and the section is removed when nothing is active.
Mid-run delivery is built on top of that section: rules that only become
active because of a read/edit/write result are appended to that tool
result, once per rule per session — see
Mid-run attachment.
The complete rule-file contract is specified in RULE-SPEC.md.
Quick start
---
description: Follow repo conventions when editing core sources.
version: 1
globs:
- "src/**/*.ts"
- "!src/**/*.test.ts"
match: touched
priority: 10
---
Use tabs, keep functions pure, and never introduce classes in src/.
Drop that file in .pi/rules/ and start pi. When you edit any
non-test src/**/*.ts file, the rule is injected into the system prompt
for the session. Run /rules to see every rule, why each is active or
inactive, what the budget dropped, and any parse errors.
An alwaysApply: true rule skips matching entirely. tools, branch, and
model conditions are AND-ed with the activation mode; absent conditions mean
"any."
Model patterns
model is a picomatch glob over the session model id, matched
case-sensitively, so one rule can cover a model family:
---
description: Extra care with long files on Claude Opus.
version: 1
model: "claude-opus-*"
---
*sonnet* and claude-opus-* match every id containing that family name.
A plain id such as claude-opus-4-5 is still valid and matches only itself, so
existing exact rules behave exactly as before. Quote any pattern that starts
with * — YAML would otherwise read it as an alias. When a model condition
fails, /rules names both the rule's pattern and the session model id.
Branch patterns
branch is the same kind of pattern over the current git branch, resolved once
per turn with git rev-parse --abbrev-ref HEAD in the session working
directory:
---
description: Release checklist.
version: 1
branch: "release/*"
---
Exact names such as main still match only themselves. Branch names contain
/ and * never crosses one, so use feat/** for a multi-segment family.
Outside a git repository, on a detached HEAD, or when git fails, the session has
no branch and a branch condition simply does not match — never an error.
Enforcement levels
enforcement states how binding a rule is. It defaults to inform, which
renders no extra line, so sections never gain noise from rules that do not ask
for it.
---
description: Never rewrite published history.
version: 1
enforcement: enforce
globs:
- "**/*.sh"
---
enforcerenders> Level: MUSTandrecommendrenders> Level: SHOULD, directly under the rule heading (after anyTriggered byline). The body follows unchanged.inform(the default) adds nothing.- In the token budget, equal-
prioritycandidates are admitted by rank:enforcebeforerecommendbeforeinform, with discovery order still breaking equal ranks. Priority always wins over rank, and the rendered order of the rules is unchanged.
Pinned and on-demand rules
Rules default to load: pinned: when active, their bodies are eligible for
injection and count toward the token budget. Use pinned rules for concise
guidance that should be available automatically whenever its conditions match.
Use load: on-demand for long references, catalogs, or specialized material
that should not be inserted into every prompt. A matched on-demand rule adds a
path-and-description entry under the Reference rules available on demand
index, not its body. The agent can fetch the full body with the read_rule tool
using that rules-relative path. On-demand bodies bypass pinned deduplication
and budgeting; the rendered index still contributes to the reported token
estimate.
---
description: Database migration reference.
version: 1
globs:
- "docs/database/**/*.md"
load: on-demand
keywords: [migration, postgres]
---
Detailed migration procedures and reference material...
Use keywords when a useful rule should be discoverable from task wording
that does not mention its file paths. A whole-word, case-insensitive keyword
match in the current turn prompt donates that rule's positive globs to
pre-loading. Keywords do not activate a rule by themselves, and donated globs
do not count as touched paths or expand the filesystem scan. Language words
also provide built-in intent globs (for example, python or pytest →
*.py; bare go intentionally has no trigger). See RULE-SPEC.md §5
for the full list and matching semantics.
Monorepo rule discovery
A working directory inside a monorepo sees the rules of every directory between
it and the enclosing git root, plus the rules of the packages nested below it.
Discovery reads <cwd>/.pi/rules, then <parent>/.pi/rules, and so on up to
<git-root>/.pi/rules — the git root itself included, and nothing above it —
and then every .pi/rules found below the working directory (bounded depth,
.git/ and node_modules/ skipped). The git root comes from
git rev-parse --show-toplevel, resolved once per working directory; when git
has no answer (not a repository, git missing) the walk-up stops at
<cwd>/.pi/rules, which is exactly single-directory behavior. Nested discovery
does not depend on git: it only ever looks below the working directory.
Each rule's globs are relative to the directory that owns its .pi/ folder, so
a rule in the repo root describes repo paths, a package rule describes package
paths, and a nested package rule describes its own package paths:
repo/ <- git root, current working directory
├── .pi/rules/root.md globs: "packages/b/src/**/*.ts" (repo-relative)
└── packages/
├── a/.pi/rules/pkg.md globs: "src/**/*.ts" (package-relative)
└── b/.pi/rules/app.md globs: "src/**/*.ts" (package-relative)
Here root.md matches /repo/packages/b/src/x.ts, app.md matches it too (as
packages/b/src/x.ts), and pkg.md does not — while from packages/a as the
working directory the same pkg.md matches src/x.ts and root.md's pattern
never claims packages/b. Nesting is bounded: a .pi/rules dir at most six
directory segments below the working directory is discovered (so
packages/b at depth 2 and apps/web/packages/x at depth 4 are found), and
.git/ or node_modules/ never hides a rule directory or a rule file.
Rules from every level are project rules: they share one id namespace (nearest
directory wins the bare stem, later collisions get -2, -3, … in the order
cwd → ancestors → nested → imported → global), they are all ordered before
global rules, and all of them stay behind the project trust gate — an untrusted
project lists and enumerates no rules directory at all, and git is not
consulted. The full
contract, including how rebased globs are evaluated, why /rules may name a
rebased pattern, and the exact ordering and depth bound, is in
RULE-SPEC.md §1.
Importers (Copilot instructions, Claude rules)
Rules written for another tool are read in place — never copied, moved, or
rewritten — so the original tool keeps working. Both importers turn each file
into an ordinary project rule: trust-gated like .pi/rules, cached by mtime
like any rule file, ordered after the nested .pi/rules dirs and before the
global dir, sharing the same id namespace, and reported in /rules under its
real path. Copilot's .github/instructions is read before Claude's
.claude/rules, so on an id collision the Copilot file keeps the earlier
suffix.
.github/instructions/typescript.instructions.md
---
applyTo: src/**/*.ts,docs/**
description: TypeScript conventions
---
Use explicit types.
applyToholds comma-separated globs, trimmed, with empty entries dropped. The entry**means "every file" → the rule always applies.- An absent
applyTo— or a file with no frontmatter at all — also means "applies everywhere" (applyTo: **), matching Copilot's own semantics. descriptionis optional; the fallback is the file stem (typescript.instructions.md→typescript).- Every other frontmatter key (
version,excludeAgent, …) is ignored: this format belongs to Copilot, so its schema is read leniently instead of through the strict native rule schema. Unknown keys never fail an import; only real document damage does (broken YAML, an unterminated block, a non-stringdescription, a non-string or emptyapplyTo). applyToglobs are relative to the working directory, exactly as authored, sosrc/**matchessrc/**— the importer's own directory never prefixes them.- The whole rule is a touched-path, pinned, inform-level project rule; imported
files cannot declare a different
match,load, orenforcement. - Parse errors are not silent:
/rulesreports them under the real file path, so an invalidapplyTois visible where it lives.
Claude rules
The Claude importer reads .claude/rules/**/*.md (plain markdown, .md only):
.claude/rules/typescript.md
---
paths: src/**/*.{ts,tsx}, docs/**
description: TypeScript conventions
---
Use explicit types.
pathsis optional and accepts three shapes: one glob string, a comma-separated string, or a YAML list of glob strings. Entries are trimmed, blank entries dropped, and duplicates collapsed.- Brace expansion works (
src/**/*.{ts,tsx}); the comma-separated form splits only on commas outside a{…}group, so a brace group is never cut in half. - An absent
paths— or a file with no frontmatter at all — means the rule always applies: that is Claude's own "no paths = always applied" default. Apathsvalue that trims down to no glob at all is a parse error, not a silent always-apply. **is not special here (unlike Copilot'sapplyTo): it stays an ordinary glob matching every touched path.!-negated entries are passed through untouched, so they behave exactly like a native rule's negated glob.descriptionprecedence: an explicit non-blankdescription, else the FIRST body heading (#,##, … with its#run stripped), else the file stem (typescript.md→typescript).pathsglobs are relative to the working directory, exactly as authored; the whole rule is a touched-path, pinned, inform-level project rule; parse errors appear in/rulesunder the real file.
Importers are on for trusted projects. Disable all of them for the Pi process with:
PI_RULES_IMPORT=none pi
Only a trimmed, case-insensitive none disables import; absent or unrecognized
values keep them on. With import disabled no importer file is listed, stat-ed,
or read, so the behavior is identical to a repository that has no such files.
Token budget
Pinned rule bodies use an estimated token budget of 2000 by default. Override it for the Pi process with a positive integer value, for example:
PI_RULES_MAX_TOKENS=3000 pi
The shell parses the override with parseInt; a missing value, a value that
parses to NaN, or a non-positive parsed value uses the default. On-demand
bodies do not consume this budget; their rendered index contributes to the
final token estimate.
Exists-scan cache
exists rules ask the project scanner for their globs on every turn. That
answer is cached per working directory and glob set, so consecutive turns reuse
one scan instead of re-walking the tree; the cache is dropped after a
completed write, edit, or bash tool result (the only results that can
change which files exist) or when the glob set changes. Results are identical
to an uncached scan, and .gitignore handling is unchanged — caching only
avoids repeating the same read. The full contract, including the staleness
window for changes made outside the agent, is in
RULE-SPEC.md §5.
Mid-run attachment
Rules can also reach the model between turns: the extension appends one small
block to a read, edit, or write tool result.
- Only those three tool results attach; every other result — including an errored one — is left exactly as pi produced it.
- Each rule is delivered once per session. The ids the injected section already carries count as delivered, so nothing is sent twice; the record is cleared by compaction and by a session switch/fork, which is what allows a rule to reach the model again after the transcript it was part of is gone.
- The block is rendered by the same code as the section, so a rule reads identically in both places. It is appended as one extra content part; the tool output pi produced is never replaced.
- Attachment evaluates the session as it stands at the tool result, so the rules
that just became active — including because of the path this call touched — are
the ones delivered. The remaining budget is what the section's own pinned
bodies leave over, so a newly active rule can degrade to a
read_rulereference instead of its body. - That evaluation only reads: it never rewrites the injected section (that stays
before_agent_start's job), and/ruleskeeps reporting the last real cycle.
Attachment is on by default. Set PI_RULES_ATTACH=off to disable it — the
exists-scan invalidation and the section injection are unaffected. The full
contract (candidates, remaining budget, degradation to a read_rule reference,
on-demand index lines) is in
RULE-SPEC.md §6.
Session persistence
Touched paths survive resume, reload, and tree navigation. Each newly touched
path batch is appended to the session as a custom entry
(pi-rules-md/touched, { paths: [...] }) that never reaches the LLM, and on
session_start (any reason) and session_tree the touched set is rebuilt from
the current branch's entries. Resuming a session therefore restores exactly the
paths its branch touched, and navigating to another branch drops paths recorded
only on the abandoned one. Repeats are never written twice. Only touched paths
persist: /rules mute state lives in memory for the current session — see
RULE-SPEC.md §5.
Install
Recommended: install the npm package through the CLI wrapper:
npx pi-rules-md install
The wrapper invokes the pi CLI for package changes; it never edits
settings.json directly.
| Command | Description |
|---|---|
pi-rules install |
Install pi-rules-md. |
pi-rules uninstall |
Remove pi-rules-md. |
pi-rules update |
Update an unpinned npm installation. |
pi-rules status |
Show installation status and available updates. |
pi-rules doctor |
Diagnose installation health. |
pi-rules lint [dir...] |
Validate rule files and print one line per error. |
pi-rules new <name> [--global] |
Scaffold a valid rule file; never overwrites one. |
pi-rules explain <path> |
Explain which rules a path would activate, and why. |
Common options: --local uses project scope for install, uninstall, and
update; --purge removes the package directory during uninstall; --global
scaffolds a global rule with new; --dry-run prints actions without changing
anything; --agent-dir <dir> selects a Pi agent directory (for lint,
new --global and explain it also moves the global rules root).
For local development, load the source directly:
pi --extension ./index.ts
For a local-path install, run pnpm build first; the package manifest loads
./dist/extension.js. Source entries track the working tree; npm entries are
updated with pi-rules update.
Linting rule files
pi-rules lint [dir...] parses native rule files and reports every problem
before you rely on them (otherwise a bad rule only shows up as a parse error
in /rules after an evaluation cycle):
pi-rules lint # .pi/rules and ~/.pi/agent/rules
pi-rules lint src/.pi/rules # explicit directories instead
Each error prints as <file>: <code>: <message> on its own line, sorted by
file then code, followed by one summary line such as
lint: 11 rule files, 2 errors. The exit code is 1 when any error was
found and 0 otherwise, so the command composes in scripts and CI.
Defaults and scope:
- Without arguments, lint scans
<cwd>/.pi/rulesand the global rules directory (~/.pi/agent/rules, or<agent dir>/ruleswith--agent-dir/PI_RULES_AGENT_DIR). A missing default directory is not an error — a project or machine without rules is a valid state. - Directories passed on the command line must exist: a missing explicit
directory is reported as
source-unreadableand exits1. - Only the native rule format (
RULE-SPEC.md, strict v1 schema) is linted. Imported formats (.github/instructions,.claude/rules) are lenient, use a different key set, and are validated by their own importers. - Listing is recursive over
**/*.md, skips.gitandnode_modules, ignores non-markdown files, and never follows symlinks. - A file that cannot be read is reported as a
read-failureerror line rather than aborting the run.
Scaffolding a rule
pi-rules new <name> [--global] writes a starter rule file you can edit
instead of remembering the frontmatter contract:
pi-rules new use-tabs # .pi/rules/use-tabs.md
pi-rules new shared-baseline --global # ~/.pi/agent/rules/shared-baseline.md
The generated rule is valid by construction: description derived from the
name, version: 1, one placeholder globs pattern with match: touched, and
the documented defaults (priority: 0, enforcement: inform, load: pinned)
spelled out, above a placeholder body. pi-rules lint accepts it and
pi-rules explain explains it as soon as it exists.
- The target rules directory is created when it is missing.
- An existing rule file is never overwritten: the write itself is an
exclusive create, so the command refuses, names the file, and exits
1with the file untouched. <name>is a file stem, not a path: no path separators, no whitespace, no leading dot. A single trailing.mdis stripped (new use-tabs.mdstill writesuse-tabs.md).- It prints one line,
created <file>, naming the absolute path written.
Explaining a path
pi-rules explain <path> prints the same report as /rules why <path>, from
the command line and outside any session:
pi-rules explain src/app.ts
why src/app.ts (treated as touched this session):
always: always active (path-independent)
docs: not activated by this path (no touched path matches globs: docs/**)
tabs: would activate — glob `src/**/*.ts`
also requires tools: edit
Rules are listed in discovery order, one group of lines each. A path is read on the working-directory scale that touched paths use, so an absolute path inside the working directory is converted to its relative form first.
It answers the path-condition question only. There is no session, so
tools/model/branch conditions appear as caveats of a path decision and are
never evaluated; muting, budgets and injected sections do not apply. The read
set is lint's default pair — <cwd>/.pi/rules and the global rules
directory — with native rule files only: ancestor and nested .pi/rules
directories and imported formats (.github/instructions, .claude/rules) are
not part of the report. Rule files that fail to parse are reported on stderr
as <file>: <code>: <message> and never change the exit code.
Exit codes: 0 for a completed report, including a path that matches no rule
(the report says why), 1 when the rules could not be read at all, 2 for a
usage error.
Commands
/rules— report of the last evaluation: active rules with reasons, inactive rules with reasons, budget drops with cause, parse/discovery errors, the injected content hash, andbudget:/section:token visibility lines. In TUI/RPC modes a status-bar entrypi-rules-mdshowsrules: <active> · ~<tokens> tokwhile anything is active./rules show <id>— one rule's path, source, configured conditions, load, enforcement, and body./rules why <path>— which rules a given path would activate and why (read-only; non-path conditions appear as caveats). The same report is available outside a session aspi-rules explain <path>./rules mute <id>//rules unmute <id>— exclude or re-include one rule for the current session. A muted rule reportsinactive: <id> (muted for this session)and stays out of the injected section; mutes reset on session switch/fork. Unknown ids answerUnknown rule: <id>.
Trust model
Project rules — .pi/rules/ at any discovered level, and every imported
Copilot/Claude file — load only when the project is trusted (pi's
isProjectTrusted()); global rules always load. In a monorepo that covers
every rules directory the walk-up and the nested enumeration find — an
untrusted project lists neither the working directory's, an ancestor's, nor a
nested .pi/rules, is never globbed for nested rule directories, reads no
importer directory, and no git query is made for it. Nothing above the
enclosing git root is ever read, and a nested rule directory can never be
outside the working directory. An unlistable or unreadable rule source
(including a failed nested enumeration) is reported via /rules and never
breaks the session.
Development
pnpm install
pnpm typecheck # strict TS, no emit
pnpm test # vitest: co-located unit tests, goldens, property tests, perf guard
- Functional core:
src/(types, frontmatter parser, conditions, match, select, render, engine, state reducer) — pure, total, property-tested. - Imperative shell:
shell/+index.ts(discovery, event collectors, injection, wiring) — thin, dependency-injected, structurally mirrored against the pi SDK types (no runtime pi import). - ODD feature tracking: odd/tasks/pi-rules-md.md.