portable-agent-layer 0.2.1 → 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
@@ -30,7 +30,10 @@ With PAL, you can:
30
30
 
31
31
  ### Prerequisites
32
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
+
33
35
  - [Bun](https://bun.sh) >= 1.3.0
36
+ - At least one of: [Claude Code](https://claude.ai/code) or [opencode](https://opencode.ai)
34
37
 
35
38
  ### Package mode (recommended)
36
39
 
@@ -77,6 +80,7 @@ pal cli status # check your setup
77
80
  | `pal cli export` | Export user state (telos, memory) to a zip |
78
81
  | `pal cli import` | Import user state from a zip |
79
82
  | `pal cli status` | Show current PAL configuration |
83
+ | `pal cli doctor` | Check prerequisites and system health |
80
84
 
81
85
  ### Target flags
82
86
 
@@ -111,6 +115,27 @@ pal cli install # both (default)
111
115
 
112
116
  ---
113
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
+
114
139
  ## Core idea
115
140
 
116
141
  PAL stands for **Portable Agent Layer**.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
@@ -48,6 +48,7 @@
48
48
  "ai:fyzz-api": "bun run src/tools/fyzz-api.ts",
49
49
  "ai:pdf-download": "bun run src/tools/pdf-download.ts",
50
50
  "ai:youtube-analyze": "bun run src/tools/youtube-analyze.ts",
51
+ "tool:graduate": "bun run src/tools/graduate.ts",
51
52
  "tool:patterns": "bun run src/tools/pattern-synthesis.ts",
52
53
  "tool:reflect": "bun run src/tools/relationship-reflect.ts",
53
54
  "tool:export": "bun run src/tools/export.ts",
package/src/cli/index.ts CHANGED
@@ -13,6 +13,7 @@
13
13
  * export [path] [--dry-run] Export user state to zip
14
14
  * import [path] [--dry-run] Import user state from zip
15
15
  * status Show current PAL configuration
16
+ * doctor Check prerequisites and system health
16
17
  */
17
18
 
18
19
  import { spawnSync } from "node:child_process";
@@ -20,6 +21,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node
20
21
  import { homedir } from "node:os";
21
22
  import { resolve } from "node:path";
22
23
  import { palHome, palPkg, platform } from "../hooks/lib/paths";
24
+ import { getPendingSuggestions } from "../hooks/lib/tags";
23
25
  import { log } from "../targets/lib";
24
26
 
25
27
  const allArgs = process.argv.slice(2);
@@ -35,18 +37,53 @@ if (allArgs[0] === "cli") {
35
37
  await session(allArgs);
36
38
  }
37
39
 
38
- // ── Session: pal [claude-args] ──
40
+ // ── Session: pal [args] ──
39
41
 
40
- async function session(claudeArgs: string[]) {
41
- // Run claude with all args, inheriting stdio for interactive TTY
42
- const result = spawnSync("claude", claudeArgs, {
42
+ interface ToolCheck {
43
+ name: string;
44
+ available: boolean;
45
+ version?: string;
46
+ }
47
+
48
+ function checkTool(cmd: string, versionArgs: string[] = ["--version"]): ToolCheck {
49
+ try {
50
+ const result = spawnSync(cmd, versionArgs, {
51
+ stdio: ["ignore", "pipe", "pipe"],
52
+ shell: true,
53
+ timeout: 5000,
54
+ });
55
+ if (result.status === 0) {
56
+ const version = (result.stdout?.toString() || "").trim().split("\n")[0];
57
+ return { name: cmd, available: true, version };
58
+ }
59
+ } catch {
60
+ // not found
61
+ }
62
+ return { name: cmd, available: false };
63
+ }
64
+
65
+ function detectAgent(): string | null {
66
+ if (checkTool("claude").available) return "claude";
67
+ if (checkTool("opencode").available) return "opencode";
68
+ return null;
69
+ }
70
+
71
+ async function session(sessionArgs: string[]) {
72
+ const agent = detectAgent();
73
+ if (!agent) {
74
+ log.error("No supported agent found. Install Claude Code or opencode.");
75
+ process.exit(1);
76
+ }
77
+
78
+ const result = spawnSync(agent, sessionArgs, {
43
79
  stdio: "inherit",
44
80
  shell: true,
45
81
  });
46
82
 
47
83
  const exitCode = result.status ?? 1;
48
84
 
49
- // Find the most recent transcript and extract session ID
85
+ // Session summary (Claude only)
86
+ if (agent !== "claude") process.exit(exitCode);
50
87
  try {
51
88
  const projectsDir = resolve(homedir(), ".claude", "projects");
52
89
  if (!existsSync(projectsDir)) process.exit(exitCode);
@@ -98,7 +135,7 @@ async function runCli(command: string | undefined, args: string[]) {
98
135
  break;
99
136
  case "install":
100
137
  banner();
101
- await install(parseTargets(args));
138
+ await install(resolveTargets(args));
102
139
  break;
103
140
  case "uninstall":
104
141
  await uninstall(args);
@@ -112,6 +149,9 @@ async function runCli(command: string | undefined, args: string[]) {
112
149
  case "status":
113
150
  await status();
114
151
  break;
152
+ case "doctor":
153
+ doctor();
154
+ break;
115
155
  case "--help":
116
156
  case "-h":
117
157
  case "help":
@@ -148,6 +188,7 @@ function showHelp() {
148
188
  pal cli export [path] [--dry-run] Export state to zip
149
189
  pal cli import [path] [--dry-run] Import state from zip
150
190
  pal cli status Show PAL configuration
191
+ pal cli doctor Check prerequisites and health
151
192
 
152
193
  Environment:
153
194
  PAL_HOME Override user state directory (default: ~/.pal or repo root)
@@ -176,6 +217,172 @@ function parseTargets(args: string[]): {
176
217
  return { claude, opencode };
177
218
  }
178
219
 
220
+ /** Resolve targets against available agents. Errors if explicitly requested but missing. */
221
+ function resolveTargets(
222
+ args: string[],
223
+ health?: DoctorResult
224
+ ): { claude: boolean; opencode: boolean } {
225
+ const requested = parseTargets(args);
226
+ const h = health || doctor(true);
227
+ const explicit = args.some(
228
+ (a) => a === "--claude" || a === "--opencode" || a === "--all"
229
+ );
230
+
231
+ if (explicit) {
232
+ // User explicitly requested — error if not available
233
+ if (requested.claude && !h.claude.available) {
234
+ log.error("Claude Code is not installed. Run 'pal cli doctor' for details.");
235
+ process.exit(1);
236
+ }
237
+ if (requested.opencode && !h.opencode.available) {
238
+ log.error("opencode is not installed. Run 'pal cli doctor' for details.");
239
+ process.exit(1);
240
+ }
241
+ return requested;
242
+ }
243
+
244
+ // Default (no flags) — install for available agents only
245
+ const targets = {
246
+ claude: h.claude.available,
247
+ opencode: h.opencode.available,
248
+ };
249
+
250
+ if (!targets.claude) log.info("Skipping Claude Code (not installed)");
251
+ if (!targets.opencode) log.info("Skipping opencode (not installed)");
252
+
253
+ return targets;
254
+ }
255
+
256
+ // ── Hook health ──
257
+
258
+ interface HookHealth {
259
+ totalErrors: number;
260
+ lastError: string | null;
261
+ }
262
+
263
+ function checkHookHealth(home: string): HookHealth {
264
+ const logPath = resolve(home, "memory", "state", "debug.log");
265
+
266
+ try {
267
+ if (!existsSync(logPath)) return { totalErrors: 0, lastError: null };
268
+
269
+ const content = readFileSync(logPath, "utf-8");
270
+ const lines = content.split("\n").filter((l) => l.includes("] ERROR "));
271
+
272
+ // Filter to last 24h
273
+ const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000);
274
+ const recentErrors = lines.filter((line) => {
275
+ const match = line.match(/^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]/);
276
+ if (!match) return false;
277
+ return new Date(match[1]) > cutoff;
278
+ });
279
+
280
+ const lastError =
281
+ recentErrors.length > 0
282
+ ? recentErrors[recentErrors.length - 1]
283
+ .replace(/^\[.*?\] ERROR /, "")
284
+ .slice(0, 120)
285
+ : null;
286
+
287
+ return { totalErrors: recentErrors.length, lastError };
288
+ } catch {
289
+ return { totalErrors: 0, lastError: null };
290
+ }
291
+ }
292
+
293
+ // ── Doctor ──
294
+
295
+ interface DoctorResult {
296
+ bun: ToolCheck;
297
+ claude: ToolCheck;
298
+ opencode: ToolCheck;
299
+ hasAgent: boolean;
300
+ }
301
+
302
+ function doctor(silent = false): DoctorResult {
303
+ // Allow CI/tests to skip agent detection
304
+ if (process.env.PAL_SKIP_DOCTOR === "1") {
305
+ return {
306
+ bun: { name: "bun", available: true, version: Bun.version },
307
+ claude: { name: "claude", available: true },
308
+ opencode: { name: "opencode", available: true },
309
+ hasAgent: true,
310
+ };
311
+ }
312
+
313
+ const bun = { name: "bun", available: true, version: Bun.version };
314
+ const claude = checkTool("claude");
315
+ const opencode = checkTool("opencode");
316
+ const hasAgent = claude.available || opencode.available;
317
+
318
+ const home = palHome();
319
+ const isRepo = existsSync(resolve(palPkg(), ".palroot"));
320
+ const telosCount = (() => {
321
+ try {
322
+ return readdirSync(resolve(home, "telos")).filter((f) => f.endsWith(".md")).length;
323
+ } catch {
324
+ return 0;
325
+ }
326
+ })();
327
+
328
+ if (!silent) {
329
+ const ok = (msg: string) => console.log(` \x1b[32m\u2713\x1b[0m ${msg}`);
330
+ const warn = (msg: string) => console.log(` \x1b[33m\u26A0\x1b[0m ${msg}`);
331
+ const fail = (msg: string) => console.log(` \x1b[31m\u2717\x1b[0m ${msg}`);
332
+
333
+ console.log("");
334
+ log.info("Doctor");
335
+ ok(`Bun ${bun.version}`);
336
+ claude.available
337
+ ? ok(`Claude Code ${claude.version || ""}`.trim())
338
+ : fail("Claude Code — not found");
339
+ opencode.available
340
+ ? ok(`opencode ${opencode.version || ""}`.trim())
341
+ : fail("opencode — not found");
342
+ ok(`PAL home: ${home} (${isRepo ? "repo" : "package"} mode)`);
343
+ telosCount > 0 ? ok(`TELOS: ${telosCount} files`) : fail("TELOS: not scaffolded");
344
+
345
+ // API key checks
346
+ process.env.ANTHROPIC_API_KEY
347
+ ? ok("ANTHROPIC_API_KEY is set")
348
+ : fail("ANTHROPIC_API_KEY — not set (hooks need it for inference)");
349
+ process.env.GEMINI_API_KEY
350
+ ? ok("GEMINI_API_KEY is set")
351
+ : warn("GEMINI_API_KEY — not set (optional, for YouTube analysis)");
352
+
353
+ // Hook health from debug.log
354
+ const hookHealth = checkHookHealth(home);
355
+ if (hookHealth.totalErrors === 0) {
356
+ ok("Hooks: no recent errors");
357
+ } else {
358
+ fail(`Hooks: ${hookHealth.totalErrors} error(s) in last 24h`);
359
+ if (hookHealth.lastError) {
360
+ log.warn(` Last: ${hookHealth.lastError}`);
361
+ }
362
+ }
363
+
364
+ // Pending tag suggestions
365
+ const pending = getPendingSuggestions();
366
+ const pendingEntries = Object.entries(pending).sort((a, b) => b[1] - a[1]);
367
+ if (pendingEntries.length > 0) {
368
+ warn(`Tags: ${pendingEntries.length} pending suggestion(s)`);
369
+ for (const [tag, count] of pendingEntries.slice(0, 5)) {
370
+ log.info(` "${tag}" (${count}/3 to promote)`);
371
+ }
372
+ } else {
373
+ ok("Tags: no pending suggestions");
374
+ }
375
+
376
+ if (!hasAgent) {
377
+ console.log("");
378
+ log.error("No supported agent found. Install Claude Code or opencode.");
379
+ }
380
+ console.log("");
381
+ }
382
+
383
+ return { bun, claude, opencode, hasAgent };
384
+ }
385
+
179
386
  // ── Commands ──
180
387
 
181
388
  async function init(args: string[]) {
@@ -184,6 +391,12 @@ async function init(args: string[]) {
184
391
 
185
392
  banner();
186
393
 
394
+ // Run doctor first — abort if no agents available
395
+ const health = doctor(false);
396
+ if (!health.hasAgent) {
397
+ process.exit(1);
398
+ }
399
+
187
400
  const home = palHome();
188
401
  const isRepo = existsSync(resolve(palPkg(), ".palroot"));
189
402
 
@@ -196,7 +409,9 @@ async function init(args: string[]) {
196
409
  scaffoldTelos();
197
410
  ensureSetupState();
198
411
 
199
- await install(parseTargets(args));
412
+ // Auto-detect available targets
413
+ const targets = resolveTargets(args, health);
414
+ await install(targets);
200
415
 
201
416
  console.log("");
202
417
  const state = ensureSetupState();
@@ -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");
@@ -2,14 +2,16 @@
2
2
  * Deep Failure Capture — full context dump for ratings 1–3.
3
3
  *
4
4
  * Writes to memory/learning/failures/YYYY-MM/{timestamp}_{slug}/
5
- * CONTEXT.md full failure context with transcript excerpt
6
- * sentiment.json — structured rating + metadata
5
+ * capture.md frontmatter metadata + failure context body
6
+ * sentiment.json — DEPRECATED legacy format (kept for backward compat)
7
7
  */
8
8
 
9
9
  import { writeFileSync } from "node:fs";
10
10
  import { resolve } from "node:path";
11
+ import { stringify } from "../lib/frontmatter";
11
12
  import { inference } from "../lib/inference";
12
13
  import { ensureDir, paths } from "../lib/paths";
14
+ import { getVocabulary, recordSuggestedTag } from "../lib/tags";
13
15
  import { fileTimestamp, monthPath } from "../lib/time";
14
16
  import { logTokenUsage } from "../lib/token-usage";
15
17
  import {
@@ -53,32 +55,38 @@ export async function captureFailure(
53
55
  resolve(paths.failures(), monthPath(), `${fileTimestamp()}_${slug}`)
54
56
  );
55
57
 
56
- // Attempt inference to fill root cause analysis
58
+ // Attempt inference to fill root cause analysis + tags
57
59
  let whatWentWrong = "";
58
60
  let whatToDoDifferently = "";
61
+ let tags: string[] = [];
59
62
  try {
63
+ const vocab = getVocabulary();
60
64
  const analysisResult = await inference({
61
- system:
62
- "You are analyzing a failed AI assistant interaction. Based on the context, identify what went wrong and what should be done differently. Be specific and actionable.",
65
+ system: `You are analyzing a failed AI assistant interaction. Based on the context, identify what went wrong and what should be done differently. Be specific and actionable. Also pick 1-3 tags from this list: [${vocab.join(", ")}]. If none fit, leave tags empty and put your suggested tag in suggested_tag.`,
63
66
  user: [
64
67
  `Rating: ${rating}/10`,
65
68
  `Context: ${context}`,
66
69
  detailedContext ? `Analysis: ${detailedContext}` : "",
67
- `User said: ${lastUser}`,
68
- `Assistant said: ${lastAssistant}`,
70
+ `Assistant response (what the user reacted to): ${lastAssistant}`,
71
+ `User reaction (the frustrated message): ${lastUser}`,
69
72
  ]
70
73
  .filter(Boolean)
71
74
  .join("\n"),
72
- maxTokens: 300,
73
- timeout: 8000,
75
+ maxTokens: 400,
76
+ timeout: 15000,
74
77
  jsonSchema: {
75
78
  type: "object" as const,
76
79
  additionalProperties: false,
77
80
  properties: {
78
81
  what_went_wrong: { type: "string" as const },
79
82
  what_to_do_differently: { type: "string" as const },
83
+ tags: {
84
+ type: "array" as const,
85
+ items: { type: "string" as const },
86
+ },
87
+ suggested_tag: { type: "string" as const },
80
88
  },
81
- required: ["what_went_wrong", "what_to_do_differently"],
89
+ required: ["what_went_wrong", "what_to_do_differently", "tags"],
82
90
  },
83
91
  });
84
92
  if (analysisResult.usage) logTokenUsage("failure", analysisResult.usage);
@@ -86,51 +94,48 @@ export async function captureFailure(
86
94
  const parsed = JSON.parse(analysisResult.output) as {
87
95
  what_went_wrong?: string;
88
96
  what_to_do_differently?: string;
97
+ tags?: string[];
98
+ suggested_tag?: string;
89
99
  };
90
100
  whatWentWrong = parsed.what_went_wrong ?? "";
91
101
  whatToDoDifferently = parsed.what_to_do_differently ?? "";
102
+ if (parsed.tags?.length) tags = parsed.tags;
103
+ if (parsed.suggested_tag) recordSuggestedTag(parsed.suggested_tag);
92
104
  }
93
105
  } catch {
94
106
  // Graceful fallback — empty sections are still useful with the other context
95
107
  }
96
108
 
97
- const contextMdPath = resolve(dir, "CONTEXT.md");
98
- writeFileSync(
99
- contextMdPath,
100
- [
101
- `# Failure Capture — Rating ${rating}/10`,
102
- `**Date:** ${new Date().toISOString().slice(0, 10)}`,
103
- `**Context:** ${context}`,
104
- "",
105
- "## Last User Message",
106
- lastUser || "*(unavailable)*",
107
- "",
108
- "## Last Assistant Response",
109
- lastAssistant || "*(unavailable)*",
110
- "",
111
- ...(detailedContext ? ["## AI Response Context", detailedContext, ""] : []),
112
- "## What Went Wrong?",
113
- whatWentWrong || "",
114
- "",
115
- "## What Should Be Done Differently?",
116
- whatToDoDifferently || "",
117
- "",
118
- ].join("\n"),
119
- "utf-8"
120
- );
109
+ const meta: Record<string, unknown> = {
110
+ rating,
111
+ context,
112
+ date: new Date().toISOString().slice(0, 10),
113
+ ts: new Date().toISOString(),
114
+ slug,
115
+ };
116
+ if (tags.length > 0) meta.tags = tags;
121
117
 
118
+ const body = [
119
+ "## Last User Message",
120
+ lastUser || "*(unavailable)*",
121
+ "",
122
+ "## Last Assistant Response",
123
+ lastAssistant || "*(unavailable)*",
124
+ "",
125
+ ...(detailedContext ? ["## AI Response Context", detailedContext, ""] : []),
126
+ "## What Went Wrong?",
127
+ whatWentWrong || "",
128
+ "",
129
+ "## What Should Be Done Differently?",
130
+ whatToDoDifferently || "",
131
+ ].join("\n");
132
+
133
+ writeFileSync(resolve(dir, "capture.md"), stringify(meta, body), "utf-8");
134
+
135
+ // DEPRECATED: legacy sentiment.json — remove once all readers use capture.md frontmatter
122
136
  writeFileSync(
123
137
  resolve(dir, "sentiment.json"),
124
- JSON.stringify(
125
- {
126
- rating,
127
- context,
128
- ts: new Date().toISOString(),
129
- slug,
130
- },
131
- null,
132
- 2
133
- ),
138
+ JSON.stringify({ rating, context, ts: new Date().toISOString(), slug }, null, 2),
134
139
  "utf-8"
135
140
  );
136
141
  }
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
12
12
  import { resolve } from "node:path";
13
+ import { stringify } from "../lib/frontmatter";
13
14
  import { inference } from "../lib/inference";
14
15
  import { categorizeLearning } from "../lib/learning-category";
15
16
  import { ensureDir, paths } from "../lib/paths";
@@ -244,24 +245,24 @@ function writeLearningMarkdown(
244
245
  const dir = ensureDir(resolve(paths.sessionLearning(), monthPath()));
245
246
  const filename = `${fileTimestamp()}_${source}-rating-${rating}_${category}.md`;
246
247
 
247
- const content = [
248
- `# ${source === "explicit" ? "Low Rating" : "Implicit Low Rating"}: ${rating}/10`,
249
- `**Title:** ${context.slice(0, 100) || "(low rating)"}`,
250
- `**Date:** ${new Date().toISOString().slice(0, 10)}`,
251
- `**Rating:** ${rating}/10`,
252
- `**Source:** ${source}`,
253
- `**Category:** ${category.toUpperCase()}`,
254
- "",
248
+ const meta: Record<string, unknown> = {
249
+ title: context.slice(0, 100) || "(low rating)",
250
+ category,
251
+ date: new Date().toISOString().slice(0, 10),
252
+ rating,
253
+ source,
254
+ };
255
+
256
+ const body = [
255
257
  "## Context",
256
258
  context || "*(unavailable)*",
257
259
  "",
258
260
  ...(detailedContext ? ["## Analysis", detailedContext, ""] : []),
259
261
  "## Last Response",
260
262
  responsePreview || "*(unavailable)*",
261
- "",
262
263
  ].join("\n");
263
264
 
264
- writeFileSync(resolve(dir, filename), content, "utf-8");
265
+ writeFileSync(resolve(dir, filename), stringify(meta, body), "utf-8");
265
266
  }
266
267
 
267
268
  function handleRating(
@@ -295,14 +296,7 @@ function handleRating(
295
296
  ),
296
297
  "utf-8"
297
298
  );
298
- // Also write learning markdown
299
- writeLearningMarkdown(
300
- rating,
301
- source,
302
- context,
303
- detailedContext ?? "",
304
- responsePreview
305
- );
299
+ // No learning markdown for ≤3 — failure capture covers it with richer analysis + tags
306
300
  } else if (rating < 5) {
307
301
  // Low but not critical — write learning markdown
308
302
  writeLearningMarkdown(
@@ -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
+ }