@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 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
- { "version": 2, "rules": [ { "rule": "…", "category": "…", "scope": "global" } ] }
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: `pai learn` — scan sessions, distill candidate rules, approve or reject
215
- them interactively, and store the approved ones. Then auditing sessions against
216
- stored rules (which rules the agent actually violated, with evidence).
230
+ Next: see [Roadmap](#roadmap).
231
+
232
+ ## Roadmap
233
+
234
+ - **`pai learn`** — scan sessions, distill candidate rules with a local model, approve/reject/edit interactively, write approved rules to `CLAUDE.pai.md`.
235
+ - **Compliance audit** — check sessions against your rules and report violations with evidence (which rule, how often, your own words) — the "is the agent following my guidance" measure.
236
+ - **Trends** — violations over time, before/after a rule was added, and comparison across models/configurations.
237
+ - **Cross-project promotion** — rules that keep appearing in several projects get proposed as global rules.
238
+ - **Better session review** — active time instead of wall-clock duration, filtering out headless/automation sessions.
239
+ - **Long-list UX** — category summaries with counts, `--category`, an interactive picker.
240
+ - **More agents** — Codex CLI adapter (`AGENTS.md`); the core stays agent-independent.
241
+ - **Optional integrations, only if needed** — Claude Code hooks for real-time capture; MCP so the agent can ask PAI about rules.
242
+
243
+ Order is not a commitment; each item ships as its own small, tested step.
@@ -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(decodeTextFile(await readFile(file)));
169
+ incoming = parseExportDocument(text, { currentPaiVersion: packageVersion() });
147
170
  }
148
171
  catch (error) {
149
- out(`Could not read ${file}: ${describeError(error)}`);
172
+ if (options.validate) {
173
+ for (const line of describeError(error).split('\n'))
174
+ out(line);
175
+ }
176
+ else {
177
+ out(`Could not read ${file}: ${describeError(error)}`);
178
+ }
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 { parseRuleStore, ruleId } from './rule-store.js';
6
- const EXPORT_VERSION = 2;
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 = { version: EXPORT_VERSION, rules };
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
- 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');
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
- 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
- };
39
+ if (doc['pai'] !== undefined && typeof doc['pai'] !== 'string') {
40
+ problems.push('pai: expected string');
42
41
  }
43
- if (raw['version'] !== EXPORT_VERSION) {
44
- return failParse(`unsupported version: ${String(raw['version'])} (expected ${EXPORT_VERSION})`);
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
- if (!Array.isArray(raw['rules'])) {
47
- return failParse('"rules" must be an array');
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
- for (const [index, entry] of raw['rules'].entries()) {
54
+ rawRules.forEach((entry, index) => {
55
+ const path = `rules[${index}]`;
51
56
  if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
52
- return failParse(`rule ${index + 1} is not an object`);
57
+ problems.push(`${path}: expected object`);
58
+ return;
53
59
  }
54
60
  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`);
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
- const category = record['category'];
60
- if (category !== undefined && typeof category !== 'string') {
61
- return failParse(`rule ${index + 1} has a non-string "category"`);
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
- if (scope !== 'global' && scope !== 'project') {
65
- return failParse(`rule ${index + 1} has an invalid "scope" (expected "global" or "project")`);
81
+ const scopeValid = scope === 'global' || scope === 'project';
82
+ if (!scopeValid) {
83
+ problems.push(`${path}.scope: expected "global" or "project"`);
66
84
  }
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
- });
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
- return { version: EXPORT_VERSION, rules };
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lastboy/pai",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "PAI — Personal AI Supervisor: a local-first CLI that observes how you work with AI coding agents and turns it into knowledge you own",
5
5
  "keywords": [
6
6
  "cli",