portable-agent-layer 0.78.2 → 0.79.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.
Files changed (36) hide show
  1. package/README.md +3 -2
  2. package/assets/schema/pal-settings.schema.json +0 -1
  3. package/assets/skills/pal-reflect/SKILL.md +2 -2
  4. package/assets/templates/PAL/SYSTEM_ARCHITECTURE.md +4 -4
  5. package/assets/templates/pal-settings.json +0 -1
  6. package/package.json +2 -2
  7. package/src/cli/builtin-tools.ts +1 -0
  8. package/src/cli/index.ts +3 -48
  9. package/src/cli/setup-auto-update.ts +7 -7
  10. package/src/hooks/AutoUpdate.ts +1 -1
  11. package/src/hooks/LoadContext.ts +13 -1
  12. package/src/hooks/PreCompactPersist.ts +2 -2
  13. package/src/hooks/handlers/persist-last-exchange.ts +128 -33
  14. package/src/hooks/handlers/session-intelligence.ts +53 -14
  15. package/src/hooks/lib/algorithm-review.ts +12 -7
  16. package/src/hooks/lib/auto-update.ts +16 -6
  17. package/src/hooks/lib/context.ts +18 -56
  18. package/src/hooks/lib/graduation.ts +2 -2
  19. package/src/hooks/lib/handoff-context.ts +112 -0
  20. package/src/hooks/lib/inference.ts +12 -45
  21. package/src/hooks/lib/log.ts +44 -3
  22. package/src/hooks/lib/paths.ts +7 -2
  23. package/src/hooks/lib/projects.ts +16 -4
  24. package/src/hooks/lib/relationship.ts +2 -2
  25. package/src/hooks/lib/retrieval-index.ts +13 -6
  26. package/src/hooks/lib/security.ts +29 -3
  27. package/src/hooks/lib/semi-static.ts +10 -3
  28. package/src/hooks/lib/session-context.ts +22 -0
  29. package/src/hooks/lib/session-dir.ts +9 -0
  30. package/src/hooks/lib/settings.ts +11 -12
  31. package/src/hooks/lib/work-tracking.ts +9 -1
  32. package/src/tools/{relationship-reflect.ts → agent/relationship-reflect.ts} +13 -12
  33. package/src/tools/control-room/ui/dist/assets/{index-DyhnNmdi.js → index-D_o9Usd_.js} +1 -1
  34. package/src/tools/control-room/ui/dist/index.html +1 -1
  35. package/src/tools/control-room/ui/screens/settings.tsx +2 -2
  36. package/src/tools/self-model.ts +1 -0
@@ -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,15 +8,17 @@ 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 { readLearnings } from "./learning-store";
11
+ import { resolveAnchor } from "./anchor";
12
+ import { loadHandoffContext } from "./handoff-context";
12
13
  import { loadOpinionContext } from "./opinions";
13
14
  import { paths, toPath } from "./paths";
14
15
  import { loadActiveProjectsContext } from "./projects";
15
16
  import { loadRecentNotes } from "./relationship";
16
17
  import { loadFailurePatterns } from "./semi-static";
18
+ import { sessionDir } from "./session-dir";
17
19
  import * as settings from "./settings";
18
20
  import { readFramePrinciples } from "./wisdom";
19
- import { readProjectHistory } from "./work-tracking";
21
+ import { lastSessionTitle, readProjectHistory } from "./work-tracking";
20
22
 
21
23
  /** Load and concatenate loadAtStartup files */
22
24
  function loadStartupFiles(): string {
@@ -50,29 +52,6 @@ export function loadWisdomContext(): string {
50
52
  }
51
53
  }
52
54
 
53
- /** Load recent session learning files as digest, with detail for current project */
54
- export function loadLearningDigest(): string {
55
- try {
56
- const cwd = process.cwd();
57
- const entries = readLearnings(paths.sessionLearning(), 10);
58
- if (entries.length === 0) return "";
59
-
60
- // This-project learnings are now in loadProjectHistoryContext(); only show cross-project here
61
- const other = entries.filter((e) => e.cwd !== cwd).slice(0, 5);
62
-
63
- if (other.length === 0) return "";
64
-
65
- const lines: string[] = [];
66
-
67
- lines.push("## Other Recent Learnings");
68
- for (const e of other) lines.push(`- ${e.title}`);
69
-
70
- return lines.join("\n");
71
- } catch {
72
- return "";
73
- }
74
- }
75
-
76
55
  /** Load self-model for session context injection */
77
56
  function loadSelfModel(): string {
78
57
  try {
@@ -105,6 +84,11 @@ function loadProjectHistoryContext(): string {
105
84
  }
106
85
  }
107
86
 
87
+ function stampedPath(stamp: string): string {
88
+ const resolved = resolveAnchor(stamp);
89
+ return resolved.state === "unresolvable" ? stamp : resolved.path;
90
+ }
91
+
108
92
  /**
109
93
  * Filter raw relationship note lines:
110
94
  * - O entries: stripped (loaded natively via digest)
@@ -125,7 +109,7 @@ function filterRelationshipNotes(notes: string, cwd: string): string {
125
109
  }
126
110
  const cwdMatch = new RegExp(/<!--.*cwd:(\S+)/).exec(line);
127
111
  if (cwdMatch) {
128
- blockCwd = cwdMatch[1];
112
+ blockCwd = stampedPath(cwdMatch[1]);
129
113
  continue;
130
114
  }
131
115
  if (/^\s*<!--/.test(line)) continue;
@@ -213,31 +197,6 @@ function loadSessionIntelligence(): string {
213
197
  }
214
198
  }
215
199
 
216
- /** Load handoff state for the current project */
217
- function loadHandoff(): string {
218
- try {
219
- const p = resolve(paths.state(), "last-handoff.json");
220
- if (!existsSync(p)) return "";
221
- const handoffs = JSON.parse(readFileSync(p, "utf-8"));
222
- const cwd = process.cwd();
223
- const entry = handoffs[cwd];
224
- if (!entry?.handoff || entry.status !== "in-progress") return "";
225
-
226
- const age = Date.now() - new Date(entry.timestamp).getTime();
227
- if (age > 7 * 24 * 60 * 60 * 1000) return ""; // stale after 7 days
228
-
229
- return [
230
- "## Pick Up Where You Left Off",
231
- `*Previous session: ${entry.title}*`,
232
- "",
233
- entry.handoff,
234
- "→ Continue this work or explicitly close it before starting something new.",
235
- ].join("\n");
236
- } catch {
237
- return "";
238
- }
239
- }
240
-
241
200
  /** Truncate text to maxChars at the last complete line boundary */
242
201
  function capSection(text: string, maxChars: number): string {
243
202
  if (text.length <= maxChars) return text;
@@ -266,7 +225,9 @@ export type AgentTarget = "claude" | "opencode" | "cursor" | "copilot";
266
225
  * opts.agent — agent target; Claude Code skips semi-static sections (self-model,
267
226
  * wisdom, opinions) that load natively via @imports in CLAUDE.md.
268
227
  */
269
- export function buildSystemReminder(opts: { agent?: AgentTarget } = {}): string {
228
+ export function buildSystemReminder(
229
+ opts: { agent?: AgentTarget; withoutHandoff?: boolean } = {}
230
+ ): string {
270
231
  // Semi-static sections loaded natively via @imports (Claude Code) or
271
232
  // instructions[] (opencode). Skip them from hook output for those agents.
272
233
  const skipSemiStatic =
@@ -281,12 +242,11 @@ export function buildSystemReminder(opts: { agent?: AgentTarget } = {}): string
281
242
  const relationship = settings.isEnabled("relationship")
282
243
  ? loadRelationshipContext()
283
244
  : "";
284
- const digest = settings.isEnabled("learningDigest") ? loadLearningDigest() : "";
285
245
  const projectHistory = settings.isEnabled("projectHistory")
286
246
  ? loadProjectHistoryContext()
287
247
  : "";
288
248
  const activeProjects = settings.isEnabled("projects")
289
- ? loadActiveProjectsContext()
249
+ ? loadActiveProjectsContext(process.cwd(), lastSessionTitle)
290
250
  : "";
291
251
  const failures =
292
252
  settings.isEnabled("failurePatterns") && !skipSemiStatic ? loadFailurePatterns() : "";
@@ -297,7 +257,10 @@ export function buildSystemReminder(opts: { agent?: AgentTarget } = {}): string
297
257
  const intelligence = settings.isEnabled("sessionIntelligence")
298
258
  ? loadSessionIntelligence()
299
259
  : "";
300
- const handoff = settings.isEnabled("handoff") ? loadHandoff() : "";
260
+ const handoff =
261
+ !opts.withoutHandoff && settings.isEnabled("handoff")
262
+ ? loadHandoffContext(sessionDir())
263
+ : "";
301
264
  // Maintainer-only: self-gates to a repo checkout, "" for everyone else.
302
265
  const algoReview = loadAlgorithmReviewNudge();
303
266
  const reflectNudge = loadReflectNudge();
@@ -315,7 +278,6 @@ export function buildSystemReminder(opts: { agent?: AgentTarget } = {}): string
315
278
  if (relationship) parts.push(relationship);
316
279
  if (activeProjects) parts.push(activeProjects);
317
280
  if (projectHistory) parts.push(projectHistory);
318
- if (digest) parts.push(digest);
319
281
  if (failures) parts.push(failures);
320
282
  if (parts.length === 0) return "";
321
283
 
@@ -12,7 +12,7 @@
12
12
 
13
13
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
14
14
  import { resolve } from "node:path";
15
- import { hasApiKey } from "./inference";
15
+ import { canInfer } from "./inference";
16
16
  import {
17
17
  type FailureEntry,
18
18
  type LearningEntry,
@@ -215,7 +215,7 @@ async function generateRecommendations(
215
215
  ratings: RatingsSummary | null
216
216
  ): Promise<string[]> {
217
217
  if (candidates.length === 0 && !ratings) return [];
218
- if (!hasApiKey()) {
218
+ if (!canInfer()) {
219
219
  return candidates
220
220
  .slice(0, 3)
221
221
  .map(
@@ -0,0 +1,112 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { resolve } from "node:path";
4
+ import { paths } from "./paths";
5
+
6
+ interface HandoffEntry {
7
+ title?: string;
8
+ handoff?: string;
9
+ status?: string;
10
+ timestamp?: string;
11
+ lastUser?: string;
12
+ lastAssistant?: string;
13
+ }
14
+
15
+ const HANDOFF_STALE_MS = 7 * 24 * 60 * 60 * 1000;
16
+ const ELSEWHERE_MAX_CHARS = 300;
17
+
18
+ function readHandoffs(): Record<string, HandoffEntry> {
19
+ const p = resolve(paths.state(), "last-handoff.json");
20
+ if (!existsSync(p)) return {};
21
+ try {
22
+ return JSON.parse(readFileSync(p, "utf-8"));
23
+ } catch {
24
+ return {};
25
+ }
26
+ }
27
+
28
+ function isFresh(entry: HandoffEntry, now: number): boolean {
29
+ if (!(entry.handoff || entry.lastUser) || !entry.timestamp) return false;
30
+ return now - new Date(entry.timestamp).getTime() <= HANDOFF_STALE_MS;
31
+ }
32
+
33
+ function isScratchDir(dir: string): boolean {
34
+ return dir.startsWith(tmpdir());
35
+ }
36
+
37
+ export function trimAtWord(text: string, max: number): string {
38
+ const flat = text.replace(/\s+/g, " ").trim();
39
+ if (flat.length <= max) return flat;
40
+ const cut = flat.slice(0, max);
41
+ const lastSpace = cut.lastIndexOf(" ");
42
+ return `${lastSpace > 0 ? cut.slice(0, lastSpace) : cut}…`;
43
+ }
44
+
45
+ function formatAgo(timestamp: string, now: number): string {
46
+ const minutes = Math.floor((now - new Date(timestamp).getTime()) / 60_000);
47
+ if (minutes < 60) return `${minutes}m ago`;
48
+ const hours = Math.floor(minutes / 60);
49
+ if (hours < 24) return `${hours}h ago`;
50
+ return `${Math.floor(hours / 24)}d ago`;
51
+ }
52
+
53
+ function lastExchangeLines(entry: HandoffEntry): string[] {
54
+ if (!entry.lastUser) return [];
55
+ const lines = ["Last exchange:", `- User: ${entry.lastUser}`];
56
+ if (entry.lastAssistant) lines.push(`- You: ${entry.lastAssistant}`);
57
+ return lines;
58
+ }
59
+
60
+ /** A finished session is still shown, for follow-ups, just not as open work. */
61
+ function hereSection(entry: HandoffEntry | undefined, now: number): string {
62
+ if (!entry || !isFresh(entry, now)) return "";
63
+ const open = entry.status !== "completed";
64
+ return [
65
+ "## Pick Up Where You Left Off",
66
+ `*Previous session: ${entry.title} · ${formatAgo(entry.timestamp ?? "", now)}*`,
67
+ ...(entry.handoff ? [entry.handoff] : []),
68
+ ...lastExchangeLines(entry),
69
+ ...(open
70
+ ? ["→ Continue this work or explicitly close it before starting something new."]
71
+ : []),
72
+ ].join("\n");
73
+ }
74
+
75
+ function latestElsewhere(
76
+ handoffs: Record<string, HandoffEntry>,
77
+ here: string,
78
+ now: number
79
+ ): [string, HandoffEntry] | undefined {
80
+ return Object.entries(handoffs)
81
+ .filter(([dir, entry]) => dir !== here && !isScratchDir(dir) && isFresh(entry, now))
82
+ .sort(([, a], [, b]) => (b.timestamp ?? "").localeCompare(a.timestamp ?? ""))[0];
83
+ }
84
+
85
+ function elsewhereSection(
86
+ handoffs: Record<string, HandoffEntry>,
87
+ here: string,
88
+ now: number
89
+ ): string {
90
+ const latest = latestElsewhere(handoffs, here, now);
91
+ if (!latest) return "";
92
+ const [dir, entry] = latest;
93
+ const hereEntry = handoffs[here];
94
+ if (hereEntry?.timestamp && hereEntry.timestamp > (entry.timestamp ?? "")) return "";
95
+ return [
96
+ "## Last Conversation Elsewhere",
97
+ `*${dir} · ${formatAgo(entry.timestamp ?? "", now)} · may be unrelated: ${entry.title}*`,
98
+ trimAtWord(entry.handoff || `User: ${entry.lastUser}`, ELSEWHERE_MAX_CHARS),
99
+ ].join("\n");
100
+ }
101
+
102
+ /**
103
+ * This folder's handoff comes first and in full, since it is what a session
104
+ * started here usually continues. The latest conversation from any other
105
+ * folder follows in brief, only when it is newer than this folder's.
106
+ */
107
+ export function loadHandoffContext(here: string, now: number = Date.now()): string {
108
+ const handoffs = readHandoffs();
109
+ return [hereSection(handoffs[here], now), elsewhereSection(handoffs, here, now)]
110
+ .filter(Boolean)
111
+ .join("\n\n");
112
+ }
@@ -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 */
@@ -391,10 +391,14 @@ const MAX_INLINE_BULLETS = 3;
391
391
  * Format the SessionStart "Active Projects" section.
392
392
  *
393
393
  * For the cwd-resolved project (`→ here`): full detail with Context, Objectives
394
- * (from goal section), Next, and Blockers. For all others: compact one-liner with
395
- * next/blocker counts only. Stale flag (>14d) and browse-mode hint included.
394
+ * (from goal section), Next, and Blockers. Other recent projects: one line with
395
+ * counts and the last session's title, enough to follow up from any folder.
396
+ * Quiet ones (>14d) collapse to a single line of names.
396
397
  */
397
- export function loadActiveProjectsContext(cwd: string = process.cwd()): string {
398
+ export function loadActiveProjectsContext(
399
+ cwd: string = process.cwd(),
400
+ lastSessionTitle: (project: string) => string | undefined = () => undefined
401
+ ): string {
398
402
  const all = readAllProjects();
399
403
  const visible = all.filter((p) => p.status === "active" || p.status === "paused");
400
404
  const resolved = resolveProjectFromCwd(cwd, visible);
@@ -411,6 +415,7 @@ export function loadActiveProjectsContext(cwd: string = process.cwd()): string {
411
415
  if (visible.length > 0) {
412
416
  lines.push("## Active Projects", "");
413
417
  const sorted = [...visible].sort((a, b) => b.updated.localeCompare(a.updated));
418
+ const quiet: string[] = [];
414
419
  for (const p of sorted) {
415
420
  const ago = formatAgo(p.updated);
416
421
  const stale = isStale(p) ? " ⚠ stale" : "";
@@ -447,14 +452,21 @@ export function loadActiveProjectsContext(cwd: string = process.cwd()): string {
447
452
  if (p.blockers?.length) {
448
453
  lines.push(` Blockers: ${p.blockers.slice(0, MAX_INLINE_BULLETS).join("; ")}`);
449
454
  }
455
+ } else if (stale) {
456
+ quiet.push(p.name);
450
457
  } else {
451
458
  const counts: string[] = [];
452
459
  if (p.next?.length) counts.push(`${p.next.length} next`);
453
460
  if (p.blockers?.length) counts.push(`${p.blockers.length} blockers`);
454
461
  const countsSuffix = counts.length > 0 ? ` — ${counts.join(", ")}` : "";
455
- lines.push(`- **${p.name}** (${statusPrefix}${ago})${countsSuffix}${stale}`);
462
+ const last = lastSessionTitle(p.name);
463
+ const lastSuffix = last ? ` — last: ${last}` : "";
464
+ lines.push(`- **${p.name}** (${statusPrefix}${ago})${countsSuffix}${lastSuffix}`);
456
465
  }
457
466
  }
467
+ if (quiet.length > 0) {
468
+ lines.push(`- Quiet for ${PROJECT_STALE_DAYS_DEFAULT}+ days: ${quiet.join(", ")}`);
469
+ }
458
470
  }
459
471
 
460
472
  if (showHint) {
@@ -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) {
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { spawn } from "node:child_process";
11
- import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
11
+ import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
12
12
  import { resolve } from "node:path";
13
13
  import { SIMILARITY_THRESHOLD } from "./graduation";
14
14
  import { readFailures, readReflections } from "./learning-store";
@@ -197,14 +197,21 @@ export function readIndex(): RetrievalIndex | null {
197
197
  }
198
198
 
199
199
  /** True if any source directory was modified after the index was built. */
200
+ function newestFileMtime(dir: string): number {
201
+ let newest = 0;
202
+ for (const entry of readdirSync(dir, { recursive: true, withFileTypes: true })) {
203
+ if (!entry.isFile()) continue;
204
+ newest = Math.max(newest, statSync(resolve(entry.parentPath, entry.name)).mtimeMs);
205
+ }
206
+ return newest;
207
+ }
208
+
200
209
  export function isStale(index: RetrievalIndex): boolean {
201
210
  try {
202
211
  const builtMs = new Date(index.builtAt).getTime();
203
- for (const dir of [paths.failures(), paths.wisdom(), paths.reflections()]) {
204
- if (!existsSync(dir)) continue;
205
- if (statSync(dir).mtimeMs > builtMs) return true;
206
- }
207
- return false;
212
+ return [paths.failures(), paths.wisdom(), paths.reflections()].some(
213
+ (dir) => newestFileMtime(dir) > builtMs
214
+ );
208
215
  } catch {
209
216
  return false;
210
217
  }
@@ -79,9 +79,14 @@ const WIN_ELEVATED_THREATS: [RegExp, string][] = [
79
79
  [/\bdiskpart\b/i, "Disk partitioning"],
80
80
  ];
81
81
 
82
+ const POSIX_ROOT_TARGET = String.raw`["']?(?:(?:~|\$HOME|\$\{HOME\})/?|/)\*?["']?(?=\s|[;&|]|$)`;
83
+
82
84
  /** Dangerous command patterns — always blocked */
83
85
  const BLOCKED_COMMANDS: [RegExp, string][] = [
84
- [/rm\s+-rf\s+[/~]/, "Recursive delete of root or home"],
86
+ [
87
+ new RegExp(String.raw`rm\s+-rf\s+${POSIX_ROOT_TARGET}`),
88
+ "Recursive delete of root or home",
89
+ ],
85
90
  [/mkfs\./, "Filesystem format"],
86
91
  [/dd\s+if=.*of=\/dev\//, "Raw disk write"],
87
92
  [/>\s*\/dev\/sd/, "Direct device write"],
@@ -232,6 +237,28 @@ function managedReasonInSegment(segment: string): string | null {
232
237
  const READ_ONLY_COMMANDS =
233
238
  /^\s*(?:cat|head|tail|less|more|grep|rg|wc|diff|stat|file|ls|dir|find|git\s+(?:log|diff|blame|show|status)|bat)\b/;
234
239
 
240
+ const COMMAND_SEPARATORS = new Set(["|", ";", "&"]);
241
+
242
+ function unquotedSegments(cmd: string): string[] {
243
+ const segments: string[] = [];
244
+ let current = "";
245
+ let quote: string | null = null;
246
+ for (const char of cmd) {
247
+ if (quote) {
248
+ if (char === quote) quote = null;
249
+ } else if (char === "'" || char === '"') {
250
+ quote = char;
251
+ } else if (COMMAND_SEPARATORS.has(char)) {
252
+ segments.push(current.trim());
253
+ current = "";
254
+ continue;
255
+ }
256
+ current += char;
257
+ }
258
+ segments.push(current.trim());
259
+ return segments;
260
+ }
261
+
235
262
  /** Check a bash command against blocked patterns. Returns reason string or null. */
236
263
  export function checkBashCommand(cmd: string): string | null {
237
264
  for (const [pattern, reason] of BLOCKED_COMMANDS) {
@@ -242,8 +269,7 @@ export function checkBashCommand(cmd: string): string | null {
242
269
  if (pattern.test(cmd)) return reason;
243
270
  }
244
271
  }
245
- const segments = cmd.split(/[|;&]/).map((s) => s.trim());
246
- for (const segment of segments) {
272
+ for (const segment of unquotedSegments(cmd)) {
247
273
  const reason = managedReasonInSegment(segment);
248
274
  if (reason && !READ_ONLY_COMMANDS.test(segment)) return reason;
249
275
  }