@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 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`) — grouped by their markdown headings.
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 PAI's own rule store as portable, versioned JSON.
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, so global rules land in the global store and project rules in the
96
- current project's store — no flag needed.
97
-
98
- The merge is **non-destructive**: new rules are added, rules you already have
99
- gain any new evidence, and nothing is overwritten or removed. Re-importing the
100
- same file changes nothing.
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 store exported on macOS or Linux imports on Windows and
114
- back. Stores contain no filesystem paths, so nothing is machine-specific, and
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 merges as one instead of duplicating.
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
- | Path | Contents |
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
- The store is agent-neutral, versioned (`version: 1`), and portable: rule
143
- identity comes from the rule text, so the same rule learned on two machines
144
- merges cleanly instead of duplicating. Each rule keeps its category, scope,
145
- source (`learned` or `manual`) and evidence your actual words, with session id
146
- and timestamp.
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 **never writes** to `~/.claude/` or to any CLAUDE.md file.
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/ PAI's own store on disk
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: `pai learn` — scan sessions, distill candidate rules, approve or reject
195
- them interactively, and store the approved ones. Then auditing sessions against
196
- stored rules (which rules the agent actually violated, with evidence).
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. Parent-directory
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(home, '.claude', 'CLAUDE.md'), scope: 'global' },
10
- { path: join(cwd, 'CLAUDE.md'), scope: 'project' },
11
- { path: join(cwd, 'CLAUDE.local.md'), scope: 'project' },
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
- const HEADING = /^#{1,6}\s+(.+?)\s*$/;
16
- const BULLET = /^\s*[-*]\s+(.+?)\s*$/;
17
- export function parseGuidelines(markdown) {
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
  }
@@ -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, parseGuidelines } from '../adapters/claude/guidelines.js';
15
+ import { findExportableGuidelineFiles, findGuidelineFiles, guidelineDir, managedImportTarget, } from '../adapters/claude/guidelines.js';
16
16
  import { filterGuidelineGroups } from '../core/guidelines.js';
17
- import { emptyStore, mergeStores, parseRuleStore, serializeRuleStore, } from '../core/rule-store.js';
18
- import { globalStorePath, projectStorePath, readStore, writeStore, } from '../persistence/rule-store-files.js';
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(async (options) => {
95
- const files = findGuidelineFiles(process.cwd());
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 PAI rules to a portable JSON file (or stdout)')
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
- const global = readStore(globalStorePath());
128
- const project = readStore(projectStorePath(process.cwd()));
129
- const combined = {
130
- ...emptyStore(),
131
- rules: [...global.rules, ...project.rules].filter((rule) => options.scope === undefined || rule.scope === options.scope),
132
- };
133
- const json = serializeRuleStore(combined);
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 ${combined.rules.length} rule(s) to ${options.out}`);
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 = parseRuleStore(decodeTextFile(await readFile(file)));
169
+ incoming = parseExportDocument(text, { currentPaiVersion: packageVersion() });
155
170
  }
156
171
  catch (error) {
157
- out(`Could not read ${file}: ${describeError(error)}`);
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
- const targets = [
162
- { scope: 'global', path: globalStorePath() },
163
- { scope: 'project', path: projectStorePath(process.cwd()) },
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
- for (const target of targets) {
167
- const rules = incoming.rules.filter((rule) => rule.scope === target.scope);
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 result = mergeStores(readStore(target.path), { ...emptyStore(), rules });
171
- if (!options.dryRun)
172
- writeStore(target.path, result.store);
173
- out(`${target.scope}: ${result.added} added, ${result.merged} updated with new evidence, ${result.store.rules.length} total → ${target.path}`);
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) {
@@ -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
- lines.push(`${group.scope === 'global' ? 'Global' : 'Project'} (${group.path})`);
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
+ }
@@ -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.
@@ -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 to its own store
7
- (`.pai/` in a project) and to agent guidance files only as an explicit,
8
- user-approved sync feature (future).
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lastboy/pai",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "PAI — Personal AI Supervisor: a local-first CLI that observes how you work with AI coding agents and turns it into knowledge you own",
5
5
  "keywords": [
6
6
  "cli",