@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 +45 -25
- package/dist/adapters/claude/guidelines.js +35 -24
- package/dist/cli/program.js +31 -33
- package/dist/cli/render.js +2 -1
- package/dist/core/guidelines.js +19 -0
- package/dist/core/managed-guidelines.js +173 -0
- package/dist/persistence/managed-guideline-files.js +88 -0
- package/docs/principles.md +4 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -58,7 +58,8 @@ pai review
|
|
|
58
58
|
|
|
59
59
|
Shows your guidelines from the CLAUDE.md files that apply to the current
|
|
60
60
|
folder — global (`~/.claude/CLAUDE.md`) and project (`CLAUDE.md`,
|
|
61
|
-
`CLAUDE.local.md`) —
|
|
61
|
+
`CLAUDE.local.md`) — plus the PAI-managed `CLAUDE.pai.md` next to each, all
|
|
62
|
+
grouped by their markdown headings. Managed files are labeled `PAI managed`.
|
|
62
63
|
|
|
63
64
|
| Option | Effect |
|
|
64
65
|
|---|---|
|
|
@@ -77,7 +78,13 @@ Search matches word starts, so `--search ask` finds "Ask me before…" but not
|
|
|
77
78
|
|
|
78
79
|
### `pai export`
|
|
79
80
|
|
|
80
|
-
Exports
|
|
81
|
+
Exports the same rules `pai rules` shows — from `CLAUDE.md` and
|
|
82
|
+
`CLAUDE.pai.md`, global then project — as portable, versioned JSON.
|
|
83
|
+
`CLAUDE.local.md` is per-machine and is not exported.
|
|
84
|
+
|
|
85
|
+
```json
|
|
86
|
+
{ "version": 2, "rules": [ { "rule": "…", "category": "…", "scope": "global" } ] }
|
|
87
|
+
```
|
|
81
88
|
|
|
82
89
|
| Option | Effect |
|
|
83
90
|
|---|---|
|
|
@@ -92,16 +99,25 @@ pai export --scope global --out mine.json # only user-level rules
|
|
|
92
99
|
### `pai import <file>`
|
|
93
100
|
|
|
94
101
|
Merges a previously exported file into this machine. Rules carry their own
|
|
95
|
-
scope
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
102
|
+
scope: global rules go to `~/.claude/CLAUDE.pai.md`, project rules to
|
|
103
|
+
`<project>/CLAUDE.pai.md` — no flag needed. For each scope PAI:
|
|
104
|
+
|
|
105
|
+
1. makes sure `CLAUDE.md` exists and contains the import line `@CLAUDE.pai.md`
|
|
106
|
+
(created with only that line if missing; the line is appended in place if
|
|
107
|
+
absent, so a symlinked CLAUDE.md stays a symlink; nothing else in the file
|
|
108
|
+
is touched);
|
|
109
|
+
2. adds the rules that `CLAUDE.pai.md` does not have yet, under their category
|
|
110
|
+
headings, keeping everything already there exactly as it is.
|
|
111
|
+
|
|
112
|
+
The merge is **non-destructive** and idempotent: nothing is overwritten or
|
|
113
|
+
removed, and re-importing the same file adds nothing. Rule identity is the
|
|
114
|
+
normalized text (case, punctuation, whitespace and line endings ignored).
|
|
115
|
+
Hand-written `CLAUDE.md` rules are never used for deduplication — only
|
|
116
|
+
`CLAUDE.pai.md` is. Files from the older `version: 1` store format are accepted.
|
|
101
117
|
|
|
102
118
|
| Option | Effect |
|
|
103
119
|
|---|---|
|
|
104
|
-
| `--dry-run` | report what would change without writing |
|
|
120
|
+
| `--dry-run` | report what would change ("would add …") without writing |
|
|
105
121
|
|
|
106
122
|
```bash
|
|
107
123
|
pai import mine.json --dry-run
|
|
@@ -110,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
|
|
114
|
-
back.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
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/
|
|
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
|
|
6
|
-
// traversal is not implemented yet.
|
|
27
|
+
// CLAUDE.md / CLAUDE.local.md at the project root, each followed by the
|
|
28
|
+
// PAI-managed sibling. Parent-directory traversal is not implemented yet.
|
|
7
29
|
export function findGuidelineFiles(cwd, home = homedir()) {
|
|
30
|
+
const global = guidelineDir('global', cwd, home);
|
|
31
|
+
const project = guidelineDir('project', cwd, home);
|
|
8
32
|
const candidates = [
|
|
9
|
-
{ path: join(
|
|
10
|
-
{ path:
|
|
11
|
-
{ path: join(
|
|
33
|
+
{ path: join(global, 'CLAUDE.md'), scope: 'global', managed: false },
|
|
34
|
+
{ path: managedGuidelinePath(global), scope: 'global', managed: true },
|
|
35
|
+
{ path: join(project, 'CLAUDE.md'), scope: 'project', managed: false },
|
|
36
|
+
{ path: join(project, LOCAL_FILE_NAME), scope: 'project', managed: false },
|
|
37
|
+
{ path: managedGuidelinePath(project), scope: 'project', managed: true },
|
|
12
38
|
];
|
|
13
39
|
return candidates.filter((candidate) => existsSync(candidate.path));
|
|
14
40
|
}
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const guidelines = [];
|
|
19
|
-
let category = 'General';
|
|
20
|
-
// A BOM (Windows editors) would otherwise hide the first heading.
|
|
21
|
-
for (const line of markdown.replace(/^/, '').split('\n')) {
|
|
22
|
-
const heading = HEADING.exec(line);
|
|
23
|
-
if (heading?.[1]) {
|
|
24
|
-
category = heading[1];
|
|
25
|
-
continue;
|
|
26
|
-
}
|
|
27
|
-
const bullet = BULLET.exec(line);
|
|
28
|
-
if (bullet?.[1]) {
|
|
29
|
-
guidelines.push({ category, text: bullet[1] });
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
return guidelines;
|
|
41
|
+
/** Files that travel between machines: CLAUDE.local.md is per-machine and stays out. */
|
|
42
|
+
export function findExportableGuidelineFiles(cwd, home = homedir()) {
|
|
43
|
+
return findGuidelineFiles(cwd, home).filter((file) => basename(file.path) !== LOCAL_FILE_NAME);
|
|
33
44
|
}
|
package/dist/cli/program.js
CHANGED
|
@@ -12,10 +12,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,
|
|
15
|
+
import { findExportableGuidelineFiles, findGuidelineFiles, guidelineDir, managedImportTarget, } from '../adapters/claude/guidelines.js';
|
|
16
16
|
import { filterGuidelineGroups } from '../core/guidelines.js';
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
17
|
+
import { parseExportDocument, serializeExportDocument, toExportRules, } from '../core/managed-guidelines.js';
|
|
18
|
+
import { importManagedGuidelines, readGuidelineGroups, } from '../persistence/managed-guideline-files.js';
|
|
19
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(
|
|
95
|
-
const
|
|
96
|
-
const groups = await Promise.all(files.map(async (file) => ({
|
|
97
|
-
scope: file.scope,
|
|
98
|
-
path: file.path,
|
|
99
|
-
guidelines: parseGuidelines(decodeTextFile(await readFile(file.path))),
|
|
100
|
-
})));
|
|
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
|
|
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
|
-
|
|
128
|
-
const
|
|
129
|
-
const
|
|
130
|
-
|
|
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 ${
|
|
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 =
|
|
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
|
|
167
|
-
const rules = incoming.rules.filter((rule) => rule.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
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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) {
|
package/dist/cli/render.js
CHANGED
|
@@ -2,7 +2,8 @@ export function renderGuidelines(groups) {
|
|
|
2
2
|
const lines = ['PAI — Guidelines'];
|
|
3
3
|
for (const group of groups) {
|
|
4
4
|
lines.push('');
|
|
5
|
-
|
|
5
|
+
const scope = group.scope === 'global' ? 'Global' : 'Project';
|
|
6
|
+
lines.push(`${group.managed ? `${scope} — PAI managed` : scope} (${group.path})`);
|
|
6
7
|
if (group.guidelines.length === 0) {
|
|
7
8
|
lines.push(' (no guidelines found)');
|
|
8
9
|
continue;
|
package/dist/core/guidelines.js
CHANGED
|
@@ -1,3 +1,22 @@
|
|
|
1
|
+
export const HEADING = /^#{1,6}\s+(.+?)\s*$/;
|
|
2
|
+
export const BULLET = /^\s*[-*]\s+(.+?)\s*$/;
|
|
3
|
+
export function parseGuidelines(markdown) {
|
|
4
|
+
const guidelines = [];
|
|
5
|
+
let category = 'General';
|
|
6
|
+
// A BOM (Windows editors) would otherwise hide the first heading.
|
|
7
|
+
for (const line of markdown.replace(/^/, '').split('\n')) {
|
|
8
|
+
const heading = HEADING.exec(line);
|
|
9
|
+
if (heading?.[1]) {
|
|
10
|
+
category = heading[1];
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
const bullet = BULLET.exec(line);
|
|
14
|
+
if (bullet?.[1]) {
|
|
15
|
+
guidelines.push({ category, text: bullet[1] });
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return guidelines;
|
|
19
|
+
}
|
|
1
20
|
export function filterGuidelineGroups(groups, filters) {
|
|
2
21
|
const query = filters.search?.trim();
|
|
3
22
|
// Word-start match: "ask" hits "Ask"/"asking" but not "tasks".
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// PAI's rules live in markdown the coding agent already reads. Hand-written
|
|
2
|
+
// guidance files are never edited; PAI owns one sibling file per scope and
|
|
3
|
+
// merges into it. Everything here is pure — paths and I/O live elsewhere.
|
|
4
|
+
import { HEADING, parseGuidelines } from './guidelines.js';
|
|
5
|
+
import { parseRuleStore, ruleId } from './rule-store.js';
|
|
6
|
+
const EXPORT_VERSION = 2;
|
|
7
|
+
export const MANAGED_HEADER = '<!-- Managed by PAI. Hand-written rules belong in CLAUDE.md; PAI adds rules here via `pai import` / `pai learn`. -->';
|
|
8
|
+
function toLf(text) {
|
|
9
|
+
return text.replace(/\r\n?/g, '\n');
|
|
10
|
+
}
|
|
11
|
+
/** Flatten groups into export order: caller supplies global before project. */
|
|
12
|
+
export function toExportRules(groups) {
|
|
13
|
+
return groups.flatMap((group) => group.guidelines.map((g) => ({ rule: g.text, category: g.category, scope: group.scope })));
|
|
14
|
+
}
|
|
15
|
+
export function serializeExportDocument(rules) {
|
|
16
|
+
const document = { version: EXPORT_VERSION, rules };
|
|
17
|
+
return `${JSON.stringify(document, null, 2)}\n`;
|
|
18
|
+
}
|
|
19
|
+
export function parseExportDocument(json) {
|
|
20
|
+
let parsed;
|
|
21
|
+
try {
|
|
22
|
+
// Tolerate a byte-order mark left by Windows editors.
|
|
23
|
+
parsed = JSON.parse(json.replace(/^/, ''));
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return failParse('file is not valid JSON');
|
|
27
|
+
}
|
|
28
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
29
|
+
return failParse('file is not a JSON object');
|
|
30
|
+
}
|
|
31
|
+
const raw = parsed;
|
|
32
|
+
if (raw['version'] === 1) {
|
|
33
|
+
// Older `.pai/rules.json` stores carry the same three fields we need.
|
|
34
|
+
return {
|
|
35
|
+
version: EXPORT_VERSION,
|
|
36
|
+
rules: parseRuleStore(json).rules.map((r) => ({
|
|
37
|
+
rule: r.rule,
|
|
38
|
+
category: r.category,
|
|
39
|
+
scope: r.scope,
|
|
40
|
+
})),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
if (raw['version'] !== EXPORT_VERSION) {
|
|
44
|
+
return failParse(`unsupported version: ${String(raw['version'])} (expected ${EXPORT_VERSION})`);
|
|
45
|
+
}
|
|
46
|
+
if (!Array.isArray(raw['rules'])) {
|
|
47
|
+
return failParse('"rules" must be an array');
|
|
48
|
+
}
|
|
49
|
+
const rules = [];
|
|
50
|
+
for (const [index, entry] of raw['rules'].entries()) {
|
|
51
|
+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
52
|
+
return failParse(`rule ${index + 1} is not an object`);
|
|
53
|
+
}
|
|
54
|
+
const record = entry;
|
|
55
|
+
const text = record['rule'];
|
|
56
|
+
if (typeof text !== 'string' || text.trim() === '') {
|
|
57
|
+
return failParse(`rule ${index + 1} is missing a non-empty "rule" field`);
|
|
58
|
+
}
|
|
59
|
+
const category = record['category'];
|
|
60
|
+
if (category !== undefined && typeof category !== 'string') {
|
|
61
|
+
return failParse(`rule ${index + 1} has a non-string "category"`);
|
|
62
|
+
}
|
|
63
|
+
const scope = record['scope'];
|
|
64
|
+
if (scope !== 'global' && scope !== 'project') {
|
|
65
|
+
return failParse(`rule ${index + 1} has an invalid "scope" (expected "global" or "project")`);
|
|
66
|
+
}
|
|
67
|
+
rules.push({
|
|
68
|
+
// A bullet is one line; line breaks inside a rule would split it.
|
|
69
|
+
rule: toLf(text).replace(/\n/g, ' ').trim(),
|
|
70
|
+
category: category === undefined || category.trim() === '' ? 'General' : category.trim(),
|
|
71
|
+
scope,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return { version: EXPORT_VERSION, rules };
|
|
75
|
+
}
|
|
76
|
+
function failParse(reason) {
|
|
77
|
+
throw new Error(`Invalid PAI export: ${reason}`);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Make sure the hand-written file references the managed one. Only a suffix
|
|
81
|
+
* is ever produced — the caller appends it in place, so a symlinked CLAUDE.md
|
|
82
|
+
* keeps pointing at its target and existing bytes are never rewritten.
|
|
83
|
+
*/
|
|
84
|
+
export function ensurePointer(claudeMd, pointer) {
|
|
85
|
+
if (claudeMd === undefined) {
|
|
86
|
+
return { suffix: `${pointer}\n`, action: 'created' };
|
|
87
|
+
}
|
|
88
|
+
const hasPointer = toLf(claudeMd)
|
|
89
|
+
.split('\n')
|
|
90
|
+
.some((line) => line.trim() === pointer);
|
|
91
|
+
if (hasPointer)
|
|
92
|
+
return { suffix: '', action: 'unchanged' };
|
|
93
|
+
const trailing = /\s*$/.exec(claudeMd)?.[0] ?? '';
|
|
94
|
+
if (trailing.length === claudeMd.length) {
|
|
95
|
+
// Empty or whitespace-only: the pointer is the first real line.
|
|
96
|
+
const needsBreak = claudeMd !== '' && !claudeMd.endsWith('\n');
|
|
97
|
+
return { suffix: `${needsBreak ? '\n' : ''}${pointer}\n`, action: 'appended' };
|
|
98
|
+
}
|
|
99
|
+
// Exactly one blank line before the pointer, given what is already there.
|
|
100
|
+
const newlines = trailing.split('\n').length - 1;
|
|
101
|
+
const separator = newlines >= 2 ? '' : newlines === 1 ? '\n' : '\n\n';
|
|
102
|
+
return { suffix: `${separator}${pointer}\n`, action: 'appended' };
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Merge rules into PAI's managed markdown. Identity is the normalized rule
|
|
106
|
+
* text (`ruleId`), so wording that differs only in case, punctuation,
|
|
107
|
+
* whitespace or line endings counts as already present. Existing content,
|
|
108
|
+
* order and prose are preserved; new bullets go under their category heading,
|
|
109
|
+
* and missing headings are appended at the end.
|
|
110
|
+
*/
|
|
111
|
+
export function mergeManagedMarkdown(existingMarkdown, rules) {
|
|
112
|
+
const isNew = existingMarkdown === undefined || existingMarkdown.trim() === '';
|
|
113
|
+
const source = isNew ? MANAGED_HEADER : toLf(existingMarkdown);
|
|
114
|
+
const seen = new Set(parseGuidelines(source).map((g) => ruleId(g.text)));
|
|
115
|
+
// Category → new bullets, in first-appearance order.
|
|
116
|
+
const additions = new Map();
|
|
117
|
+
let added = 0;
|
|
118
|
+
let existing = 0;
|
|
119
|
+
for (const incoming of rules) {
|
|
120
|
+
const id = ruleId(incoming.rule);
|
|
121
|
+
if (seen.has(id)) {
|
|
122
|
+
existing += 1;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
seen.add(id);
|
|
126
|
+
added += 1;
|
|
127
|
+
const bullets = additions.get(incoming.category) ?? [];
|
|
128
|
+
bullets.push(`- ${toLf(incoming.rule).replace(/\n/g, ' ').trim()}`);
|
|
129
|
+
additions.set(incoming.category, bullets);
|
|
130
|
+
}
|
|
131
|
+
if (added === 0) {
|
|
132
|
+
return { markdown: existingMarkdown ?? '', added, existing };
|
|
133
|
+
}
|
|
134
|
+
// Each existing heading's section ends at its last non-blank line; new
|
|
135
|
+
// bullets for that category are inserted right after it.
|
|
136
|
+
const lines = source.split('\n');
|
|
137
|
+
const sectionEnd = new Map();
|
|
138
|
+
let current;
|
|
139
|
+
for (const [index, line] of lines.entries()) {
|
|
140
|
+
const heading = HEADING.exec(line);
|
|
141
|
+
if (heading?.[1]) {
|
|
142
|
+
current = sectionEnd.has(heading[1]) ? undefined : heading[1];
|
|
143
|
+
if (current !== undefined)
|
|
144
|
+
sectionEnd.set(current, index);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (current !== undefined && line.trim() !== '')
|
|
148
|
+
sectionEnd.set(current, index);
|
|
149
|
+
}
|
|
150
|
+
const insertAfter = new Map();
|
|
151
|
+
const missing = [];
|
|
152
|
+
for (const [category, bullets] of additions) {
|
|
153
|
+
const end = sectionEnd.get(category);
|
|
154
|
+
if (end === undefined) {
|
|
155
|
+
missing.push(category);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
insertAfter.set(end, bullets);
|
|
159
|
+
}
|
|
160
|
+
const output = [];
|
|
161
|
+
for (const [index, line] of lines.entries()) {
|
|
162
|
+
output.push(line);
|
|
163
|
+
const bullets = insertAfter.get(index);
|
|
164
|
+
if (bullets)
|
|
165
|
+
output.push(...bullets);
|
|
166
|
+
}
|
|
167
|
+
while (output.length > 0 && output[output.length - 1]?.trim() === '')
|
|
168
|
+
output.pop();
|
|
169
|
+
for (const category of missing) {
|
|
170
|
+
output.push('', `## ${category}`, ...(additions.get(category) ?? []));
|
|
171
|
+
}
|
|
172
|
+
return { markdown: `${output.join('\n')}\n`, added, existing };
|
|
173
|
+
}
|
|
@@ -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
|
+
}
|
package/docs/principles.md
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
## Principles
|
|
4
4
|
|
|
5
5
|
1. **Read-only toward agents' data.** PAI never modifies coding-agent files
|
|
6
|
-
(transcripts, configs, CLAUDE.md). PAI writes only
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
(transcripts, configs, CLAUDE.md). PAI writes only its own `CLAUDE.pai.md`
|
|
7
|
+
next to each CLAUDE.md, plus a single pointer line (`@CLAUDE.pai.md`) in
|
|
8
|
+
CLAUDE.md so the agent reads it — it never edits hand-written rules.
|
|
9
|
+
`.pai/` is reserved for PAI's own metadata.
|
|
9
10
|
2. **Approval gates knowledge.** Nothing becomes a stored rule without the
|
|
10
11
|
user approving it. Every stored or reported item carries evidence: the
|
|
11
12
|
user's actual words, session id, timestamp.
|
package/package.json
CHANGED