From de13c7104998b5f63a0f803e3d4caa9893c27369 Mon Sep 17 00:00:00 2001 From: Dean Harel Date: Sat, 2 May 2026 13:52:57 +0300 Subject: [PATCH 1/5] feat(rules): establish .agents/rules/ universal rules directory - Move voice.md from .claude/rules/ to .agents/rules/ - Replace .claude/rules/ with symlink to ../.agents/rules/ - Enables Claude Code native discovery + prepares for Pi extension --- {.claude => .agents}/rules/voice.md | 0 .claude/rules | 1 + .../2026-05-02-agents-rules-implementation.md | 559 ++++++++++++++++++ .../specs/2026-05-02-agents-rules-design.md | 166 ++++++ 4 files changed, 726 insertions(+) rename {.claude => .agents}/rules/voice.md (100%) create mode 120000 .claude/rules create mode 100644 docs/superpowers/plans/2026-05-02-agents-rules-implementation.md create mode 100644 docs/superpowers/specs/2026-05-02-agents-rules-design.md diff --git a/.claude/rules/voice.md b/.agents/rules/voice.md similarity index 100% rename from .claude/rules/voice.md rename to .agents/rules/voice.md diff --git a/.claude/rules b/.claude/rules new file mode 120000 index 0000000..2d5c9a9 --- /dev/null +++ b/.claude/rules @@ -0,0 +1 @@ +../.agents/rules \ No newline at end of file diff --git a/docs/superpowers/plans/2026-05-02-agents-rules-implementation.md b/docs/superpowers/plans/2026-05-02-agents-rules-implementation.md new file mode 100644 index 0000000..d2eebf4 --- /dev/null +++ b/docs/superpowers/plans/2026-05-02-agents-rules-implementation.md @@ -0,0 +1,559 @@ +# `.agents/rules/` Universal Rules Directory — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement the `.agents/rules/` universal rules directory with native Claude Code support (via symlink) and Pi support (via global extension with path-scoped auto-injection). + +**Architecture:** Move rule files to `.agents/rules/`, replace `.claude/rules/` with a directory symlink so Claude Code discovers them natively. Build a Pi global extension that discovers `.agents/rules/`, parses frontmatter, records file-access paths per turn, and injects a rule catalog + matching full-rule content into the system prompt on `before_agent_start`. + +**Tech Stack:** TypeScript (Pi extension), Node.js `fs`/`path`, YAML frontmatter parsing, picomatch for glob matching, shell symlinks. + +--- + +## Task 1: Repo Structure — Create `.agents/rules/` and Symlink `.claude/rules/` + +**Files:** +- Create: `.agents/rules/` (new directory) +- Move: `.claude/rules/voice.md` → `.agents/rules/voice.md` +- Delete: `.claude/rules/` (empty directory after move) +- Create: `.claude/rules` (symlink → `../.agents/rules`) + +- [ ] **Step 1: Create `.agents/rules/` and move `voice.md`** + +```bash +mkdir -p .agents/rules +mv .claude/rules/voice.md .agents/rules/voice.md +``` + +- [ ] **Step 2: Replace `.claude/rules/` with a symlink** + +```bash +rm -rf .claude/rules +ln -s ../.agents/rules .claude/rules +``` + +- [ ] **Step 3: Verify the symlink works** + +```bash +ls -la .claude/rules/ +# Expected: symlink pointing to ../.agents/rules, voice.md visible +readlink .claude/rules +# Expected: ../.agents/rules +``` + +- [ ] **Step 4: Commit the structural change** + +```bash +git add .agents/rules/voice.md .claude/rules docs/superpowers/plans/2026-05-02-agents-rules-implementation.md +git commit -m "feat(rules): establish .agents/rules/ universal rules directory + +- Move voice.md from .claude/rules/ to .agents/rules/ +- Replace .claude/rules/ with symlink to ../.agents/rules/ +- Enables Claude Code native discovery + prepares for Pi extension" +``` + +--- + +## Task 2: Pi Extension — Package Structure and Dependency Setup + +**Files:** +- Create: `~/.pi/agent/extensions/rules-loader/package.json` +- Create: `~/.pi/agent/extensions/rules-loader/index.ts` + +The extension uses `picomatch` for glob matching, declared in a local `package.json`. + +- [ ] **Step 1: Create the extension directory and package.json** + +```bash +mkdir -p ~/.pi/agent/extensions/rules-loader +``` + +Create `~/.pi/agent/extensions/rules-loader/package.json`: + +```json +{ + "name": "rules-loader", + "version": "1.0.0", + "dependencies": { + "picomatch": "^4.0.2" + }, + "pi": { + "extensions": ["./index.ts"] + } +} +``` + +- [ ] **Step 2: Install dependencies** + +```bash +cd ~/.pi/agent/extensions/rules-loader && npm install +``` + +Expected: `node_modules/picomatch/` created. No errors. + +--- + +## Task 3: Pi Extension — Core Implementation + +**Files:** +- Create: `~/.pi/agent/extensions/rules-loader/index.ts` + +- [ ] **Step 1: Write the full extension** + +```typescript +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { isToolCallEventType } from "@mariozechner/pi-coding-agent"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as picomatch from "picomatch"; + +interface RuleFile { + name: string; + fullPath: string; + paths: string[] | null; // null = global rule + content: string; +} + +/** + * Simple YAML frontmatter parser. Extracts only the `paths:` key. + * Returns { frontmatter: Record | null, body: string } + */ +function parseFrontmatter(text: string): { + frontmatter: Record | null; + body: string; +} { + const match = text.match(/^---\s*\n([\s\S]*?)\n---\s*\n?/); + if (!match) { + return { frontmatter: null, body: text }; + } + + const raw = match[1]; + const frontmatter: Record = {}; + + for (const line of raw.split("\n")) { + const colonIndex = line.indexOf(":"); + if (colonIndex === -1) continue; + const key = line.slice(0, colonIndex).trim(); + const value = line.slice(colonIndex + 1).trim(); + + if (key === "paths" && value === "") { + // Multi-line array after `paths:` + frontmatter[key] = []; + } else if (key === "paths" && value.startsWith("[") && value.endsWith("]")) { + // Inline array: `["foo", "bar"]` + try { + frontmatter[key] = JSON.parse(value); + } catch { + frontmatter[key] = [value]; + } + } else if (key.startsWith("- ")) { + // Array item under `paths:` + const item = key.slice(2).trim(); + if (!Array.isArray(frontmatter["paths"])) { + frontmatter["paths"] = []; + } + (frontmatter["paths"] as string[]).push(item); + } else if (key === "paths") { + frontmatter[key] = [value]; + } else { + frontmatter[key] = value; + } + } + + return { + frontmatter, + body: text.slice(match[0].length), + }; +} + +/** + * Recursively find all .md files in a directory. + */ +function findMarkdownFiles(dir: string, basePath: string = ""): string[] { + const results: string[] = []; + if (!fs.existsSync(dir)) return results; + + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const relativePath = basePath ? `${basePath}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + results.push(...findMarkdownFiles(path.join(dir, entry.name), relativePath)); + } else if (entry.isFile() && entry.name.endsWith(".md")) { + results.push(relativePath); + } + } + return results; +} + +/** + * Discover `.agents/rules/` walking up from cwd until found. + * Order: cwd/.agents/rules/ → parent dirs → ~/.agents/rules/ + */ +function discoverRulesDir(cwd: string): string | null { + let current = path.resolve(cwd); + const home = process.env.HOME ? path.resolve(process.env.HOME) : ""; + + while (true) { + const candidate = path.join(current, ".agents", "rules"); + if (fs.existsSync(candidate)) { + return candidate; + } + + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + + // Fallback to user-level rules + if (home) { + const fallback = path.join(home, ".agents", "rules"); + if (fs.existsSync(fallback)) { + return fallback; + } + } + + return null; +} + +export default function rulesLoaderExtension(pi: ExtensionAPI) { + let discoveredRules: RuleFile[] = []; + let rulesDir: string | null = null; + const recordedPaths: Set = new Set(); + + // --- Session Start: discover rules --- + pi.on("session_start", async (_event, ctx) => { + rulesDir = discoverRulesDir(ctx.cwd); + discoveredRules = []; + + if (!rulesDir) return; + + const files = findMarkdownFiles(rulesDir); + for (const relPath of files) { + const fullPath = path.join(rulesDir, relPath); + const content = fs.readFileSync(fullPath, "utf8"); + const { frontmatter, body } = parseFrontmatter(content); + + const paths = frontmatter?.paths; + const pathsArray = + Array.isArray(paths) && paths.every((p) => typeof p === "string") + ? (paths as string[]) + : null; + + discoveredRules.push({ + name: relPath, + fullPath, + paths: pathsArray, + content: body, + }); + } + + if (discoveredRules.length > 0 && ctx.hasUI) { + const list = discoveredRules + .map((r) => { + const scopes = r.paths ? r.paths.join(", ") : "global"; + return `${r.name} (${scopes})`; + }) + .join("\n "); + ctx.ui.notify( + `Loaded rules: ${list}`, + "info" + ); + } + }); + + // --- Tool Call: record file access paths --- + pi.on("tool_call", async (event) => { + if ( + isToolCallEventType("read", event) || + isToolCallEventType("edit", event) || + isToolCallEventType("write", event) + ) { + const accessedPath = event.input.path; + if (typeof accessedPath === "string") { + recordedPaths.add(accessedPath); + } + } + }); + + // --- Before Agent Start: inject catalog + matching rules --- + pi.on("before_agent_start", async (event) => { + if (discoveredRules.length === 0) { + return; + } + + // Build catalog (always present) + const catalogLines = discoveredRules.map((r) => { + const scope = r.paths ? `applies to: ${r.paths.join(", ")}` : "global"; + return `- ${r.name} — ${scope}`; + }); + + let catalog = `\n\n## Project Rules\n\nThe following rules are available in .agents/rules/:\n\n${catalogLines.join( + "\n" + )}`; + + // Find matching path-scoped rules + const matchedRules: RuleFile[] = []; + for (const rule of discoveredRules) { + // Global rules are NOT auto-injected (catalog only) + if (!rule.paths) continue; + + for (const accessedPath of recordedPaths) { + for (const pattern of rule.paths) { + if (picomatch.isMatch(accessedPath, pattern)) { + matchedRules.push(rule); + break; + } + } + if (matchedRules.includes(rule)) break; + } + } + + // Append full content of matched rules + if (matchedRules.length > 0) { + catalog += "\n\n---\n\n"; + for (const rule of matchedRules) { + catalog += `\n${rule.content.trim()}\n\n`; + } + } + + return { + systemPrompt: event.systemPrompt + catalog, + }; + }); + + // --- Turn End: clear recorded paths --- + pi.on("turn_end", async () => { + recordedPaths.clear(); + }); +} +``` + +- [ ] **Step 2: Verify extension loads without errors** + +```bash +cd ~/Developer/engineering-notes +pi -e ~/.pi/agent/extensions/rules-loader/index.ts -p "test" +``` + +Expected: Pi starts, extension loads, no TypeScript errors. The `-p` print mode may not show notifications but should not crash. + +Alternative inline test: +```bash +node -e "require('jiti')('~/.pi/agent/extensions/rules-loader/index.ts')" +``` + +Actually, jiti may not resolve the import path. Better to test by running `pi` in the project and checking if the extension appears in the startup header (`[Extensions] rules-loader/index.ts`). + +- [ ] **Step 3: Verify rule discovery notification** + +In a Pi session inside `~/Developer/engineering-notes`, check startup: + +``` +[Extensions] rules-loader/index.ts +Loaded rules: voice.md (entries/drafts/**, entries/*/body.md, entries/wip/**/draft.md) +``` + +The notification should appear in the TUI message area. + +- [ ] **Step 4: Verify path-scoped injection** + +Inside the Pi session, trigger a file read that matches a rule: + +``` +read entries/drafts/some-draft.md +``` + +Then type a follow-up prompt. On the next `before_agent_start`, the system prompt should include: +1. The catalog section +2. The full content of `voice.md` after `---` + +Verify by checking tool behavior: if the LLM now references voice/tone guidance, injection is working. + +- [ ] **Step 5: Verify one-shot behavior** + +On a subsequent turn where NO file access matches `voice.md`'s paths, confirm the full rule content is NOT re-injected (only catalog remains). The catalog is always present; matched rules content is one-shot per turn. + +- [ ] **Step 6: Commit the extension** + +The extension lives in `~/.pi/agent/extensions/` — this is **outside the repo** (global install). Document it in the repo so it's tracked as part of this feature. + +Create `.pi/extensions/rules-loader/` as a **project-local copy** of the extension so the repo contains the source: + +```bash +mkdir -p .pi/extensions/rules-loader +cp ~/.pi/agent/extensions/rules-loader/package.json .pi/extensions/rules-loader/package.json +cp ~/.pi/agent/extensions/rules-loader/index.ts .pi/extensions/rules-loader/index.ts +``` + +Commit: +```bash +git add .pi/extensions/rules-loader/ +git commit -m "feat(rules): add Pi rules-loader extension + +- Discovers .agents/rules/ walking up from cwd +- Parses frontmatter for path-scoped rules +- Records read/edit/write paths per turn +- Injects catalog + matching rule content into system prompt" +``` + +--- + +## Task 4: Verify End-to-End Integration + +- [ ] **Step 1: Claude Code compatibility** + +Open the project in Claude Code. Access a file matching `voice.md` paths: + +``` +read entries/drafts/README.md +``` + +Expected: Claude Code auto-loads `voice.md` (via the symlink `.claude/rules/` → `.agents/rules/`). The rule content should appear in context. + +- [ ] **Step 2: Pi auto-injection** + +In a Pi session inside the project: + +``` +read entries/drafts/README.md +``` + +Expected: On the NEXT turn, the system prompt includes the catalog + full `voice.md` content. The LLM should reference voice rules. + +- [ ] **Step 3: Global rule behavior** + +Create a test global rule in `.agents/rules/test-global.md` (no frontmatter): + +```markdown +# Test Global + +Always respond with "ACK". +``` + +In Pi, verify: +- The catalog lists `test-global.md — global` +- The full content is NEVER auto-injected (catalog only) + +Clean up afterward: +```bash +rm .agents/rules/test-global.md +``` + +- [ ] **Step 4: Edge case — no matching paths** + +In Pi, read a file that does NOT match any rule path (e.g., `.gitignore`). + +Expected: Catalog is present. No rule content is injected. + +- [ ] **Step 5: Edge case — no rules directory** + +Launch Pi in a directory without `.agents/rules/`: + +```bash +cd /tmp && pi +``` + +Expected: Extension loads silently (no rules discovered, no notification, no catalog injection). + +--- + +## Task 5: Documentation + +- [ ] **Step 1: Add a brief README note** + +If `.agents/README.md` exists, add: + +```markdown +## `.agents/rules/` + +Universal rules directory compatible with Claude Code (via `.claude/rules/` symlink) +and Pi (via `rules-loader` extension). + +### Format + +Each `.md` file is a rule. Optional YAML frontmatter with `paths:` for scoping: + +```yaml +--- +paths: + - src/**/*.ts +--- +``` + +- `paths:` present → path-scoped, auto-applied when accessed files match +- `paths:` absent → global, catalog-only (not auto-injected) +``` + +If no `.agents/README.md` exists, skip this step or create a minimal one. + +- [ ] **Step 2: Update repo root documentation** + +Add a brief mention in the main `README.md` or `CLAUDE.md` about `.agents/rules/`: + +```markdown +### Project Rules + +Rules live in `.agents/rules/` and are consumed by: +- **Claude Code** — natively via `.claude/rules/` symlink +- **Pi** — via the `rules-loader` extension (auto-injected when file paths match) +``` + +- [ ] **Step 3: Commit docs** + +```bash +git add -A +git commit -m "docs: document .agents/rules/ universal rules directory" +``` + +--- + +## Self-Review + +### 1. Spec Coverage + +| Spec Requirement | Task/Step | +|---|---| +| Create `.agents/rules/` directory | Task 1, Step 1 | +| Move `voice.md` to `.agents/rules/` | Task 1, Step 1 | +| Replace `.claude/rules/` with symlink | Task 1, Step 2 | +| Claude Code native discovery | Task 1 (symlink) + Task 4, Step 1 | +| Pi extension scans `.agents/rules/` | Task 3, `discoverRulesDir()` | +| Session-start notification | Task 3, `session_start` handler | +| Record read/edit/write paths | Task 3, `tool_call` handler | +| `before_agent_start` injects catalog | Task 3, `before_agent_start` handler | +| `before_agent_start` injects matching rules | Task 3, `before_agent_start` handler | +| `turn_end` clears recorded paths | Task 3, `turn_end` handler | +| One-shot injection (no sticky retention) | Task 3, `turn_end` clears set + spec comment | +| Global rules: catalog only | Task 3, skips rules without `paths:` | +| Path-scoped rules: full content when matched | Task 3, `picomatch.isMatch` loop | +| Walk up from cwd for discovery | Task 3, `discoverRulesDir()` | +| Fallback to `~/.agents/rules/` | Task 3, `discoverRulesDir()` | +| Frontmatter parsing | Task 3, `parseFrontmatter()` | + +### 2. Placeholder Scan + +- No "TBD", "TODO", or "implement later" +- No vague "handle edge cases" steps — each edge case has a concrete verification step +- No "similar to Task N" references +- Code blocks contain complete, runnable code + +### 3. Type Consistency + +- `RuleFile` interface used consistently +- `paths` is `string[] | null` — null for global, string[] for scoped +- `picomatch.isMatch` signature matches usage +- `isToolCallEventType` used correctly with built-in tool names + +--- + +## Execution Handoff + +**Plan complete and saved to `docs/superpowers/plans/2026-05-02-agents-rules-implementation.md`.** + +Two execution options: + +**1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration + +**2. Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints + +**Which approach?** diff --git a/docs/superpowers/specs/2026-05-02-agents-rules-design.md b/docs/superpowers/specs/2026-05-02-agents-rules-design.md new file mode 100644 index 0000000..63c14f9 --- /dev/null +++ b/docs/superpowers/specs/2026-05-02-agents-rules-design.md @@ -0,0 +1,166 @@ +# Design: `.agents/rules/` — Universal Rules Directory + +**Date:** 2026-05-02 +**Status:** Draft for review + +## Problem Statement + +Each AI coding harness (Claude Code, Pi, Codex, Cursor, etc.) loads project-level context differently. Claude Code supports path-scoped progressive disclosure via `.claude/rules/*.md` with `paths:` frontmatter. No other harness has an equivalent. This forces duplication or leaving harnesses unsupported. + +We want a single source of truth for project rules that: +- Works natively with Claude Code's path-scoped progressive disclosure +- Works with Pi via its extension system (as close to Claude's auto-disclosure as possible) +- Does not duplicate rule content across harness-specific directories + +## Decision: Park `AGENTS.md` / `CLAUDE.md` as a separate concern + +This design focuses on **rules** (path-scoped or global conventions). The existing `CLAUDE.md` at the repo root remains untouched; its fate is a separate discussion. + +## Directory Layout + +Currently there is only one rule file: + +``` +.agents/ # Agent Skills standard root (already exists) +├── skills/ +│ └── entry/ +│ └── SKILL.md # existing skill for blog entry workflow +└── rules/ # NEW — universal rules directory + └── voice.md # path-scoped (Claude native) +``` + +## Rule Format + +Each file is Markdown with optional YAML frontmatter. The format is compatible with Claude Code's `.claude/rules/` files. + +### Path-scoped rule (current: `voice.md`) + +```markdown +--- +paths: + - entries/drafts/** + - entries/*/body.md + - entries/wip/**/draft.md +--- + +# Voice Rules + +Semi-formal with conversational swagger... +``` + +### Future: global rule (not yet present) + +```markdown +# Conventions + +Use sentence case for all commit messages... +``` + +### Frontmatter semantics + +| Frontmatter | Meaning | +|-------------|---------| +| `paths:` present | Path-scoped. Auto-applied when accessed files match the glob patterns. | +| `paths:` absent | Global. Always in context. | +| No frontmatter at all | Treated as global. | + +**Note:** The original Claude Code `paths:` syntax is the only scoping mechanism. There is no `always: true` flag — the absence of `paths:` already means "always apply." + +## Harness Mapping + +### Claude Code + +``` +.claude/rules/ → symlink to .agents/rules/ (directory symlink, matches skills/ pattern) +``` + +- Claude Code resolves the symlink and discovers rule files natively +- Path-scoped rules are auto-loaded when you edit files matching the `paths:` glob +- Zero duplication; no format changes needed + +### Pi + +`~/.pi/agent/extensions/rules-loader.ts` → globally installed Pi extension. + +Behavior per lifecycle event: + +| Event | Action | +|-------|--------| +| `session_start` | Scans `.agents/rules/` walking up from cwd. Notifies user with the count/list of discovered rules. | +| `tool_call` (read/edit/write) | Records the `path` being accessed in a turn-local set. | +| `before_agent_start` | (1) Injects a lightweight **catalog** into the system prompt (always).
(2) If file paths were recorded this turn, matches them against each rule's `paths:` glob and appends the **full content** of matching path-scoped rules. | +| `turn_end` | Clears the recorded paths. No state persists across turns. | + +**Scope:** One-shot only. Once injected, path-scoped rules are not automatically retained for subsequent turns. This is the simplest starting point; sticky multi-turn retention can be added later if needed. + +### Codex / Cursor / Windsurf / Aider / Cline + +No direct support. These harnesses use global-eager context (`AGENTS.md`, `.cursor/rules/`, `.clinerules`, etc.) and do not have path-scoped progressive disclosure. Consequently: this directory is invisible to them. Global conventions that must reach all harnesses should live in `AGENTS.md` (separate concern, parked). + +## Pi Extension Details + +### Session-start notification + +The extension calls `ctx.ui.notify()` on `session_start` to show which rules were loaded: + +``` +Loaded rules: voice.md (entries/drafts/**, entries/*/body.md, entries/wip/**/draft.md) +``` + +This appears as a transient message in the TUI's message area, *separate* from Pi's built-in startup header. Pi's own header already auto-lists loaded extensions by filename (e.g., `[Extensions] rules-loader.ts`); the extension can't inject custom text into that line because the header is rendered before `session_start` fires. + +### System prompt catalog + +The catalog is injected into **every** system prompt: + +``` +## Project Rules + +The following rules are available in .agents/rules/: + +- voice.md — applies to: entries/drafts/**, entries/*/body.md, entries/wip/**/draft.md +``` + +**Why a catalog?** + +1. **Metadata index** — Always in context. The LLM knows what rules exist and what scopes they cover even when the full content is not injected. +2. **Injection anchor** — The extension appends full content of active rules directly after the catalog section, creating a predictable structure. +3. **Fallback transparency** — If auto-injection misses a match (glob edge case, uninstrumented tool), the LLM can still `read` the rule explicitly because it knows the filename. + +### One-shot auto-injection flow + +1. **Turn 1, step "read `entries/drafts/draft.md`"**: The `tool_call` handler records `entries/drafts/draft.md`. +2. **Turn 1 ends**: `turn_end` fires. The set of recorded paths is: `{entries/drafts/draft.md}`. +3. **Before Turn 2 LLM call**: `before_agent_start` matches `entries/drafts/draft.md` against `voice.md`'s `paths:`. It's a match. +4. **Turn 2 system prompt** now includes the full content of `voice.md` after the catalog section. +5. **Turn 2, no file access matches** `voice.md`: The next `before_agent_start` does not re-inject `voice.md`. + +### Global extension: discovery strategy + +Since the extension is global (`~/.pi/agent/extensions/`), it must discover `.agents/rules/` local to the current project. Scans in this order until found: + +1. Look in `ctx.cwd/.agents/rules/` +2. Walk up parent directories (stopping at filesystem root or git repo root) +3. Fall back to `~/.agents/rules/` (user-level personal rules) + +This mirrors how Pi discovers project skills and other local resources. + +### Glob matching + +Use picomatch or equivalent for glob matching. The `paths:` patterns in `.claude/rules/` follow picomatch-compatible glob syntax. + +## Repo Changes + +| Step | Action | +|------|--------| +| 1 | Create `.agents/rules/` directory | +| 2 | Move `.claude/rules/voice.md` → `.agents/rules/voice.md` | +| 3 | Replace `.claude/rules/` with a **symlink** to `.agents/rules/` (directory symlink, same pattern as `.claude/skills/`) | +| 4 | Create `~/.pi/agent/extensions/rules-loader.ts` extension (global) | + +## Out of Scope (Future) + +- Sticky / multi-turn rule retention: rules that stay active once triggered until you leave the directory. +- Codex support: would require either (a) Codex adding path-scoped context, or (b) a tool that compiles `.agents/rules/` into `AGENTS.md` flat text. +- `AGENTS.md` / `CLAUDE.md` migration: separate decision, not covered here. +- Cursor / Windsurf / Aider / Cline support: would require writing a flat-rules compiler to their respective global config files. From 910da8ae418551103f27521e18f25e0e87e36440 Mon Sep 17 00:00:00 2001 From: Dean Harel Date: Sat, 2 May 2026 13:57:37 +0300 Subject: [PATCH 2/5] feat(rules): add Pi rules-loader extension - Discovers .agents/rules/ walking up from cwd - Parses frontmatter for path-scoped rules - Records read/edit/write paths per turn - Injects catalog + matching rule content into system prompt - One-shot injection (turn_end clears recorded paths) --- .pi/extensions/rules-loader/.gitignore | 2 + .pi/extensions/rules-loader/index.ts | 229 +++++++++++++++++++++++ .pi/extensions/rules-loader/package.json | 10 + 3 files changed, 241 insertions(+) create mode 100644 .pi/extensions/rules-loader/.gitignore create mode 100644 .pi/extensions/rules-loader/index.ts create mode 100644 .pi/extensions/rules-loader/package.json diff --git a/.pi/extensions/rules-loader/.gitignore b/.pi/extensions/rules-loader/.gitignore new file mode 100644 index 0000000..504afef --- /dev/null +++ b/.pi/extensions/rules-loader/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +package-lock.json diff --git a/.pi/extensions/rules-loader/index.ts b/.pi/extensions/rules-loader/index.ts new file mode 100644 index 0000000..03baf60 --- /dev/null +++ b/.pi/extensions/rules-loader/index.ts @@ -0,0 +1,229 @@ +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { isToolCallEventType } from "@mariozechner/pi-coding-agent"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as picomatch from "picomatch"; + +interface RuleFile { + name: string; + fullPath: string; + paths: string[] | null; // null = global rule + content: string; +} + +/** + * Simple YAML frontmatter parser. Handles the `paths:` array syntax used + * in Claude Code rule files: + * + * --- + * paths: + * - entries/drafts/** + * --- + * + * Returns { frontmatter: Record | null, body: string } + */ +function parseFrontmatter(text: string): { + frontmatter: Record | null; + body: string; +} { + const match = text.match(/^---\s*\n([\s\S]*?)\n---\s*\n?/); + if (!match) { + return { frontmatter: null, body: text }; + } + + const raw = match[1]; + const frontmatter: Record = {}; + + for (const line of raw.split("\n")) { + const colonIndex = line.indexOf(":"); + if (colonIndex === -1) continue; + const key = line.slice(0, colonIndex).trim(); + const value = line.slice(colonIndex + 1).trim(); + + if (key === "paths" && value === "") { + // Multi-line array after `paths:` + frontmatter[key] = []; + } else if (key === "paths" && value.startsWith("[") && value.endsWith("]")) { + // Inline array: `["foo", "bar"]` + try { + frontmatter[key] = JSON.parse(value); + } catch { + frontmatter[key] = [value]; + } + } else if (key.startsWith("- ")) { + // Array item under `paths:` + const item = key.slice(2).trim(); + if (!Array.isArray(frontmatter["paths"])) { + frontmatter["paths"] = []; + } + (frontmatter["paths"] as string[]).push(item); + } else if (key === "paths") { + frontmatter[key] = [value]; + } else { + frontmatter[key] = value; + } + } + + return { + frontmatter, + body: text.slice(match[0].length), + }; +} + +/** + * Recursively find all .md files in a directory. + */ +function findMarkdownFiles(dir: string, basePath: string = ""): string[] { + const results: string[] = []; + if (!fs.existsSync(dir)) return results; + + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const relativePath = basePath ? `${basePath}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + results.push(...findMarkdownFiles(path.join(dir, entry.name), relativePath)); + } else if (entry.isFile() && entry.name.endsWith(".md")) { + results.push(relativePath); + } + } + return results; +} + +/** + * Discover `.agents/rules/` walking up from cwd until found. + * Order: cwd/.agents/rules/ → parent dirs → ~/.agents/rules/ + */ +function discoverRulesDir(cwd: string): string | null { + let current = path.resolve(cwd); + const home = process.env.HOME ? path.resolve(process.env.HOME) : ""; + + while (true) { + const candidate = path.join(current, ".agents", "rules"); + if (fs.existsSync(candidate)) { + return candidate; + } + + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + + // Fallback to user-level rules + if (home) { + const fallback = path.join(home, ".agents", "rules"); + if (fs.existsSync(fallback)) { + return fallback; + } + } + + return null; +} + +export default function rulesLoaderExtension(pi: ExtensionAPI) { + let discoveredRules: RuleFile[] = []; + let rulesDir: string | null = null; + const recordedPaths: Set = new Set(); + + // --- Session Start: discover rules --- + pi.on("session_start", async (_event, ctx) => { + rulesDir = discoverRulesDir(ctx.cwd); + discoveredRules = []; + + if (!rulesDir) return; + + const files = findMarkdownFiles(rulesDir); + for (const relPath of files) { + const fullPath = path.join(rulesDir, relPath); + const content = fs.readFileSync(fullPath, "utf8"); + const { frontmatter, body } = parseFrontmatter(content); + + const paths = frontmatter?.paths; + const pathsArray = + Array.isArray(paths) && paths.every((p) => typeof p === "string") + ? (paths as string[]) + : null; + + discoveredRules.push({ + name: relPath, + fullPath, + paths: pathsArray, + content: body, + }); + } + + if (discoveredRules.length > 0 && ctx.hasUI) { + const list = discoveredRules + .map((r) => { + const scopes = r.paths ? r.paths.join(", ") : "global"; + return `${r.name} (${scopes})`; + }) + .join("\n "); + ctx.ui.notify(`Loaded rules: ${list}`, "info"); + } + }); + + // --- Tool Call: record file access paths --- + pi.on("tool_call", async (event) => { + if ( + isToolCallEventType("read", event) || + isToolCallEventType("edit", event) || + isToolCallEventType("write", event) + ) { + const accessedPath = event.input.path; + if (typeof accessedPath === "string") { + recordedPaths.add(accessedPath); + } + } + }); + + // --- Before Agent Start: inject catalog + matching rules --- + pi.on("before_agent_start", async (event) => { + if (discoveredRules.length === 0) { + return; + } + + // Build catalog (always present) + const catalogLines = discoveredRules.map((r) => { + const scope = r.paths ? `applies to: ${r.paths.join(", ")}` : "global"; + return `- ${r.name} — ${scope}`; + }); + + let catalog = `\n\n## Project Rules\n\nThe following rules are available in .agents/rules/:\n\n${catalogLines.join( + "\n" + )}`; + + // Find matching path-scoped rules + const matchedRules: RuleFile[] = []; + for (const rule of discoveredRules) { + // Global rules are NOT auto-injected (catalog only) + if (!rule.paths) continue; + + for (const accessedPath of recordedPaths) { + for (const pattern of rule.paths) { + if (picomatch.isMatch(accessedPath, pattern)) { + matchedRules.push(rule); + break; + } + } + if (matchedRules.includes(rule)) break; + } + } + + // Append full content of matched rules + if (matchedRules.length > 0) { + catalog += "\n\n---\n\n"; + for (const rule of matchedRules) { + catalog += `\n${rule.content.trim()}\n\n`; + } + } + + return { + systemPrompt: event.systemPrompt + catalog, + }; + }); + + // --- Turn End: clear recorded paths --- + pi.on("turn_end", async () => { + recordedPaths.clear(); + }); +} diff --git a/.pi/extensions/rules-loader/package.json b/.pi/extensions/rules-loader/package.json new file mode 100644 index 0000000..252de20 --- /dev/null +++ b/.pi/extensions/rules-loader/package.json @@ -0,0 +1,10 @@ +{ + "name": "rules-loader", + "version": "1.0.0", + "dependencies": { + "picomatch": "^4.0.2" + }, + "pi": { + "extensions": ["./index.ts"] + } +} From 0f6b29867104a6c109af6d0250afc460d90150e6 Mon Sep 17 00:00:00 2001 From: Dean Harel Date: Sat, 2 May 2026 14:00:53 +0300 Subject: [PATCH 3/5] fix(rules-loader): fix frontmatter array-item parsing Array items under paths: (e.g. '- entries/drafts/**') have no colon, so the old parser skipped them entirely. Now array items are detected via trimmed.startsWith('- ') before the colon check, so multi-line YAML arrays parse correctly. --- .pi/extensions/rules-loader/index.ts | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/.pi/extensions/rules-loader/index.ts b/.pi/extensions/rules-loader/index.ts index 03baf60..636612b 100644 --- a/.pi/extensions/rules-loader/index.ts +++ b/.pi/extensions/rules-loader/index.ts @@ -35,14 +35,28 @@ function parseFrontmatter(text: string): { const frontmatter: Record = {}; for (const line of raw.split("\n")) { + const trimmed = line.trim(); + + // Array item — e.g. ` - entries/drafts/**` + if (trimmed.startsWith("- ")) { + const item = trimmed.slice(2).trim(); + if (!Array.isArray(frontmatter["paths"])) { + frontmatter["paths"] = []; + } + (frontmatter["paths"] as string[]).push(item); + continue; + } + const colonIndex = line.indexOf(":"); if (colonIndex === -1) continue; const key = line.slice(0, colonIndex).trim(); const value = line.slice(colonIndex + 1).trim(); if (key === "paths" && value === "") { - // Multi-line array after `paths:` - frontmatter[key] = []; + // Multi-line array after `paths:` — ensure array exists + if (!Array.isArray(frontmatter["paths"])) { + frontmatter[key] = []; + } } else if (key === "paths" && value.startsWith("[") && value.endsWith("]")) { // Inline array: `["foo", "bar"]` try { @@ -50,13 +64,6 @@ function parseFrontmatter(text: string): { } catch { frontmatter[key] = [value]; } - } else if (key.startsWith("- ")) { - // Array item under `paths:` - const item = key.slice(2).trim(); - if (!Array.isArray(frontmatter["paths"])) { - frontmatter["paths"] = []; - } - (frontmatter["paths"] as string[]).push(item); } else if (key === "paths") { frontmatter[key] = [value]; } else { From 29c4d149dec374dcdc0ade7b9eb3720de60897ab Mon Sep 17 00:00:00 2001 From: Dean Harel Date: Sat, 2 May 2026 14:02:58 +0300 Subject: [PATCH 4/5] docs: document .agents/rules/ universal rules directory --- CLAUDE.md | 2 +- README.md | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 014ce99..ee3b0d9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,7 @@ See `entries/drafts/README.md` for the contribution workflow. - Sentence case for titles (e.g., "Learning new topics faster with AI") - Conversational tone, 2-5 min read -- See `.claude/rules/voice.md` for writing voice guidelines (auto-loaded when editing entries) +- See `.agents/rules/voice.md` for writing voice guidelines (auto-loaded when editing entries) ## entry.yml diff --git a/README.md b/README.md index f006fa3..9ef1700 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,12 @@ Some notes on engineering. +## Project Rules + +Rules live in `.agents/rules/` and are consumed by: +- **Claude Code** — natively via `.claude/rules/` symlink +- **Pi** — via the `rules-loader` extension (auto-injected when file paths match) + ## Entries 1. [Dude, where's my team?](./entries/001-dude-wheres-my-team/) — _Mar 23, 2026 · ~7 min_ From 506e60cc49c20c1dfa352da67b69a6dfcd3d1a03 Mon Sep 17 00:00:00 2001 From: Dean Harel Date: Sat, 2 May 2026 14:09:26 +0300 Subject: [PATCH 5/5] test(rules): add test-rule.md for live Pi/Claude verification A simple rule that triggers on README.md access and produces an unambiguous output marker, making it easy to confirm injection works in both Pi and Claude Code. --- .agents/rules/test-rule.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .agents/rules/test-rule.md diff --git a/.agents/rules/test-rule.md b/.agents/rules/test-rule.md new file mode 100644 index 0000000..f3b718d --- /dev/null +++ b/.agents/rules/test-rule.md @@ -0,0 +1,12 @@ +--- +paths: + - README.md +--- + +# Test Rule — Pi Rules-Loader Verification + +When this rule is active, begin every response with the exact text: + +**[rules-loader extension: rule injection verified]** + +This confirms the `.agents/rules/` path-scoped auto-injection is working correctly in Pi.