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.
- package/README.md +25 -13
- package/extensions/claude-rules.ts +158 -54
- package/extensions/commands.ts +417 -41
- package/extensions/context-imports.ts +446 -61
- package/extensions/hooks.ts +473 -73
- package/extensions/init.ts +81 -0
- package/extensions/internal/agent-run.ts +42 -0
- package/extensions/internal/bash-rules.ts +27 -0
- package/extensions/internal/command-file.ts +423 -66
- package/extensions/internal/html-markdown.ts +71 -0
- package/extensions/internal/instruction-events.ts +70 -0
- package/extensions/internal/managed-settings.ts +38 -0
- package/extensions/internal/mcp-call.ts +28 -0
- package/extensions/internal/mcp-oauth.ts +177 -0
- package/extensions/internal/model-complete.ts +68 -0
- package/extensions/internal/path-rules.ts +80 -0
- package/extensions/internal/plugins.ts +138 -0
- package/extensions/internal/project-approval.ts +2 -3
- package/extensions/internal/project-root.ts +78 -0
- package/extensions/internal/shell-split.ts +65 -0
- package/extensions/internal/strip-comments.ts +100 -0
- package/extensions/internal/web-transport.ts +3 -1
- package/extensions/mcp.ts +579 -30
- package/extensions/memory.ts +158 -35
- package/extensions/notify.ts +76 -4
- package/extensions/output-styles.ts +34 -6
- package/extensions/plan-mode/utils.ts +3 -57
- package/extensions/question.ts +2 -2
- package/extensions/skills.ts +11 -1
- package/extensions/status-line.ts +100 -5
- package/extensions/subagent/agents.ts +72 -61
- package/extensions/subagent/background.ts +25 -6
- package/extensions/subagent/index.ts +310 -31
- package/extensions/web.ts +93 -15
- 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` (
|
|
41
|
-
| Custom slash commands | `.claude/commands/**/*.md` (namespaced `/dir:name`), `$ARGUMENTS`/`$
|
|
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
|
|
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
|
|
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-
|
|
53
|
-
| WebSearch / WebFetch | key-free DuckDuckGo search
|
|
54
|
-
| AskUserQuestion | 1-4 questions per call (asked in sequence), each with `header
|
|
55
|
-
| Statusline | Claude `statusLine` command contract (stdin JSON, `padding`, `refreshInterval
|
|
56
|
-
| Notifications |
|
|
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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* Path-scoped rules: a rule file may declare `paths:` frontmatter
|
|
10
|
-
* list of globs).
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
|
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
|
|
121
|
-
inline: string
|
|
122
|
-
scoped:
|
|
154
|
+
interface RuleSet {
|
|
155
|
+
inline: string[]
|
|
156
|
+
scoped: ScopedRule[]
|
|
123
157
|
}
|
|
124
158
|
|
|
125
|
-
|
|
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
|
|
163
|
+
function readRules(rulesDir: string): RuleSet {
|
|
128
164
|
const inline: string[] = []
|
|
129
|
-
const scoped:
|
|
130
|
-
for (const file of findMarkdownFiles(
|
|
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(
|
|
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
|
-
|
|
138
|
-
|
|
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
|
|
183
|
+
return { inline, scoped }
|
|
141
184
|
}
|
|
142
185
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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:
|
|
156
|
-
let projectRules:
|
|
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 =
|
|
160
|
-
// Project
|
|
161
|
-
// isProjectTrusted alone is true for a repo
|
|
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
|
-
|
|
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
|
-
|
|
167
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
}
|