@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 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,13 @@ 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
+ { "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, 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.
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
- | Path | Contents |
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
- The store is agent-neutral, versioned (`version: 1`), and portable: rule
136
- identity comes from the rule text, so the same rule learned on two machines
137
- merges cleanly instead of duplicating. Each rule keeps its category, scope,
138
- source (`learned` or `manual`) and evidence your actual words, with session id
139
- and timestamp.
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 **never writes** to `~/.claude/` or to any CLAUDE.md file.
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/ PAI's own store on disk
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. 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
- 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
  }
@@ -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 { 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, 'utf8');
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(async (options) => {
90
- const files = findGuidelineFiles(process.cwd());
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 PAI rules to a portable JSON file (or stdout)')
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
- const global = readStore(globalStorePath());
122
- const project = readStore(projectStorePath(process.cwd()));
123
- const combined = {
124
- ...emptyStore(),
125
- rules: [...global.rules, ...project.rules].filter((rule) => options.scope === undefined || rule.scope === options.scope),
126
- };
127
- const json = serializeRuleStore(combined);
128
- if (options.out === undefined) {
129
- out(json.trimEnd());
130
- return;
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 = parseRuleStore(await readFile(file, 'utf8'));
146
+ incoming = parseExportDocument(decodeTextFile(await readFile(file)));
144
147
  }
145
148
  catch (error) {
146
- out(error instanceof Error ? error.message : String(error));
149
+ out(`Could not read ${file}: ${describeError(error)}`);
147
150
  process.exitCode = 1;
148
151
  return;
149
152
  }
150
- const targets = [
151
- { scope: 'global', path: globalStorePath() },
152
- { scope: 'project', path: projectStorePath(process.cwd()) },
153
- ];
154
- for (const target of targets) {
155
- const rules = incoming.rules.filter((rule) => rule.scope === target.scope);
156
- if (rules.length === 0)
157
- continue;
158
- const result = mergeStores(readStore(target.path), { ...emptyStore(), rules });
159
- if (!options.dryRun)
160
- writeStore(target.path, result.store);
161
- out(`${target.scope}: ${result.added} added, ${result.merged} updated with new evidence, ${result.store.rules.length} total → ${target.path}`);
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, 'utf8');
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, 'utf8');
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);
@@ -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;
@@ -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
+ }
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, readFileSync, writeFileSync } from 'node:fs';
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 = readFileSync(path, 'utf8');
17
+ contents = readTextFile(path);
16
18
  }
17
19
  catch {
18
20
  return emptyStore();
19
21
  }
20
- return parseRuleStore(contents);
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
- writeFileSync(path, serializeRuleStore(store), 'utf8');
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
+ }
@@ -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.1.0",
3
+ "version": "0.3.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",