@lastboy/pai 0.2.0 → 0.4.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 +75 -28
- package/dist/adapters/claude/guidelines.js +35 -24
- package/dist/cli/program.js +78 -35
- package/dist/cli/render.js +2 -1
- package/dist/core/export-migrations.js +41 -0
- package/dist/core/guidelines.js +19 -0
- package/dist/core/managed-guidelines.js +220 -0
- package/dist/persistence/managed-guideline-files.js +88 -0
- package/docs/export-format.md +83 -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,23 @@ 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
|
+
{
|
|
87
|
+
"version": 2,
|
|
88
|
+
"pai": "0.3.1",
|
|
89
|
+
"exportedAt": "2026-08-29T12:00:00.000Z",
|
|
90
|
+
"rules": [ { "rule": "…", "category": "…", "scope": "global" } ]
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`version` is the export format (for compatibility checks), `pai` is the PAI
|
|
95
|
+
version that produced the file, and `exportedAt` is when it was written;
|
|
96
|
+
files from a newer format than this `pai` supports are rejected with an
|
|
97
|
+
upgrade hint.
|
|
81
98
|
|
|
82
99
|
| Option | Effect |
|
|
83
100
|
|---|---|
|
|
@@ -92,30 +109,46 @@ pai export --scope global --out mine.json # only user-level rules
|
|
|
92
109
|
### `pai import <file>`
|
|
93
110
|
|
|
94
111
|
Merges a previously exported file into this machine. Rules carry their own
|
|
95
|
-
scope
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
112
|
+
scope: global rules go to `~/.claude/CLAUDE.pai.md`, project rules to
|
|
113
|
+
`<project>/CLAUDE.pai.md` — no flag needed. For each scope PAI:
|
|
114
|
+
|
|
115
|
+
1. makes sure `CLAUDE.md` exists and contains the import line `@CLAUDE.pai.md`
|
|
116
|
+
(created with only that line if missing; the line is appended in place if
|
|
117
|
+
absent, so a symlinked CLAUDE.md stays a symlink; nothing else in the file
|
|
118
|
+
is touched);
|
|
119
|
+
2. adds the rules that `CLAUDE.pai.md` does not have yet, under their category
|
|
120
|
+
headings, keeping everything already there exactly as it is.
|
|
121
|
+
|
|
122
|
+
The merge is **non-destructive** and idempotent: nothing is overwritten or
|
|
123
|
+
removed, and re-importing the same file adds nothing. Rule identity is the
|
|
124
|
+
normalized text (case, punctuation, whitespace and line endings ignored).
|
|
125
|
+
Hand-written `CLAUDE.md` rules are never used for deduplication — only
|
|
126
|
+
`CLAUDE.pai.md` is. Files from the older `version: 1` store format are accepted.
|
|
101
127
|
|
|
102
128
|
| Option | Effect |
|
|
103
129
|
|---|---|
|
|
104
|
-
| `--dry-run` | report what would change without writing |
|
|
130
|
+
| `--dry-run` | report what would change ("would add …") without writing |
|
|
131
|
+
| `--validate` | check the file only — parse, migrate, validate — and print a summary; writes nothing |
|
|
105
132
|
|
|
106
133
|
```bash
|
|
107
134
|
pai import mine.json --dry-run
|
|
108
135
|
pai import mine.json
|
|
136
|
+
pai import mine.json --validate
|
|
109
137
|
```
|
|
110
138
|
|
|
111
139
|
Invalid or unsupported files fail with a clear message and a non-zero exit code.
|
|
140
|
+
Files from an older export format are migrated automatically; files from a
|
|
141
|
+
newer format than this `pai` supports are rejected with an upgrade hint. See
|
|
142
|
+
[the export format contract](docs/export-format.md) for the full JSON shape,
|
|
143
|
+
field constraints, and migration/compatibility rules.
|
|
112
144
|
|
|
113
|
-
**Cross-platform.** A
|
|
114
|
-
back.
|
|
145
|
+
**Cross-platform.** A file exported on macOS or Linux imports on Windows and
|
|
146
|
+
back. Exports contain no filesystem paths, so nothing is machine-specific, and
|
|
115
147
|
import accepts the encodings Windows tooling produces: UTF-8, UTF-8 with a BOM
|
|
116
148
|
(Notepad, `Set-Content`) and UTF-16 with a BOM (PowerShell 5.1's `>` redirect).
|
|
117
149
|
Line endings are normalized, so the same rule written on Windows and on
|
|
118
|
-
macOS/Linux
|
|
150
|
+
macOS/Linux counts as one instead of duplicating; `CLAUDE.pai.md` is always
|
|
151
|
+
written as UTF-8 with LF line endings.
|
|
119
152
|
|
|
120
153
|
### `pai experiment …`
|
|
121
154
|
|
|
@@ -134,26 +167,29 @@ Default model: `qwen2.5:14b`.
|
|
|
134
167
|
|
|
135
168
|
## Where PAI stores things
|
|
136
169
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
| `~/.pai/rules.json` | your user-level rules |
|
|
140
|
-
| `<project>/.pai/rules.json` | rules scoped to that project |
|
|
170
|
+
Your rules have one source of truth: the CLAUDE.md family of files the coding
|
|
171
|
+
agent already reads.
|
|
141
172
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
173
|
+
| Path | Contents | Who writes it |
|
|
174
|
+
|---|---|---|
|
|
175
|
+
| `~/.claude/CLAUDE.md` | your hand-written global rules | you |
|
|
176
|
+
| `~/.claude/CLAUDE.pai.md` | global rules PAI added | PAI |
|
|
177
|
+
| `<project>/CLAUDE.md` | hand-written project rules | you |
|
|
178
|
+
| `<project>/CLAUDE.pai.md` | project rules PAI added | PAI |
|
|
179
|
+
| `~/.pai/`, `<project>/.pai/` | reserved for PAI metadata (future) | PAI |
|
|
147
180
|
|
|
148
|
-
PAI
|
|
181
|
+
PAI never merges into a hand-written CLAUDE.md. The only change it makes there
|
|
182
|
+
is adding the single line `@CLAUDE.pai.md` — Claude Code's native import — so
|
|
183
|
+
the agent reads the managed file too. Rule identity comes from the rule text,
|
|
184
|
+
so the same rule imported twice, or written on two machines, counts once.
|
|
149
185
|
|
|
150
186
|
## What PAI reads
|
|
151
187
|
|
|
152
188
|
| Path | Used for |
|
|
153
189
|
|---|---|
|
|
154
190
|
| `~/.claude/projects/<encoded-cwd>/*.jsonl` | session transcripts (read-only) |
|
|
155
|
-
| `~/.claude/CLAUDE.md` | global guidelines |
|
|
156
|
-
| `<project>/CLAUDE.md`, `CLAUDE.local.md` | project guidelines |
|
|
191
|
+
| `~/.claude/CLAUDE.md`, `CLAUDE.pai.md` | global guidelines |
|
|
192
|
+
| `<project>/CLAUDE.md`, `CLAUDE.local.md`, `CLAUDE.pai.md` | project guidelines |
|
|
157
193
|
|
|
158
194
|
The encoded folder name is the project path with every non-alphanumeric
|
|
159
195
|
character replaced by `-`.
|
|
@@ -163,7 +199,7 @@ character replaced by `-`.
|
|
|
163
199
|
```
|
|
164
200
|
src/core/ agent-independent logic (sessions, corrections, rules, review)
|
|
165
201
|
src/adapters/ per-agent integration — currently only claude/
|
|
166
|
-
src/persistence/
|
|
202
|
+
src/persistence/ file I/O — CLAUDE.pai.md, text decoding, the .pai/ store
|
|
167
203
|
src/experiments/ local-LLM experiments (Ollama, optional)
|
|
168
204
|
src/cli/ command wiring and rendering
|
|
169
205
|
tests/ vitest suites and sanitized fixtures
|
|
@@ -191,6 +227,17 @@ repository.
|
|
|
191
227
|
Working today: `status`, `review`, `rules`, `export`, `import`, and the two
|
|
192
228
|
experiments.
|
|
193
229
|
|
|
194
|
-
Next:
|
|
195
|
-
|
|
196
|
-
|
|
230
|
+
Next: see [Roadmap](#roadmap).
|
|
231
|
+
|
|
232
|
+
## Roadmap
|
|
233
|
+
|
|
234
|
+
- **`pai learn`** — scan sessions, distill candidate rules with a local model, approve/reject/edit interactively, write approved rules to `CLAUDE.pai.md`.
|
|
235
|
+
- **Compliance audit** — check sessions against your rules and report violations with evidence (which rule, how often, your own words) — the "is the agent following my guidance" measure.
|
|
236
|
+
- **Trends** — violations over time, before/after a rule was added, and comparison across models/configurations.
|
|
237
|
+
- **Cross-project promotion** — rules that keep appearing in several projects get proposed as global rules.
|
|
238
|
+
- **Better session review** — active time instead of wall-clock duration, filtering out headless/automation sessions.
|
|
239
|
+
- **Long-list UX** — category summaries with counts, `--category`, an interactive picker.
|
|
240
|
+
- **More agents** — Codex CLI adapter (`AGENTS.md`); the core stays agent-independent.
|
|
241
|
+
- **Optional integrations, only if needed** — Claude Code hooks for real-time capture; MCP so the agent can ask PAI about rules.
|
|
242
|
+
|
|
243
|
+
Order is not a commitment; each item ships as its own small, tested step.
|
|
@@ -1,33 +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
|
-
// A BOM (Windows editors) would otherwise hide the first heading.
|
|
21
|
-
for (const line of markdown.replace(/^/, '').split('\n')) {
|
|
22
|
-
const heading = HEADING.exec(line);
|
|
23
|
-
if (heading?.[1]) {
|
|
24
|
-
category = heading[1];
|
|
25
|
-
continue;
|
|
26
|
-
}
|
|
27
|
-
const bullet = BULLET.exec(line);
|
|
28
|
-
if (bullet?.[1]) {
|
|
29
|
-
guidelines.push({ category, text: bullet[1] });
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
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);
|
|
33
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 { CURRENT_EXPORT_FORMAT } from '../core/export-migrations.js';
|
|
19
|
+
import { importManagedGuidelines, readGuidelineGroups, } from '../persistence/managed-guideline-files.js';
|
|
19
20
|
import { decodeTextFile } from '../persistence/text-file.js';
|
|
20
21
|
import { parseSelection, renderGuidelines, renderReview, renderSessionList } from './render.js';
|
|
21
22
|
async function ask(question) {
|
|
@@ -53,8 +54,25 @@ async function chooseSession(out) {
|
|
|
53
54
|
function describeError(error) {
|
|
54
55
|
return error instanceof Error ? error.message : String(error);
|
|
55
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Best-effort read of the file's own `pai`/`exportedAt` fields for the
|
|
59
|
+
* `--validate` summary. Only called after `parseExportDocument` has already
|
|
60
|
+
* succeeded on the same text, so this re-parse cannot fail in practice.
|
|
61
|
+
*/
|
|
62
|
+
function readExportMeta(text) {
|
|
63
|
+
try {
|
|
64
|
+
const parsed = JSON.parse(text.replace(/^/, ''));
|
|
65
|
+
return {
|
|
66
|
+
...(typeof parsed['pai'] === 'string' ? { pai: parsed['pai'] } : {}),
|
|
67
|
+
...(typeof parsed['exportedAt'] === 'string' ? { exportedAt: parsed['exportedAt'] } : {}),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return {};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
56
74
|
// Same relative depth from src/cli and dist/cli.
|
|
57
|
-
function packageVersion() {
|
|
75
|
+
export function packageVersion() {
|
|
58
76
|
const pkg = JSON.parse(readFileSync(join(import.meta.dirname, '..', '..', 'package.json'), 'utf8'));
|
|
59
77
|
return pkg.version ?? '0.0.0';
|
|
60
78
|
}
|
|
@@ -88,16 +106,11 @@ export function createProgram(out) {
|
|
|
88
106
|
program
|
|
89
107
|
.command('rules')
|
|
90
108
|
.description('Show your guidelines from CLAUDE.md files, categorized')
|
|
91
|
-
.option('--global', 'only global rules (~/.claude/CLAUDE.md)')
|
|
92
|
-
.option('--project', 'only project rules (CLAUDE.md, CLAUDE.local.md)')
|
|
109
|
+
.option('--global', 'only global rules (~/.claude/CLAUDE.md, CLAUDE.pai.md)')
|
|
110
|
+
.option('--project', 'only project rules (CLAUDE.md, CLAUDE.local.md, CLAUDE.pai.md)')
|
|
93
111
|
.option('--search <text>', 'only rules whose text or category starts a word with <text>')
|
|
94
|
-
.action(
|
|
95
|
-
const
|
|
96
|
-
const groups = await Promise.all(files.map(async (file) => ({
|
|
97
|
-
scope: file.scope,
|
|
98
|
-
path: file.path,
|
|
99
|
-
guidelines: parseGuidelines(decodeTextFile(await readFile(file.path))),
|
|
100
|
-
})));
|
|
112
|
+
.action((options) => {
|
|
113
|
+
const groups = readGuidelineGroups(findGuidelineFiles(process.cwd()));
|
|
101
114
|
// --global and --project together = no scope filter (same as neither).
|
|
102
115
|
const scope = options.global && !options.project
|
|
103
116
|
? 'global'
|
|
@@ -114,7 +127,7 @@ export function createProgram(out) {
|
|
|
114
127
|
});
|
|
115
128
|
program
|
|
116
129
|
.command('export')
|
|
117
|
-
.description('Export
|
|
130
|
+
.description('Export your rules (CLAUDE.md + CLAUDE.pai.md) to a portable JSON file (or stdout)')
|
|
118
131
|
.option('--out <file>', 'write to this file instead of stdout')
|
|
119
132
|
.option('--scope <scope>', 'export only "global" or "project" rules')
|
|
120
133
|
.action((options) => {
|
|
@@ -124,19 +137,19 @@ export function createProgram(out) {
|
|
|
124
137
|
return;
|
|
125
138
|
}
|
|
126
139
|
try {
|
|
127
|
-
|
|
128
|
-
const
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
140
|
+
// Global files come first, so the output order is global → project.
|
|
141
|
+
const files = findExportableGuidelineFiles(process.cwd()).filter((file) => options.scope === undefined || file.scope === options.scope);
|
|
142
|
+
const rules = toExportRules(readGuidelineGroups(files));
|
|
143
|
+
const json = serializeExportDocument(rules, {
|
|
144
|
+
pai: packageVersion(),
|
|
145
|
+
exportedAt: new Date().toISOString(),
|
|
146
|
+
});
|
|
134
147
|
if (options.out === undefined) {
|
|
135
148
|
out(json.trimEnd());
|
|
136
149
|
return;
|
|
137
150
|
}
|
|
138
151
|
writeFileSync(options.out, json, 'utf8');
|
|
139
|
-
out(`Exported ${
|
|
152
|
+
out(`Exported ${rules.length} rule(s) to ${options.out}`);
|
|
140
153
|
}
|
|
141
154
|
catch (error) {
|
|
142
155
|
out(`Export failed: ${describeError(error)}`);
|
|
@@ -146,31 +159,61 @@ export function createProgram(out) {
|
|
|
146
159
|
program
|
|
147
160
|
.command('import')
|
|
148
161
|
.argument('<file>', 'JSON file previously produced by "pai export"')
|
|
149
|
-
.description('Merge rules from a file into this machine (never overwrites)')
|
|
162
|
+
.description('Merge rules from a file into CLAUDE.pai.md on this machine (never overwrites)')
|
|
150
163
|
.option('--dry-run', 'show what would change without writing')
|
|
164
|
+
.option('--validate', 'check the file only; print a summary and exit without writing')
|
|
151
165
|
.action(async (file, options) => {
|
|
166
|
+
const text = decodeTextFile(await readFile(file));
|
|
152
167
|
let incoming;
|
|
153
168
|
try {
|
|
154
|
-
incoming =
|
|
169
|
+
incoming = parseExportDocument(text, { currentPaiVersion: packageVersion() });
|
|
155
170
|
}
|
|
156
171
|
catch (error) {
|
|
157
|
-
|
|
172
|
+
if (options.validate) {
|
|
173
|
+
for (const line of describeError(error).split('\n'))
|
|
174
|
+
out(line);
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
out(`Could not read ${file}: ${describeError(error)}`);
|
|
178
|
+
}
|
|
158
179
|
process.exitCode = 1;
|
|
159
180
|
return;
|
|
160
181
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
{
|
|
164
|
-
|
|
182
|
+
if (options.validate) {
|
|
183
|
+
const meta = readExportMeta(text);
|
|
184
|
+
const paiPart = meta.pai !== undefined ? `pai ${meta.pai}` : 'pai unknown';
|
|
185
|
+
const exportedPart = meta.exportedAt !== undefined ? `, exported ${meta.exportedAt}` : '';
|
|
186
|
+
out(`Valid PAI export (format ${CURRENT_EXPORT_FORMAT}, ${paiPart}${exportedPart})`);
|
|
187
|
+
const global = incoming.rules.filter((rule) => rule.scope === 'global').length;
|
|
188
|
+
const project = incoming.rules.filter((rule) => rule.scope === 'project').length;
|
|
189
|
+
out(`Rules: ${incoming.rules.length} (global ${global}, project ${project})`);
|
|
190
|
+
if (incoming.migratedFrom !== undefined) {
|
|
191
|
+
out(`Migrated from format ${incoming.migratedFrom} (would be imported as format ${CURRENT_EXPORT_FORMAT})`);
|
|
192
|
+
}
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
165
195
|
try {
|
|
166
|
-
|
|
167
|
-
|
|
196
|
+
if (incoming.migratedFrom !== undefined) {
|
|
197
|
+
out(`Migrated from format ${incoming.migratedFrom}.`);
|
|
198
|
+
}
|
|
199
|
+
for (const scope of ['global', 'project']) {
|
|
200
|
+
const rules = incoming.rules.filter((rule) => rule.scope === scope);
|
|
168
201
|
if (rules.length === 0)
|
|
169
202
|
continue;
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
203
|
+
const dir = guidelineDir(scope, process.cwd());
|
|
204
|
+
out(`Target (${scope}): ${dir}`);
|
|
205
|
+
const result = importManagedGuidelines(managedImportTarget(dir), rules, {
|
|
206
|
+
dryRun: options.dryRun,
|
|
207
|
+
});
|
|
208
|
+
const dry = options.dryRun === true;
|
|
209
|
+
if (result.pointer === 'created') {
|
|
210
|
+
out(dry ? ' would create CLAUDE.md with pointer' : ' created CLAUDE.md with pointer');
|
|
211
|
+
}
|
|
212
|
+
if (result.pointer === 'appended') {
|
|
213
|
+
out(dry ? ' would add pointer line to CLAUDE.md' : ' added pointer line to CLAUDE.md');
|
|
214
|
+
}
|
|
215
|
+
const added = dry ? `would add ${result.added} rule(s)` : `${result.added} added`;
|
|
216
|
+
out(`${scope}: ${added}, ${result.existing} already present → ${result.managedPath}`);
|
|
174
217
|
}
|
|
175
218
|
}
|
|
176
219
|
catch (error) {
|
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;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Migration chain that brings an older PAI export up to the current format.
|
|
2
|
+
// Pure: no I/O, no filesystem, no process access.
|
|
3
|
+
import { parseRuleStore } from './rule-store.js';
|
|
4
|
+
export const CURRENT_EXPORT_FORMAT = 2;
|
|
5
|
+
/** Older `.pai/rules.json` stores carry the same three fields an export needs. */
|
|
6
|
+
function v1ToV2(doc) {
|
|
7
|
+
const store = parseRuleStore(JSON.stringify(doc));
|
|
8
|
+
return {
|
|
9
|
+
version: 2,
|
|
10
|
+
rules: store.rules.map((r) => ({ rule: r.rule, category: r.category, scope: r.scope })),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
// Adding a future migration is one new entry here: key N migrates format N to N+1.
|
|
14
|
+
const MIGRATIONS = {
|
|
15
|
+
1: v1ToV2,
|
|
16
|
+
};
|
|
17
|
+
export function migrateExport(raw, options) {
|
|
18
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
19
|
+
throw new Error('Invalid PAI export: file is not a JSON object');
|
|
20
|
+
}
|
|
21
|
+
const record = raw;
|
|
22
|
+
const version = record['version'];
|
|
23
|
+
if (typeof version !== 'number' || !Number.isInteger(version) || version < 1) {
|
|
24
|
+
throw new Error('Invalid PAI export: "version" must be a positive integer');
|
|
25
|
+
}
|
|
26
|
+
if (version > CURRENT_EXPORT_FORMAT) {
|
|
27
|
+
const producer = typeof record['pai'] === 'string' && record['pai'].trim() !== '' ? record['pai'] : 'unknown';
|
|
28
|
+
throw new Error(`Invalid PAI export: format ${version} was produced by pai ${producer}; this pai (${options.currentPaiVersion}) supports up to format ${CURRENT_EXPORT_FORMAT} — upgrade pai`);
|
|
29
|
+
}
|
|
30
|
+
let document = record;
|
|
31
|
+
let from = version;
|
|
32
|
+
while (from < CURRENT_EXPORT_FORMAT) {
|
|
33
|
+
const migrate = MIGRATIONS[from];
|
|
34
|
+
if (!migrate) {
|
|
35
|
+
throw new Error(`Invalid PAI export: no migration path from format ${from}`);
|
|
36
|
+
}
|
|
37
|
+
document = migrate(document);
|
|
38
|
+
from += 1;
|
|
39
|
+
}
|
|
40
|
+
return { document, ...(version !== CURRENT_EXPORT_FORMAT ? { migratedFrom: version } : {}) };
|
|
41
|
+
}
|
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,220 @@
|
|
|
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 { ruleId } from './rule-store.js';
|
|
6
|
+
import { CURRENT_EXPORT_FORMAT, migrateExport } from './export-migrations.js';
|
|
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, meta) {
|
|
16
|
+
const document = {
|
|
17
|
+
version: CURRENT_EXPORT_FORMAT,
|
|
18
|
+
pai: meta.pai,
|
|
19
|
+
exportedAt: meta.exportedAt,
|
|
20
|
+
rules,
|
|
21
|
+
};
|
|
22
|
+
return `${JSON.stringify(document, null, 2)}\n`;
|
|
23
|
+
}
|
|
24
|
+
const MAX_RULE_LENGTH = 500;
|
|
25
|
+
const MAX_CATEGORY_LENGTH = 80;
|
|
26
|
+
const MAX_PROBLEMS_SHOWN = 20;
|
|
27
|
+
/**
|
|
28
|
+
* Strict validation of a format-2 document (after migration). Collects every
|
|
29
|
+
* problem instead of stopping at the first one; each message is prefixed
|
|
30
|
+
* with a JSON-path so multiple problems in one file can be fixed at once.
|
|
31
|
+
* Unknown extra fields, top-level or per-rule, are ignored for forward
|
|
32
|
+
* compatibility.
|
|
33
|
+
*/
|
|
34
|
+
export function validateExportDocument(doc) {
|
|
35
|
+
const problems = [];
|
|
36
|
+
if (doc['version'] !== CURRENT_EXPORT_FORMAT) {
|
|
37
|
+
problems.push(`version: expected ${CURRENT_EXPORT_FORMAT}`);
|
|
38
|
+
}
|
|
39
|
+
if (doc['pai'] !== undefined && typeof doc['pai'] !== 'string') {
|
|
40
|
+
problems.push('pai: expected string');
|
|
41
|
+
}
|
|
42
|
+
if (doc['exportedAt'] !== undefined) {
|
|
43
|
+
const exportedAt = doc['exportedAt'];
|
|
44
|
+
if (typeof exportedAt !== 'string' || Number.isNaN(Date.parse(exportedAt))) {
|
|
45
|
+
problems.push('exportedAt: expected ISO-8601 timestamp');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const rawRules = doc['rules'];
|
|
49
|
+
if (!Array.isArray(rawRules)) {
|
|
50
|
+
problems.push('rules: expected array');
|
|
51
|
+
return { ok: false, problems };
|
|
52
|
+
}
|
|
53
|
+
const rules = [];
|
|
54
|
+
rawRules.forEach((entry, index) => {
|
|
55
|
+
const path = `rules[${index}]`;
|
|
56
|
+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
57
|
+
problems.push(`${path}: expected object`);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const record = entry;
|
|
61
|
+
const rawText = record['rule'];
|
|
62
|
+
// A bullet is one line; line breaks inside a rule would split it.
|
|
63
|
+
const normalizedText = typeof rawText === 'string' ? toLf(rawText).replace(/\n/g, ' ').trim() : undefined;
|
|
64
|
+
const ruleValid = normalizedText !== undefined && normalizedText !== '' && normalizedText.length <= MAX_RULE_LENGTH;
|
|
65
|
+
if (!ruleValid) {
|
|
66
|
+
problems.push(`${path}.rule: expected non-empty string (max ${MAX_RULE_LENGTH} chars)`);
|
|
67
|
+
}
|
|
68
|
+
let category = 'General';
|
|
69
|
+
let categoryValid = true;
|
|
70
|
+
const rawCategory = record['category'];
|
|
71
|
+
if (rawCategory !== undefined) {
|
|
72
|
+
if (typeof rawCategory !== 'string' || rawCategory.trim().length > MAX_CATEGORY_LENGTH) {
|
|
73
|
+
categoryValid = false;
|
|
74
|
+
problems.push(`${path}.category: expected string (max ${MAX_CATEGORY_LENGTH} chars)`);
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
category = rawCategory.trim() === '' ? 'General' : rawCategory.trim();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const scope = record['scope'];
|
|
81
|
+
const scopeValid = scope === 'global' || scope === 'project';
|
|
82
|
+
if (!scopeValid) {
|
|
83
|
+
problems.push(`${path}.scope: expected "global" or "project"`);
|
|
84
|
+
}
|
|
85
|
+
if (ruleValid && categoryValid && scopeValid) {
|
|
86
|
+
rules.push({ rule: normalizedText, category, scope: scope });
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
if (problems.length > 0)
|
|
90
|
+
return { ok: false, problems };
|
|
91
|
+
return { ok: true, document: { version: CURRENT_EXPORT_FORMAT, rules } };
|
|
92
|
+
}
|
|
93
|
+
export function parseExportDocument(json, options) {
|
|
94
|
+
let parsed;
|
|
95
|
+
try {
|
|
96
|
+
// Tolerate a byte-order mark left by Windows editors.
|
|
97
|
+
parsed = JSON.parse(json.replace(/^/, ''));
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return failParse('file is not valid JSON');
|
|
101
|
+
}
|
|
102
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
103
|
+
return failParse('file is not a JSON object');
|
|
104
|
+
}
|
|
105
|
+
const { document, migratedFrom } = migrateExport(parsed, {
|
|
106
|
+
currentPaiVersion: options.currentPaiVersion,
|
|
107
|
+
});
|
|
108
|
+
const result = validateExportDocument(document);
|
|
109
|
+
if (!result.ok)
|
|
110
|
+
failValidation(result.problems);
|
|
111
|
+
return { ...result.document, ...(migratedFrom !== undefined ? { migratedFrom } : {}) };
|
|
112
|
+
}
|
|
113
|
+
function failParse(reason) {
|
|
114
|
+
throw new Error(`Invalid PAI export: ${reason}`);
|
|
115
|
+
}
|
|
116
|
+
function failValidation(problems) {
|
|
117
|
+
const shown = problems.slice(0, MAX_PROBLEMS_SHOWN);
|
|
118
|
+
const remaining = problems.length - shown.length;
|
|
119
|
+
const lines = [
|
|
120
|
+
`Invalid PAI export: ${problems.length} problem(s)`,
|
|
121
|
+
...shown.map((problem) => ` - ${problem}`),
|
|
122
|
+
...(remaining > 0 ? [` - … and ${remaining} more`] : []),
|
|
123
|
+
];
|
|
124
|
+
throw new Error(lines.join('\n'));
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Make sure the hand-written file references the managed one. Only a suffix
|
|
128
|
+
* is ever produced — the caller appends it in place, so a symlinked CLAUDE.md
|
|
129
|
+
* keeps pointing at its target and existing bytes are never rewritten.
|
|
130
|
+
*/
|
|
131
|
+
export function ensurePointer(claudeMd, pointer) {
|
|
132
|
+
if (claudeMd === undefined) {
|
|
133
|
+
return { suffix: `${pointer}\n`, action: 'created' };
|
|
134
|
+
}
|
|
135
|
+
const hasPointer = toLf(claudeMd)
|
|
136
|
+
.split('\n')
|
|
137
|
+
.some((line) => line.trim() === pointer);
|
|
138
|
+
if (hasPointer)
|
|
139
|
+
return { suffix: '', action: 'unchanged' };
|
|
140
|
+
const trailing = /\s*$/.exec(claudeMd)?.[0] ?? '';
|
|
141
|
+
if (trailing.length === claudeMd.length) {
|
|
142
|
+
// Empty or whitespace-only: the pointer is the first real line.
|
|
143
|
+
const needsBreak = claudeMd !== '' && !claudeMd.endsWith('\n');
|
|
144
|
+
return { suffix: `${needsBreak ? '\n' : ''}${pointer}\n`, action: 'appended' };
|
|
145
|
+
}
|
|
146
|
+
// Exactly one blank line before the pointer, given what is already there.
|
|
147
|
+
const newlines = trailing.split('\n').length - 1;
|
|
148
|
+
const separator = newlines >= 2 ? '' : newlines === 1 ? '\n' : '\n\n';
|
|
149
|
+
return { suffix: `${separator}${pointer}\n`, action: 'appended' };
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Merge rules into PAI's managed markdown. Identity is the normalized rule
|
|
153
|
+
* text (`ruleId`), so wording that differs only in case, punctuation,
|
|
154
|
+
* whitespace or line endings counts as already present. Existing content,
|
|
155
|
+
* order and prose are preserved; new bullets go under their category heading,
|
|
156
|
+
* and missing headings are appended at the end.
|
|
157
|
+
*/
|
|
158
|
+
export function mergeManagedMarkdown(existingMarkdown, rules) {
|
|
159
|
+
const isNew = existingMarkdown === undefined || existingMarkdown.trim() === '';
|
|
160
|
+
const source = isNew ? MANAGED_HEADER : toLf(existingMarkdown);
|
|
161
|
+
const seen = new Set(parseGuidelines(source).map((g) => ruleId(g.text)));
|
|
162
|
+
// Category → new bullets, in first-appearance order.
|
|
163
|
+
const additions = new Map();
|
|
164
|
+
let added = 0;
|
|
165
|
+
let existing = 0;
|
|
166
|
+
for (const incoming of rules) {
|
|
167
|
+
const id = ruleId(incoming.rule);
|
|
168
|
+
if (seen.has(id)) {
|
|
169
|
+
existing += 1;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
seen.add(id);
|
|
173
|
+
added += 1;
|
|
174
|
+
const bullets = additions.get(incoming.category) ?? [];
|
|
175
|
+
bullets.push(`- ${toLf(incoming.rule).replace(/\n/g, ' ').trim()}`);
|
|
176
|
+
additions.set(incoming.category, bullets);
|
|
177
|
+
}
|
|
178
|
+
if (added === 0) {
|
|
179
|
+
return { markdown: existingMarkdown ?? '', added, existing };
|
|
180
|
+
}
|
|
181
|
+
// Each existing heading's section ends at its last non-blank line; new
|
|
182
|
+
// bullets for that category are inserted right after it.
|
|
183
|
+
const lines = source.split('\n');
|
|
184
|
+
const sectionEnd = new Map();
|
|
185
|
+
let current;
|
|
186
|
+
for (const [index, line] of lines.entries()) {
|
|
187
|
+
const heading = HEADING.exec(line);
|
|
188
|
+
if (heading?.[1]) {
|
|
189
|
+
current = sectionEnd.has(heading[1]) ? undefined : heading[1];
|
|
190
|
+
if (current !== undefined)
|
|
191
|
+
sectionEnd.set(current, index);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (current !== undefined && line.trim() !== '')
|
|
195
|
+
sectionEnd.set(current, index);
|
|
196
|
+
}
|
|
197
|
+
const insertAfter = new Map();
|
|
198
|
+
const missing = [];
|
|
199
|
+
for (const [category, bullets] of additions) {
|
|
200
|
+
const end = sectionEnd.get(category);
|
|
201
|
+
if (end === undefined) {
|
|
202
|
+
missing.push(category);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
insertAfter.set(end, bullets);
|
|
206
|
+
}
|
|
207
|
+
const output = [];
|
|
208
|
+
for (const [index, line] of lines.entries()) {
|
|
209
|
+
output.push(line);
|
|
210
|
+
const bullets = insertAfter.get(index);
|
|
211
|
+
if (bullets)
|
|
212
|
+
output.push(...bullets);
|
|
213
|
+
}
|
|
214
|
+
while (output.length > 0 && output[output.length - 1]?.trim() === '')
|
|
215
|
+
output.pop();
|
|
216
|
+
for (const category of missing) {
|
|
217
|
+
output.push('', `## ${category}`, ...(additions.get(category) ?? []));
|
|
218
|
+
}
|
|
219
|
+
return { markdown: `${output.join('\n')}\n`, added, existing };
|
|
220
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# PAI export format
|
|
2
|
+
|
|
3
|
+
The contract for the JSON file `pai export` writes and `pai import` reads.
|
|
4
|
+
It exists so a file exported by one version of PAI, on one machine, imports
|
|
5
|
+
safely — or fails clearly — on another.
|
|
6
|
+
|
|
7
|
+
## Format 2 (current)
|
|
8
|
+
|
|
9
|
+
```json
|
|
10
|
+
{
|
|
11
|
+
"version": 2,
|
|
12
|
+
"pai": "0.3.1",
|
|
13
|
+
"exportedAt": "2026-08-29T12:00:00.000Z",
|
|
14
|
+
"rules": [
|
|
15
|
+
{ "rule": "Keep answers short.", "category": "Communication", "scope": "global" },
|
|
16
|
+
{ "rule": "Never force-push.", "category": "Git", "scope": "project" }
|
|
17
|
+
]
|
|
18
|
+
}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
| Field | Type | Required | Constraint |
|
|
22
|
+
|---|---|---|---|
|
|
23
|
+
| `version` | number | yes | must be `2` |
|
|
24
|
+
| `pai` | string | no | PAI version that produced the file |
|
|
25
|
+
| `exportedAt` | string | no | ISO-8601 timestamp, parseable by `Date` |
|
|
26
|
+
| `rules` | array | yes | array of rule objects (may be empty) |
|
|
27
|
+
| `rules[].rule` | string | yes | non-empty after trim, max 500 chars |
|
|
28
|
+
| `rules[].category` | string | no | max 80 chars; defaults to `"General"` |
|
|
29
|
+
| `rules[].scope` | string | yes | `"global"` or `"project"` |
|
|
30
|
+
|
|
31
|
+
Unknown extra fields — at the top level or inside a rule object — are
|
|
32
|
+
ignored, both by validation and on import. This is what lets a newer PAI add
|
|
33
|
+
fields to the format without breaking an older one that only understands the
|
|
34
|
+
fields above.
|
|
35
|
+
|
|
36
|
+
## Normalization
|
|
37
|
+
|
|
38
|
+
On import, each rule is normalized before it is compared or written:
|
|
39
|
+
|
|
40
|
+
- CRLF and lone CR are converted to LF
|
|
41
|
+
- newlines inside `rule` become a single space (a rule is one bullet, one line)
|
|
42
|
+
- the result is trimmed
|
|
43
|
+
- an empty or missing `category` becomes `"General"`
|
|
44
|
+
|
|
45
|
+
## Compatibility policy
|
|
46
|
+
|
|
47
|
+
- **Same format** (`version: 2`) — always safe to import, on any PAI version
|
|
48
|
+
that supports format 2.
|
|
49
|
+
- **Older format** — migrated automatically. Migrations applied so far:
|
|
50
|
+
- **1 → 2**: reads the old `.pai/rules.json` store shape
|
|
51
|
+
(`rules[].{rule,category,scope}`, plus `id`, `evidence`, `source`,
|
|
52
|
+
`createdAt`/`updatedAt`). Only `rule`, `category` and `scope` survive;
|
|
53
|
+
`id`, `evidence`, `source` and the timestamps are dropped — none of them
|
|
54
|
+
are part of the export format.
|
|
55
|
+
- **Newer format** — rejected with an exact, upgrade-pointing message:
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
Invalid PAI export: format <N> was produced by pai <pai or "unknown">; this pai (<current>) supports up to format 2 — upgrade pai
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Validation errors
|
|
62
|
+
|
|
63
|
+
An invalid file (right format, wrong contents) is rejected with every
|
|
64
|
+
problem found, not just the first one:
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
Invalid PAI export: 2 problem(s)
|
|
68
|
+
- rules[0].rule: expected non-empty string (max 500 chars)
|
|
69
|
+
- rules[0].scope: expected "global" or "project"
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Each line is prefixed with a JSON path to the offending field. At most 20
|
|
73
|
+
problems are listed; beyond that, a final line reads ` - … and N more`.
|
|
74
|
+
|
|
75
|
+
Run `pai import <file> --validate` to check a file against this contract —
|
|
76
|
+
parse, migrate, and validate — without writing anything.
|
|
77
|
+
|
|
78
|
+
## Encodings accepted
|
|
79
|
+
|
|
80
|
+
`pai import` decodes, in order of likelihood: UTF-8, UTF-8 with a BOM
|
|
81
|
+
(as written by Notepad or PowerShell's `Set-Content`), and UTF-16 with a BOM
|
|
82
|
+
(as written by PowerShell 5.1's `>` redirect). `pai export` always writes
|
|
83
|
+
UTF-8 with LF line endings and no BOM.
|
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