portable-agent-layer 0.78.1 → 0.78.3

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
@@ -77,7 +77,7 @@ pal cli status # check your setup
77
77
  | `pal cli init` | Scaffold PAL home directory and install hooks |
78
78
  | `pal cli install` | Register hooks/skills for targets |
79
79
  | `pal cli uninstall` | Remove hooks/skills for targets |
80
- | `pal cli update` | Update PAL (git pull or npm update) and reinstall hooks. Install asks once whether to do this daily on its own, applied when a session closes so opening PAL is never slowed; the switch lives in the control room under Settings → Updates. On a repo install a daily run waits while the clone has uncommitted changes |
80
+ | `pal cli update` | Update PAL (git pull or npm update) and reinstall hooks. Install asks once whether to do this on its own: when a session closes PAL checks for a release (at most hourly) and installs it, so opening PAL is never slowed. A failed update waits a day before retrying. The switch lives in the control room under Settings → Updates. On a repo install an update waits while the clone has uncommitted changes |
81
81
  | `pal cli export` | Export user state (telos, memory) to a zip |
82
82
  | `pal cli import` | Import user state from a zip |
83
83
  | `pal cli status` | Show current PAL configuration |
@@ -156,7 +156,7 @@ PAL routes inference through the host agent's subscription CLI by default. API k
156
156
  Enable verbose hook logging with:
157
157
 
158
158
  ```
159
- pal cli debug on # enable → logs to memory/state/debug.log
159
+ pal cli debug on # enable → logs to debug/debug.log
160
160
  pal cli debug off # disable
161
161
  pal cli debug # show current status and log path
162
162
  ```
@@ -165,6 +165,7 @@ pal cli debug # show current status and log path
165
165
  |----------|-------------|
166
166
  | `PAL_INFERENCE_DISABLED` | Set to `1` to disable all inference (used by the test suite to prevent real CLI spawns) |
167
167
  | `PAL_NOTIFICATIONS_DISABLED` | Set to `1` to suppress desktop notifications (used by the test suite) |
168
+ | `PAL_TEST_SANDBOX` | Set by the test suite. Without a `PAL_HOME`, state goes to a temp directory instead of `~/.pal`, and installers refuse to touch real agent directories |
168
169
 
169
170
  ---
170
171
 
@@ -19,10 +19,10 @@ When `/pal-reflect` is invoked (by you in response to a nudge, or by the user di
19
19
  ## 1. Run the reflect tool
20
20
 
21
21
  ```bash
22
- bun run tool:reflect
22
+ pal cli relationship-reflect
23
23
  ```
24
24
 
25
- If the user passed `--dry-run`, append it: `bun run tool:reflect -- --dry-run`
25
+ If the user passed `--dry-run`, append it: `pal cli relationship-reflect --dry-run`
26
26
 
27
27
  ## 2. Parse and present the output
28
28
 
@@ -383,7 +383,7 @@ Relationship notes (O/B types)
383
383
  - Content is global/user-level — safe to pre-compile (not project-scoped)
384
384
 
385
385
  **Tier 3 — Dynamic** (injected fresh each session by LoadContext hook):
386
- - Handoff notes, session intelligence, open threads, relationship notes, project history
386
+ - Handoff notes, session intelligence, relationship notes, project history
387
387
  - Changes per-session or is project-scoped — can't be pre-compiled
388
388
  - Injected as `<system-reminder>` block via stdout
389
389
  - Each section independently toggleable in `pal-settings.json → dynamicContext`
@@ -402,12 +402,12 @@ LoadContext.ts
402
402
  └─► Build system-reminder (dynamic sections only):
403
403
  1. loadAtStartup files (user-configured)
404
404
  2. Handoff note (in-progress work from last session)
405
- 3. Session intelligence (rating trend, algorithm performance)
406
- 4. Open threads (current project only)
405
+ 3. Due nudges (algorithm review, relationship reflect, learning analysis)
406
+ 4. Session intelligence (rating trend, algorithm performance)
407
407
  5. Recent interaction notes (last 2 days)
408
408
  6. Active projects
409
409
  7. Project session history (this project)
410
- 8. Signal trends (today/week/trend)
410
+ 8. Other recent learnings (other projects, titles only)
411
411
  ```
412
412
 
413
413
  ### On-Demand Context
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.78.1",
3
+ "version": "0.78.3",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
@@ -64,7 +64,7 @@
64
64
  "tool:synthesize": "bun run src/tools/agent/synthesize.ts",
65
65
  "tool:thread": "bun run src/tools/agent/thread.ts",
66
66
  "tool:wisdom-frame": "bun run src/tools/agent/wisdom-frame.ts",
67
- "tool:reflect": "bun run src/tools/relationship-reflect.ts",
67
+ "tool:reflect": "bun run src/tools/agent/relationship-reflect.ts",
68
68
  "eval:sentiment": "bun eval/run.ts sentiment",
69
69
  "eval:failure-principle": "bun eval/run.ts failure-principle",
70
70
  "eval:goal-links": "bun eval/run.ts goal-links",
@@ -13,6 +13,7 @@ const BUILTIN_TOOLS = {
13
13
  "handoff-note": () => import("../tools/agent/handoff-note"),
14
14
  project: () => import("../tools/agent/project"),
15
15
  "relationship-note": () => import("../tools/agent/relationship-note"),
16
+ "relationship-reflect": () => import("../tools/agent/relationship-reflect"),
16
17
  synthesize: () => import("../tools/agent/synthesize"),
17
18
  thread: () => import("../tools/agent/thread"),
18
19
  "wisdom-frame": () => import("../tools/agent/wisdom-frame"),
package/src/cli/index.ts CHANGED
@@ -47,7 +47,7 @@ import {
47
47
  summarize,
48
48
  } from "../hooks/lib/import-merge";
49
49
  import { inference, previewInferenceRoute } from "../hooks/lib/inference";
50
- import { DEBUG_LOG_MAX_ROTATED, logDebug } from "../hooks/lib/log";
50
+ import { logDebug, recentHookErrors } from "../hooks/lib/log";
51
51
  import { ensureRegistered, writeRegistryEntry } from "../hooks/lib/machine";
52
52
  import { palHome, palPkg, paths, platform, toPath } from "../hooks/lib/paths";
53
53
  import { auditBindings, describeBindingIssue } from "../hooks/lib/projects";
@@ -462,11 +462,6 @@ function resolveTargets(args: string[], health?: DoctorResult): Targets {
462
462
 
463
463
  // ── Hook health ──
464
464
 
465
- interface HookHealth {
466
- totalErrors: number;
467
- lastError: string | null;
468
- }
469
-
470
465
  function checkClaudeHooksRegistered(): boolean {
471
466
  const settingsPath = resolve(platform.claudeDir(), "settings.json");
472
467
  if (!existsSync(settingsPath)) return false;
@@ -645,7 +640,7 @@ async function probeInference(): Promise<void> {
645
640
  );
646
641
  } else {
647
642
  console.log(
648
- ` ${red}✗${reset} ${tag} ${String(elapsedMs).padStart(6)}ms ${dim}failed — see ~/.pal/memory/state/debug.log${reset}`
643
+ ` ${red}✗${reset} ${tag} ${String(elapsedMs).padStart(6)}ms ${dim}failed — see ~/.pal/debug/debug.log${reset}`
649
644
  );
650
645
  }
651
646
  }
@@ -733,46 +728,6 @@ function rtkInstallHint(): string {
733
728
  return "`curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh | sh`";
734
729
  }
735
730
 
736
- function checkHookHealth(home: string): HookHealth {
737
- const stateDir = resolve(home, "memory", "state");
738
- // Read current + all rotated logs (`.1`..`.5`) + legacy `.prev` so a recent
739
- // rotation doesn't make the 24h window appear empty.
740
- const candidates = [
741
- resolve(stateDir, "debug.log"),
742
- resolve(stateDir, "debug.log.prev"),
743
- ...Array.from({ length: DEBUG_LOG_MAX_ROTATED }, (_, i) =>
744
- resolve(stateDir, `debug.log.${i + 1}`)
745
- ),
746
- ];
747
-
748
- try {
749
- let content = "";
750
- for (const path of candidates) {
751
- if (existsSync(path)) content += `${readFileSync(path, "utf-8")}\n`;
752
- }
753
- if (!content) return { totalErrors: 0, lastError: null };
754
-
755
- const lines = content.split("\n").filter((l) => l.includes("] ERROR "));
756
-
757
- // Filter to last 24h
758
- const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000);
759
- const recentErrors = lines.filter((line) => {
760
- const match = new RegExp(/^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]/).exec(line);
761
- if (!match) return false;
762
- return new Date(match[1]) > cutoff;
763
- });
764
-
765
- const lastError =
766
- recentErrors.length > 0
767
- ? (recentErrors.at(-1) ?? "").replace(/^\[.*?\] ERROR /, "").slice(0, 120)
768
- : null;
769
-
770
- return { totalErrors: recentErrors.length, lastError };
771
- } catch {
772
- return { totalErrors: 0, lastError: null };
773
- }
774
- }
775
-
776
731
  // ── Doctor ──
777
732
 
778
733
  interface DoctorResult {
@@ -1131,7 +1086,7 @@ function doctor(silent = false): DoctorResult {
1131
1086
  : warn("PAL_PERPLEXITY_API_KEY — not set (optional, for Perplexity researcher)");
1132
1087
 
1133
1088
  // Hook health from debug.log
1134
- const hookHealth = checkHookHealth(home);
1089
+ const hookHealth = recentHookErrors();
1135
1090
  if (hookHealth.totalErrors === 0) {
1136
1091
  ok("Hooks: no recent errors");
1137
1092
  } else {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Daily unattended updates — one-time opt-in prompt.
2
+ * Unattended updates — one-time opt-in prompt.
3
3
  *
4
4
  * Asked once during `pal install`, the same seam git attribution uses, so a new
5
5
  * user sees it at init and an existing one on their next update. The non-TTY
@@ -14,11 +14,11 @@ import { raw as readSettings, write as writeSettings } from "../hooks/lib/settin
14
14
 
15
15
  /** Only a git clone can be mid-change; a global package install has no such state. */
16
16
  function whatItDoes(): string {
17
- const daily =
18
- "Once a day, when you close a session, PAL updates itself in the background.\nOpening PAL is never slowed down, and the statusline says when to restart.";
17
+ const onClose =
18
+ "When you close a session, PAL checks for a new release (at most hourly) and installs it in the background.\nOpening PAL is never slowed down, and the statusline says when to restart.";
19
19
  return isRepoMode()
20
- ? `${daily}\nIt waits while this clone has uncommitted changes.`
21
- : daily;
20
+ ? `${onClose}\nIt waits while this clone has uncommitted changes.`
21
+ : onClose;
22
22
  }
23
23
 
24
24
  export async function promptAutoUpdate(): Promise<void> {
@@ -31,7 +31,7 @@ export async function promptAutoUpdate(): Promise<void> {
31
31
  clack.note(whatItDoes(), "Keep PAL up to date on its own?");
32
32
 
33
33
  const enabled = await clack.confirm({
34
- message: "Turn on daily automatic updates?",
34
+ message: "Turn on automatic updates?",
35
35
  initialValue: false,
36
36
  });
37
37
  if (clack.isCancel(enabled)) {
@@ -41,6 +41,6 @@ export async function promptAutoUpdate(): Promise<void> {
41
41
 
42
42
  settings.autoUpdate = { enabled: enabled === true, decided: true };
43
43
  writeSettings(settings);
44
- const state = enabled ? "Daily updates on" : "Daily updates off";
44
+ const state = enabled ? "Automatic updates on" : "Automatic updates off";
45
45
  clack.outro(`${state} ✓ · change later: control room → Settings → Updates`);
46
46
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Spawned, never awaited — runs the daily self-update in a process of its own.
2
+ * Spawned, never awaited — runs the unattended self-update in a process of its own.
3
3
  *
4
4
  * LoadContext decides whether today's update is due; the control room's button
5
5
  * starts this directly. Either way the work happens here so nothing waits on a
@@ -35,6 +35,34 @@ function isProtectedHandoff(entry: unknown): boolean {
35
35
  return Date.now() - new Date(e.timestamp).getTime() <= HANDOFF_STALE_MS;
36
36
  }
37
37
 
38
+ interface AutoHandoff {
39
+ title: string;
40
+ status: string;
41
+ handoff: string;
42
+ }
43
+
44
+ function readHandoffs(handoffPath: string): Record<string, unknown> {
45
+ try {
46
+ return existsSync(handoffPath) ? JSON.parse(readFileSync(handoffPath, "utf-8")) : {};
47
+ } catch {
48
+ return {};
49
+ }
50
+ }
51
+
52
+ /** Record this cwd's handoff unless a fresh deliberate note already owns it (ISC-39). */
53
+ export function writeAutoHandoff(cwd: string, entry: AutoHandoff): void {
54
+ const handoffPath = resolve(ensureDir(paths.state()), "last-handoff.json");
55
+ const existing = readHandoffs(handoffPath);
56
+ if (isProtectedHandoff(existing[cwd])) return;
57
+ existing[cwd] = {
58
+ timestamp: new Date().toISOString(),
59
+ ...entry,
60
+ artifacts: [],
61
+ source: "auto",
62
+ };
63
+ writeFileSync(handoffPath, JSON.stringify(existing, null, 2), "utf-8");
64
+ }
65
+
38
66
  export function persistLastExchange(
39
67
  messages: ParsedMessage[],
40
68
  sessionId: string,
@@ -59,30 +87,17 @@ export function persistLastExchange(
59
87
  writeFileSync(resolve(stateDir, `${sessionId}.json`), json, "utf-8");
60
88
  writeFileSync(resolve(stateDir, "latest.json"), json, "utf-8");
61
89
 
62
- // 2. Write last-handoff.json for "Pick Up Where You Left Off" — unless a
63
- // fresh deliberate note already owns this cwd (ISC-39).
64
- const handoffPath = resolve(paths.state(), "last-handoff.json");
65
- const existing: Record<string, unknown> = existsSync(handoffPath)
66
- ? JSON.parse(readFileSync(handoffPath, "utf-8"))
67
- : {};
68
- if (!isProtectedHandoff(existing[cwd])) {
69
- const title = (lastUser.slice(0, 80).replace(/\n/g, " ") || "Session").trim();
70
- const handoff = [
71
- lastUser ? `Last user message:\n${lastUser.slice(0, 500)}` : "",
72
- lastAssistant ? `\nLast assistant response:\n${lastAssistant.slice(0, 500)}` : "",
73
- ]
74
- .filter(Boolean)
75
- .join("");
76
- existing[cwd] = {
77
- timestamp: new Date().toISOString(),
78
- title,
79
- status: detectStatus(lastAssistant),
80
- handoff,
81
- artifacts: [],
82
- source: "auto",
83
- };
84
- writeFileSync(handoffPath, JSON.stringify(existing, null, 2), "utf-8");
85
- }
90
+ const handoff = [
91
+ lastUser ? `Last user message:\n${lastUser.slice(0, 500)}` : "",
92
+ lastAssistant ? `\nLast assistant response:\n${lastAssistant.slice(0, 500)}` : "",
93
+ ]
94
+ .filter(Boolean)
95
+ .join("");
96
+ writeAutoHandoff(cwd, {
97
+ title: (lastUser.slice(0, 80).replace(/\n/g, " ") || "Session").trim(),
98
+ status: detectStatus(lastAssistant),
99
+ handoff,
100
+ });
86
101
 
87
102
  logDebug(
88
103
  "persist-last-exchange",
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Stop handler: unified session intelligence capture.
3
3
  *
4
- * Produces: title, summary, insights via Haiku.
5
- * Writes: session learning file, project history.
4
+ * Produces: title, summary, insights, handoff via Haiku.
5
+ * Writes: session learning file, project history, auto handoff (in-progress only).
6
6
  *
7
7
  * Relationship notes → written in ALGORITHM LEARN phase via relationship-note.ts
8
8
  * Handoff notes → written in ALGORITHM LEARN phase via handoff-note.ts
@@ -32,6 +32,7 @@ import {
32
32
  parseMessages,
33
33
  } from "../lib/transcript";
34
34
  import { appendProjectHistory, detectStatus } from "../lib/work-tracking";
35
+ import { writeAutoHandoff } from "./persist-last-exchange";
35
36
 
36
37
  // ── JSON schema for merged Haiku call ──
37
38
 
@@ -140,6 +141,9 @@ export async function captureSessionIntelligence(
140
141
  const title = output?.title || extractContent(lastUser).slice(0, 80) || "session";
141
142
  const summary = output?.summary || lastAssistantText.slice(0, 600);
142
143
  const insights = output?.insights || "";
144
+ if (status === "in-progress" && output?.handoff) {
145
+ writeAutoHandoff(process.cwd(), { title, status, handoff: output.handoff });
146
+ }
143
147
  // ── Write session learning file ──
144
148
 
145
149
  const category = categorizeLearning(title, summary);
@@ -35,11 +35,13 @@ export function writeReviewMark(ts: string): void {
35
35
  writeFileSync(markFile(), `${JSON.stringify({ lastReviewedTs: ts }, null, 2)}\n`);
36
36
  }
37
37
 
38
- /** True only in a maintainer repo checkout — the repo-only update skill is present. */
39
- export function isMaintainerEnv(): boolean {
40
- return existsSync(
41
- resolve(palPkg(), ".agents", "skills", "algorithm-update", "SKILL.md")
42
- );
38
+ function hasAlgorithmUpdateSkill(root: string): boolean {
39
+ return existsSync(resolve(root, ".agents", "skills", "algorithm-update", "SKILL.md"));
40
+ }
41
+
42
+ /** True when PAL runs from its checkout, or a session is working inside one. */
43
+ export function isMaintainerEnv(cwd: string = process.cwd()): boolean {
44
+ return hasAlgorithmUpdateSkill(palPkg()) || hasAlgorithmUpdateSkill(cwd);
43
45
  }
44
46
 
45
47
  /** Count reflections newer than the last review mark (all of them, if never reviewed). */
@@ -60,8 +62,11 @@ export interface ReviewNudge {
60
62
  * Nudge data when BOTH thresholds are met (≥25 new AND ≥7 days) in a maintainer
61
63
  * env, else null. `now` is injected for testability.
62
64
  */
63
- export function algorithmReviewNudge(now: Date): ReviewNudge | null {
64
- if (!isMaintainerEnv()) return null;
65
+ export function algorithmReviewNudge(
66
+ now: Date,
67
+ cwd: string = process.cwd()
68
+ ): ReviewNudge | null {
69
+ if (!isMaintainerEnv(cwd)) return null;
65
70
  const mark = readReviewMark();
66
71
  const count = countUnreviewed(mark);
67
72
  if (count < NUDGE_MIN_COUNT) return null;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The daily self-update: when it may run unattended, and what came of it.
2
+ * The unattended self-update: when it may run, and what came of it.
3
3
  *
4
4
  * `pal cli update` still owns the pull and the reinstall — this module only
5
5
  * decides whether to start it and records the outcome, so the command shape
@@ -41,7 +41,8 @@ export interface AutoUpdateStatus {
41
41
  last: AutoUpdateLedger | null;
42
42
  }
43
43
 
44
- const DAY_MS = 24 * 60 * 60 * 1000;
44
+ const HOUR_MS = 60 * 60 * 1000;
45
+ const DAY_MS = 24 * HOUR_MS;
45
46
  const RESCUE_AFTER_MS = 3 * DAY_MS;
46
47
  const LOCK_STALE_MS = 30 * 60 * 1000;
47
48
  const DIRTY_TREE = "uncommitted changes in the PAL repo";
@@ -122,15 +123,24 @@ function recordSkip(reason: string): void {
122
123
  logDebug("auto-update", `skipped: ${reason}`);
123
124
  }
124
125
 
125
- function attemptedWithin(ledger: AutoUpdateLedger | null, now: number): boolean {
126
+ /**
127
+ * A clean run — including one that found nothing to install — only waits out the
128
+ * hour, so a release published after the morning's check still lands today. A
129
+ * failed or interrupted run waits a day, so a broken update cannot loop.
130
+ */
131
+ function waitAfter(ledger: AutoUpdateLedger): number {
132
+ return ledger.ok === true ? HOUR_MS : DAY_MS;
133
+ }
134
+
135
+ function heldByLastRun(ledger: AutoUpdateLedger | null, now: number): boolean {
126
136
  if (!ledger?.attemptedAt) return false;
127
- return now - new Date(ledger.attemptedAt).getTime() < DAY_MS;
137
+ return now - new Date(ledger.attemptedAt).getTime() < waitAfter(ledger);
128
138
  }
129
139
 
130
- /** The unattended gate: opted in, not already tried today, and safe to pull. */
140
+ /** The unattended gate: opted in, not held by the last run, and safe to pull. */
131
141
  export function shouldAutoUpdate(now: number = Date.now()): boolean {
132
142
  if (!isAutoUpdateEnabled()) return false;
133
- if (attemptedWithin(readLedger(), now)) return false;
143
+ if (heldByLastRun(readLedger(), now)) return false;
134
144
  if (hasUncommittedChanges()) {
135
145
  recordSkip(DIRTY_TREE);
136
146
  return false;
@@ -8,6 +8,7 @@ import { resolve } from "node:path";
8
8
  import { loadReflectNudge } from "../handlers/reflect-trigger";
9
9
  import { loadAlgorithmReviewNudge } from "./algorithm-review";
10
10
  import { loadAnalyzeNudge } from "./analyze-nudge";
11
+ import { resolveAnchor } from "./anchor";
11
12
  import { readLearnings } from "./learning-store";
12
13
  import { loadOpinionContext } from "./opinions";
13
14
  import { paths, toPath } from "./paths";
@@ -105,6 +106,11 @@ function loadProjectHistoryContext(): string {
105
106
  }
106
107
  }
107
108
 
109
+ function stampedPath(stamp: string): string {
110
+ const resolved = resolveAnchor(stamp);
111
+ return resolved.state === "unresolvable" ? stamp : resolved.path;
112
+ }
113
+
108
114
  /**
109
115
  * Filter raw relationship note lines:
110
116
  * - O entries: stripped (loaded natively via digest)
@@ -125,7 +131,7 @@ function filterRelationshipNotes(notes: string, cwd: string): string {
125
131
  }
126
132
  const cwdMatch = new RegExp(/<!--.*cwd:(\S+)/).exec(line);
127
133
  if (cwdMatch) {
128
- blockCwd = cwdMatch[1];
134
+ blockCwd = stampedPath(cwdMatch[1]);
129
135
  continue;
130
136
  }
131
137
  if (/^\s*<!--/.test(line)) continue;
@@ -214,40 +214,32 @@ export async function inference(opts: InferenceOptions): Promise<InferenceResult
214
214
  // Per-agent CLI metadata — binary presence + argv builders
215
215
  // ─────────────────────────────────────────────────────────────────────────────
216
216
 
217
- let claudeBinaryCache: string | null | undefined;
218
- let codexBinaryCache: string | null | undefined;
219
- let opencodeBinaryCache: string | null | undefined;
220
- let copilotBinaryCache: string | null | undefined;
221
- let cursorBinaryCache: string | null | undefined;
217
+ const binaryCache = new Map<string, string | null>();
218
+
219
+ function cachedBinary(name: string): string | null {
220
+ const key = `${name}\0${process.env.PATH ?? ""}`;
221
+ if (!binaryCache.has(key)) binaryCache.set(key, findBinaryOnPath(name));
222
+ return binaryCache.get(key) ?? null;
223
+ }
222
224
 
223
225
  function getClaudeBinary(): string | null {
224
- if (claudeBinaryCache !== undefined) return claudeBinaryCache;
225
- claudeBinaryCache = findBinaryOnPath("claude");
226
- return claudeBinaryCache;
226
+ return cachedBinary("claude");
227
227
  }
228
228
 
229
229
  function getCodexBinary(): string | null {
230
- if (codexBinaryCache !== undefined) return codexBinaryCache;
231
- codexBinaryCache = findBinaryOnPath("codex");
232
- return codexBinaryCache;
230
+ return cachedBinary("codex");
233
231
  }
234
232
 
235
233
  function getOpencodeBinary(): string | null {
236
- if (opencodeBinaryCache !== undefined) return opencodeBinaryCache;
237
- opencodeBinaryCache = findBinaryOnPath("opencode");
238
- return opencodeBinaryCache;
234
+ return cachedBinary("opencode");
239
235
  }
240
236
 
241
237
  function getCopilotBinary(): string | null {
242
- if (copilotBinaryCache !== undefined) return copilotBinaryCache;
243
- copilotBinaryCache = findBinaryOnPath("copilot");
244
- return copilotBinaryCache;
238
+ return cachedBinary("copilot");
245
239
  }
246
240
 
247
241
  function getCursorBinary(): string | null {
248
- if (cursorBinaryCache !== undefined) return cursorBinaryCache;
249
- cursorBinaryCache = findBinaryOnPath("cursor-agent");
250
- return cursorBinaryCache;
242
+ return cachedBinary("cursor-agent");
251
243
  }
252
244
 
253
245
  function hasClaudeBinary(): boolean {
@@ -266,31 +258,6 @@ function hasCursorBinary(): boolean {
266
258
  return getCursorBinary() !== null;
267
259
  }
268
260
 
269
- /** Test-only: reset the cached claude-binary resolution. */
270
- export function _resetClaudeBinaryCache(): void {
271
- claudeBinaryCache = undefined;
272
- }
273
-
274
- /** Test-only: reset the cached codex-binary resolution. */
275
- export function _resetCodexBinaryCache(): void {
276
- codexBinaryCache = undefined;
277
- }
278
-
279
- /** Test-only: reset the cached opencode-binary resolution. */
280
- export function _resetOpencodeBinaryCache(): void {
281
- opencodeBinaryCache = undefined;
282
- }
283
-
284
- /** Test-only: reset the cached copilot-binary resolution. */
285
- export function _resetCopilotBinaryCache(): void {
286
- copilotBinaryCache = undefined;
287
- }
288
-
289
- /** Test-only: reset the cached cursor-binary resolution. */
290
- export function _resetCursorBinaryCache(): void {
291
- cursorBinaryCache = undefined;
292
- }
293
-
294
261
  /**
295
262
  * Build the argv for `claude --print …`. Pure.
296
263
  *
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Simple file-based debug logger for PAL hooks.
3
- * Writes to memory/state/debug.log — rotated on each session start.
3
+ * Writes to debug/debug.log, rotated by size.
4
4
  *
5
5
  * Only writes when debug is enabled (`pal cli debug on`) or when called via logError (always logged).
6
6
  */
@@ -8,6 +8,7 @@
8
8
  import {
9
9
  appendFileSync,
10
10
  existsSync,
11
+ readFileSync,
11
12
  renameSync,
12
13
  statSync,
13
14
  unlinkSync,
@@ -78,8 +79,48 @@ function rotateIfNeeded(path: string): void {
78
79
  }
79
80
  }
80
81
 
81
- /** Test-only: max rotated count for callers that need to enumerate. */
82
- export const DEBUG_LOG_MAX_ROTATED = MAX_ROTATED;
82
+ export interface HookHealth {
83
+ totalErrors: number;
84
+ lastError: string | null;
85
+ }
86
+
87
+ const DAY_MS = 24 * 60 * 60 * 1000;
88
+
89
+ function logFilesOldestFirst(): string[] {
90
+ const path = logFile();
91
+ const rotated = Array.from(
92
+ { length: MAX_ROTATED },
93
+ (_, i) => `${path}.${MAX_ROTATED - i}`
94
+ );
95
+ return [...rotated, `${path}.prev`, path];
96
+ }
97
+
98
+ function readAllLogs(): string {
99
+ return logFilesOldestFirst()
100
+ .filter((path) => existsSync(path))
101
+ .map((path) => readFileSync(path, "utf-8"))
102
+ .join("\n");
103
+ }
104
+
105
+ function loggedAt(line: string): number {
106
+ const match = /^\[(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})\]/.exec(line);
107
+ return match ? Date.parse(`${match[1]}T${match[2]}Z`) : 0;
108
+ }
109
+
110
+ export function recentHookErrors(now: number = Date.now()): HookHealth {
111
+ try {
112
+ const recent = readAllLogs()
113
+ .split("\n")
114
+ .filter((line) => line.includes("] ERROR ") && loggedAt(line) > now - DAY_MS);
115
+ const last = recent.at(-1);
116
+ return {
117
+ totalErrors: recent.length,
118
+ lastError: last ? last.replace(/^\[.*?\] ERROR /, "").slice(0, 120) : null,
119
+ };
120
+ } catch {
121
+ return { totalErrors: 0, lastError: null };
122
+ }
123
+ }
83
124
 
84
125
  function isDebugEnabled(): boolean {
85
126
  return existsSync(resolve(palHome(), "memory", "state", "debug-enabled"));
@@ -1,5 +1,5 @@
1
1
  import { existsSync, mkdirSync } from "node:fs";
2
- import { homedir } from "node:os";
2
+ import { homedir, tmpdir } from "node:os";
3
3
  import { resolve } from "node:path";
4
4
 
5
5
  /**
@@ -56,8 +56,13 @@ export function palPkg(): string {
56
56
  * Always resolves to ~/.pal/ regardless of where the package lives.
57
57
  * Power users who want memory/telos versioned in a repo can override via PAL_HOME.
58
58
  */
59
+ function defaultPalHome(): string {
60
+ if (process.env.PAL_TEST_SANDBOX) return resolve(tmpdir(), "pal-test-home");
61
+ return resolve(homedir(), ".pal");
62
+ }
63
+
59
64
  export function palHome(): string {
60
- return envPath("PAL_HOME", resolve(homedir(), ".pal"));
65
+ return envPath("PAL_HOME", defaultPalHome());
61
66
  }
62
67
 
63
68
  /** Ensure a directory exists, creating it recursively if needed */
@@ -76,8 +76,8 @@ export function appendNotes(notes: RelationshipNote[], sessionId?: string): Appe
76
76
 
77
77
  const timestamp = new Date().toTimeString().slice(0, 5);
78
78
  lines.push(`## ${timestamp}`);
79
- if (sessionId)
80
- lines.push(`<!-- session:${sessionId} cwd:${encodeAnchor(process.cwd())} -->`);
79
+ const session = sessionId ? `session:${sessionId} ` : "";
80
+ lines.push(`<!-- ${session}cwd:${encodeAnchor(process.cwd())} -->`);
81
81
 
82
82
  for (const note of fresh) {
83
83
  if (note.type === "O" && note.confidence !== undefined) {