pi-code 1.0.4 → 1.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +25 -13
  2. package/extensions/claude-rules.ts +158 -54
  3. package/extensions/commands.ts +417 -41
  4. package/extensions/context-imports.ts +446 -61
  5. package/extensions/hooks.ts +473 -73
  6. package/extensions/init.ts +81 -0
  7. package/extensions/internal/agent-run.ts +42 -0
  8. package/extensions/internal/bash-rules.ts +27 -0
  9. package/extensions/internal/command-file.ts +423 -66
  10. package/extensions/internal/html-markdown.ts +71 -0
  11. package/extensions/internal/instruction-events.ts +70 -0
  12. package/extensions/internal/managed-settings.ts +38 -0
  13. package/extensions/internal/mcp-call.ts +28 -0
  14. package/extensions/internal/mcp-oauth.ts +177 -0
  15. package/extensions/internal/model-complete.ts +68 -0
  16. package/extensions/internal/path-rules.ts +80 -0
  17. package/extensions/internal/plugins.ts +138 -0
  18. package/extensions/internal/project-approval.ts +2 -3
  19. package/extensions/internal/project-root.ts +78 -0
  20. package/extensions/internal/shell-split.ts +65 -0
  21. package/extensions/internal/strip-comments.ts +100 -0
  22. package/extensions/internal/web-transport.ts +3 -1
  23. package/extensions/mcp.ts +579 -30
  24. package/extensions/memory.ts +158 -35
  25. package/extensions/notify.ts +76 -4
  26. package/extensions/output-styles.ts +34 -6
  27. package/extensions/plan-mode/utils.ts +3 -57
  28. package/extensions/question.ts +2 -2
  29. package/extensions/skills.ts +11 -1
  30. package/extensions/status-line.ts +100 -5
  31. package/extensions/subagent/agents.ts +72 -61
  32. package/extensions/subagent/background.ts +25 -6
  33. package/extensions/subagent/index.ts +310 -31
  34. package/extensions/web.ts +93 -15
  35. package/package.json +1 -1
package/README.md CHANGED
@@ -37,23 +37,27 @@ One `pi install` and everything below loads on the next start. `pi list` shows w
37
37
 
38
38
  | Feature | Reads / provides | Extension |
39
39
  |---|---|---|
40
- | Global + project rules | `~/.claude/rules`, `.claude/rules` (+ `paths:` frontmatter scoping) | `claude-rules.ts` |
41
- | Custom slash commands | `.claude/commands/**/*.md` (namespaced `/dir:name`), `$ARGUMENTS`/`$1`, `` !`cmd` `` bash output, `@file` inlining, `allowed-tools`/`argument-hint` frontmatter (`model` is parsed but not yet applied); project commands gated on approval | `commands.ts` |
40
+ | Global + project rules | `~/.claude/rules`, `.claude/rules` (nearest at or above cwd); unscoped rules inlined in full, `paths:`-scoped rules surfaced as pointers and auto-attached (the rule body is appended to a read/edit/write result when a matching file is touched, once per rule per session) | `claude-rules.ts` |
41
+ | Custom slash commands | `.claude/commands/**/*.md` (namespaced `/dir:name`); `$ARGUMENTS` (with the `ARGUMENTS:` append when unused), 0-based `$ARGUMENTS[N]`/`$N`, named `arguments:` frontmatter, `${CLAUDE_SESSION_ID}`/`${CLAUDE_EFFORT}`/`${CLAUDE_SKILL_DIR}`/`${CLAUDE_PROJECT_DIR}` (in bodies and `allowed-tools` rules); `` !`cmd` `` and multi-line ```` ```! ```` bash (whitespace-bounded, merged stderr, 2-minute budget, a failure aborts the invocation with the documented exit-1 carveout), `@file` inlining; `allowed-tools` with `Bash(...)` and `Read`/`Edit`/`Write` path scopes enforced at call time (gitignore anchors, Edit governs writes), `disallowed-tools`, `argument-hint`, `model` (switches the session model for the command's turn, restored after), `shell: powershell` (runs the command's injected spans through PowerShell when a pwsh binary is present, else falls back to /bin/sh); `disable-model-invocation` is parsed but not applied; project commands gated on approval | `commands.ts` |
42
+ | `/init` | generates a project context file: detects an existing `AGENTS.md`/`CLAUDE.md` (proposes improvements) or none (creates `AGENTS.md`, pi's preferred name), ingesting `.cursor/rules`, `.cursorrules`, and `.github/copilot-instructions.md` when present; drives the main agent with full tools via a prompt (not a tool-less completion) so it analyzes the codebase and writes the file itself | `init.ts` |
42
43
  | Skills | `.claude/skills` → pi skill discovery, project skills gated on approval (pi reads `name`, `description`, `disable-model-invocation`; `allowed-tools` is inert in pi's loader) | `skills.ts` |
43
- | Hooks | `.claude/settings.json` hooks: PreToolUse (blocks, rewrites input via `updatedInput`), PostToolUse (feedback and `additionalContext` land next to the tool result), PostToolUseFailure, SessionStart (context injection), UserPromptSubmit (blocks and injects context), Stop (a block continues the conversation), SubagentStart/SubagentStop, PreCompact, PostCompact, SessionEnd; Claude matcher semantics incl. `mcp__server__tool` names; payloads carry session_id, transcript_path, cwd, permission_mode, effort | `hooks.ts` |
44
- | Output styles | `.claude/output-styles` + active `outputStyle`; Claude replace semantics with `keep-coding-instructions`; bundled Explanatory/Learning/Proactive; `/output-style [name]` | `output-styles.ts` |
45
- | CLAUDE.md `@imports` | resolves `@path` imports pi's native loader skips; loads `CLAUDE.local.md` (approval-gated) | `context-imports.ts` |
46
- | MCP servers | user `~/.claude.json` (incl. per-project `projects[cwd]` local scope), `~/.pi/agent/mcp.json`; project `.mcp.json`, `.pi/mcp.json` (once approved; `enabledMcpjsonServers`/`disabledMcpjsonServers`/`enableAllProjectMcpServers` honored, consent keys only from non-repo settings); stdio/HTTP/SSE by `type`; `${VAR:-default}` expansion; `MCP_TIMEOUT`/`MCP_TOOL_TIMEOUT`; tools refresh on `list_changed` | `mcp.ts` |
44
+ | Hooks | `.claude/settings.json` hooks: PreToolUse (blocks, rewrites input via `updatedInput`), PostToolUse (feedback and `additionalContext` land next to the tool result), PostToolUseFailure, SessionStart (context injection), UserPromptSubmit (blocks and injects context), Stop (a block continues the conversation), SubagentStart/SubagentStop, PreCompact, PostCompact, SessionEnd, Notification (idle_prompt, the type pi can source), InstructionsLoaded (observational: fires per loaded context file at session start, plus `path_glob_match` on a scoped-rule attach and `include` per resolved `@import`; deduped per session; `nested_traversal`/`compact` reasons never fire since pi does not lazily load nested CLAUDE.md or reload after compaction); `type: http` entries POST the payload (a 2xx JSON body renders the decision, everything else is non-blocking per Claude's contract), `type: prompt` evaluates in-process against the session model, `type: mcp_tool` calls a connected server's tool, and `type: agent` (experimental) spawns a read-only Read/Grep/Glob subagent that returns the JSON decision (a missing model/server/runner is non-blocking, only a PreToolUse timeout fails closed); Claude matcher semantics incl. `mcp__server__tool` names; payloads carry session_id, transcript_path, cwd, permission_mode, effort; `permissionDecision: "ask"` prompts via a confirm dialog (blocks when headless), SubagentStop/PostToolUseFailure are notify-only, and a timed-out PreToolUse/UserPromptSubmit hook fails closed at a 60s default (Claude: 600s, non-blocking) since pi has no permission backstop | `hooks.ts` |
45
+ | Output styles | `.claude/output-styles` + active `outputStyle`; plus styles shipped by enabled plugins (manifest `outputStyles`, default `output-styles/`, ranked below the user's and project's own); Claude replace semantics with `keep-coding-instructions`; bundled Explanatory/Learning/Proactive; `/output-style [name]` | `output-styles.ts` |
46
+ | CLAUDE.md `@imports` and rewriting | resolves `@path` imports pi's native loader skips (4-hop depth, budget-capped); loads every `CLAUDE.local.md` from the repo root down to cwd (approval-gated, root first); injects managed `claudeMd` from `managed-settings.json` at the top of context; honors `claudeMdExcludes` (glob/absolute-path skip list from user, approved-project, and managed settings, merged; managed content never excluded); strips block-level HTML comments from CLAUDE.md, rule, and imported bodies (fenced-code comments preserved), so a commented-out `@import` does not expand; with `CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD` set, loads `CLAUDE.md`/`.claude/CLAUDE.md`/`.claude/rules/*.md`/`CLAUDE.local.md` from each `--add-dir` directory (comma-separated for several, since pi's flag is single-value) | `context-imports.ts` |
47
+ | MCP servers | user `~/.claude.json` (incl. per-project `projects[cwd]` local scope), `~/.pi/agent/mcp.json`; project `.mcp.json`, `.pi/mcp.json` (once approved; `enabledMcpjsonServers`/`disabledMcpjsonServers`/`enableAllProjectMcpServers` honored, consent keys only from non-repo settings); stdio/HTTP/SSE/WebSocket by `type` (WebSocket is url-only: any `headers`/`bearerToken`/`headersHelper` on a ws server is ignored with a warning); `${VAR:-default}` expansion; a `headersHelper` command whose stdout JSON merges into the transport headers (http/sse); managed `allowedMcpServers`/`deniedMcpServers` read from `managed-settings.json` only (`{serverName}` entries, applied globally across scopes, allow list exclusive with an empty array = lockdown, deny wins); `MCP_TIMEOUT`/`MCP_TOOL_TIMEOUT`; tools refresh on `list_changed`; bearer tokens, or OAuth for remote servers (browser login on 401 after a confirm, tokens under `~/.pi/agent/mcp-oauth`, silent refresh later) | `mcp.ts` |
47
48
  | Project trust | prompts before loading project config (MCP servers, hooks, agents, rules, output styles, commands, skills) that pi would otherwise trust silently | `internal/project-approval.ts` |
48
- | Subagents / Task | builtin Explore/Plan/general-purpose agents, `~/.claude/agents` and `~/.pi/agent/agents`, plus project `.claude/agents` and `.pi/agents`; agent roster with descriptions in the system prompt; `skills` preload; background runs with cancel and resume | `subagent/` |
49
+ | Subagents / Task | builtin Explore/Plan/general-purpose agents, `~/.claude/agents` and `~/.pi/agent/agents`, plus project `.claude/agents` and `.pi/agents` (scanned recursively into subfolders) merged by default once the project is trusted (project wins on a name clash); frontmatter `tools`/`disallowedTools`/`model` (sonnet/opus/haiku/fable tier aliases or a concrete id)/`effort`/`skills` preload/`permissionMode: plan`/`maxTurns`/`memory` (`user`/`project`/`local` give the child its own persistent store under `.claude/agent-memory[-local]`, injected with Read/Write/Edit enabled, gated on auto memory; the parent conversation's memory is never loaded into a subagent, matching Claude); parallel and chain modes (pi extensions), one nesting level; background runs with cancel and resume | `subagent/` |
49
50
  | Plan mode | `plan_mode_complete` tool, tool snapshot/restore that survives `/reload` | `plan-mode/` |
50
51
  | Todo list | persistent overlay, status machine, compaction-safe | `todo.ts` |
51
52
  | Checkpoints / rewind | shadow-repo snapshots; restore overwrites checkpointed files, keeps files created later; 100 per session, repos pruned after 30 days | `git-checkpoint.ts` |
52
- | Persistent memory | per-project memories, index injected each session within Claude's 200-line/25KB bound; a save that would overflow it reports why | `memory.ts` |
53
- | WebSearch / WebFetch | key-free DuckDuckGo search, SSRF-guarded fetch | `web.ts` |
54
- | AskUserQuestion | 1-4 questions per call (asked in sequence), each with `header`, single- or `multiSelect` options, plus free-text | `question.ts` |
55
- | Statusline | Claude `statusLine` command contract (stdin JSON, `padding`, `refreshInterval`); built-in turn state + session cost fallback | `status-line.ts` |
56
- | Notifications | vendored example | `notify.ts` |
53
+ | Persistent memory | per-repo memories under `~/.pi/agent/memory` keyed on the repository root (subdirectory sessions share one store, as Claude does; pi's own store, separate from Claude's), index injected each session within Claude's 200-line/25KB bound (YAML frontmatter and block HTML comments stripped before it counts or loads); a save that would overflow it reports why; a memory written with frontmatter gets a `modified:` ISO timestamp; honors `autoMemoryEnabled` (settings) and `CLAUDE_CODE_DISABLE_AUTO_MEMORY` (env) to turn it off, and `autoMemoryDirectory` (absolute or `~/`) to relocate the store | `memory.ts` |
54
+ | WebSearch / WebFetch | key-free DuckDuckGo search (with `allowed_domains`/`blocked_domains`); SSRF-guarded fetch that prefers markdown via `Accept` then converts HTML, with Claude's 15-minute per-URL cache and an optional `prompt` that runs the page through the model in-process and returns the answer (falls back to markdown when headless or on error) | `web.ts` |
55
+ | AskUserQuestion | 1-4 questions per call (asked in sequence), each with `header` and 2-4 options, single- or `multiSelect`, plus free-text | `question.ts` |
56
+ | Statusline | Claude `statusLine` command contract (stdin JSON incl. `version`, `hook_event_name`, `session_name`, cost durations and line counters, context percentages; `padding`, `refreshInterval`, 300ms debounce); built-in turn state + session cost fallback | `status-line.ts` |
57
+ | Notifications | terminal notification when a turn ends (OSC 777 / Kitty OSC 99 / Windows toast); honors `preferredNotifChannel` (`terminal_bell`, `notifications_disabled`, `iterm2_with_bell`, else desktop) from user settings; fires only when you "appear to be away" (approximated by turn duration, since pi exposes no terminal-focus signal) | `notify.ts` |
58
+ | Claude plugins | installed marketplace plugins (`~/.claude/plugins/cache`), active per `enabledPlugins` in user settings only (a checked-out repo cannot flip which code-bearing plugins run, so project settings never toggle them): commands as `/plugin:name`, agents, hooks and MCP servers (tools aliased `mcp__plugin_<plugin>_<server>__<tool>`) and output styles with `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PLUGIN_DATA}` and `${user_config.KEY}` (from `pluginConfigs[id].options` in user settings) substituted; skill dirs contribute too, though pi's loader names them without the plugin prefix | `internal/plugins.ts` |
59
+
60
+ pi has no general permission system, so most of what Claude routes through a permission prompt maps to hard behavior here: `allowed-tools` restricts the turn's tool set instead of pre-approving calls, and a hook that times out on PreToolUse or UserPromptSubmit fails closed. A hook's `permissionDecision: "ask"` is the exception: it shows a confirm dialog and lets the call through when you approve (a headless run has no dialog, so it blocks). Where a Claude restriction cannot be expressed at all (an argument-scoped grant in an agent's `tools:`), the definition is rejected rather than widened.
57
61
 
58
62
  `CLAUDE.md` itself needs no extension: pi loads `CLAUDE.md` / `AGENTS.md` context files natively (global + walking cwd to root). `context-imports.ts` only adds the `@import` resolution pi's loader lacks, appending the imported files without re-injecting the base.
59
63
 
@@ -71,4 +75,12 @@ scripts/e2e-full.sh # every README feature end to end, model turns included
71
75
  scripts/record-demos.sh # re-records demos/*.tape with vhs at low thinking
72
76
  ```
73
77
 
74
- Extensions live in `extensions/`, tests in `tests/`. Install a local checkout with `pi install ./pi-code`, then `/reload` after edits.
78
+ Extensions live in `extensions/`, tests in `tests/`. Install a local checkout with `pi install ./pi-code`, then `/reload` after edits. Development needs Node `>=22.19`.
79
+
80
+ ## Contributing
81
+
82
+ Issues and pull requests are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, the test and review conventions, and the release process, and the [Code of Conduct](CODE_OF_CONDUCT.md) for community expectations. Report security issues privately as described in [SECURITY.md](SECURITY.md). Release notes are on the [Releases page](https://github.com/ilovepixelart/pi-code/releases).
83
+
84
+ ## License
85
+
86
+ [MIT](LICENSE).
@@ -2,14 +2,16 @@
2
2
  * Claude Rules Extension
3
3
  *
4
4
  * Replicates Claude Code's rules loading:
5
- * - Unscoped global rules (~/.claude/rules/*.md) are inlined in full into the system prompt.
6
- * - Path-scoped global rules and all project rules (.claude/rules/*.md) are listed as
7
- * pointers the agent reads on demand.
8
- *
9
- * Path-scoped rules: a rule file may declare `paths:` frontmatter (a glob or
10
- * list of globs). Pointers surface that scope so the agent knows to read the
11
- * rule when working on matching files. Frontmatter is stripped from inlined
12
- * global rules.
5
+ * - Unscoped rules are inlined in full into the system prompt, global
6
+ * (~/.claude/rules/*.md) and approved-project (.claude/rules/*.md) alike:
7
+ * Claude loads rules without `paths:` frontmatter at launch with the same
8
+ * priority as .claude/CLAUDE.md.
9
+ * - Path-scoped rules auto-attach: a rule file may declare `paths:` frontmatter
10
+ * (a glob or list of globs). Its scope is surfaced upfront as a pointer, and
11
+ * when a read/edit/write touches a file the globs cover, the rule body is
12
+ * appended to that tool's result, once per rule per session. This mirrors
13
+ * Claude Code, which attaches a scoped rule when a matching file is touched
14
+ * rather than inlining it everywhere. Frontmatter is stripped from rule text.
13
15
  *
14
16
  * Adapted from the pi v0.74.2 claude-rules example.
15
17
  */
@@ -19,7 +21,11 @@ import * as os from 'node:os'
19
21
  import * as path from 'node:path'
20
22
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
21
23
 
24
+ import { publishInstructionLoad } from './internal/instruction-events.js'
25
+ import { globToRegExpSource } from './internal/path-rules.js'
22
26
  import { isProjectApproved } from './internal/project-approval.js'
27
+ import { findNearestDir } from './internal/project-root.js'
28
+ import { stripBlockComments } from './internal/strip-comments.js'
23
29
 
24
30
  export interface Frontmatter {
25
31
  paths: string[]
@@ -64,6 +70,32 @@ export function parseFrontmatter(content: string): Frontmatter {
64
70
  return { paths: parsePaths(match[1]), body: content.slice(match[0].length) }
65
71
  }
66
72
 
73
+ /**
74
+ * Whether a file path matches at least one of a rule's `paths:` globs. `*` stays in
75
+ * a segment, `**` crosses directories, a slashless pattern (`*.ts`) matches the
76
+ * basename at any depth (gitignore-style), and a trailing slash (`docs/`) scopes to
77
+ * that directory's contents. `./` and a leading `/` are stripped so a project-root
78
+ * anchored glob resolves the same as a bare one. A bare directory name without a
79
+ * trailing slash or `**` matches a file of that name, not the directory's contents;
80
+ * write `dir/**` to scope to a directory. `relPath` is the touched file relative to
81
+ * the rule set's root.
82
+ */
83
+ export function pathMatchesGlobs(relPath: string, globs: string[]): boolean {
84
+ const posix = relPath.split(path.sep).join('/')
85
+ const base = posix.split('/').pop() ?? posix
86
+ return globs.some((raw) => {
87
+ let glob = raw.trim()
88
+ if (!glob) return false
89
+ if (glob.startsWith('./')) glob = glob.slice(2)
90
+ else if (glob.startsWith('/')) glob = glob.slice(1)
91
+ // A trailing slash means the directory's contents, like gitignore; `docs/` alone
92
+ // would compile to `^docs/$` and match nothing.
93
+ if (glob.endsWith('/')) glob += '**'
94
+ const target = glob.includes('/') ? posix : base
95
+ return new RegExp(`^${globToRegExpSource(glob)}$`).test(target)
96
+ })
97
+ }
98
+
67
99
  /** A rule pointer line, annotated with its path scope when present. */
68
100
  export function formatRulePointer(rel: string, paths: string[], base = '.claude/rules'): string {
69
101
  const ref = `- ${base}/${rel}`
@@ -112,83 +144,155 @@ function findMarkdownFiles(dir: string, basePath = '', visited = new Set<string>
112
144
  return results
113
145
  }
114
146
 
115
- interface ProjectRule {
147
+ interface ScopedRule {
116
148
  rel: string
117
149
  paths: string[]
150
+ /** The rule text, attached when a matching file is touched. */
151
+ body: string
118
152
  }
119
153
 
120
- interface GlobalRules {
121
- inline: string
122
- scoped: ProjectRule[]
154
+ interface RuleSet {
155
+ inline: string[]
156
+ scoped: ScopedRule[]
123
157
  }
124
158
 
125
- /** Unscoped global rules are inlined; path-scoped ones keep their scope as pointers,
159
+ const EMPTY_RULES: RuleSet = { inline: [], scoped: [] }
160
+
161
+ /** Unscoped rules are inlined; path-scoped ones keep their scope as pointers,
126
162
  * mirroring Claude Code, where scoped rules attach only to matching files. */
127
- function readGlobalRules(globalRulesDir: string): GlobalRules {
163
+ function readRules(rulesDir: string): RuleSet {
128
164
  const inline: string[] = []
129
- const scoped: ProjectRule[] = []
130
- for (const file of findMarkdownFiles(globalRulesDir)) {
165
+ const scoped: ScopedRule[] = []
166
+ for (const file of findMarkdownFiles(rulesDir)) {
131
167
  let parsed: Frontmatter
132
168
  try {
133
- parsed = parseFrontmatter(fs.readFileSync(path.join(globalRulesDir, file), 'utf-8'))
169
+ parsed = parseFrontmatter(fs.readFileSync(path.join(rulesDir, file), 'utf-8'))
134
170
  } catch {
135
171
  continue // one unreadable rule must not take down session start
136
172
  }
137
- if (parsed.paths.length > 0) scoped.push({ rel: file, paths: parsed.paths })
138
- else if (parsed.body.trim().length > 0) inline.push(parsed.body.trim())
173
+ // Rule bodies get the same block-level comment strip as CLAUDE.md files
174
+ // before they reach the prompt or attach to a tool result.
175
+ const body = stripBlockComments(parsed.body).trim()
176
+ // A body that strips to nothing has nothing to inline or attach; attaching
177
+ // an empty text block to a tool result is rejected by the API when the
178
+ // result's content is a block array (image-bearing results).
179
+ if (body.length === 0) continue
180
+ if (parsed.paths.length > 0) scoped.push({ rel: file, paths: parsed.paths, body })
181
+ else inline.push(body)
139
182
  }
140
- return { inline: inline.join('\n\n'), scoped }
183
+ return { inline, scoped }
141
184
  }
142
185
 
143
- function readProjectRules(projectRulesDir: string): ProjectRule[] {
144
- return findMarkdownFiles(projectRulesDir).map((rel) => {
145
- try {
146
- return { rel, paths: parseFrontmatter(fs.readFileSync(path.join(projectRulesDir, rel), 'utf-8')).paths }
147
- } catch {
148
- return { rel, paths: [] }
149
- }
150
- })
186
+ /** The system-prompt section for one rule set: inlined bodies, then scoped pointers. */
187
+ function rulesSection(title: string, rules: RuleSet, base: string): string {
188
+ if (rules.inline.length === 0 && rules.scoped.length === 0) return ''
189
+ let section = `\n\n## ${title}`
190
+ if (rules.inline.length > 0) {
191
+ section += `\n\nThese rules always apply:\n\n${rules.inline.join('\n\n')}`
192
+ }
193
+ if (rules.scoped.length > 0) {
194
+ const scopedList = rules.scoped.map((rule) => formatRulePointer(rule.rel, rule.paths, base)).join('\n')
195
+ section += `\n\nPath-scoped rules, available in ${base}/:\n\n${scopedList}\n\nRead the relevant rule file with the read tool before working on the files it covers.`
196
+ }
197
+ return section
198
+ }
199
+
200
+ /** A scoped rule resolved to the root its globs match against, ready to attach. */
201
+ interface AttachTarget {
202
+ key: string
203
+ globs: string[]
204
+ body: string
205
+ /** The absolute directory `paths:` globs are matched relative to. */
206
+ root: string
207
+ /** The rule file's absolute path, reported on the instruction-events bus. */
208
+ file: string
209
+ /** Claude's memory_type for the rule's origin: global rules are User config. */
210
+ memoryType: 'User' | 'Project'
151
211
  }
152
212
 
153
213
  export default function claudeRulesExtension(pi: ExtensionAPI) {
154
214
  const globalRulesDir = path.join(os.homedir(), '.claude', 'rules')
155
- let globalRules: GlobalRules = { inline: '', scoped: [] }
156
- let projectRules: ProjectRule[] = []
215
+ let globalRules: RuleSet = EMPTY_RULES
216
+ let projectRules: RuleSet = EMPTY_RULES
217
+ // The base a scoped-rule pointer is written against, so the model's read resolves.
218
+ // The project rules dir may sit at an ancestor of cwd, where a cwd-relative
219
+ // '.claude/rules' would point the read at a path that does not exist.
220
+ let projectRulesBase = '.claude/rules'
221
+ // Scoped rules ready to attach when a matching file is touched, and the set of
222
+ // rules already attached this session so each attaches at most once.
223
+ let attachTargets: AttachTarget[] = []
224
+ const attached = new Set<string>()
157
225
 
158
226
  pi.on('session_start', async (_event, ctx) => {
159
- globalRules = readGlobalRules(globalRulesDir)
160
- // Project rule filenames and their paths: frontmatter are surfaced in the system prompt.
161
- // isProjectTrusted alone is true for a repo pi never asked about; see project-approval.
227
+ globalRules = readRules(globalRulesDir)
228
+ // Project rules are repository text landing in the system prompt, so they load
229
+ // only once the project is approved. isProjectTrusted alone is true for a repo
230
+ // pi never asked about; see project-approval.
162
231
  const approved = await isProjectApproved(ctx)
163
- projectRules = approved ? readProjectRules(path.join(ctx.cwd, '.claude', 'rules')) : []
232
+ // Nearest at-or-above cwd, so a subdirectory session still reads the rules the
233
+ // approval walk gated on.
234
+ const projectRulesDir = approved ? findNearestDir(ctx.cwd, path.join('.claude', 'rules')) : null
235
+ projectRules = projectRulesDir ? readRules(projectRulesDir) : EMPTY_RULES
236
+
237
+ // Global globs are relative to cwd; project globs to the project root (the dir
238
+ // holding .claude), so `db/**` in a repo rule matches repo-relative paths even
239
+ // from a subdirectory session. Reset per session so a re-run re-attaches.
240
+ attached.clear()
241
+ const projectRoot = projectRulesDir ? path.dirname(path.dirname(projectRulesDir)) : ctx.cwd
242
+ attachTargets = [
243
+ ...globalRules.scoped.map((rule) => ({ key: `global:${rule.rel}`, globs: rule.paths, body: rule.body, root: ctx.cwd, file: path.join(globalRulesDir, rule.rel), memoryType: 'User' as const })),
244
+ ...projectRules.scoped.map((rule) => ({ key: `project:${rule.rel}`, globs: rule.paths, body: rule.body, root: projectRoot, file: path.join(projectRulesDir ?? path.join(ctx.cwd, '.claude', 'rules'), rule.rel), memoryType: 'Project' as const })),
245
+ ]
246
+ // Relative to cwd, which the read tool resolves: an ancestor dir yields a
247
+ // `../…/.claude/rules` the model can follow, where a bare '.claude/rules'
248
+ // would point at a nonexistent path under the subdirectory.
249
+ projectRulesBase = projectRulesDir === null ? '.claude/rules' : path.relative(ctx.cwd, projectRulesDir) || '.claude/rules'
164
250
 
165
251
  const hasGlobal = globalRules.inline.length > 0 || globalRules.scoped.length > 0
166
- if (hasGlobal || projectRules.length > 0) {
167
- ctx.ui.notify(`Rules loaded: global ${hasGlobal ? 'yes' : 'no'}, project ${projectRules.length}`, 'info')
252
+ const projectCount = projectRules.inline.length + projectRules.scoped.length
253
+ if (hasGlobal || projectCount > 0) {
254
+ ctx.ui.notify(`Rules loaded: global ${hasGlobal ? 'yes' : 'no'}, project ${projectCount}`, 'info')
168
255
  }
169
256
  })
170
257
 
171
258
  pi.on('before_agent_start', async (event) => {
172
- let addition = ''
173
-
174
- if (globalRules.inline.length > 0 || globalRules.scoped.length > 0) {
175
- addition += `\n\n## Global Rules`
176
- if (globalRules.inline.length > 0) {
177
- addition += `\n\nThese rules always apply:\n\n${globalRules.inline}`
178
- }
179
- if (globalRules.scoped.length > 0) {
180
- const scopedList = globalRules.scoped.map((rule) => formatRulePointer(rule.rel, rule.paths, '~/.claude/rules')).join('\n')
181
- addition += `\n\nPath-scoped global rules, available in ~/.claude/rules/:\n\n${scopedList}\n\nRead the relevant rule file with the read tool before working on the files it covers.`
182
- }
183
- }
184
-
185
- if (projectRules.length > 0) {
186
- const rulesList = projectRules.map((rule) => formatRulePointer(rule.rel, rule.paths)).join('\n')
187
- addition += `\n\n## Project Rules\n\nThe following project rules are available in .claude/rules/:\n\n${rulesList}\n\nRead the relevant rule file with the read tool before working on the files it covers; rules with an "applies when" scope are path-scoped.`
188
- }
189
-
259
+ // Global first: Claude loads user-level rules before project rules, so project
260
+ // rules read later and take priority.
261
+ const addition = rulesSection('Global Rules', globalRules, '~/.claude/rules') + rulesSection('Project Rules', projectRules, projectRulesBase)
190
262
  if (addition.length === 0) return
191
263
 
192
264
  return { systemPrompt: event.systemPrompt + addition }
193
265
  })
266
+
267
+ // Lazy attach: when a file tool touches a path a scoped rule covers, append the
268
+ // rule body to that tool's result so it enters context, once per rule per session.
269
+ // This mirrors Claude Code, which attaches a scoped rule when a matching file is
270
+ // read or edited rather than inlining it upfront.
271
+ pi.on('tool_result', async (event, ctx) => {
272
+ if (attachTargets.length === 0) return
273
+ if (event.isError) return
274
+ if (event.toolName !== 'read' && event.toolName !== 'edit' && event.toolName !== 'write') return
275
+ const rel = (event.input as { path?: unknown } | undefined)?.path
276
+ if (typeof rel !== 'string' || rel.length === 0) return
277
+ const abs = path.resolve(ctx.cwd, rel)
278
+
279
+ const bodies: string[] = []
280
+ for (const target of attachTargets) {
281
+ if (attached.has(target.key)) continue
282
+ const relativeToRoot = path.relative(target.root, abs)
283
+ // A file outside the rule root cannot match its project-relative globs. Test for
284
+ // a real parent-traversal segment, not a leading '..' (a file named `..config` is
285
+ // inside the root).
286
+ if (relativeToRoot === '..' || relativeToRoot.startsWith(`..${path.sep}`) || path.isAbsolute(relativeToRoot)) continue
287
+ if (!pathMatchesGlobs(relativeToRoot, target.globs)) continue
288
+ attached.add(target.key)
289
+ bodies.push(target.body)
290
+ // The lazy attach is Claude's path_glob_match instruction load; the hooks
291
+ // extension bridges the bus event to the InstructionsLoaded hook. The
292
+ // once-per-session attach set above also bounds the events to one per rule.
293
+ publishInstructionLoad(pi.events, { file_path: target.file, memory_type: target.memoryType, load_reason: 'path_glob_match', globs: target.globs, trigger_file_path: abs })
294
+ }
295
+ if (bodies.length === 0) return
296
+ return { content: [...event.content, ...bodies.map((text) => ({ type: 'text' as const, text }))] }
297
+ })
194
298
  }