pi-rules-md 0.0.0-stage → 0.1.0

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 (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +163 -2
  3. package/RULE-SPEC.md +191 -0
  4. package/dist/cli.mjs +842 -0
  5. package/dist/extension.js +11297 -0
  6. package/index.ts +165 -0
  7. package/package.json +52 -3
  8. package/shell/collectors.test.ts +172 -0
  9. package/shell/collectors.ts +109 -0
  10. package/shell/discovery.test.ts +597 -0
  11. package/shell/discovery.ts +348 -0
  12. package/shell/injection.test.ts +164 -0
  13. package/shell/injection.ts +88 -0
  14. package/shell/pipeline.test.ts +243 -0
  15. package/shell/pipeline.ts +200 -0
  16. package/shell/read-rule.test.ts +57 -0
  17. package/shell/read-rule.ts +49 -0
  18. package/shell/rules-paths.test.ts +26 -0
  19. package/shell/rules-paths.ts +17 -0
  20. package/src/cli/constants.ts +1 -0
  21. package/src/cli/debug.ts +6 -0
  22. package/src/cli/doctor.test.ts +161 -0
  23. package/src/cli/doctor.ts +109 -0
  24. package/src/cli/install.test.ts +97 -0
  25. package/src/cli/install.ts +63 -0
  26. package/src/cli/interlock.test.ts +29 -0
  27. package/src/cli/interlock.ts +6 -0
  28. package/src/cli/main.test.ts +88 -0
  29. package/src/cli/main.ts +123 -0
  30. package/src/cli/match.test.ts +86 -0
  31. package/src/cli/match.ts +76 -0
  32. package/src/cli/packaging.test.ts +47 -0
  33. package/src/cli/paths.test.ts +54 -0
  34. package/src/cli/paths.ts +45 -0
  35. package/src/cli/pi-runner.test.ts +79 -0
  36. package/src/cli/pi-runner.ts +62 -0
  37. package/src/cli/registry.test.ts +68 -0
  38. package/src/cli/registry.ts +53 -0
  39. package/src/cli/scopes.ts +19 -0
  40. package/src/cli/settings.test.ts +76 -0
  41. package/src/cli/settings.ts +68 -0
  42. package/src/cli/status.test.ts +107 -0
  43. package/src/cli/status.ts +73 -0
  44. package/src/cli/uninstall.test.ts +149 -0
  45. package/src/cli/uninstall.ts +123 -0
  46. package/src/cli/update.test.ts +126 -0
  47. package/src/cli/update.ts +76 -0
  48. package/src/conditions/predicates.test.ts +118 -0
  49. package/src/conditions/predicates.ts +79 -0
  50. package/src/conditions/table.test.ts +314 -0
  51. package/src/conditions/table.ts +215 -0
  52. package/src/engine.test.ts +476 -0
  53. package/src/engine.ts +165 -0
  54. package/src/frontmatter.test.ts +376 -0
  55. package/src/frontmatter.ts +303 -0
  56. package/src/intent.test.ts +20 -0
  57. package/src/intent.ts +25 -0
  58. package/src/match.test.ts +336 -0
  59. package/src/match.ts +146 -0
  60. package/src/render.golden/multi-rule-mixed-reasons.md +18 -0
  61. package/src/render.golden/no-reasons.md +6 -0
  62. package/src/render.golden/single-rule.md +7 -0
  63. package/src/render.test.ts +162 -0
  64. package/src/render.ts +103 -0
  65. package/src/result.test.ts +27 -0
  66. package/src/result.ts +16 -0
  67. package/src/select.test.ts +280 -0
  68. package/src/select.ts +116 -0
  69. package/src/state.test.ts +205 -0
  70. package/src/state.ts +78 -0
  71. package/src/types.test.ts +291 -0
  72. package/src/types.ts +219 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MetalbolicX
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,164 @@
1
- # Temporary Holding Version
1
+ # pi-rules-md
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Pi extension that discovers markdown rule files and injects conditionally
4
+ matched ones into the system prompt. Rules can activate when the agent touches
5
+ files matching their globs, matching files exist in the project, a selected
6
+ tool or model matches, prompt-derived intent satisfies its glob condition, or
7
+ the rule is always active.
8
+
9
+ Functional core / imperative shell; pure functions and data, no classes.
10
+
11
+ ## How it works
12
+
13
+ 1. **Discovery** — every turn, rule files are discovered from
14
+ `<project>/.pi/rules/**/*.md` (when the project is trusted) and
15
+ `~/.pi/agent/rules/**/*.md` (always), parsed with a strict schema, and
16
+ cached by mtime.
17
+ 2. **Matching** — each rule's activation is evaluated against the live
18
+ session: touched paths, existing files, selected tools, current model, and
19
+ prompt-derived intent.
20
+ 3. **Selection** — pinned rule bodies are content-deduped, budgeted, and
21
+ ordered for presentation. On-demand rules are listed by path instead of
22
+ having their bodies injected.
23
+ 4. **Injection** — the selected rules and on-demand index are rendered into a
24
+ single `pi-rules-md` system-prompt section via
25
+ `systemPromptOptions.sections` in `before_agent_start`. The section is
26
+ re-applied every turn while rules are active; identical content produces no
27
+ prompt diff, and the section is removed when nothing is active.
28
+
29
+ The complete rule-file contract is specified in [RULE-SPEC.md](RULE-SPEC.md).
30
+
31
+ ## Quick start
32
+
33
+ ```markdown
34
+ ---
35
+ description: Follow repo conventions when editing core sources.
36
+ version: 1
37
+ globs:
38
+ - "src/**/*.ts"
39
+ - "!src/**/*.test.ts"
40
+ match: touched
41
+ priority: 10
42
+ ---
43
+ Use tabs, keep functions pure, and never introduce classes in src/.
44
+ ```
45
+
46
+ Drop that file in `.pi/rules/` and start pi. When you edit any
47
+ non-test `src/**/*.ts` file, the rule is injected into the system prompt
48
+ for the session. Run `/rules` to see every rule, why each is active or
49
+ inactive, what the budget dropped, and any parse errors.
50
+
51
+ An `alwaysApply: true` rule skips matching entirely. `tools`, `branch`, and
52
+ `model` conditions are AND-ed with the activation mode; absent conditions mean
53
+ "any."
54
+
55
+ ## Pinned and on-demand rules
56
+
57
+ Rules default to `load: pinned`: when active, their bodies are eligible for
58
+ injection and count toward the token budget. Use pinned rules for concise
59
+ guidance that should be available automatically whenever its conditions match.
60
+
61
+ Use `load: on-demand` for long references, catalogs, or specialized material
62
+ that should not be inserted into every prompt. A matched on-demand rule adds a
63
+ path-and-description entry under the **Reference rules available on demand**
64
+ index, not its body. The agent can fetch the full body with the `read_rule` tool
65
+ using that rules-relative path. On-demand bodies bypass pinned deduplication
66
+ and budgeting; the rendered index still contributes to the reported token
67
+ estimate.
68
+
69
+ ```markdown
70
+ ---
71
+ description: Database migration reference.
72
+ version: 1
73
+ globs:
74
+ - "docs/database/**/*.md"
75
+ load: on-demand
76
+ keywords: [migration, postgres]
77
+ ---
78
+ Detailed migration procedures and reference material...
79
+ ```
80
+
81
+ Use `keywords` when a useful rule should be discoverable from task wording
82
+ that does not mention its file paths. A whole-word, case-insensitive keyword
83
+ match in the current turn prompt donates that rule's positive globs to
84
+ pre-loading. Keywords do not activate a rule by themselves, and donated globs
85
+ do not count as touched paths or expand the filesystem scan. Language words
86
+ also provide built-in intent globs (for example, `python` or `pytest` →
87
+ `*.py`; bare `go` intentionally has no trigger). See [RULE-SPEC.md §5](RULE-SPEC.md#5-activation-semantics)
88
+ for the full list and matching semantics.
89
+
90
+ ## Token budget
91
+
92
+ Pinned rule bodies use an estimated token budget of 2000 by default. Override
93
+ it for the Pi process with a positive integer value, for example:
94
+
95
+ ```sh
96
+ PI_RULES_MAX_TOKENS=3000 pi
97
+ ```
98
+
99
+ The shell parses the override with `parseInt`; a missing value, a value that
100
+ parses to `NaN`, or a non-positive parsed value uses the default. On-demand
101
+ bodies do not consume this budget; their rendered index contributes to the
102
+ final token estimate.
103
+
104
+ ## Install
105
+
106
+ Recommended: install the npm package through the CLI wrapper:
107
+
108
+ ```sh
109
+ npx pi-rules-md install
110
+ ```
111
+
112
+ The wrapper invokes the `pi` CLI for package changes; it never edits
113
+ `settings.json` directly.
114
+
115
+ | Command | Description |
116
+ | --- | --- |
117
+ | `pi-rules install` | Install pi-rules-md. |
118
+ | `pi-rules uninstall` | Remove pi-rules-md. |
119
+ | `pi-rules update` | Update an unpinned npm installation. |
120
+ | `pi-rules status` | Show installation status and available updates. |
121
+ | `pi-rules doctor` | Diagnose installation health. |
122
+
123
+ Common options: `--local` uses project scope for install, uninstall, and
124
+ update; `--purge` removes the package directory during uninstall; `--dry-run`
125
+ prints actions without changing anything; `--agent-dir <dir>` selects a Pi
126
+ agent directory.
127
+
128
+ For local development, load the source directly:
129
+
130
+ ```sh
131
+ pi --extension ./index.ts
132
+ ```
133
+
134
+ For a local-path install, run `pnpm build` first; the package manifest loads
135
+ `./dist/extension.js`. Source entries track the working tree; npm entries are
136
+ updated with `pi-rules update`.
137
+
138
+ ## Commands
139
+
140
+ - `/rules` — report of the last evaluation: active rules with reasons,
141
+ inactive rules with reasons, budget drops with cause, parse/discovery
142
+ errors, and the injected content hash.
143
+
144
+ ## Trust model
145
+
146
+ Project rules (`.pi/rules/`) load only when the project is trusted
147
+ (pi's `isProjectTrusted()`); global rules always load. An unlistable or
148
+ unreadable rule source is reported via `/rules` and never breaks the
149
+ session.
150
+
151
+ ## Development
152
+
153
+ ```sh
154
+ pnpm install
155
+ pnpm typecheck # strict TS, no emit
156
+ pnpm test # vitest: co-located unit tests, goldens, property tests, perf guard
157
+ ```
158
+
159
+ - Functional core: `src/` (types, frontmatter parser, conditions, match,
160
+ select, render, engine, state reducer) — pure, total, property-tested.
161
+ - Imperative shell: `shell/` + `index.ts` (discovery, event collectors,
162
+ injection, wiring) — thin, dependency-injected, structurally mirrored
163
+ against the pi SDK types (no runtime pi import).
164
+ - ODD feature tracking: [odd/tasks/pi-rules-md.md](odd/tasks/pi-rules-md.md).
package/RULE-SPEC.md ADDED
@@ -0,0 +1,191 @@
1
+ # Rule Format v1.1 (RULE-SPEC)
2
+
3
+ Normative specification for pi-rules-md rule files and runtime behavior.
4
+ Frontmatter shape and value constraints are enforced by the parser
5
+ (`src/frontmatter.ts`); the discovery, matching, selection, and rendering
6
+ pipeline applies the remaining semantics.
7
+
8
+ ## 1. File placement and identity
9
+
10
+ | Source | Directory | Loaded when |
11
+ | --- | --- | --- |
12
+ | Project rules | `<project>/.pi/rules/**/*.md` | the project is trusted (`ctx.isProjectTrusted()`) |
13
+ | Global rules | `~/.pi/agent/rules/**/*.md` | always |
14
+
15
+ - Discovery is recursive (`**/*.md`); non-`.md` files are ignored; VCS
16
+ internals (`.git/**`) and dependency trees (`node_modules/**`) are never
17
+ scanned.
18
+ - **Rule id** = file stem, disambiguated across the whole merged pass:
19
+ on collision the first file in deterministic order (project before
20
+ global; sorted by path within each source) keeps the bare stem; later
21
+ collisions get `-2`, `-3`, … (e.g. project `a.md` → `a`, global
22
+ `a.md` → `a-2`).
23
+ - Project rules come first; within a source, files are sorted by path.
24
+ - An unlistable source (e.g. permission error) produces a
25
+ `source-unreadable` error entry for `/rules` and contributes no rules;
26
+ it never breaks the session. An unreadable file produces a
27
+ `read-failure` entry. A file that vanishes mid-scan is skipped
28
+ silently.
29
+
30
+ ## 2. Document shape
31
+
32
+ A rule file is YAML frontmatter plus a markdown body:
33
+
34
+ ```markdown
35
+ ---
36
+ description: One line explaining what the rule enforces.
37
+ version: 1
38
+ globs:
39
+ - "src/**/*.ts"
40
+ - "!src/**/*.test.ts"
41
+ match: touched
42
+ tools: [edit, write]
43
+ branch: main
44
+ model: sonnet
45
+ priority: 10
46
+ enforcement: enforce
47
+ load: pinned
48
+ keywords: [typescript, style]
49
+ alwaysApply: false
50
+ ---
51
+ The rule body: instructions injected verbatim into the system prompt
52
+ section when the rule is active.
53
+ ```
54
+
55
+ ## 3. Keys
56
+
57
+ | Key | Type | Required | Default | Meaning |
58
+ | --- | --- | --- | --- | --- |
59
+ | `description` | string | ✔ | — | Shown in `/rules`; never injected. |
60
+ | `version` | number | ✔ | — | **Exactly `1`** (YAML integer `1`; `1.0` is rejected by strict equality). |
61
+ | `globs` | string[] | see §4 | — | Glob patterns (OR semantics); `!`-prefixed entries are negations. |
62
+ | `match` | `touched` \| `exists` \| `both` | ✘ | `both` | Activation mode (§5). |
63
+ | `tools` | string[] | ✘ | — | Active only when the current tool selection intersects. |
64
+ | `branch` | string | ✘ | — | Active only on this git branch (v1: no live branch source in pi; reserved). |
65
+ | `model` | string | ✘ | — | Active only when the current model name matches. |
66
+ | `priority` | integer | ✘ | `0` | Higher wins in selection; ties keep discovery order (stable). |
67
+ | `enforcement` | `enforce` \| `recommend` \| `inform` | ✘ | `inform` | Parsed and validated; does NOT affect selection or rendering in v1 (reserved for future emphasis). |
68
+ | `load` | `pinned` \| `on-demand` | ✘ | `pinned` | Loading policy metadata; materialized by parsing and does not itself alter condition matching. |
69
+ | `keywords` | string[] | ✘ | — | Intent-donation metadata; trimmed on parse and does not itself gate matching. When a keyword matches a whole word in the current turn prompt (case-insensitive), this rule's positive globs are donated to intent pre-loading. Empty arrays are allowed. |
70
+ | `alwaysApply` | boolean | ✘ | `false` | When true the rule bypasses ALL condition matching (still subject to the trust gate for project rules). |
71
+
72
+ **Unknown keys are rejected** (strict schema). Types must match exactly
73
+ (`globs` and `keywords` entries are strings; `load` is exactly `pinned` or
74
+ `on-demand`; `priority` is an integer; `version` is the number `1`).
75
+
76
+ ## 4. Activation constraints
77
+
78
+ A rule must declare at least one activation trigger: non-empty `globs`,
79
+ non-empty `tools`, `branch`, `model`, or `alwaysApply: true`. Otherwise
80
+ the parse fails with `no-activation-condition`.
81
+
82
+ Special case: an explicit `match` key **requires** `globs`
83
+ (`match-without-globs`) — implicit default `match` without globs is fine
84
+ as long as another trigger exists.
85
+
86
+ Glob entries are picomatch patterns evaluated against cwd-relative,
87
+ `/`-separated, normalized paths. Positive patterns OR; negations
88
+ (`!pattern`) apply per-rule: a path is a candidate when it matches ≥1
89
+ positive pattern, then excluded if it matches any negation.
90
+
91
+ ## 5. Activation semantics
92
+
93
+ - `touched` — active when an agent tool's pre-execution arguments name a path
94
+ matching the globs this session. Paths are captured from `tool_execution_start`
95
+ events; first occurrence wins, order preserved.
96
+ - `exists` — active when any path matching the globs exists on disk
97
+ (checked via the scan seam each evaluation).
98
+ - `both` — touched **OR** exists (the default).
99
+ - Condition keys (`tools`, `branch`, `model`) are AND-ed on top of the
100
+ activation mode; absent means "any".
101
+ - `alwaysApply: true` skips activation and conditions entirely.
102
+ - Before evaluation, the current turn prompt is checked for whole-word, case-insensitive language names: python/pytest → `*.py`, rust → `*.rs`, typescript → `*.ts`, javascript → `*.js`, golang → `*.go`, kotlin → `*.kt`, java → `*.java`, csharp → `*.cs`, ruby → `*.rb`. There is intentionally no bare `go` language trigger.
103
+ - The current turn prompt is also checked for whole-word, case-insensitive matches against each rule's `keywords`; a hit donates that rule's positive globs to the intent set. Keyword metadata does not activate the rule by itself. Intent globs can satisfy a rule's `globs` condition independently of touched/existing path matches, but do not bypass other conditions.
104
+ - **Intent globs never enter the context path pool** (`touchedPaths` or `existingPaths`) and do not expand the filesystem scan. They are a separate input to the globs-condition check.
105
+
106
+ ### Pre-execution path capture
107
+
108
+ `tool_execution_start` is observed before tool execution. The event's `args` are
109
+ untrusted; missing, malformed, non-object, or non-string path arguments and
110
+ unknown tools are ignored. Recognized paths are normalized and deduplicated by
111
+ the session-state reducer.
112
+
113
+ | Pi built-in | Captured argument | Behavior |
114
+ | --- | --- | --- |
115
+ | `read`, `edit`, `write` | `path` | Captured as a file path. |
116
+ | `grep` | optional `path` | Captured as the search scope when present. |
117
+ | `find` | optional `path` | Captured as the glob-search scope when present. |
118
+ | `bash` | none | No capture: Pi's schema has only `command` and `timeout`. |
119
+
120
+ These keys are verified against the installed Pi schemas in
121
+ `dist/core/tools/{read,edit,write,grep,find,bash}.js`. Unlike OpenCode, Pi's
122
+ bash tool has no `workdir` argument. This is a deliberate runtime divergence:
123
+ we do not parse paths from shell command strings.
124
+
125
+ ## 6. Selection, budget, rendering
126
+
127
+ - Every discovered rule receives a monotonic `index` in discovery order:
128
+ project rules first, then global rules, retaining each source's order.
129
+ - Content-level dedupe: rules with byte-identical full content are
130
+ deduplicated; higher `priority` wins, and equal priorities keep the later
131
+ discovery `index`. Losers are dropped with cause `duplicate`. This
132
+ supersedes v1's first-occurrence-wins behavior.
133
+ - Budget candidates are considered by `priority` DESC, then `index` ASC.
134
+ Greedy fill admits each candidate that fits and skips a non-fitting rule
135
+ while continuing to later candidates. If candidates exist, the first is
136
+ always kept even when it exceeds the budget; this supersedes v1's stop-at-
137
+ first-non-fit behavior.
138
+ - After budget selection, presentation order is unconditional rules first
139
+ (`alwaysApply: true` or no declared activation conditions), then
140
+ `priority` DESC, then `index` ASC.
141
+ - Token budget defaults to 2000 estimated tokens (`ceil(chars/4)` of each
142
+ pinned rule body's content). The shell parses `PI_RULES_MAX_TOKENS` with
143
+ `parseInt`; an absent value, a value that parses to `NaN`, or a non-positive
144
+ parsed value uses the default. A positive parsed integer overrides it.
145
+ - On-demand rules bypass pinned-rule dedupe and budget selection. Their bodies
146
+ are never injected or counted against that budget. Instead, the rendered
147
+ section appends an index headed exactly
148
+ `## Reference rules available on demand`, with one `- <rules-relative-path>
149
+ — <description> (fetch with the read_rule tool)` line per rule. No index is
150
+ rendered when no on-demand rule
151
+ matched. Each rule's `relativePath` is a POSIX path relative to its source's
152
+ rules root, never an absolute path.
153
+ - `read_rule` looks up a discovered rule by exact `relativePath` and returns
154
+ its parsed `content` body directly; it performs no filesystem read. Absolute
155
+ paths (POSIX or Windows) and any name containing a `..` segment, as well as
156
+ unknown names, return `Unknown rule: <name>`. The FROZEN tool description is:
157
+ `Fetch the full content of a reference rule by its rules-relative path. Use
158
+ when a "Reference rules available on demand" index entry is relevant to the
159
+ current task.`
160
+ - The evaluation trace partitions matched rules into pinned `active` and
161
+ `onDemand`, reports all matched rule file paths (including on-demand paths),
162
+ and estimates tokens over the complete rendered block including its index.
163
+ - Rendered as one `pi-rules-md` system-prompt section: `# Rules` header,
164
+ framing line, one entry per selected pinned rule (`## <description>`), optional
165
+ `> Triggered by: <reasons>` when reasons exist, and `---` separators.
166
+ Pinned rule bodies are injected verbatim with trailing blank-line
167
+ normalization; the section ends with exactly one trailing newline.
168
+ - The section is re-applied on **every** agent turn while any pinned or
169
+ on-demand rule is active (Pi rebuilds its prompt sections per turn); identical
170
+ content produces no prompt diff (zero churn). When no rule is active and one
171
+ was previously injected, the section is removed.
172
+
173
+ ### v1 behavior superseded by v1.1
174
+
175
+ - Equal-content pinned rules no longer keep the first occurrence: the higher
176
+ priority wins, with the later discovery index breaking priority ties.
177
+ - Budget selection no longer stops at the first non-fitting candidate. It skips
178
+ that candidate and continues; the first priority-sorted candidate is retained
179
+ even when it exceeds the budget.
180
+ - The pinned entry heading is `## <description>` (not `## <id>`), matching the
181
+ renderer and its contract tests.
182
+
183
+ ## 7. Errors (visible via `/rules`)
184
+
185
+ Parse and discovery errors never break the session; they are collected
186
+ and reported: `yaml-syntax`, `missing-frontmatter`, `unknown-key`,
187
+ `invalid-version`, `missing-description`, `invalid-value` (field type),
188
+ `match-without-globs`, `no-activation-condition`, `read-failure`,
189
+ `source-unreadable`. Error entries carry the offending file (or
190
+ directory for `source-unreadable`) and are reused from the mtime cache
191
+ while the file is unchanged.