portable-agent-layer 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
@@ -26,6 +26,116 @@ With PAL, you can:
26
26
 
27
27
  ---
28
28
 
29
+ ## Install
30
+
31
+ ### Prerequisites
32
+
33
+ > **Bun is required.** PAL is built on [Bun](https://bun.sh) and will not work with Node.js or other runtimes. Install it with `curl -fsSL https://bun.sh/install | bash`.
34
+
35
+ - [Bun](https://bun.sh) >= 1.3.0
36
+ - At least one of: [Claude Code](https://claude.ai/code) or [opencode](https://opencode.ai)
37
+
38
+ ### Package mode (recommended)
39
+
40
+ ```bash
41
+ bun add -g portable-agent-layer
42
+ pal cli init
43
+ ```
44
+
45
+ ### Repo mode (for development / contributors)
46
+
47
+ ```bash
48
+ git clone https://github.com/kovrichard/portable-agent-layer.git
49
+ cd portable-agent-layer
50
+ bun install
51
+ bun run install:all
52
+ ```
53
+
54
+ In repo mode, add an alias to your shell profile:
55
+
56
+ ```bash
57
+ alias pal="bun run ~/path/to/portable-agent-layer/src/cli/index.ts"
58
+ ```
59
+
60
+ ---
61
+
62
+ ## Quick start
63
+
64
+ ```bash
65
+ pal cli init # scaffold home, install hooks for all targets
66
+ pal # start a Claude session (with session summary on exit)
67
+ pal cli status # check your setup
68
+ ```
69
+
70
+ ---
71
+
72
+ ## Commands
73
+
74
+ | Command | Description |
75
+ |---------|-------------|
76
+ | `pal` | Start a Claude session with session summary on exit |
77
+ | `pal cli init` | Scaffold PAL home directory and install hooks |
78
+ | `pal cli install` | Register hooks/skills for targets |
79
+ | `pal cli uninstall` | Remove hooks/skills for targets |
80
+ | `pal cli export` | Export user state (telos, memory) to a zip |
81
+ | `pal cli import` | Import user state from a zip |
82
+ | `pal cli status` | Show current PAL configuration |
83
+ | `pal cli doctor` | Check prerequisites and system health |
84
+
85
+ ### Target flags
86
+
87
+ `init`, `install`, and `uninstall` accept target flags:
88
+
89
+ ```bash
90
+ pal cli install --claude # Claude Code only
91
+ pal cli install --opencode # opencode only
92
+ pal cli install # both (default)
93
+ ```
94
+
95
+ ---
96
+
97
+ ## Environment variables
98
+
99
+ ### Required
100
+
101
+ | Variable | Description |
102
+ |----------|-------------|
103
+ | `ANTHROPIC_API_KEY` | Required for PAL's hook inference (sentiment analysis, session naming). Uses Haiku for low-cost background calls. |
104
+
105
+ ### Optional
106
+
107
+ | Variable | Description |
108
+ |----------|-------------|
109
+ | `GEMINI_API_KEY` | For YouTube video analysis skill |
110
+ | `PAL_HOME` | Override user state directory (default: `~/.pal` or repo root) |
111
+ | `PAL_PKG` | Override package root |
112
+ | `PAL_CLAUDE_DIR` | Override Claude config dir (default: `~/.claude`) |
113
+ | `PAL_OPENCODE_DIR` | Override opencode config dir (default: `~/.config/opencode`) |
114
+ | `PAL_AGENTS_DIR` | Override agents dir (default: `~/.agents`) |
115
+
116
+ ---
117
+
118
+ ## Skills
119
+
120
+ PAL ships with built-in skills that extend your agent's capabilities:
121
+
122
+ | Skill | Description |
123
+ |-------|-------------|
124
+ | `analyze-pdf` | Download and analyze PDF files |
125
+ | `analyze-youtube` | Analyze YouTube videos using Gemini |
126
+ | `council` | Multi-perspective parallel debate on decisions |
127
+ | `create-skill` | Scaffold a new skill from a description |
128
+ | `extract-entities` | Extract people and companies from content |
129
+ | `extract-wisdom` | Extract structured insights from content |
130
+ | `first-principles` | Break down problems to fundamentals |
131
+ | `fyzz-chat-api` | Query Fyzz Chat conversations via API |
132
+ | `reflect` | Diagnose why a PAL behavior didn't trigger |
133
+ | `research` | Multi-agent parallel research |
134
+ | `review` | Security-focused code review |
135
+ | `summarize` | Structured summarization |
136
+
137
+ ---
138
+
29
139
  ## Core idea
30
140
 
31
141
  PAL stands for **Portable Agent Layer**.
@@ -78,3 +188,9 @@ PAL is for people who want:
78
188
  - to move between machines without rebuilding everything
79
189
  - a durable way to store and reuse context
80
190
  - an open foundation for portable agent workflows
191
+
192
+ ---
193
+
194
+ ## License
195
+
196
+ [MIT](LICENSE)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,7 +9,6 @@
9
9
  "files": [
10
10
  "src/",
11
11
  "assets/",
12
- "bin/",
13
12
  "README.md",
14
13
  "LICENSE"
15
14
  ],
package/src/cli/index.ts CHANGED
@@ -2,23 +2,168 @@
2
2
  /**
3
3
  * PAL CLI — Portable Agent Layer
4
4
  *
5
- * Usage: pal <command> [options]
5
+ * Usage:
6
+ * pal [claude-args...] Start a Claude session with session summary on exit
7
+ * pal cli <command> [options] Admin commands
6
8
  *
7
- * Commands:
8
- * init Scaffold PAL home, install hooks for all targets
9
- * install Register hooks/skills for targets
10
- * uninstall Remove hooks/skills for targets
11
- * export Export user state (telos, memory) to a zip
12
- * import Import user state from a zip
13
- * status Show current PAL configuration
9
+ * Admin commands (pal cli ...):
10
+ * init Scaffold PAL home, install hooks for all targets
11
+ * install [--claude] [--opencode] Register hooks/skills for targets
12
+ * uninstall [--claude] [--opencode] Remove hooks/skills for targets
13
+ * export [path] [--dry-run] Export user state to zip
14
+ * import [path] [--dry-run] Import user state from zip
15
+ * status Show current PAL configuration
16
+ * doctor Check prerequisites and system health
14
17
  */
15
18
 
16
- import { existsSync, mkdirSync } from "node:fs";
19
+ import { spawnSync } from "node:child_process";
20
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
21
+ import { homedir } from "node:os";
17
22
  import { resolve } from "node:path";
18
23
  import { palHome, palPkg, platform } from "../hooks/lib/paths";
19
24
  import { log } from "../targets/lib";
20
25
 
21
- const [command, ...args] = process.argv.slice(2);
26
+ const allArgs = process.argv.slice(2);
27
+
28
+ // ── Route: pal cli <command> or pal [claude-args] ──
29
+
30
+ if (allArgs[0] === "cli") {
31
+ const [, command, ...args] = allArgs;
32
+ await runCli(command, args);
33
+ } else if (allArgs[0] === "--help" || allArgs[0] === "-h" || allArgs[0] === "help") {
34
+ showHelp();
35
+ } else {
36
+ await session(allArgs);
37
+ }
38
+
39
+ // ── Session: pal [args] ──
40
+
41
+ interface ToolCheck {
42
+ name: string;
43
+ available: boolean;
44
+ version?: string;
45
+ }
46
+
47
+ function checkTool(cmd: string, versionArgs: string[] = ["--version"]): ToolCheck {
48
+ try {
49
+ const result = spawnSync(cmd, versionArgs, {
50
+ stdio: ["ignore", "pipe", "pipe"],
51
+ shell: true,
52
+ timeout: 5000,
53
+ });
54
+ if (result.status === 0) {
55
+ const version = (result.stdout?.toString() || "").trim().split("\n")[0];
56
+ return { name: cmd, available: true, version };
57
+ }
58
+ } catch {
59
+ // not found
60
+ }
61
+ return { name: cmd, available: false };
62
+ }
63
+
64
+ function detectAgent(): string | null {
65
+ if (checkTool("claude").available) return "claude";
66
+ if (checkTool("opencode").available) return "opencode";
67
+ return null;
68
+ }
69
+
70
+ async function session(sessionArgs: string[]) {
71
+ const agent = detectAgent();
72
+ if (!agent) {
73
+ log.error("No supported agent found. Install Claude Code or opencode.");
74
+ process.exit(1);
75
+ }
76
+
77
+ const result = spawnSync(agent, sessionArgs, {
78
+ stdio: "inherit",
79
+ shell: true,
80
+ });
81
+
82
+ const exitCode = result.status ?? 1;
83
+
84
+ // Session summary (Claude only)
85
+ if (agent !== "claude") process.exit(exitCode);
86
+ try {
87
+ const projectsDir = resolve(homedir(), ".claude", "projects");
88
+ if (!existsSync(projectsDir)) process.exit(exitCode);
89
+
90
+ // Find most recently modified .jsonl file
91
+ let latestFile = "";
92
+ let latestMtime = 0;
93
+
94
+ for (const project of readdirSync(projectsDir, { withFileTypes: true })) {
95
+ if (!project.isDirectory()) continue;
96
+ const dir = resolve(projectsDir, project.name);
97
+ for (const file of readdirSync(dir)) {
98
+ if (!file.endsWith(".jsonl")) continue;
99
+ const filepath = resolve(dir, file);
100
+ const { mtimeMs } = statSync(filepath);
101
+ if (mtimeMs > latestMtime) {
102
+ latestMtime = mtimeMs;
103
+ latestFile = filepath;
104
+ }
105
+ }
106
+ }
107
+
108
+ if (latestFile) {
109
+ const content = readFileSync(latestFile, "utf-8").trim();
110
+ const lastLine = content.split("\n").pop();
111
+ if (lastLine) {
112
+ const sessionId = JSON.parse(lastLine).sessionId;
113
+ if (sessionId) {
114
+ const summaryScript = resolve(palPkg(), "src", "tools", "session-summary.ts");
115
+ spawnSync("bun", ["run", summaryScript, "--", "--session", sessionId], {
116
+ stdio: "inherit",
117
+ });
118
+ }
119
+ }
120
+ }
121
+ } catch {
122
+ // Silently ignore summary errors
123
+ }
124
+
125
+ process.exit(exitCode);
126
+ }
127
+
128
+ // ── CLI dispatcher ──
129
+
130
+ async function runCli(command: string | undefined, args: string[]) {
131
+ switch (command) {
132
+ case "init":
133
+ await init(args);
134
+ break;
135
+ case "install":
136
+ banner();
137
+ await install(resolveTargets(args));
138
+ break;
139
+ case "uninstall":
140
+ await uninstall(args);
141
+ break;
142
+ case "export":
143
+ await exportState(args);
144
+ break;
145
+ case "import":
146
+ await importState(args);
147
+ break;
148
+ case "status":
149
+ await status();
150
+ break;
151
+ case "doctor":
152
+ doctor();
153
+ break;
154
+ case "--help":
155
+ case "-h":
156
+ case "help":
157
+ showHelp();
158
+ break;
159
+ default:
160
+ if (command) log.error(`Unknown command: ${command}`);
161
+ showHelp();
162
+ process.exit(command ? 1 : 0);
163
+ }
164
+ }
165
+
166
+ // ── Helpers ──
22
167
 
23
168
  function banner() {
24
169
  console.log("");
@@ -31,15 +176,19 @@ function banner() {
31
176
 
32
177
  function showHelp() {
33
178
  console.log(`
34
- Usage: pal <command> [options]
35
-
36
- Commands:
37
- init [--claude] [--opencode] [--all] Scaffold and install (default: all)
38
- install [--claude] [--opencode] [--all] Register hooks for targets
39
- uninstall [--claude] [--opencode] [--all] Remove hooks for targets
40
- export [path] [--dry-run] Export state to zip
41
- import [path] [--dry-run] Import state from zip
42
- status Show PAL configuration
179
+ Usage:
180
+ pal [claude-args...] Start a Claude session
181
+ pal cli <command> [options] Admin commands
182
+
183
+ Admin commands:
184
+ pal cli init [--claude] [--opencode] Scaffold and install (default: all)
185
+ pal cli install [--claude] [--opencode] Register hooks for targets
186
+ pal cli uninstall [--claude] [--opencode] Remove hooks for targets
187
+ pal cli export [path] [--dry-run] Export state to zip
188
+ pal cli import [path] [--dry-run] Import state from zip
189
+ pal cli status Show PAL configuration
190
+ pal cli doctor Check prerequisites and health
191
+
43
192
  Environment:
44
193
  PAL_HOME Override user state directory (default: ~/.pal or repo root)
45
194
  PAL_PKG Override package root
@@ -53,8 +202,6 @@ function parseTargets(args: string[]): {
53
202
  claude: boolean;
54
203
  opencode: boolean;
55
204
  } {
56
- if (args.length === 0) return { claude: true, opencode: true };
57
-
58
205
  let claude = false;
59
206
  let opencode = false;
60
207
  for (const arg of args) {
@@ -65,24 +212,125 @@ function parseTargets(args: string[]): {
65
212
  opencode = true;
66
213
  }
67
214
  }
68
- // If no target flags, default to all
69
215
  if (!claude && !opencode) return { claude: true, opencode: true };
70
216
  return { claude, opencode };
71
217
  }
72
218
 
219
+ /** Resolve targets against available agents. Errors if explicitly requested but missing. */
220
+ function resolveTargets(
221
+ args: string[],
222
+ health?: DoctorResult
223
+ ): { claude: boolean; opencode: boolean } {
224
+ const requested = parseTargets(args);
225
+ const h = health || doctor(true);
226
+ const explicit = args.some(
227
+ (a) => a === "--claude" || a === "--opencode" || a === "--all"
228
+ );
229
+
230
+ if (explicit) {
231
+ // User explicitly requested — error if not available
232
+ if (requested.claude && !h.claude.available) {
233
+ log.error("Claude Code is not installed. Run 'pal cli doctor' for details.");
234
+ process.exit(1);
235
+ }
236
+ if (requested.opencode && !h.opencode.available) {
237
+ log.error("opencode is not installed. Run 'pal cli doctor' for details.");
238
+ process.exit(1);
239
+ }
240
+ return requested;
241
+ }
242
+
243
+ // Default (no flags) — install for available agents only
244
+ const targets = {
245
+ claude: h.claude.available,
246
+ opencode: h.opencode.available,
247
+ };
248
+
249
+ if (!targets.claude) log.info("Skipping Claude Code (not installed)");
250
+ if (!targets.opencode) log.info("Skipping opencode (not installed)");
251
+
252
+ return targets;
253
+ }
254
+
255
+ // ── Doctor ──
256
+
257
+ interface DoctorResult {
258
+ bun: ToolCheck;
259
+ claude: ToolCheck;
260
+ opencode: ToolCheck;
261
+ hasAgent: boolean;
262
+ }
263
+
264
+ function doctor(silent = false): DoctorResult {
265
+ // Allow CI/tests to skip agent detection
266
+ if (process.env.PAL_SKIP_DOCTOR === "1") {
267
+ return {
268
+ bun: { name: "bun", available: true, version: Bun.version },
269
+ claude: { name: "claude", available: true },
270
+ opencode: { name: "opencode", available: true },
271
+ hasAgent: true,
272
+ };
273
+ }
274
+
275
+ const bun = { name: "bun", available: true, version: Bun.version };
276
+ const claude = checkTool("claude");
277
+ const opencode = checkTool("opencode");
278
+ const hasAgent = claude.available || opencode.available;
279
+
280
+ const home = palHome();
281
+ const isRepo = existsSync(resolve(palPkg(), ".palroot"));
282
+ const telosCount = (() => {
283
+ try {
284
+ return readdirSync(resolve(home, "telos")).filter((f) => f.endsWith(".md")).length;
285
+ } catch {
286
+ return 0;
287
+ }
288
+ })();
289
+
290
+ if (!silent) {
291
+ const ok = (msg: string) => log.info(` \u2713 ${msg}`);
292
+ const fail = (msg: string) => log.warn(` \u2717 ${msg}`);
293
+
294
+ console.log("");
295
+ log.info("Doctor");
296
+ ok(`Bun ${bun.version}`);
297
+ claude.available
298
+ ? ok(`Claude Code ${claude.version || ""}`.trim())
299
+ : fail("Claude Code — not found");
300
+ opencode.available
301
+ ? ok(`opencode ${opencode.version || ""}`.trim())
302
+ : fail("opencode — not found");
303
+ ok(`PAL home: ${home} (${isRepo ? "repo" : "package"} mode)`);
304
+ telosCount > 0 ? ok(`TELOS: ${telosCount} files`) : fail("TELOS: not scaffolded");
305
+
306
+ if (!hasAgent) {
307
+ console.log("");
308
+ log.error("No supported agent found. Install Claude Code or opencode.");
309
+ }
310
+ console.log("");
311
+ }
312
+
313
+ return { bun, claude, opencode, hasAgent };
314
+ }
315
+
73
316
  // ── Commands ──
74
317
 
75
- async function init() {
318
+ async function init(args: string[]) {
76
319
  const { ensureSetupState, isSetupComplete } = await import("../hooks/lib/setup");
77
320
  const { scaffoldTelos } = await import("../targets/lib");
78
321
 
79
322
  banner();
80
323
 
324
+ // Run doctor first — abort if no agents available
325
+ const health = doctor(false);
326
+ if (!health.hasAgent) {
327
+ process.exit(1);
328
+ }
329
+
81
330
  const home = palHome();
82
331
  const isRepo = existsSync(resolve(palPkg(), ".palroot"));
83
332
 
84
333
  if (!isRepo) {
85
- // Package mode — scaffold ~/.pal/
86
334
  log.info(`Creating PAL home at ${home}`);
87
335
  mkdirSync(resolve(home, "telos"), { recursive: true });
88
336
  mkdirSync(resolve(home, "memory"), { recursive: true });
@@ -91,7 +339,8 @@ async function init() {
91
339
  scaffoldTelos();
92
340
  ensureSetupState();
93
341
 
94
- const targets = parseTargets(args);
342
+ // Auto-detect available targets
343
+ const targets = resolveTargets(args, health);
95
344
  await install(targets);
96
345
 
97
346
  console.log("");
@@ -101,16 +350,14 @@ async function init() {
101
350
  }
102
351
  }
103
352
 
104
- async function install(targets?: { claude: boolean; opencode: boolean }) {
105
- const t = targets || parseTargets(args);
106
-
107
- if (t.claude) {
353
+ async function install(targets: { claude: boolean; opencode: boolean }) {
354
+ if (targets.claude) {
108
355
  console.log("━━━ Claude Code ━━━");
109
356
  await import("../targets/claude/install");
110
357
  console.log("");
111
358
  }
112
359
 
113
- if (t.opencode) {
360
+ if (targets.opencode) {
114
361
  console.log("━━━ opencode ━━━");
115
362
  await import("../targets/opencode/install");
116
363
  console.log("");
@@ -119,7 +366,7 @@ async function install(targets?: { claude: boolean; opencode: boolean }) {
119
366
  log.success("Done. Existing config was preserved — only new entries were added.");
120
367
  }
121
368
 
122
- async function uninstall() {
369
+ async function uninstall(args: string[]) {
123
370
  const targets = parseTargets(args);
124
371
 
125
372
  if (targets.claude) {
@@ -139,13 +386,13 @@ async function uninstall() {
139
386
  );
140
387
  }
141
388
 
142
- async function exportState() {
389
+ async function exportState(args: string[]) {
143
390
  const { collectExportFiles, exportZip, timestamp } = await import(
144
391
  "../hooks/lib/export"
145
392
  );
146
393
 
147
394
  const dryRun = args.includes("--dry-run");
148
- const pathArg = args.find((a) => !a.startsWith("-") && a !== "export");
395
+ const pathArg = args.find((a) => !a.startsWith("-"));
149
396
  const outputPath = pathArg || resolve(palHome(), `pal-export-${timestamp()}.zip`);
150
397
 
151
398
  if (dryRun) {
@@ -166,14 +413,14 @@ async function exportState() {
166
413
  }
167
414
  }
168
415
 
169
- async function importState() {
170
- const { readdirSync, statSync } = await import("node:fs");
416
+ async function importState(args: string[]) {
417
+ const { statSync } = await import("node:fs");
171
418
  const { createInterface } = await import("node:readline");
172
419
  const AdmZip = (await import("adm-zip")).default;
173
420
 
174
421
  const home = palHome();
175
422
  const dryRun = args.includes("--dry-run");
176
- const pathArg = args.find((a) => !a.startsWith("-") && a !== "import");
423
+ const pathArg = args.find((a) => !a.startsWith("-"));
177
424
 
178
425
  function findLatest(): string | null {
179
426
  const candidates: string[] = [];
@@ -214,7 +461,7 @@ async function importState() {
214
461
  } else {
215
462
  const latest = findLatest();
216
463
  if (!latest) {
217
- log.error("No export or backup files found. Provide a path: pal import <path>");
464
+ log.error("No export or backup files found. Provide a path: pal cli import <path>");
218
465
  process.exit(1);
219
466
  }
220
467
  console.log(`Found: ${latest}`);
@@ -254,13 +501,11 @@ async function importState() {
254
501
  } else {
255
502
  zip.extractAllTo(home, true);
256
503
  console.log(`Imported ${entries.length} files → ${home}`);
257
- log.info("Run 'pal install' to re-register hooks.");
504
+ log.info("Run 'pal cli install' to re-register hooks.");
258
505
  }
259
506
  }
260
507
 
261
508
  async function status() {
262
- const { existsSync, readdirSync, readFileSync } = await import("node:fs");
263
-
264
509
  const home = palHome();
265
510
  const pkg = palPkg();
266
511
  const isRepo = existsSync(resolve(pkg, ".palroot"));
@@ -274,13 +519,11 @@ async function status() {
274
519
  log.info(`Home: ${home}`);
275
520
  console.log("");
276
521
 
277
- // Platform dirs
278
522
  log.info(`Claude: ${platform.claudeDir()}`);
279
523
  log.info(`opencode: ${platform.opencodeDir()}`);
280
524
  log.info(`Agents: ${platform.agentsDir()}`);
281
525
  console.log("");
282
526
 
283
- // Counts
284
527
  const count = (dir: string, ext?: string) => {
285
528
  try {
286
529
  const files = readdirSync(dir);
@@ -298,7 +541,6 @@ async function status() {
298
541
  const agentsDir = resolve(platform.claudeDir(), "agents");
299
542
  log.info(`Agents: ${count(agentsDir, ".md")} installed`);
300
543
 
301
- // Check if hooks are registered
302
544
  const settingsPath = resolve(platform.claudeDir(), "settings.json");
303
545
  try {
304
546
  const settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
@@ -309,36 +551,3 @@ async function status() {
309
551
  }
310
552
  console.log("");
311
553
  }
312
-
313
- // ── Dispatch ──
314
-
315
- switch (command) {
316
- case "init":
317
- await init();
318
- break;
319
- case "install":
320
- banner();
321
- await install();
322
- break;
323
- case "uninstall":
324
- await uninstall();
325
- break;
326
- case "export":
327
- await exportState();
328
- break;
329
- case "import":
330
- await importState();
331
- break;
332
- case "status":
333
- await status();
334
- break;
335
- case "--help":
336
- case "-h":
337
- case "help":
338
- showHelp();
339
- break;
340
- default:
341
- if (command) log.error(`Unknown command: ${command}`);
342
- showHelp();
343
- process.exit(command ? 1 : 0);
344
- }
@@ -6,6 +6,7 @@
6
6
  * Transcript is read from the file at transcript_path, NOT from stdin.
7
7
  */
8
8
 
9
+ import { checkReadmeSync } from "./handlers/readme-sync";
9
10
  import { logError } from "./lib/log";
10
11
  import { readStdinJSON } from "./lib/stdin";
11
12
  import { runStopHandlers } from "./lib/stop";
@@ -17,6 +18,17 @@ interface StopHookInput {
17
18
  last_assistant_message?: string;
18
19
  }
19
20
 
21
+ // Check README sync before anything else — may block the session
22
+ try {
23
+ const decision = checkReadmeSync();
24
+ if (decision.decision === "block") {
25
+ console.log(JSON.stringify(decision));
26
+ process.exit(0);
27
+ }
28
+ } catch (err) {
29
+ logError("StopOrchestrator:readme-sync", err);
30
+ }
31
+
20
32
  const input = await readStdinJSON<StopHookInput>();
21
33
  if (!input?.transcript_path) {
22
34
  logError("StopOrchestrator", "No transcript_path in hook input");
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Stop handler: check if README.md is out of sync with code.
3
+ *
4
+ * Runs git diff to see if documentable files changed in this session.
5
+ * If they did and README is stale, returns a block decision.
6
+ */
7
+
8
+ import { execSync } from "node:child_process";
9
+ import { logDebug } from "../lib/log";
10
+ import { palPkg } from "../lib/paths";
11
+ import { validateReadmeSync, WATCHED_PATHS } from "../lib/readme-sync";
12
+
13
+ /** Check if any watched files have uncommitted changes. */
14
+ function hasDocumentableChanges(): boolean {
15
+ try {
16
+ const diff = execSync("git diff --name-only HEAD", {
17
+ cwd: palPkg(),
18
+ encoding: "utf-8",
19
+ }).trim();
20
+
21
+ const staged = execSync("git diff --name-only --cached", {
22
+ cwd: palPkg(),
23
+ encoding: "utf-8",
24
+ }).trim();
25
+
26
+ const changed = `${diff}\n${staged}`.split("\n").filter((f) => f.length > 0);
27
+
28
+ return changed.some((file) =>
29
+ WATCHED_PATHS.some((watched) => file === watched || file.startsWith(`${watched}/`))
30
+ );
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ export interface ReadmeSyncDecision {
37
+ decision?: "block";
38
+ reason?: string;
39
+ }
40
+
41
+ /** Returns a block decision if README is stale, or empty object to allow stop. */
42
+ export function checkReadmeSync(): ReadmeSyncDecision {
43
+ if (!hasDocumentableChanges()) {
44
+ logDebug("readme-sync", "No documentable changes detected");
45
+ return {};
46
+ }
47
+
48
+ logDebug("readme-sync", "Documentable files changed — validating README");
49
+ const result = validateReadmeSync();
50
+
51
+ if (!result.ok) {
52
+ logDebug("readme-sync", `README out of sync: ${result.issues.join("; ")}`);
53
+ return {
54
+ decision: "block",
55
+ reason: `README.md is out of date. Please update it before finishing:\n${result.issues.map((i) => `- ${i}`).join("\n")}`,
56
+ };
57
+ }
58
+
59
+ logDebug("readme-sync", "README is in sync");
60
+ return {};
61
+ }
@@ -18,7 +18,7 @@ import {
18
18
  } from "node:fs";
19
19
  import { dirname, relative, resolve } from "node:path";
20
20
  import { loadTelos } from "./context";
21
- import { assets, palHome, paths, platform } from "./paths";
21
+ import { assets, ensureDir, palHome, paths, platform } from "./paths";
22
22
  import { buildSetupPrompt, readSetupState } from "./setup";
23
23
 
24
24
  const TEMPLATE_PATH = assets.agentsMdTemplate();
@@ -116,6 +116,7 @@ export function regenerateIfNeeded(): boolean {
116
116
  const { outputPath } = getOutputPaths();
117
117
  ensureSymlink();
118
118
  if (!needsRebuild()) return false;
119
+ ensureDir(dirname(outputPath));
119
120
  writeFileSync(outputPath, buildClaudeMd(), "utf-8");
120
121
  return true;
121
122
  }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * README sync validation — ensures README.md reflects current code surfaces.
3
+ *
4
+ * Checks CLI commands, environment variables, and skills against README content.
5
+ * Used by tests (CI/pre-commit) and the Stop hook (blocks session if stale).
6
+ */
7
+
8
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
9
+ import { resolve } from "node:path";
10
+ import { palPkg } from "./paths";
11
+
12
+ export interface SyncResult {
13
+ ok: boolean;
14
+ issues: string[];
15
+ }
16
+
17
+ /** Files that, when changed, should trigger a README check. */
18
+ export const WATCHED_PATHS = [
19
+ "src/cli/index.ts",
20
+ "src/hooks/lib/paths.ts",
21
+ "src/hooks/lib/inference.ts",
22
+ "src/tools/youtube-analyze.ts",
23
+ "assets/skills",
24
+ "assets/agents",
25
+ ];
26
+
27
+ /** Extract CLI command names from the switch statement in index.ts */
28
+ function extractCliCommands(): string[] {
29
+ const pkg = palPkg();
30
+ const cliPath = resolve(pkg, "src", "cli", "index.ts");
31
+ if (!existsSync(cliPath)) return [];
32
+
33
+ const content = readFileSync(cliPath, "utf-8");
34
+ const matches = content.matchAll(/case\s+"([^"]+)":/g);
35
+ const commands: string[] = [];
36
+
37
+ for (const match of matches) {
38
+ const cmd = match[1];
39
+ // Skip help aliases and internal routing
40
+ if (["--help", "-h", "help", "cli"].includes(cmd)) continue;
41
+ commands.push(cmd);
42
+ }
43
+
44
+ return [...new Set(commands)];
45
+ }
46
+
47
+ /** Extract PAL_* env var names from paths.ts + API keys from source */
48
+ function extractEnvVars(): string[] {
49
+ const pkg = palPkg();
50
+ const vars: Set<string> = new Set();
51
+
52
+ // PAL_* from paths.ts
53
+ const pathsFile = resolve(pkg, "src", "hooks", "lib", "paths.ts");
54
+ if (existsSync(pathsFile)) {
55
+ const content = readFileSync(pathsFile, "utf-8");
56
+ for (const match of content.matchAll(/process\.env\.(PAL_\w+)/g)) {
57
+ vars.add(match[1]);
58
+ }
59
+ }
60
+
61
+ // ANTHROPIC_API_KEY from inference.ts
62
+ const inferenceFile = resolve(pkg, "src", "hooks", "lib", "inference.ts");
63
+ if (existsSync(inferenceFile)) {
64
+ const content = readFileSync(inferenceFile, "utf-8");
65
+ if (content.includes("ANTHROPIC_API_KEY")) {
66
+ vars.add("ANTHROPIC_API_KEY");
67
+ }
68
+ }
69
+
70
+ // GEMINI_API_KEY from youtube-analyze.ts
71
+ const youtubeFile = resolve(pkg, "src", "tools", "youtube-analyze.ts");
72
+ if (existsSync(youtubeFile)) {
73
+ const content = readFileSync(youtubeFile, "utf-8");
74
+ if (content.includes("GEMINI_API_KEY")) {
75
+ vars.add("GEMINI_API_KEY");
76
+ }
77
+ }
78
+
79
+ return [...vars];
80
+ }
81
+
82
+ /** Extract skill names from assets/skills/ */
83
+ function extractSkillNames(): string[] {
84
+ const pkg = palPkg();
85
+ const skillsDir = resolve(pkg, "assets", "skills");
86
+ if (!existsSync(skillsDir)) return [];
87
+
88
+ return readdirSync(skillsDir)
89
+ .filter((f) => f.endsWith(".md"))
90
+ .map((f) => f.replace(/\.md$/, ""));
91
+ }
92
+
93
+ /** Validate that README.md documents all code surfaces. */
94
+ export function validateReadmeSync(): SyncResult {
95
+ const pkg = palPkg();
96
+ const readmePath = resolve(pkg, "README.md");
97
+
98
+ if (!existsSync(readmePath)) {
99
+ return { ok: false, issues: ["README.md not found"] };
100
+ }
101
+
102
+ const readme = readFileSync(readmePath, "utf-8");
103
+ const issues: string[] = [];
104
+
105
+ // Check CLI commands
106
+ for (const cmd of extractCliCommands()) {
107
+ if (!readme.includes(`pal cli ${cmd}`)) {
108
+ issues.push(`CLI command "${cmd}" exists in code but not documented in README`);
109
+ }
110
+ }
111
+
112
+ // Check environment variables
113
+ for (const envVar of extractEnvVars()) {
114
+ if (!readme.includes(envVar)) {
115
+ issues.push(
116
+ `Environment variable "${envVar}" used in code but not documented in README`
117
+ );
118
+ }
119
+ }
120
+
121
+ // Check skills — just verify the count is mentioned or each name appears
122
+ const skills = extractSkillNames();
123
+ const undocumentedSkills = skills.filter((name) => !readme.includes(name));
124
+ if (undocumentedSkills.length > 0) {
125
+ issues.push(`Skills not documented in README: ${undocumentedSkills.join(", ")}`);
126
+ }
127
+
128
+ return { ok: issues.length === 0, issues };
129
+ }
@@ -48,6 +48,7 @@ export function scaffoldTelos(): void {
48
48
  const templatesDir = assets.telosTemplates();
49
49
  const telosDir = resolve(palHome(), "telos");
50
50
  if (!existsSync(templatesDir)) return;
51
+ mkdirSync(telosDir, { recursive: true });
51
52
 
52
53
  for (const file of readdirSync(templatesDir).filter((f) => f.endsWith(".md"))) {
53
54
  const src = resolve(templatesDir, file);
package/bin/pal DELETED
@@ -1,24 +0,0 @@
1
- #!/usr/bin/env bash
2
- # Jarvis — Claude Code wrapper with session summary on exit.
3
- #
4
- # After Claude exits, finds the most recently modified transcript JSONL
5
- # in ~/.claude/projects/ and extracts the sessionId from its last line.
6
-
7
- PAL_DIR="$(cd "$(dirname "$0")/.." && pwd)"
8
-
9
- # Run Claude (blocking — keeps the interactive terminal)
10
- claude "$@"
11
- EXIT_CODE=$?
12
-
13
- # Find the most recently modified transcript and extract its session ID
14
- LATEST=$(find "$HOME/.claude/projects" -name '*.jsonl' -type f -print0 2>/dev/null \
15
- | xargs -0 ls -t 2>/dev/null | head -1)
16
-
17
- if [ -n "$LATEST" ]; then
18
- SESSION_ID=$(tail -1 "$LATEST" | python3 -c "import sys,json; print(json.loads(sys.stdin.readline()).get('sessionId',''))" 2>/dev/null)
19
- if [ -n "$SESSION_ID" ]; then
20
- bun run "$PAL_DIR/src/tools/session-summary.ts" -- --session "$SESSION_ID" 2>/dev/null
21
- fi
22
- fi
23
-
24
- exit $EXIT_CODE
package/bin/pal.bat DELETED
@@ -1,8 +0,0 @@
1
- @echo off
2
- REM Jarvis — Claude Code wrapper with session summary on exit.
3
- REM
4
- REM Uses PowerShell to start Claude, capture its PID, read the session ID
5
- REM from %USERPROFILE%\.claude\sessions\<PID>.json, then show a cost
6
- REM summary after Claude exits.
7
-
8
- powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0pal.ps1" %*
package/bin/pal.ps1 DELETED
@@ -1,30 +0,0 @@
1
- # Jarvis — Claude Code wrapper with session summary on exit.
2
- #
3
- # After Claude exits, finds the most recently modified transcript JSONL
4
- # in ~/.claude/projects/ and extracts the sessionId from its last line.
5
-
6
- $palDir = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
7
-
8
- # Run Claude (blocking — keeps the interactive terminal)
9
- & claude @args
10
- $exitCode = $LASTEXITCODE
11
-
12
- # Find the most recently modified transcript and extract its session ID
13
- $latest = Get-ChildItem "$env:USERPROFILE\.claude\projects\*\*.jsonl" -ErrorAction SilentlyContinue |
14
- Sort-Object LastWriteTime -Descending |
15
- Select-Object -First 1
16
-
17
- if ($latest) {
18
- $lastLine = Get-Content $latest.FullName -Tail 1 -ErrorAction SilentlyContinue
19
- if ($lastLine) {
20
- try {
21
- $sessionId = ($lastLine | ConvertFrom-Json).sessionId
22
- if ($sessionId) {
23
- $summaryScript = Join-Path $palDir "src" "tools" "session-summary.ts"
24
- & bun run $summaryScript -- --session $sessionId 2>$null
25
- }
26
- } catch {}
27
- }
28
- }
29
-
30
- exit $exitCode