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.
@@ -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
  }
@@ -4,6 +4,9 @@
4
4
  */
5
5
 
6
6
  import { lstatSync } from "node:fs";
7
+ import { homedir } from "node:os";
8
+ import { resolve } from "node:path";
9
+ import { palHome, platform } from "./paths";
7
10
 
8
11
  // PowerShell aliases rm, rmdir, del, erase, rd and ri all to Remove-Item, and
9
12
  // cmd ships its own rd and del — so the verb alone never says which shell ran it.
@@ -76,9 +79,14 @@ const WIN_ELEVATED_THREATS: [RegExp, string][] = [
76
79
  [/\bdiskpart\b/i, "Disk partitioning"],
77
80
  ];
78
81
 
82
+ const POSIX_ROOT_TARGET = String.raw`["']?(?:(?:~|\$HOME|\$\{HOME\})/?|/)\*?["']?(?=\s|[;&|]|$)`;
83
+
79
84
  /** Dangerous command patterns — always blocked */
80
85
  const BLOCKED_COMMANDS: [RegExp, string][] = [
81
- [/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
+ ],
82
90
  [/mkfs\./, "Filesystem format"],
83
91
  [/dd\s+if=.*of=\/dev\//, "Raw disk write"],
84
92
  [/>\s*\/dev\/sd/, "Direct device write"],
@@ -137,13 +145,8 @@ const HOOK_MANAGED_DIRS = [
137
145
  "debug",
138
146
  ];
139
147
 
140
- /** Escape a string for use in a RegExp */
141
- function escapeRegExp(s: string): string {
142
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
143
- }
144
-
145
148
  /** PAL-deployed dirs — engine-managed, overwritten on every `pal install` */
146
- const PAL_INSTALLED_DIRS_RE = /[/\\]\.pal[/\\](?:docs|skills|tools)[/\\]/;
149
+ const PAL_INSTALLED_DIRS = ["docs", "skills", "tools"];
147
150
 
148
151
  /** Paths that should never be written to */
149
152
  const PROTECTED_PATHS: RegExp[] = [
@@ -154,31 +157,108 @@ const PROTECTED_PATHS: RegExp[] = [
154
157
  /\.gnupg\//,
155
158
  // Claude Code auto-memory — PAL owns memory; writes here indicate wrong system is being used
156
159
  /\.claude\/projects\/[^/]+\/memory\//,
157
- // PAL_INSTALLED_DIRS_RE is enforced by the dedicated branch in checkFilePath
158
- // (which exempts personal skill dirs); keeping it here would re-block them.
159
- // Derived from HOOK_MANAGED_FILES — scoped to managed roots only
160
- ...HOOK_MANAGED_FILES.map(
161
- (name) =>
162
- new RegExp(
163
- String.raw`[/\\]\.(?:pal|claude|agents|cursor)[/\\].*${escapeRegExp(name)}$`
164
- )
165
- ),
166
160
  ];
167
161
 
168
- /** Roots where managed files/dirs are protected (user state, not repo templates) */
169
- const MANAGED_ROOTS = [".pal/", ".claude/", ".agents/", ".config/opencode/", ".cursor/"];
162
+ function comparable(path: string): string {
163
+ const forward = path.replaceAll("\\", "/").replace(/\/+$/, "");
164
+ return process.platform === "win32" ? forward.toLowerCase() : forward;
165
+ }
166
+
167
+ function absoluteComparable(path: string): string {
168
+ return comparable(resolve(path.replaceAll("\\", "/")));
169
+ }
170
+
171
+ /** The part of `path` below `root`, or null when `path` is outside it. */
172
+ function pathBelow(root: string, path: string): string | null {
173
+ const base = absoluteComparable(root);
174
+ const target = absoluteComparable(path);
175
+ return target.startsWith(`${base}/`) ? target.slice(base.length + 1) : null;
176
+ }
177
+
178
+ /** The real directories PAL writes user state into, not any folder that shares their name. */
179
+ function managedRoots(): string[] {
180
+ return [
181
+ palHome(),
182
+ platform.claudeDir(),
183
+ platform.agentsDir(),
184
+ platform.cursorDir(),
185
+ platform.opencodeDir(),
186
+ ];
187
+ }
188
+
189
+ function pathInsideManagedRoot(path: string): string | null {
190
+ for (const root of managedRoots()) {
191
+ const below = pathBelow(root, path);
192
+ if (below !== null) return below;
193
+ }
194
+ return null;
195
+ }
170
196
 
171
- function isUnderManagedRoot(path: string): boolean {
172
- const normalized = path.replaceAll("\\", "/");
173
- return MANAGED_ROOTS.some(
174
- (root) => normalized.includes(`/${root}`) || normalized.includes(`\\.${root}`)
175
- );
197
+ function managedFileAt(below: string): string | undefined {
198
+ return HOOK_MANAGED_FILES.find((name) => {
199
+ const file = comparable(name);
200
+ return below === file || below.endsWith(`/${file}`);
201
+ });
202
+ }
203
+
204
+ function managedDirAt(below: string): string | undefined {
205
+ return HOOK_MANAGED_DIRS.find((dir) => `/${below}/`.includes(`/${comparable(dir)}/`));
206
+ }
207
+
208
+ function managedPathReason(path: string): string | null {
209
+ const below = pathInsideManagedRoot(path);
210
+ if (below === null) return null;
211
+ const file = managedFileAt(below);
212
+ if (file) return `${file} is managed automatically by hooks — do not edit directly`;
213
+ const dir = managedDirAt(below);
214
+ if (dir) return `${dir}/ is managed automatically by hooks — do not edit directly`;
215
+ return null;
216
+ }
217
+
218
+ const HOME_PREFIX =
219
+ /^(?:~|\$HOME|\$\{HOME\}|\$env:USERPROFILE|%USERPROFILE%)(?=[/\\]|$)/i;
220
+ const COMMAND_TOKEN = /[^\s'"`<>|;&()=]+/g;
221
+
222
+ function pathsNamedIn(segment: string): string[] {
223
+ return Array.from(segment.matchAll(COMMAND_TOKEN), (m) => m[0])
224
+ .filter((token) => HOME_PREFIX.test(token) || /[/\\]/.test(token))
225
+ .map((token) => token.replace(HOME_PREFIX, () => homedir()));
226
+ }
227
+
228
+ function managedReasonInSegment(segment: string): string | null {
229
+ for (const path of pathsNamedIn(segment)) {
230
+ const reason = managedPathReason(path);
231
+ if (reason) return reason;
232
+ }
233
+ return null;
176
234
  }
177
235
 
178
236
  /** Read-only commands allowed to reference protected files */
179
237
  const READ_ONLY_COMMANDS =
180
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/;
181
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
+
182
262
  /** Check a bash command against blocked patterns. Returns reason string or null. */
183
263
  export function checkBashCommand(cmd: string): string | null {
184
264
  for (const [pattern, reason] of BLOCKED_COMMANDS) {
@@ -189,26 +269,9 @@ export function checkBashCommand(cmd: string): string | null {
189
269
  if (pattern.test(cmd)) return reason;
190
270
  }
191
271
  }
192
- // If command references a managed file in a managed root path, block unless read-only.
193
- // The filename must appear IN the same path as the managed root (e.g. .pal/.../file.json).
194
- const segments = cmd.split(/[|;&&]/).map((s) => s.trim());
195
- for (const name of HOOK_MANAGED_FILES) {
196
- const pattern = new RegExp(
197
- String.raw`\.(?:pal|claude|agents|cursor|config/opencode)[/\\]\S*${escapeRegExp(name)}`
198
- );
199
- const managed = segments.filter((s) => pattern.test(s));
200
- if (managed.length > 0 && !managed.every((s) => READ_ONLY_COMMANDS.test(s))) {
201
- return `${name} is managed automatically by hooks — do not edit directly`;
202
- }
203
- }
204
- for (const dir of HOOK_MANAGED_DIRS) {
205
- const pattern = new RegExp(
206
- String.raw`\.(?:pal|claude|agents|cursor|config/opencode)[/\\]\S*${escapeRegExp(dir)}`
207
- );
208
- const managed = segments.filter((s) => pattern.test(s));
209
- if (managed.length > 0 && !managed.every((s) => READ_ONLY_COMMANDS.test(s))) {
210
- return `${dir} is managed automatically by hooks — do not edit directly`;
211
- }
272
+ for (const segment of unquotedSegments(cmd)) {
273
+ const reason = managedReasonInSegment(segment);
274
+ if (reason && !READ_ONLY_COMMANDS.test(segment)) return reason;
212
275
  }
213
276
  return null;
214
277
  }
@@ -218,44 +281,25 @@ export function checkBashCommand(cmd: string): string | null {
218
281
  * skills are real dirs authored in place. A not-yet-created skill dir is also
219
282
  * personal (scaffolding). So: symlink → shipped/protected, otherwise → personal.
220
283
  */
221
- function isShippedSkillPath(normalized: string): boolean {
222
- const m = /\.pal\/skills\/([^/]+)/.exec(normalized);
223
- if (!m) return false;
224
- const skillRoot = normalized.slice(0, m.index + m[0].length);
284
+ function isShippedSkill(name: string): boolean {
225
285
  try {
226
- return lstatSync(skillRoot).isSymbolicLink();
286
+ return lstatSync(resolve(palHome(), "skills", name)).isSymbolicLink();
227
287
  } catch {
228
288
  return false;
229
289
  }
230
290
  }
231
291
 
292
+ function palInstalledReason(path: string): string | null {
293
+ const [dir, entry] = pathBelow(palHome(), path)?.split("/") ?? [];
294
+ if (!dir || !entry || !PAL_INSTALLED_DIRS.includes(dir)) return null;
295
+ if (dir === "skills" && !isShippedSkill(entry)) return null;
296
+ return `~/.pal/${dir}/ is managed by 'pal install' — edit the source in the PAL repo instead`;
297
+ }
298
+
232
299
  /** Check a file path against protected patterns. Returns a reason string or null. */
233
300
  export function checkFilePath(filePath: string): string | null {
234
- const normalized = filePath.replaceAll("\\", "/");
235
- // Check hook-managed files — only under managed roots (not repo templates)
236
- if (isUnderManagedRoot(normalized)) {
237
- const matchedFile = HOOK_MANAGED_FILES.find((name) =>
238
- normalized.endsWith(`/${name}`)
239
- );
240
- if (matchedFile) {
241
- return `${matchedFile} is managed automatically by hooks — do not edit directly`;
242
- }
243
- }
244
- // Check hook-managed directories
245
- const matchedDir = HOOK_MANAGED_DIRS.find((dir) => normalized.includes(`/${dir}/`));
246
- if (matchedDir) {
247
- return `${matchedDir}/ is managed automatically by hooks — do not edit directly`;
248
- }
249
- // PAL-deployed dirs — edit source in the PAL repo, not the installed copy
250
- if (PAL_INSTALLED_DIRS_RE.test(normalized)) {
251
- const match = new RegExp(/\.pal[/\\](docs|skills|tools)/).exec(normalized);
252
- const dir = match ? match[1] : "docs/skills/tools";
253
- const isPersonalSkill = dir === "skills" && !isShippedSkillPath(normalized);
254
- if (!isPersonalSkill) {
255
- return `~/.pal/${dir}/ is managed by 'pal install' — edit the source in the PAL repo instead`;
256
- }
257
- }
258
- // Check remaining system-protected paths
301
+ const reason = managedPathReason(filePath) ?? palInstalledReason(filePath);
302
+ if (reason) return reason;
259
303
  if (PROTECTED_PATHS.some((pattern) => pattern.test(filePath))) {
260
304
  return `Protected path: ${filePath}`;
261
305
  }
@@ -7,10 +7,11 @@
7
7
  */
8
8
 
9
9
  import { existsSync, readFileSync } from "node:fs";
10
- import { resolve } from "node:path";
10
+ import { basename, resolve } from "node:path";
11
11
  import { type FailureEntry, readFailures } from "./learning-store";
12
12
  import { loadOpinionContext } from "./opinions";
13
13
  import { palHome, paths } from "./paths";
14
+ import { type ProjectProgress, readAllProjects, resolveProjectFromCwd } from "./projects";
14
15
  import { readFramePrinciples } from "./wisdom";
15
16
 
16
17
  /** A single semi-static context source — built at session stop, loaded natively at session start. */
@@ -67,6 +68,12 @@ export function rankFailures(
67
68
  .slice(0, limit);
68
69
  }
69
70
 
71
+ function projectTag(cwd: string, projects: ProjectProgress[]): string {
72
+ if (!cwd) return "";
73
+ const name = resolveProjectFromCwd(cwd, projects)?.name ?? basename(cwd);
74
+ return `[${name}]`;
75
+ }
76
+
70
77
  /** Build the failure avoid-list, prioritized by relevance to the current project. */
71
78
  export function loadFailurePatterns(): string {
72
79
  try {
@@ -74,11 +81,11 @@ export function loadFailurePatterns(): string {
74
81
  const all = readFailures(paths.failures());
75
82
  if (all.length === 0) return "";
76
83
 
84
+ const projects = readAllProjects();
77
85
  const lines = rankFailures(all, cwd).map((e) => {
78
86
  const label = e.rating ? `[${e.rating}/10]` : "";
79
- const tag = e.cwd === cwd ? "[project]" : "[other]";
80
87
  const text = e.principle || e.context;
81
- return `- ${label} ${tag} ${text}`.trim();
88
+ return ["-", label, projectTag(e.cwd, projects), text].filter(Boolean).join(" ");
82
89
  });
83
90
 
84
91
  return ["## Lessons from Recent Failures — Apply These Now", ...lines].join("\n");
@@ -54,28 +54,27 @@ const IDENTITY_DEFAULTS: Identity = {
54
54
 
55
55
  // ── Singleton ──
56
56
 
57
- let cached: PalSettingsData | null = null;
57
+ let cached: { path: string; data: PalSettingsData } | null = null;
58
58
 
59
59
  function settingsPath(): string {
60
60
  return resolve(paths.memory(), "pal-settings.json");
61
61
  }
62
62
 
63
- function load(): PalSettingsData {
64
- if (cached) return cached;
65
- const p = settingsPath();
66
- if (!existsSync(p)) {
67
- cached = {};
68
- return cached;
69
- }
63
+ function readSettingsFile(p: string): PalSettingsData {
64
+ if (!existsSync(p)) return {};
70
65
  try {
71
- cached = JSON.parse(readFileSync(p, "utf-8")) as PalSettingsData;
72
- return cached;
66
+ return JSON.parse(readFileSync(p, "utf-8")) as PalSettingsData;
73
67
  } catch {
74
- cached = {};
75
- return cached;
68
+ return {};
76
69
  }
77
70
  }
78
71
 
72
+ function load(): PalSettingsData {
73
+ const p = settingsPath();
74
+ if (cached?.path !== p) cached = { path: p, data: readSettingsFile(p) };
75
+ return cached.data;
76
+ }
77
+
79
78
  /** Force re-read from disk (useful after writes) */
80
79
  export function reload(): PalSettingsData {
81
80
  cached = null;
@@ -8,17 +8,17 @@
8
8
  * - Generate a summary report
9
9
  *
10
10
  * Usage:
11
- * bun run tool:reflect # Reflect on last 7 days
12
- * bun run tool:reflect -- --month # Reflect on last 30 days
13
- * bun run tool:reflect -- --dry-run # Preview without writing
11
+ * pal cli relationship-reflect # Reflect on last 7 days
12
+ * pal cli relationship-reflect --month # Reflect on last 30 days
13
+ * pal cli relationship-reflect --dry-run # Preview without writing
14
14
  */
15
15
 
16
16
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
17
17
  import { resolve } from "node:path";
18
18
  import { parseArgs } from "node:util";
19
- import { readOpinions, saveOpinion, setLastReflectDate } from "../hooks/lib/opinions";
20
- import { palHome } from "../hooks/lib/paths";
21
- import { emit } from "./lib/emit";
19
+ import { readOpinions, saveOpinion, setLastReflectDate } from "../../hooks/lib/opinions";
20
+ import { palHome } from "../../hooks/lib/paths";
21
+ import { emit } from "../lib/emit";
22
22
  import {
23
23
  consoleLines,
24
24
  formatReport,
@@ -27,7 +27,8 @@ import {
27
27
  loadRatings,
28
28
  planPromotions,
29
29
  reportPath,
30
- } from "./lib/relationship-reflect";
30
+ } from "../lib/relationship-reflect";
31
+ import { scriptArgs } from "../lib/script-args";
31
32
 
32
33
  const HELP = `
33
34
  RelationshipReflect — Periodic reflection + opinion promotion
@@ -36,9 +37,9 @@ Reads recent relationship notes and ratings. Promotes recurring
36
37
  observations (O type) into tracked opinions with confidence scoring.
37
38
 
38
39
  Usage:
39
- bun run tool:reflect Reflect on last 7 days (default)
40
- bun run tool:reflect -- --month Reflect on last 30 days
41
- bun run tool:reflect -- --dry-run Preview without writing
40
+ pal cli relationship-reflect Reflect on last 7 days (default)
41
+ pal cli relationship-reflect --month Reflect on last 30 days
42
+ pal cli relationship-reflect --dry-run Preview without writing
42
43
 
43
44
  Output:
44
45
  - Updates memory/relationship/opinions.json (confidence tracking)
@@ -56,9 +57,9 @@ function saveReport(report: string, period: string): string {
56
57
  return filepath;
57
58
  }
58
59
 
59
- function run() {
60
+ export function run(argv: string[] = scriptArgs()) {
60
61
  const { values } = parseArgs({
61
- args: Bun.argv.slice(2),
62
+ args: argv,
62
63
  options: {
63
64
  month: { type: "boolean" },
64
65
  "dry-run": { type: "boolean" },