@lastboy/pai 0.2.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,12 +126,13 @@ 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
 
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
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
115
131
  import accepts the encodings Windows tooling produces: UTF-8, UTF-8 with a BOM
116
132
  (Notepad, `Set-Content`) and UTF-16 with a BOM (PowerShell 5.1's `>` redirect).
117
133
  Line endings are normalized, so the same rule written on Windows and on
118
- macOS/Linux merges as one instead of duplicating.
134
+ macOS/Linux counts as one instead of duplicating; `CLAUDE.pai.md` is always
135
+ written as UTF-8 with LF line endings.
119
136
 
120
137
  ### `pai experiment …`
121
138
 
@@ -134,26 +151,29 @@ Default model: `qwen2.5:14b`.
134
151
 
135
152
  ## Where PAI stores things
136
153
 
137
- | Path | Contents |
138
- |---|---|
139
- | `~/.pai/rules.json` | your user-level rules |
140
- | `<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.
141
156
 
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.
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 |
147
164
 
148
- 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.
149
169
 
150
170
  ## What PAI reads
151
171
 
152
172
  | Path | Used for |
153
173
  |---|---|
154
174
  | `~/.claude/projects/<encoded-cwd>/*.jsonl` | session transcripts (read-only) |
155
- | `~/.claude/CLAUDE.md` | global guidelines |
156
- | `<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 |
157
177
 
158
178
  The encoded folder name is the project path with every non-alphanumeric
159
179
  character replaced by `-`.
@@ -163,7 +183,7 @@ character replaced by `-`.
163
183
  ```
164
184
  src/core/ agent-independent logic (sessions, corrections, rules, review)
165
185
  src/adapters/ per-agent integration — currently only claude/
166
- src/persistence/ PAI's own store on disk
186
+ src/persistence/ file I/O CLAUDE.pai.md, text decoding, the .pai/ store
167
187
  src/experiments/ local-LLM experiments (Ollama, optional)
168
188
  src/cli/ command wiring and rendering
169
189
  tests/ vitest suites and sanitized fixtures
@@ -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,10 @@ 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
19
  import { decodeTextFile } from '../persistence/text-file.js';
20
20
  import { parseSelection, renderGuidelines, renderReview, renderSessionList } from './render.js';
21
21
  async function ask(question) {
@@ -88,16 +88,11 @@ export function createProgram(out) {
88
88
  program
89
89
  .command('rules')
90
90
  .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)')
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)')
93
93
  .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
- })));
94
+ .action((options) => {
95
+ const groups = readGuidelineGroups(findGuidelineFiles(process.cwd()));
101
96
  // --global and --project together = no scope filter (same as neither).
102
97
  const scope = options.global && !options.project
103
98
  ? 'global'
@@ -114,7 +109,7 @@ export function createProgram(out) {
114
109
  });
115
110
  program
116
111
  .command('export')
117
- .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)')
118
113
  .option('--out <file>', 'write to this file instead of stdout')
119
114
  .option('--scope <scope>', 'export only "global" or "project" rules')
120
115
  .action((options) => {
@@ -124,19 +119,16 @@ export function createProgram(out) {
124
119
  return;
125
120
  }
126
121
  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);
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);
134
126
  if (options.out === undefined) {
135
127
  out(json.trimEnd());
136
128
  return;
137
129
  }
138
130
  writeFileSync(options.out, json, 'utf8');
139
- out(`Exported ${combined.rules.length} rule(s) to ${options.out}`);
131
+ out(`Exported ${rules.length} rule(s) to ${options.out}`);
140
132
  }
141
133
  catch (error) {
142
134
  out(`Export failed: ${describeError(error)}`);
@@ -146,31 +138,37 @@ export function createProgram(out) {
146
138
  program
147
139
  .command('import')
148
140
  .argument('<file>', 'JSON file previously produced by "pai export"')
149
- .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)')
150
142
  .option('--dry-run', 'show what would change without writing')
151
143
  .action(async (file, options) => {
152
144
  let incoming;
153
145
  try {
154
- incoming = parseRuleStore(decodeTextFile(await readFile(file)));
146
+ incoming = parseExportDocument(decodeTextFile(await readFile(file)));
155
147
  }
156
148
  catch (error) {
157
149
  out(`Could not read ${file}: ${describeError(error)}`);
158
150
  process.exitCode = 1;
159
151
  return;
160
152
  }
161
- const targets = [
162
- { scope: 'global', path: globalStorePath() },
163
- { scope: 'project', path: projectStorePath(process.cwd()) },
164
- ];
165
153
  try {
166
- for (const target of targets) {
167
- const rules = incoming.rules.filter((rule) => rule.scope === target.scope);
154
+ for (const scope of ['global', 'project']) {
155
+ const rules = incoming.rules.filter((rule) => rule.scope === scope);
168
156
  if (rules.length === 0)
169
157
  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}`);
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}`);
174
172
  }
175
173
  }
176
174
  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;
@@ -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
+ }
@@ -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
+ }
@@ -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.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",