@lastboy/pai 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -4
- package/dist/cli/program.js +49 -4
- package/dist/core/export-migrations.js +41 -0
- package/dist/core/managed-guidelines.js +94 -47
- package/docs/export-format.md +83 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -83,9 +83,19 @@ Exports the same rules `pai rules` shows — from `CLAUDE.md` and
|
|
|
83
83
|
`CLAUDE.local.md` is per-machine and is not exported.
|
|
84
84
|
|
|
85
85
|
```json
|
|
86
|
-
{
|
|
86
|
+
{
|
|
87
|
+
"version": 2,
|
|
88
|
+
"pai": "0.3.1",
|
|
89
|
+
"exportedAt": "2026-08-29T12:00:00.000Z",
|
|
90
|
+
"rules": [ { "rule": "…", "category": "…", "scope": "global" } ]
|
|
91
|
+
}
|
|
87
92
|
```
|
|
88
93
|
|
|
94
|
+
`version` is the export format (for compatibility checks), `pai` is the PAI
|
|
95
|
+
version that produced the file, and `exportedAt` is when it was written;
|
|
96
|
+
files from a newer format than this `pai` supports are rejected with an
|
|
97
|
+
upgrade hint.
|
|
98
|
+
|
|
89
99
|
| Option | Effect |
|
|
90
100
|
|---|---|
|
|
91
101
|
| `--out <file>` | write to a file instead of stdout |
|
|
@@ -118,13 +128,19 @@ Hand-written `CLAUDE.md` rules are never used for deduplication — only
|
|
|
118
128
|
| Option | Effect |
|
|
119
129
|
|---|---|
|
|
120
130
|
| `--dry-run` | report what would change ("would add …") without writing |
|
|
131
|
+
| `--validate` | check the file only — parse, migrate, validate — and print a summary; writes nothing |
|
|
121
132
|
|
|
122
133
|
```bash
|
|
123
134
|
pai import mine.json --dry-run
|
|
124
135
|
pai import mine.json
|
|
136
|
+
pai import mine.json --validate
|
|
125
137
|
```
|
|
126
138
|
|
|
127
139
|
Invalid or unsupported files fail with a clear message and a non-zero exit code.
|
|
140
|
+
Files from an older export format are migrated automatically; files from a
|
|
141
|
+
newer format than this `pai` supports are rejected with an upgrade hint. See
|
|
142
|
+
[the export format contract](docs/export-format.md) for the full JSON shape,
|
|
143
|
+
field constraints, and migration/compatibility rules.
|
|
128
144
|
|
|
129
145
|
**Cross-platform.** A file exported on macOS or Linux imports on Windows and
|
|
130
146
|
back. Exports contain no filesystem paths, so nothing is machine-specific, and
|
|
@@ -211,6 +227,17 @@ repository.
|
|
|
211
227
|
Working today: `status`, `review`, `rules`, `export`, `import`, and the two
|
|
212
228
|
experiments.
|
|
213
229
|
|
|
214
|
-
Next:
|
|
215
|
-
|
|
216
|
-
|
|
230
|
+
Next: see [Roadmap](#roadmap).
|
|
231
|
+
|
|
232
|
+
## Roadmap
|
|
233
|
+
|
|
234
|
+
- **`pai learn`** — scan sessions, distill candidate rules with a local model, approve/reject/edit interactively, write approved rules to `CLAUDE.pai.md`.
|
|
235
|
+
- **Compliance audit** — check sessions against your rules and report violations with evidence (which rule, how often, your own words) — the "is the agent following my guidance" measure.
|
|
236
|
+
- **Trends** — violations over time, before/after a rule was added, and comparison across models/configurations.
|
|
237
|
+
- **Cross-project promotion** — rules that keep appearing in several projects get proposed as global rules.
|
|
238
|
+
- **Better session review** — active time instead of wall-clock duration, filtering out headless/automation sessions.
|
|
239
|
+
- **Long-list UX** — category summaries with counts, `--category`, an interactive picker.
|
|
240
|
+
- **More agents** — Codex CLI adapter (`AGENTS.md`); the core stays agent-independent.
|
|
241
|
+
- **Optional integrations, only if needed** — Claude Code hooks for real-time capture; MCP so the agent can ask PAI about rules.
|
|
242
|
+
|
|
243
|
+
Order is not a commitment; each item ships as its own small, tested step.
|
package/dist/cli/program.js
CHANGED
|
@@ -15,6 +15,7 @@ import { buildDistillPrompt, parseDistillResponse } from '../experiments/rule-di
|
|
|
15
15
|
import { findExportableGuidelineFiles, findGuidelineFiles, guidelineDir, managedImportTarget, } from '../adapters/claude/guidelines.js';
|
|
16
16
|
import { filterGuidelineGroups } from '../core/guidelines.js';
|
|
17
17
|
import { parseExportDocument, serializeExportDocument, toExportRules, } from '../core/managed-guidelines.js';
|
|
18
|
+
import { CURRENT_EXPORT_FORMAT } from '../core/export-migrations.js';
|
|
18
19
|
import { importManagedGuidelines, readGuidelineGroups, } from '../persistence/managed-guideline-files.js';
|
|
19
20
|
import { decodeTextFile } from '../persistence/text-file.js';
|
|
20
21
|
import { parseSelection, renderGuidelines, renderReview, renderSessionList } from './render.js';
|
|
@@ -53,8 +54,25 @@ async function chooseSession(out) {
|
|
|
53
54
|
function describeError(error) {
|
|
54
55
|
return error instanceof Error ? error.message : String(error);
|
|
55
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Best-effort read of the file's own `pai`/`exportedAt` fields for the
|
|
59
|
+
* `--validate` summary. Only called after `parseExportDocument` has already
|
|
60
|
+
* succeeded on the same text, so this re-parse cannot fail in practice.
|
|
61
|
+
*/
|
|
62
|
+
function readExportMeta(text) {
|
|
63
|
+
try {
|
|
64
|
+
const parsed = JSON.parse(text.replace(/^/, ''));
|
|
65
|
+
return {
|
|
66
|
+
...(typeof parsed['pai'] === 'string' ? { pai: parsed['pai'] } : {}),
|
|
67
|
+
...(typeof parsed['exportedAt'] === 'string' ? { exportedAt: parsed['exportedAt'] } : {}),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return {};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
56
74
|
// Same relative depth from src/cli and dist/cli.
|
|
57
|
-
function packageVersion() {
|
|
75
|
+
export function packageVersion() {
|
|
58
76
|
const pkg = JSON.parse(readFileSync(join(import.meta.dirname, '..', '..', 'package.json'), 'utf8'));
|
|
59
77
|
return pkg.version ?? '0.0.0';
|
|
60
78
|
}
|
|
@@ -122,7 +140,10 @@ export function createProgram(out) {
|
|
|
122
140
|
// Global files come first, so the output order is global → project.
|
|
123
141
|
const files = findExportableGuidelineFiles(process.cwd()).filter((file) => options.scope === undefined || file.scope === options.scope);
|
|
124
142
|
const rules = toExportRules(readGuidelineGroups(files));
|
|
125
|
-
const json = serializeExportDocument(rules
|
|
143
|
+
const json = serializeExportDocument(rules, {
|
|
144
|
+
pai: packageVersion(),
|
|
145
|
+
exportedAt: new Date().toISOString(),
|
|
146
|
+
});
|
|
126
147
|
if (options.out === undefined) {
|
|
127
148
|
out(json.trimEnd());
|
|
128
149
|
return;
|
|
@@ -140,17 +161,41 @@ export function createProgram(out) {
|
|
|
140
161
|
.argument('<file>', 'JSON file previously produced by "pai export"')
|
|
141
162
|
.description('Merge rules from a file into CLAUDE.pai.md on this machine (never overwrites)')
|
|
142
163
|
.option('--dry-run', 'show what would change without writing')
|
|
164
|
+
.option('--validate', 'check the file only; print a summary and exit without writing')
|
|
143
165
|
.action(async (file, options) => {
|
|
166
|
+
const text = decodeTextFile(await readFile(file));
|
|
144
167
|
let incoming;
|
|
145
168
|
try {
|
|
146
|
-
incoming = parseExportDocument(
|
|
169
|
+
incoming = parseExportDocument(text, { currentPaiVersion: packageVersion() });
|
|
147
170
|
}
|
|
148
171
|
catch (error) {
|
|
149
|
-
|
|
172
|
+
if (options.validate) {
|
|
173
|
+
for (const line of describeError(error).split('\n'))
|
|
174
|
+
out(line);
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
out(`Could not read ${file}: ${describeError(error)}`);
|
|
178
|
+
}
|
|
150
179
|
process.exitCode = 1;
|
|
151
180
|
return;
|
|
152
181
|
}
|
|
182
|
+
if (options.validate) {
|
|
183
|
+
const meta = readExportMeta(text);
|
|
184
|
+
const paiPart = meta.pai !== undefined ? `pai ${meta.pai}` : 'pai unknown';
|
|
185
|
+
const exportedPart = meta.exportedAt !== undefined ? `, exported ${meta.exportedAt}` : '';
|
|
186
|
+
out(`Valid PAI export (format ${CURRENT_EXPORT_FORMAT}, ${paiPart}${exportedPart})`);
|
|
187
|
+
const global = incoming.rules.filter((rule) => rule.scope === 'global').length;
|
|
188
|
+
const project = incoming.rules.filter((rule) => rule.scope === 'project').length;
|
|
189
|
+
out(`Rules: ${incoming.rules.length} (global ${global}, project ${project})`);
|
|
190
|
+
if (incoming.migratedFrom !== undefined) {
|
|
191
|
+
out(`Migrated from format ${incoming.migratedFrom} (would be imported as format ${CURRENT_EXPORT_FORMAT})`);
|
|
192
|
+
}
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
153
195
|
try {
|
|
196
|
+
if (incoming.migratedFrom !== undefined) {
|
|
197
|
+
out(`Migrated from format ${incoming.migratedFrom}.`);
|
|
198
|
+
}
|
|
154
199
|
for (const scope of ['global', 'project']) {
|
|
155
200
|
const rules = incoming.rules.filter((rule) => rule.scope === scope);
|
|
156
201
|
if (rules.length === 0)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Migration chain that brings an older PAI export up to the current format.
|
|
2
|
+
// Pure: no I/O, no filesystem, no process access.
|
|
3
|
+
import { parseRuleStore } from './rule-store.js';
|
|
4
|
+
export const CURRENT_EXPORT_FORMAT = 2;
|
|
5
|
+
/** Older `.pai/rules.json` stores carry the same three fields an export needs. */
|
|
6
|
+
function v1ToV2(doc) {
|
|
7
|
+
const store = parseRuleStore(JSON.stringify(doc));
|
|
8
|
+
return {
|
|
9
|
+
version: 2,
|
|
10
|
+
rules: store.rules.map((r) => ({ rule: r.rule, category: r.category, scope: r.scope })),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
// Adding a future migration is one new entry here: key N migrates format N to N+1.
|
|
14
|
+
const MIGRATIONS = {
|
|
15
|
+
1: v1ToV2,
|
|
16
|
+
};
|
|
17
|
+
export function migrateExport(raw, options) {
|
|
18
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
19
|
+
throw new Error('Invalid PAI export: file is not a JSON object');
|
|
20
|
+
}
|
|
21
|
+
const record = raw;
|
|
22
|
+
const version = record['version'];
|
|
23
|
+
if (typeof version !== 'number' || !Number.isInteger(version) || version < 1) {
|
|
24
|
+
throw new Error('Invalid PAI export: "version" must be a positive integer');
|
|
25
|
+
}
|
|
26
|
+
if (version > CURRENT_EXPORT_FORMAT) {
|
|
27
|
+
const producer = typeof record['pai'] === 'string' && record['pai'].trim() !== '' ? record['pai'] : 'unknown';
|
|
28
|
+
throw new Error(`Invalid PAI export: format ${version} was produced by pai ${producer}; this pai (${options.currentPaiVersion}) supports up to format ${CURRENT_EXPORT_FORMAT} — upgrade pai`);
|
|
29
|
+
}
|
|
30
|
+
let document = record;
|
|
31
|
+
let from = version;
|
|
32
|
+
while (from < CURRENT_EXPORT_FORMAT) {
|
|
33
|
+
const migrate = MIGRATIONS[from];
|
|
34
|
+
if (!migrate) {
|
|
35
|
+
throw new Error(`Invalid PAI export: no migration path from format ${from}`);
|
|
36
|
+
}
|
|
37
|
+
document = migrate(document);
|
|
38
|
+
from += 1;
|
|
39
|
+
}
|
|
40
|
+
return { document, ...(version !== CURRENT_EXPORT_FORMAT ? { migratedFrom: version } : {}) };
|
|
41
|
+
}
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// guidance files are never edited; PAI owns one sibling file per scope and
|
|
3
3
|
// merges into it. Everything here is pure — paths and I/O live elsewhere.
|
|
4
4
|
import { HEADING, parseGuidelines } from './guidelines.js';
|
|
5
|
-
import {
|
|
6
|
-
|
|
5
|
+
import { ruleId } from './rule-store.js';
|
|
6
|
+
import { CURRENT_EXPORT_FORMAT, migrateExport } from './export-migrations.js';
|
|
7
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
8
|
function toLf(text) {
|
|
9
9
|
return text.replace(/\r\n?/g, '\n');
|
|
@@ -12,70 +12,117 @@ function toLf(text) {
|
|
|
12
12
|
export function toExportRules(groups) {
|
|
13
13
|
return groups.flatMap((group) => group.guidelines.map((g) => ({ rule: g.text, category: g.category, scope: group.scope })));
|
|
14
14
|
}
|
|
15
|
-
export function serializeExportDocument(rules) {
|
|
16
|
-
const document = {
|
|
15
|
+
export function serializeExportDocument(rules, meta) {
|
|
16
|
+
const document = {
|
|
17
|
+
version: CURRENT_EXPORT_FORMAT,
|
|
18
|
+
pai: meta.pai,
|
|
19
|
+
exportedAt: meta.exportedAt,
|
|
20
|
+
rules,
|
|
21
|
+
};
|
|
17
22
|
return `${JSON.stringify(document, null, 2)}\n`;
|
|
18
23
|
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
24
|
+
const MAX_RULE_LENGTH = 500;
|
|
25
|
+
const MAX_CATEGORY_LENGTH = 80;
|
|
26
|
+
const MAX_PROBLEMS_SHOWN = 20;
|
|
27
|
+
/**
|
|
28
|
+
* Strict validation of a format-2 document (after migration). Collects every
|
|
29
|
+
* problem instead of stopping at the first one; each message is prefixed
|
|
30
|
+
* with a JSON-path so multiple problems in one file can be fixed at once.
|
|
31
|
+
* Unknown extra fields, top-level or per-rule, are ignored for forward
|
|
32
|
+
* compatibility.
|
|
33
|
+
*/
|
|
34
|
+
export function validateExportDocument(doc) {
|
|
35
|
+
const problems = [];
|
|
36
|
+
if (doc['version'] !== CURRENT_EXPORT_FORMAT) {
|
|
37
|
+
problems.push(`version: expected ${CURRENT_EXPORT_FORMAT}`);
|
|
30
38
|
}
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
};
|
|
39
|
+
if (doc['pai'] !== undefined && typeof doc['pai'] !== 'string') {
|
|
40
|
+
problems.push('pai: expected string');
|
|
42
41
|
}
|
|
43
|
-
if (
|
|
44
|
-
|
|
42
|
+
if (doc['exportedAt'] !== undefined) {
|
|
43
|
+
const exportedAt = doc['exportedAt'];
|
|
44
|
+
if (typeof exportedAt !== 'string' || Number.isNaN(Date.parse(exportedAt))) {
|
|
45
|
+
problems.push('exportedAt: expected ISO-8601 timestamp');
|
|
46
|
+
}
|
|
45
47
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
+
const rawRules = doc['rules'];
|
|
49
|
+
if (!Array.isArray(rawRules)) {
|
|
50
|
+
problems.push('rules: expected array');
|
|
51
|
+
return { ok: false, problems };
|
|
48
52
|
}
|
|
49
53
|
const rules = [];
|
|
50
|
-
|
|
54
|
+
rawRules.forEach((entry, index) => {
|
|
55
|
+
const path = `rules[${index}]`;
|
|
51
56
|
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
52
|
-
|
|
57
|
+
problems.push(`${path}: expected object`);
|
|
58
|
+
return;
|
|
53
59
|
}
|
|
54
60
|
const record = entry;
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
61
|
+
const rawText = record['rule'];
|
|
62
|
+
// A bullet is one line; line breaks inside a rule would split it.
|
|
63
|
+
const normalizedText = typeof rawText === 'string' ? toLf(rawText).replace(/\n/g, ' ').trim() : undefined;
|
|
64
|
+
const ruleValid = normalizedText !== undefined && normalizedText !== '' && normalizedText.length <= MAX_RULE_LENGTH;
|
|
65
|
+
if (!ruleValid) {
|
|
66
|
+
problems.push(`${path}.rule: expected non-empty string (max ${MAX_RULE_LENGTH} chars)`);
|
|
58
67
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
68
|
+
let category = 'General';
|
|
69
|
+
let categoryValid = true;
|
|
70
|
+
const rawCategory = record['category'];
|
|
71
|
+
if (rawCategory !== undefined) {
|
|
72
|
+
if (typeof rawCategory !== 'string' || rawCategory.trim().length > MAX_CATEGORY_LENGTH) {
|
|
73
|
+
categoryValid = false;
|
|
74
|
+
problems.push(`${path}.category: expected string (max ${MAX_CATEGORY_LENGTH} chars)`);
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
category = rawCategory.trim() === '' ? 'General' : rawCategory.trim();
|
|
78
|
+
}
|
|
62
79
|
}
|
|
63
80
|
const scope = record['scope'];
|
|
64
|
-
|
|
65
|
-
|
|
81
|
+
const scopeValid = scope === 'global' || scope === 'project';
|
|
82
|
+
if (!scopeValid) {
|
|
83
|
+
problems.push(`${path}.scope: expected "global" or "project"`);
|
|
66
84
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
85
|
+
if (ruleValid && categoryValid && scopeValid) {
|
|
86
|
+
rules.push({ rule: normalizedText, category, scope: scope });
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
if (problems.length > 0)
|
|
90
|
+
return { ok: false, problems };
|
|
91
|
+
return { ok: true, document: { version: CURRENT_EXPORT_FORMAT, rules } };
|
|
92
|
+
}
|
|
93
|
+
export function parseExportDocument(json, options) {
|
|
94
|
+
let parsed;
|
|
95
|
+
try {
|
|
96
|
+
// Tolerate a byte-order mark left by Windows editors.
|
|
97
|
+
parsed = JSON.parse(json.replace(/^/, ''));
|
|
73
98
|
}
|
|
74
|
-
|
|
99
|
+
catch {
|
|
100
|
+
return failParse('file is not valid JSON');
|
|
101
|
+
}
|
|
102
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
103
|
+
return failParse('file is not a JSON object');
|
|
104
|
+
}
|
|
105
|
+
const { document, migratedFrom } = migrateExport(parsed, {
|
|
106
|
+
currentPaiVersion: options.currentPaiVersion,
|
|
107
|
+
});
|
|
108
|
+
const result = validateExportDocument(document);
|
|
109
|
+
if (!result.ok)
|
|
110
|
+
failValidation(result.problems);
|
|
111
|
+
return { ...result.document, ...(migratedFrom !== undefined ? { migratedFrom } : {}) };
|
|
75
112
|
}
|
|
76
113
|
function failParse(reason) {
|
|
77
114
|
throw new Error(`Invalid PAI export: ${reason}`);
|
|
78
115
|
}
|
|
116
|
+
function failValidation(problems) {
|
|
117
|
+
const shown = problems.slice(0, MAX_PROBLEMS_SHOWN);
|
|
118
|
+
const remaining = problems.length - shown.length;
|
|
119
|
+
const lines = [
|
|
120
|
+
`Invalid PAI export: ${problems.length} problem(s)`,
|
|
121
|
+
...shown.map((problem) => ` - ${problem}`),
|
|
122
|
+
...(remaining > 0 ? [` - … and ${remaining} more`] : []),
|
|
123
|
+
];
|
|
124
|
+
throw new Error(lines.join('\n'));
|
|
125
|
+
}
|
|
79
126
|
/**
|
|
80
127
|
* Make sure the hand-written file references the managed one. Only a suffix
|
|
81
128
|
* is ever produced — the caller appends it in place, so a symlinked CLAUDE.md
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# PAI export format
|
|
2
|
+
|
|
3
|
+
The contract for the JSON file `pai export` writes and `pai import` reads.
|
|
4
|
+
It exists so a file exported by one version of PAI, on one machine, imports
|
|
5
|
+
safely — or fails clearly — on another.
|
|
6
|
+
|
|
7
|
+
## Format 2 (current)
|
|
8
|
+
|
|
9
|
+
```json
|
|
10
|
+
{
|
|
11
|
+
"version": 2,
|
|
12
|
+
"pai": "0.3.1",
|
|
13
|
+
"exportedAt": "2026-08-29T12:00:00.000Z",
|
|
14
|
+
"rules": [
|
|
15
|
+
{ "rule": "Keep answers short.", "category": "Communication", "scope": "global" },
|
|
16
|
+
{ "rule": "Never force-push.", "category": "Git", "scope": "project" }
|
|
17
|
+
]
|
|
18
|
+
}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
| Field | Type | Required | Constraint |
|
|
22
|
+
|---|---|---|---|
|
|
23
|
+
| `version` | number | yes | must be `2` |
|
|
24
|
+
| `pai` | string | no | PAI version that produced the file |
|
|
25
|
+
| `exportedAt` | string | no | ISO-8601 timestamp, parseable by `Date` |
|
|
26
|
+
| `rules` | array | yes | array of rule objects (may be empty) |
|
|
27
|
+
| `rules[].rule` | string | yes | non-empty after trim, max 500 chars |
|
|
28
|
+
| `rules[].category` | string | no | max 80 chars; defaults to `"General"` |
|
|
29
|
+
| `rules[].scope` | string | yes | `"global"` or `"project"` |
|
|
30
|
+
|
|
31
|
+
Unknown extra fields — at the top level or inside a rule object — are
|
|
32
|
+
ignored, both by validation and on import. This is what lets a newer PAI add
|
|
33
|
+
fields to the format without breaking an older one that only understands the
|
|
34
|
+
fields above.
|
|
35
|
+
|
|
36
|
+
## Normalization
|
|
37
|
+
|
|
38
|
+
On import, each rule is normalized before it is compared or written:
|
|
39
|
+
|
|
40
|
+
- CRLF and lone CR are converted to LF
|
|
41
|
+
- newlines inside `rule` become a single space (a rule is one bullet, one line)
|
|
42
|
+
- the result is trimmed
|
|
43
|
+
- an empty or missing `category` becomes `"General"`
|
|
44
|
+
|
|
45
|
+
## Compatibility policy
|
|
46
|
+
|
|
47
|
+
- **Same format** (`version: 2`) — always safe to import, on any PAI version
|
|
48
|
+
that supports format 2.
|
|
49
|
+
- **Older format** — migrated automatically. Migrations applied so far:
|
|
50
|
+
- **1 → 2**: reads the old `.pai/rules.json` store shape
|
|
51
|
+
(`rules[].{rule,category,scope}`, plus `id`, `evidence`, `source`,
|
|
52
|
+
`createdAt`/`updatedAt`). Only `rule`, `category` and `scope` survive;
|
|
53
|
+
`id`, `evidence`, `source` and the timestamps are dropped — none of them
|
|
54
|
+
are part of the export format.
|
|
55
|
+
- **Newer format** — rejected with an exact, upgrade-pointing message:
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
Invalid PAI export: format <N> was produced by pai <pai or "unknown">; this pai (<current>) supports up to format 2 — upgrade pai
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Validation errors
|
|
62
|
+
|
|
63
|
+
An invalid file (right format, wrong contents) is rejected with every
|
|
64
|
+
problem found, not just the first one:
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
Invalid PAI export: 2 problem(s)
|
|
68
|
+
- rules[0].rule: expected non-empty string (max 500 chars)
|
|
69
|
+
- rules[0].scope: expected "global" or "project"
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Each line is prefixed with a JSON path to the offending field. At most 20
|
|
73
|
+
problems are listed; beyond that, a final line reads ` - … and N more`.
|
|
74
|
+
|
|
75
|
+
Run `pai import <file> --validate` to check a file against this contract —
|
|
76
|
+
parse, migrate, and validate — without writing anything.
|
|
77
|
+
|
|
78
|
+
## Encodings accepted
|
|
79
|
+
|
|
80
|
+
`pai import` decodes, in order of likelihood: UTF-8, UTF-8 with a BOM
|
|
81
|
+
(as written by Notepad or PowerShell's `Set-Content`), and UTF-16 with a BOM
|
|
82
|
+
(as written by PowerShell 5.1's `>` redirect). `pai export` always writes
|
|
83
|
+
UTF-8 with LF line endings and no BOM.
|
package/package.json
CHANGED