@lastboy/pai 0.1.0 → 0.3.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.
- package/README.md +49 -22
- package/dist/adapters/claude/guidelines.js +35 -23
- package/dist/cli/program.js +60 -43
- package/dist/cli/render.js +2 -1
- package/dist/core/guidelines.js +19 -0
- package/dist/core/managed-guidelines.js +173 -0
- package/dist/core/rule-store.js +0 -0
- package/dist/persistence/managed-guideline-files.js +88 -0
- package/dist/persistence/rule-store-files.js +28 -4
- package/dist/persistence/text-file.js +23 -0
- package/docs/principles.md +4 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -58,7 +58,8 @@ pai review
|
|
|
58
58
|
|
|
59
59
|
Shows your guidelines from the CLAUDE.md files that apply to the current
|
|
60
60
|
folder — global (`~/.claude/CLAUDE.md`) and project (`CLAUDE.md`,
|
|
61
|
-
`CLAUDE.local.md`) —
|
|
61
|
+
`CLAUDE.local.md`) — plus the PAI-managed `CLAUDE.pai.md` next to each, all
|
|
62
|
+
grouped by their markdown headings. Managed files are labeled `PAI managed`.
|
|
62
63
|
|
|
63
64
|
| Option | Effect |
|
|
64
65
|
|---|---|
|
|
@@ -77,7 +78,13 @@ Search matches word starts, so `--search ask` finds "Ask me before…" but not
|
|
|
77
78
|
|
|
78
79
|
### `pai export`
|
|
79
80
|
|
|
80
|
-
Exports
|
|
81
|
+
Exports the same rules `pai rules` shows — from `CLAUDE.md` and
|
|
82
|
+
`CLAUDE.pai.md`, global then project — as portable, versioned JSON.
|
|
83
|
+
`CLAUDE.local.md` is per-machine and is not exported.
|
|
84
|
+
|
|
85
|
+
```json
|
|
86
|
+
{ "version": 2, "rules": [ { "rule": "…", "category": "…", "scope": "global" } ] }
|
|
87
|
+
```
|
|
81
88
|
|
|
82
89
|
| Option | Effect |
|
|
83
90
|
|---|---|
|
|
@@ -92,16 +99,25 @@ pai export --scope global --out mine.json # only user-level rules
|
|
|
92
99
|
### `pai import <file>`
|
|
93
100
|
|
|
94
101
|
Merges a previously exported file into this machine. Rules carry their own
|
|
95
|
-
scope
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
102
|
+
scope: global rules go to `~/.claude/CLAUDE.pai.md`, project rules to
|
|
103
|
+
`<project>/CLAUDE.pai.md` — no flag needed. For each scope PAI:
|
|
104
|
+
|
|
105
|
+
1. makes sure `CLAUDE.md` exists and contains the import line `@CLAUDE.pai.md`
|
|
106
|
+
(created with only that line if missing; the line is appended in place if
|
|
107
|
+
absent, so a symlinked CLAUDE.md stays a symlink; nothing else in the file
|
|
108
|
+
is touched);
|
|
109
|
+
2. adds the rules that `CLAUDE.pai.md` does not have yet, under their category
|
|
110
|
+
headings, keeping everything already there exactly as it is.
|
|
111
|
+
|
|
112
|
+
The merge is **non-destructive** and idempotent: nothing is overwritten or
|
|
113
|
+
removed, and re-importing the same file adds nothing. Rule identity is the
|
|
114
|
+
normalized text (case, punctuation, whitespace and line endings ignored).
|
|
115
|
+
Hand-written `CLAUDE.md` rules are never used for deduplication — only
|
|
116
|
+
`CLAUDE.pai.md` is. Files from the older `version: 1` store format are accepted.
|
|
101
117
|
|
|
102
118
|
| Option | Effect |
|
|
103
119
|
|---|---|
|
|
104
|
-
| `--dry-run` | report what would change without writing |
|
|
120
|
+
| `--dry-run` | report what would change ("would add …") without writing |
|
|
105
121
|
|
|
106
122
|
```bash
|
|
107
123
|
pai import mine.json --dry-run
|
|
@@ -110,6 +126,14 @@ pai import mine.json
|
|
|
110
126
|
|
|
111
127
|
Invalid or unsupported files fail with a clear message and a non-zero exit code.
|
|
112
128
|
|
|
129
|
+
**Cross-platform.** A file exported on macOS or Linux imports on Windows and
|
|
130
|
+
back. Exports contain no filesystem paths, so nothing is machine-specific, and
|
|
131
|
+
import accepts the encodings Windows tooling produces: UTF-8, UTF-8 with a BOM
|
|
132
|
+
(Notepad, `Set-Content`) and UTF-16 with a BOM (PowerShell 5.1's `>` redirect).
|
|
133
|
+
Line endings are normalized, so the same rule written on Windows and on
|
|
134
|
+
macOS/Linux counts as one instead of duplicating; `CLAUDE.pai.md` is always
|
|
135
|
+
written as UTF-8 with LF line endings.
|
|
136
|
+
|
|
113
137
|
### `pai experiment …`
|
|
114
138
|
|
|
115
139
|
Experimental commands. They may change or disappear, and they are the only
|
|
@@ -127,26 +151,29 @@ Default model: `qwen2.5:14b`.
|
|
|
127
151
|
|
|
128
152
|
## Where PAI stores things
|
|
129
153
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
| `~/.pai/rules.json` | your user-level rules |
|
|
133
|
-
| `<project>/.pai/rules.json` | rules scoped to that project |
|
|
154
|
+
Your rules have one source of truth: the CLAUDE.md family of files the coding
|
|
155
|
+
agent already reads.
|
|
134
156
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
157
|
+
| Path | Contents | Who writes it |
|
|
158
|
+
|---|---|---|
|
|
159
|
+
| `~/.claude/CLAUDE.md` | your hand-written global rules | you |
|
|
160
|
+
| `~/.claude/CLAUDE.pai.md` | global rules PAI added | PAI |
|
|
161
|
+
| `<project>/CLAUDE.md` | hand-written project rules | you |
|
|
162
|
+
| `<project>/CLAUDE.pai.md` | project rules PAI added | PAI |
|
|
163
|
+
| `~/.pai/`, `<project>/.pai/` | reserved for PAI metadata (future) | PAI |
|
|
140
164
|
|
|
141
|
-
PAI
|
|
165
|
+
PAI never merges into a hand-written CLAUDE.md. The only change it makes there
|
|
166
|
+
is adding the single line `@CLAUDE.pai.md` — Claude Code's native import — so
|
|
167
|
+
the agent reads the managed file too. Rule identity comes from the rule text,
|
|
168
|
+
so the same rule imported twice, or written on two machines, counts once.
|
|
142
169
|
|
|
143
170
|
## What PAI reads
|
|
144
171
|
|
|
145
172
|
| Path | Used for |
|
|
146
173
|
|---|---|
|
|
147
174
|
| `~/.claude/projects/<encoded-cwd>/*.jsonl` | session transcripts (read-only) |
|
|
148
|
-
| `~/.claude/CLAUDE.md` | global guidelines |
|
|
149
|
-
| `<project>/CLAUDE.md`, `CLAUDE.local.md` | project guidelines |
|
|
175
|
+
| `~/.claude/CLAUDE.md`, `CLAUDE.pai.md` | global guidelines |
|
|
176
|
+
| `<project>/CLAUDE.md`, `CLAUDE.local.md`, `CLAUDE.pai.md` | project guidelines |
|
|
150
177
|
|
|
151
178
|
The encoded folder name is the project path with every non-alphanumeric
|
|
152
179
|
character replaced by `-`.
|
|
@@ -156,7 +183,7 @@ character replaced by `-`.
|
|
|
156
183
|
```
|
|
157
184
|
src/core/ agent-independent logic (sessions, corrections, rules, review)
|
|
158
185
|
src/adapters/ per-agent integration — currently only claude/
|
|
159
|
-
src/persistence/
|
|
186
|
+
src/persistence/ file I/O — CLAUDE.pai.md, text decoding, the .pai/ store
|
|
160
187
|
src/experiments/ local-LLM experiments (Ollama, optional)
|
|
161
188
|
src/cli/ command wiring and rendering
|
|
162
189
|
tests/ vitest suites and sanitized fixtures
|
|
@@ -1,32 +1,44 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
|
-
import { join } from 'node:path';
|
|
3
|
+
import { basename, join } from 'node:path';
|
|
4
|
+
import { parseGuidelines } from '../../core/guidelines.js';
|
|
5
|
+
export { parseGuidelines };
|
|
6
|
+
/** PAI's own guidance file, next to the hand-written CLAUDE.md. */
|
|
7
|
+
export const MANAGED_FILE_NAME = 'CLAUDE.pai.md';
|
|
8
|
+
/** Claude Code's native import line; added once to CLAUDE.md so it reads ours. */
|
|
9
|
+
export const POINTER_LINE = `@${MANAGED_FILE_NAME}`;
|
|
10
|
+
const LOCAL_FILE_NAME = 'CLAUDE.local.md';
|
|
11
|
+
/** Directory that holds a scope's CLAUDE.md family of files. */
|
|
12
|
+
export function guidelineDir(scope, cwd, home = homedir()) {
|
|
13
|
+
return scope === 'global' ? join(home, '.claude') : cwd;
|
|
14
|
+
}
|
|
15
|
+
export function managedGuidelinePath(dir) {
|
|
16
|
+
return join(dir, MANAGED_FILE_NAME);
|
|
17
|
+
}
|
|
18
|
+
/** Everything `pai import` needs to know about one scope's directory. */
|
|
19
|
+
export function managedImportTarget(dir) {
|
|
20
|
+
return {
|
|
21
|
+
claudeMdPath: join(dir, 'CLAUDE.md'),
|
|
22
|
+
managedPath: managedGuidelinePath(dir),
|
|
23
|
+
pointer: POINTER_LINE,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
4
26
|
// Claude Code guidance locations: ~/.claude/CLAUDE.md (global) and
|
|
5
|
-
// CLAUDE.md / CLAUDE.local.md at the project root
|
|
6
|
-
// traversal is not implemented yet.
|
|
27
|
+
// CLAUDE.md / CLAUDE.local.md at the project root, each followed by the
|
|
28
|
+
// PAI-managed sibling. Parent-directory traversal is not implemented yet.
|
|
7
29
|
export function findGuidelineFiles(cwd, home = homedir()) {
|
|
30
|
+
const global = guidelineDir('global', cwd, home);
|
|
31
|
+
const project = guidelineDir('project', cwd, home);
|
|
8
32
|
const candidates = [
|
|
9
|
-
{ path: join(
|
|
10
|
-
{ path:
|
|
11
|
-
{ path: join(
|
|
33
|
+
{ path: join(global, 'CLAUDE.md'), scope: 'global', managed: false },
|
|
34
|
+
{ path: managedGuidelinePath(global), scope: 'global', managed: true },
|
|
35
|
+
{ path: join(project, 'CLAUDE.md'), scope: 'project', managed: false },
|
|
36
|
+
{ path: join(project, LOCAL_FILE_NAME), scope: 'project', managed: false },
|
|
37
|
+
{ path: managedGuidelinePath(project), scope: 'project', managed: true },
|
|
12
38
|
];
|
|
13
39
|
return candidates.filter((candidate) => existsSync(candidate.path));
|
|
14
40
|
}
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const guidelines = [];
|
|
19
|
-
let category = 'General';
|
|
20
|
-
for (const line of markdown.split('\n')) {
|
|
21
|
-
const heading = HEADING.exec(line);
|
|
22
|
-
if (heading?.[1]) {
|
|
23
|
-
category = heading[1];
|
|
24
|
-
continue;
|
|
25
|
-
}
|
|
26
|
-
const bullet = BULLET.exec(line);
|
|
27
|
-
if (bullet?.[1]) {
|
|
28
|
-
guidelines.push({ category, text: bullet[1] });
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
return guidelines;
|
|
41
|
+
/** Files that travel between machines: CLAUDE.local.md is per-machine and stays out. */
|
|
42
|
+
export function findExportableGuidelineFiles(cwd, home = homedir()) {
|
|
43
|
+
return findGuidelineFiles(cwd, home).filter((file) => basename(file.path) !== LOCAL_FILE_NAME);
|
|
32
44
|
}
|
package/dist/cli/program.js
CHANGED
|
@@ -12,10 +12,11 @@ import { detectPossibleCorrections } from '../core/corrections.js';
|
|
|
12
12
|
import { analyzeWithOllama, buildCorrectionContext, buildPrompt, } from '../experiments/correction-analysis.js';
|
|
13
13
|
import { ollamaGenerate } from '../experiments/ollama.js';
|
|
14
14
|
import { buildDistillPrompt, parseDistillResponse } from '../experiments/rule-distillation.js';
|
|
15
|
-
import { findGuidelineFiles,
|
|
15
|
+
import { findExportableGuidelineFiles, findGuidelineFiles, guidelineDir, managedImportTarget, } from '../adapters/claude/guidelines.js';
|
|
16
16
|
import { filterGuidelineGroups } from '../core/guidelines.js';
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
17
|
+
import { parseExportDocument, serializeExportDocument, toExportRules, } from '../core/managed-guidelines.js';
|
|
18
|
+
import { importManagedGuidelines, readGuidelineGroups, } from '../persistence/managed-guideline-files.js';
|
|
19
|
+
import { decodeTextFile } from '../persistence/text-file.js';
|
|
19
20
|
import { parseSelection, renderGuidelines, renderReview, renderSessionList } from './render.js';
|
|
20
21
|
async function ask(question) {
|
|
21
22
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
@@ -48,6 +49,10 @@ async function chooseSession(out) {
|
|
|
48
49
|
out('');
|
|
49
50
|
return sessions[index];
|
|
50
51
|
}
|
|
52
|
+
/** Node's fs errors are readable already; anything else gets stringified. */
|
|
53
|
+
function describeError(error) {
|
|
54
|
+
return error instanceof Error ? error.message : String(error);
|
|
55
|
+
}
|
|
51
56
|
// Same relative depth from src/cli and dist/cli.
|
|
52
57
|
function packageVersion() {
|
|
53
58
|
const pkg = JSON.parse(readFileSync(join(import.meta.dirname, '..', '..', 'package.json'), 'utf8'));
|
|
@@ -74,7 +79,7 @@ export function createProgram(out) {
|
|
|
74
79
|
process.exitCode = 1;
|
|
75
80
|
return;
|
|
76
81
|
}
|
|
77
|
-
const transcript = await readFile(chosen.path
|
|
82
|
+
const transcript = decodeTextFile(await readFile(chosen.path));
|
|
78
83
|
const session = parseTranscript(transcript, chosen.id);
|
|
79
84
|
for (const line of renderReview(buildReview(session))) {
|
|
80
85
|
out(line);
|
|
@@ -83,16 +88,11 @@ export function createProgram(out) {
|
|
|
83
88
|
program
|
|
84
89
|
.command('rules')
|
|
85
90
|
.description('Show your guidelines from CLAUDE.md files, categorized')
|
|
86
|
-
.option('--global', 'only global rules (~/.claude/CLAUDE.md)')
|
|
87
|
-
.option('--project', 'only project rules (CLAUDE.md, CLAUDE.local.md)')
|
|
91
|
+
.option('--global', 'only global rules (~/.claude/CLAUDE.md, CLAUDE.pai.md)')
|
|
92
|
+
.option('--project', 'only project rules (CLAUDE.md, CLAUDE.local.md, CLAUDE.pai.md)')
|
|
88
93
|
.option('--search <text>', 'only rules whose text or category starts a word with <text>')
|
|
89
|
-
.action(
|
|
90
|
-
const
|
|
91
|
-
const groups = await Promise.all(files.map(async (file) => ({
|
|
92
|
-
scope: file.scope,
|
|
93
|
-
path: file.path,
|
|
94
|
-
guidelines: parseGuidelines(await readFile(file.path, 'utf8')),
|
|
95
|
-
})));
|
|
94
|
+
.action((options) => {
|
|
95
|
+
const groups = readGuidelineGroups(findGuidelineFiles(process.cwd()));
|
|
96
96
|
// --global and --project together = no scope filter (same as neither).
|
|
97
97
|
const scope = options.global && !options.project
|
|
98
98
|
? 'global'
|
|
@@ -109,7 +109,7 @@ export function createProgram(out) {
|
|
|
109
109
|
});
|
|
110
110
|
program
|
|
111
111
|
.command('export')
|
|
112
|
-
.description('Export
|
|
112
|
+
.description('Export your rules (CLAUDE.md + CLAUDE.pai.md) to a portable JSON file (or stdout)')
|
|
113
113
|
.option('--out <file>', 'write to this file instead of stdout')
|
|
114
114
|
.option('--scope <scope>', 'export only "global" or "project" rules')
|
|
115
115
|
.action((options) => {
|
|
@@ -118,47 +118,64 @@ export function createProgram(out) {
|
|
|
118
118
|
process.exitCode = 1;
|
|
119
119
|
return;
|
|
120
120
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
121
|
+
try {
|
|
122
|
+
// Global files come first, so the output order is global → project.
|
|
123
|
+
const files = findExportableGuidelineFiles(process.cwd()).filter((file) => options.scope === undefined || file.scope === options.scope);
|
|
124
|
+
const rules = toExportRules(readGuidelineGroups(files));
|
|
125
|
+
const json = serializeExportDocument(rules);
|
|
126
|
+
if (options.out === undefined) {
|
|
127
|
+
out(json.trimEnd());
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
writeFileSync(options.out, json, 'utf8');
|
|
131
|
+
out(`Exported ${rules.length} rule(s) to ${options.out}`);
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
out(`Export failed: ${describeError(error)}`);
|
|
135
|
+
process.exitCode = 1;
|
|
131
136
|
}
|
|
132
|
-
writeFileSync(options.out, json, 'utf8');
|
|
133
|
-
out(`Exported ${combined.rules.length} rule(s) to ${options.out}`);
|
|
134
137
|
});
|
|
135
138
|
program
|
|
136
139
|
.command('import')
|
|
137
140
|
.argument('<file>', 'JSON file previously produced by "pai export"')
|
|
138
|
-
.description('Merge rules from a file into this machine (never overwrites)')
|
|
141
|
+
.description('Merge rules from a file into CLAUDE.pai.md on this machine (never overwrites)')
|
|
139
142
|
.option('--dry-run', 'show what would change without writing')
|
|
140
143
|
.action(async (file, options) => {
|
|
141
144
|
let incoming;
|
|
142
145
|
try {
|
|
143
|
-
incoming =
|
|
146
|
+
incoming = parseExportDocument(decodeTextFile(await readFile(file)));
|
|
144
147
|
}
|
|
145
148
|
catch (error) {
|
|
146
|
-
out(
|
|
149
|
+
out(`Could not read ${file}: ${describeError(error)}`);
|
|
147
150
|
process.exitCode = 1;
|
|
148
151
|
return;
|
|
149
152
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
153
|
+
try {
|
|
154
|
+
for (const scope of ['global', 'project']) {
|
|
155
|
+
const rules = incoming.rules.filter((rule) => rule.scope === scope);
|
|
156
|
+
if (rules.length === 0)
|
|
157
|
+
continue;
|
|
158
|
+
const dir = guidelineDir(scope, process.cwd());
|
|
159
|
+
out(`Target (${scope}): ${dir}`);
|
|
160
|
+
const result = importManagedGuidelines(managedImportTarget(dir), rules, {
|
|
161
|
+
dryRun: options.dryRun,
|
|
162
|
+
});
|
|
163
|
+
const dry = options.dryRun === true;
|
|
164
|
+
if (result.pointer === 'created') {
|
|
165
|
+
out(dry ? ' would create CLAUDE.md with pointer' : ' created CLAUDE.md with pointer');
|
|
166
|
+
}
|
|
167
|
+
if (result.pointer === 'appended') {
|
|
168
|
+
out(dry ? ' would add pointer line to CLAUDE.md' : ' added pointer line to CLAUDE.md');
|
|
169
|
+
}
|
|
170
|
+
const added = dry ? `would add ${result.added} rule(s)` : `${result.added} added`;
|
|
171
|
+
out(`${scope}: ${added}, ${result.existing} already present → ${result.managedPath}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
out(`Import failed: ${describeError(error)}`);
|
|
176
|
+
out('Nothing was changed for the scope that failed.');
|
|
177
|
+
process.exitCode = 1;
|
|
178
|
+
return;
|
|
162
179
|
}
|
|
163
180
|
if (incoming.rules.length === 0)
|
|
164
181
|
out('Nothing to import — the file contains no rules.');
|
|
@@ -179,7 +196,7 @@ export function createProgram(out) {
|
|
|
179
196
|
process.exitCode = 1;
|
|
180
197
|
return;
|
|
181
198
|
}
|
|
182
|
-
const transcript = await readFile(chosen.path
|
|
199
|
+
const transcript = decodeTextFile(await readFile(chosen.path));
|
|
183
200
|
const events = parseEvents(transcript);
|
|
184
201
|
const candidates = events
|
|
185
202
|
.map((event, index) => ({ event, index }))
|
|
@@ -265,7 +282,7 @@ export function createProgram(out) {
|
|
|
265
282
|
process.exitCode = 1;
|
|
266
283
|
return;
|
|
267
284
|
}
|
|
268
|
-
const transcript = await readFile(chosen.path
|
|
285
|
+
const transcript = decodeTextFile(await readFile(chosen.path));
|
|
269
286
|
const messages = parseEvents(transcript).filter((e) => e.kind === 'user');
|
|
270
287
|
const limit = Math.max(1, Number(options.limit) || 10);
|
|
271
288
|
const sample = messages.slice(-limit);
|
package/dist/cli/render.js
CHANGED
|
@@ -2,7 +2,8 @@ export function renderGuidelines(groups) {
|
|
|
2
2
|
const lines = ['PAI — Guidelines'];
|
|
3
3
|
for (const group of groups) {
|
|
4
4
|
lines.push('');
|
|
5
|
-
|
|
5
|
+
const scope = group.scope === 'global' ? 'Global' : 'Project';
|
|
6
|
+
lines.push(`${group.managed ? `${scope} — PAI managed` : scope} (${group.path})`);
|
|
6
7
|
if (group.guidelines.length === 0) {
|
|
7
8
|
lines.push(' (no guidelines found)');
|
|
8
9
|
continue;
|
package/dist/core/guidelines.js
CHANGED
|
@@ -1,3 +1,22 @@
|
|
|
1
|
+
export const HEADING = /^#{1,6}\s+(.+?)\s*$/;
|
|
2
|
+
export const BULLET = /^\s*[-*]\s+(.+?)\s*$/;
|
|
3
|
+
export function parseGuidelines(markdown) {
|
|
4
|
+
const guidelines = [];
|
|
5
|
+
let category = 'General';
|
|
6
|
+
// A BOM (Windows editors) would otherwise hide the first heading.
|
|
7
|
+
for (const line of markdown.replace(/^/, '').split('\n')) {
|
|
8
|
+
const heading = HEADING.exec(line);
|
|
9
|
+
if (heading?.[1]) {
|
|
10
|
+
category = heading[1];
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
const bullet = BULLET.exec(line);
|
|
14
|
+
if (bullet?.[1]) {
|
|
15
|
+
guidelines.push({ category, text: bullet[1] });
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return guidelines;
|
|
19
|
+
}
|
|
1
20
|
export function filterGuidelineGroups(groups, filters) {
|
|
2
21
|
const query = filters.search?.trim();
|
|
3
22
|
// Word-start match: "ask" hits "Ask"/"asking" but not "tasks".
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// PAI's rules live in markdown the coding agent already reads. Hand-written
|
|
2
|
+
// guidance files are never edited; PAI owns one sibling file per scope and
|
|
3
|
+
// merges into it. Everything here is pure — paths and I/O live elsewhere.
|
|
4
|
+
import { HEADING, parseGuidelines } from './guidelines.js';
|
|
5
|
+
import { parseRuleStore, ruleId } from './rule-store.js';
|
|
6
|
+
const EXPORT_VERSION = 2;
|
|
7
|
+
export const MANAGED_HEADER = '<!-- Managed by PAI. Hand-written rules belong in CLAUDE.md; PAI adds rules here via `pai import` / `pai learn`. -->';
|
|
8
|
+
function toLf(text) {
|
|
9
|
+
return text.replace(/\r\n?/g, '\n');
|
|
10
|
+
}
|
|
11
|
+
/** Flatten groups into export order: caller supplies global before project. */
|
|
12
|
+
export function toExportRules(groups) {
|
|
13
|
+
return groups.flatMap((group) => group.guidelines.map((g) => ({ rule: g.text, category: g.category, scope: group.scope })));
|
|
14
|
+
}
|
|
15
|
+
export function serializeExportDocument(rules) {
|
|
16
|
+
const document = { version: EXPORT_VERSION, rules };
|
|
17
|
+
return `${JSON.stringify(document, null, 2)}\n`;
|
|
18
|
+
}
|
|
19
|
+
export function parseExportDocument(json) {
|
|
20
|
+
let parsed;
|
|
21
|
+
try {
|
|
22
|
+
// Tolerate a byte-order mark left by Windows editors.
|
|
23
|
+
parsed = JSON.parse(json.replace(/^/, ''));
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return failParse('file is not valid JSON');
|
|
27
|
+
}
|
|
28
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
29
|
+
return failParse('file is not a JSON object');
|
|
30
|
+
}
|
|
31
|
+
const raw = parsed;
|
|
32
|
+
if (raw['version'] === 1) {
|
|
33
|
+
// Older `.pai/rules.json` stores carry the same three fields we need.
|
|
34
|
+
return {
|
|
35
|
+
version: EXPORT_VERSION,
|
|
36
|
+
rules: parseRuleStore(json).rules.map((r) => ({
|
|
37
|
+
rule: r.rule,
|
|
38
|
+
category: r.category,
|
|
39
|
+
scope: r.scope,
|
|
40
|
+
})),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
if (raw['version'] !== EXPORT_VERSION) {
|
|
44
|
+
return failParse(`unsupported version: ${String(raw['version'])} (expected ${EXPORT_VERSION})`);
|
|
45
|
+
}
|
|
46
|
+
if (!Array.isArray(raw['rules'])) {
|
|
47
|
+
return failParse('"rules" must be an array');
|
|
48
|
+
}
|
|
49
|
+
const rules = [];
|
|
50
|
+
for (const [index, entry] of raw['rules'].entries()) {
|
|
51
|
+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
52
|
+
return failParse(`rule ${index + 1} is not an object`);
|
|
53
|
+
}
|
|
54
|
+
const record = entry;
|
|
55
|
+
const text = record['rule'];
|
|
56
|
+
if (typeof text !== 'string' || text.trim() === '') {
|
|
57
|
+
return failParse(`rule ${index + 1} is missing a non-empty "rule" field`);
|
|
58
|
+
}
|
|
59
|
+
const category = record['category'];
|
|
60
|
+
if (category !== undefined && typeof category !== 'string') {
|
|
61
|
+
return failParse(`rule ${index + 1} has a non-string "category"`);
|
|
62
|
+
}
|
|
63
|
+
const scope = record['scope'];
|
|
64
|
+
if (scope !== 'global' && scope !== 'project') {
|
|
65
|
+
return failParse(`rule ${index + 1} has an invalid "scope" (expected "global" or "project")`);
|
|
66
|
+
}
|
|
67
|
+
rules.push({
|
|
68
|
+
// A bullet is one line; line breaks inside a rule would split it.
|
|
69
|
+
rule: toLf(text).replace(/\n/g, ' ').trim(),
|
|
70
|
+
category: category === undefined || category.trim() === '' ? 'General' : category.trim(),
|
|
71
|
+
scope,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return { version: EXPORT_VERSION, rules };
|
|
75
|
+
}
|
|
76
|
+
function failParse(reason) {
|
|
77
|
+
throw new Error(`Invalid PAI export: ${reason}`);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Make sure the hand-written file references the managed one. Only a suffix
|
|
81
|
+
* is ever produced — the caller appends it in place, so a symlinked CLAUDE.md
|
|
82
|
+
* keeps pointing at its target and existing bytes are never rewritten.
|
|
83
|
+
*/
|
|
84
|
+
export function ensurePointer(claudeMd, pointer) {
|
|
85
|
+
if (claudeMd === undefined) {
|
|
86
|
+
return { suffix: `${pointer}\n`, action: 'created' };
|
|
87
|
+
}
|
|
88
|
+
const hasPointer = toLf(claudeMd)
|
|
89
|
+
.split('\n')
|
|
90
|
+
.some((line) => line.trim() === pointer);
|
|
91
|
+
if (hasPointer)
|
|
92
|
+
return { suffix: '', action: 'unchanged' };
|
|
93
|
+
const trailing = /\s*$/.exec(claudeMd)?.[0] ?? '';
|
|
94
|
+
if (trailing.length === claudeMd.length) {
|
|
95
|
+
// Empty or whitespace-only: the pointer is the first real line.
|
|
96
|
+
const needsBreak = claudeMd !== '' && !claudeMd.endsWith('\n');
|
|
97
|
+
return { suffix: `${needsBreak ? '\n' : ''}${pointer}\n`, action: 'appended' };
|
|
98
|
+
}
|
|
99
|
+
// Exactly one blank line before the pointer, given what is already there.
|
|
100
|
+
const newlines = trailing.split('\n').length - 1;
|
|
101
|
+
const separator = newlines >= 2 ? '' : newlines === 1 ? '\n' : '\n\n';
|
|
102
|
+
return { suffix: `${separator}${pointer}\n`, action: 'appended' };
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Merge rules into PAI's managed markdown. Identity is the normalized rule
|
|
106
|
+
* text (`ruleId`), so wording that differs only in case, punctuation,
|
|
107
|
+
* whitespace or line endings counts as already present. Existing content,
|
|
108
|
+
* order and prose are preserved; new bullets go under their category heading,
|
|
109
|
+
* and missing headings are appended at the end.
|
|
110
|
+
*/
|
|
111
|
+
export function mergeManagedMarkdown(existingMarkdown, rules) {
|
|
112
|
+
const isNew = existingMarkdown === undefined || existingMarkdown.trim() === '';
|
|
113
|
+
const source = isNew ? MANAGED_HEADER : toLf(existingMarkdown);
|
|
114
|
+
const seen = new Set(parseGuidelines(source).map((g) => ruleId(g.text)));
|
|
115
|
+
// Category → new bullets, in first-appearance order.
|
|
116
|
+
const additions = new Map();
|
|
117
|
+
let added = 0;
|
|
118
|
+
let existing = 0;
|
|
119
|
+
for (const incoming of rules) {
|
|
120
|
+
const id = ruleId(incoming.rule);
|
|
121
|
+
if (seen.has(id)) {
|
|
122
|
+
existing += 1;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
seen.add(id);
|
|
126
|
+
added += 1;
|
|
127
|
+
const bullets = additions.get(incoming.category) ?? [];
|
|
128
|
+
bullets.push(`- ${toLf(incoming.rule).replace(/\n/g, ' ').trim()}`);
|
|
129
|
+
additions.set(incoming.category, bullets);
|
|
130
|
+
}
|
|
131
|
+
if (added === 0) {
|
|
132
|
+
return { markdown: existingMarkdown ?? '', added, existing };
|
|
133
|
+
}
|
|
134
|
+
// Each existing heading's section ends at its last non-blank line; new
|
|
135
|
+
// bullets for that category are inserted right after it.
|
|
136
|
+
const lines = source.split('\n');
|
|
137
|
+
const sectionEnd = new Map();
|
|
138
|
+
let current;
|
|
139
|
+
for (const [index, line] of lines.entries()) {
|
|
140
|
+
const heading = HEADING.exec(line);
|
|
141
|
+
if (heading?.[1]) {
|
|
142
|
+
current = sectionEnd.has(heading[1]) ? undefined : heading[1];
|
|
143
|
+
if (current !== undefined)
|
|
144
|
+
sectionEnd.set(current, index);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (current !== undefined && line.trim() !== '')
|
|
148
|
+
sectionEnd.set(current, index);
|
|
149
|
+
}
|
|
150
|
+
const insertAfter = new Map();
|
|
151
|
+
const missing = [];
|
|
152
|
+
for (const [category, bullets] of additions) {
|
|
153
|
+
const end = sectionEnd.get(category);
|
|
154
|
+
if (end === undefined) {
|
|
155
|
+
missing.push(category);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
insertAfter.set(end, bullets);
|
|
159
|
+
}
|
|
160
|
+
const output = [];
|
|
161
|
+
for (const [index, line] of lines.entries()) {
|
|
162
|
+
output.push(line);
|
|
163
|
+
const bullets = insertAfter.get(index);
|
|
164
|
+
if (bullets)
|
|
165
|
+
output.push(...bullets);
|
|
166
|
+
}
|
|
167
|
+
while (output.length > 0 && output[output.length - 1]?.trim() === '')
|
|
168
|
+
output.pop();
|
|
169
|
+
for (const category of missing) {
|
|
170
|
+
output.push('', `## ${category}`, ...(additions.get(category) ?? []));
|
|
171
|
+
}
|
|
172
|
+
return { markdown: `${output.join('\n')}\n`, added, existing };
|
|
173
|
+
}
|
package/dist/core/rule-store.js
CHANGED
|
Binary file
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { appendFileSync, lstatSync, mkdirSync, renameSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { parseGuidelines } from '../core/guidelines.js';
|
|
4
|
+
import { ensurePointer, mergeManagedMarkdown, } from '../core/managed-guidelines.js';
|
|
5
|
+
import { readTextFile } from './text-file.js';
|
|
6
|
+
export function readGuidelineGroups(files) {
|
|
7
|
+
return files.map((file) => ({
|
|
8
|
+
scope: file.scope,
|
|
9
|
+
path: file.path,
|
|
10
|
+
managed: file.managed,
|
|
11
|
+
guidelines: parseGuidelines(readTextFile(file.path)),
|
|
12
|
+
}));
|
|
13
|
+
}
|
|
14
|
+
/** A missing file is `undefined`; any other read error propagates. */
|
|
15
|
+
function readOptionalTextFile(path) {
|
|
16
|
+
try {
|
|
17
|
+
return readTextFile(path);
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
if (error.code === 'ENOENT')
|
|
21
|
+
return undefined;
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function isSymlink(path) {
|
|
26
|
+
try {
|
|
27
|
+
return lstatSync(path).isSymbolicLink();
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Written via a temporary file and renamed into place, so an interrupted
|
|
35
|
+
* write cannot leave a half-written file behind. A symlink is written through
|
|
36
|
+
* instead — renaming over it would replace the link with a plain file.
|
|
37
|
+
* UTF-8, no BOM.
|
|
38
|
+
*/
|
|
39
|
+
function writeTextFileAtomic(path, content) {
|
|
40
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
41
|
+
if (isSymlink(path)) {
|
|
42
|
+
writeFileSync(path, content, 'utf8');
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const temporary = `${path}.tmp`;
|
|
46
|
+
try {
|
|
47
|
+
writeFileSync(temporary, content, 'utf8');
|
|
48
|
+
renameSync(temporary, path);
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
try {
|
|
52
|
+
unlinkSync(temporary);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// Nothing to clean up.
|
|
56
|
+
}
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Bring one scope's directory up to date: CLAUDE.md gets the pointer line if
|
|
62
|
+
* it lacks one, CLAUDE.pai.md gets the rules it does not have yet. The
|
|
63
|
+
* hand-written file is only ever appended to in place (never renamed over,
|
|
64
|
+
* so symlinks into a dotfiles repo survive) and is not read for deduplication.
|
|
65
|
+
*/
|
|
66
|
+
export function importManagedGuidelines(target, rules, options = {}) {
|
|
67
|
+
const { claudeMdPath, managedPath } = target;
|
|
68
|
+
const pointer = ensurePointer(readOptionalTextFile(claudeMdPath), target.pointer);
|
|
69
|
+
const merge = mergeManagedMarkdown(readOptionalTextFile(managedPath), rules);
|
|
70
|
+
if (!options.dryRun) {
|
|
71
|
+
if (pointer.action === 'created') {
|
|
72
|
+
mkdirSync(dirname(claudeMdPath), { recursive: true });
|
|
73
|
+
writeFileSync(claudeMdPath, pointer.suffix, 'utf8');
|
|
74
|
+
}
|
|
75
|
+
else if (pointer.action === 'appended') {
|
|
76
|
+
appendFileSync(claudeMdPath, pointer.suffix, 'utf8');
|
|
77
|
+
}
|
|
78
|
+
if (merge.added > 0)
|
|
79
|
+
writeTextFileAtomic(managedPath, merge.markdown);
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
claudeMdPath,
|
|
83
|
+
managedPath,
|
|
84
|
+
pointer: pointer.action,
|
|
85
|
+
added: merge.added,
|
|
86
|
+
existing: merge.existing,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { mkdirSync,
|
|
1
|
+
import { mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
4
|
import { emptyStore, parseRuleStore, serializeRuleStore } from '../core/rule-store.js';
|
|
5
|
+
import { readTextFile } from './text-file.js';
|
|
5
6
|
// PAI writes only to its own agent-neutral `.pai/` directories.
|
|
6
7
|
export function globalStorePath(home = homedir()) {
|
|
7
8
|
return join(home, '.pai', 'rules.json');
|
|
@@ -9,17 +10,40 @@ export function globalStorePath(home = homedir()) {
|
|
|
9
10
|
export function projectStorePath(cwd) {
|
|
10
11
|
return join(cwd, '.pai', 'rules.json');
|
|
11
12
|
}
|
|
13
|
+
/** A missing store is empty; a corrupt one is an error naming the file. */
|
|
12
14
|
export function readStore(path) {
|
|
13
15
|
let contents;
|
|
14
16
|
try {
|
|
15
|
-
contents =
|
|
17
|
+
contents = readTextFile(path);
|
|
16
18
|
}
|
|
17
19
|
catch {
|
|
18
20
|
return emptyStore();
|
|
19
21
|
}
|
|
20
|
-
|
|
22
|
+
try {
|
|
23
|
+
return parseRuleStore(contents);
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)} — ${path}`);
|
|
27
|
+
}
|
|
21
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Written via a temporary file and renamed into place, so an interrupted
|
|
31
|
+
* write cannot leave a half-written store behind.
|
|
32
|
+
*/
|
|
22
33
|
export function writeStore(path, store) {
|
|
23
34
|
mkdirSync(dirname(path), { recursive: true });
|
|
24
|
-
|
|
35
|
+
const temporary = `${path}.tmp`;
|
|
36
|
+
try {
|
|
37
|
+
writeFileSync(temporary, serializeRuleStore(store), 'utf8');
|
|
38
|
+
renameSync(temporary, path);
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
try {
|
|
42
|
+
unlinkSync(temporary);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// Nothing to clean up.
|
|
46
|
+
}
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
25
49
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
// Files exported on one platform are routinely imported on another. Windows
|
|
3
|
+
// tooling adds byte-order marks and PowerShell 5.1's `>` redirect writes
|
|
4
|
+
// UTF-16LE, both of which break a plain UTF-8 read.
|
|
5
|
+
export function decodeTextFile(bytes) {
|
|
6
|
+
if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) {
|
|
7
|
+
return bytes.subarray(3).toString('utf8');
|
|
8
|
+
}
|
|
9
|
+
if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe) {
|
|
10
|
+
return bytes.subarray(2).toString('utf16le');
|
|
11
|
+
}
|
|
12
|
+
if (bytes.length >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff) {
|
|
13
|
+
const body = bytes.subarray(2);
|
|
14
|
+
if (body.length % 2 !== 0)
|
|
15
|
+
return body.toString('utf8');
|
|
16
|
+
// swap16 mutates, so work on a copy of the caller's buffer.
|
|
17
|
+
return Buffer.from(body).swap16().toString('utf16le');
|
|
18
|
+
}
|
|
19
|
+
return bytes.toString('utf8');
|
|
20
|
+
}
|
|
21
|
+
export function readTextFile(path) {
|
|
22
|
+
return decodeTextFile(readFileSync(path));
|
|
23
|
+
}
|
package/docs/principles.md
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
## Principles
|
|
4
4
|
|
|
5
5
|
1. **Read-only toward agents' data.** PAI never modifies coding-agent files
|
|
6
|
-
(transcripts, configs, CLAUDE.md). PAI writes only
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
(transcripts, configs, CLAUDE.md). PAI writes only its own `CLAUDE.pai.md`
|
|
7
|
+
next to each CLAUDE.md, plus a single pointer line (`@CLAUDE.pai.md`) in
|
|
8
|
+
CLAUDE.md so the agent reads it — it never edits hand-written rules.
|
|
9
|
+
`.pai/` is reserved for PAI's own metadata.
|
|
9
10
|
2. **Approval gates knowledge.** Nothing becomes a stored rule without the
|
|
10
11
|
user approving it. Every stored or reported item carries evidence: the
|
|
11
12
|
user's actual words, session id, timestamp.
|
package/package.json
CHANGED