portable-agent-layer 0.85.15 → 0.85.16

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.85.15",
3
+ "version": "0.85.16",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli/index.ts CHANGED
@@ -524,6 +524,9 @@ async function install(targets: Targets, args: string[]): Promise<number> {
524
524
  log.warn("bun install failed — continuing anyway, but hooks may not work");
525
525
  }
526
526
 
527
+ const { applyPendingMigrations } = await import("./migrate");
528
+ for (const line of applyPendingMigrations()) log.info(line);
529
+
527
530
  // Uses `bun x` (not `bunx`) for Windows compatibility — bunx resolves unreliably under cmd.exe.
528
531
  if (process.env.PAL_SKIP_BROWSER_INSTALL !== "1") installChromium();
529
532
 
@@ -0,0 +1,118 @@
1
+ import { existsSync, lstatSync, readlinkSync, renameSync, unlinkSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { paths, platform } from "../hooks/lib/paths";
4
+ import { readJson, writeJson } from "../targets/lib";
5
+
6
+ /** Something an older PAL wrote that the current one neither writes nor reads. */
7
+ export interface Leftover {
8
+ id: string;
9
+ description: string;
10
+ find(): string[];
11
+ remove(): void;
12
+ }
13
+
14
+ type Json = Record<string, unknown>;
15
+
16
+ const claudeSettings = () => resolve(platform.claudeDir(), "settings.json");
17
+ const palSettings = () => resolve(paths.memory(), "pal-settings.json");
18
+
19
+ function isPathScopedFileToolRule(rule: string): boolean {
20
+ return /^(?:Grep|Glob)\(/.test(rule);
21
+ }
22
+
23
+ function claudeAllowRules(): string[] {
24
+ const settings = readJson<Json>(claudeSettings(), {});
25
+ const allow = (settings.permissions as { allow?: unknown } | undefined)?.allow;
26
+ return Array.isArray(allow)
27
+ ? allow.filter((r): r is string => typeof r === "string")
28
+ : [];
29
+ }
30
+
31
+ const claudeFileToolRules: Leftover = {
32
+ id: "claude-file-tool-rules",
33
+ description:
34
+ "Remove Grep()/Glob() allow rules older templates wrote — Claude Code ignores them and warns on every prompt",
35
+ find: () => claudeAllowRules().filter(isPathScopedFileToolRule),
36
+ remove() {
37
+ const settings = readJson<Json>(claudeSettings(), {});
38
+ const permissions = settings.permissions as { allow: string[] };
39
+ permissions.allow = permissions.allow.filter(
40
+ (rule) => !isPathScopedFileToolRule(rule)
41
+ );
42
+ writeJson(claudeSettings(), settings);
43
+ },
44
+ };
45
+
46
+ function startupFiles(): string[] {
47
+ const settings = readJson<Json>(palSettings(), {});
48
+ const files = (settings.loadAtStartup as { files?: unknown } | undefined)?.files;
49
+ return Array.isArray(files)
50
+ ? files.filter((f): f is string => typeof f === "string")
51
+ : [];
52
+ }
53
+
54
+ const isProjectsFile = (file: string) => file.endsWith("PROJECTS.md");
55
+
56
+ const startupProjectsFile: Leftover = {
57
+ id: "startup-projects-md",
58
+ description:
59
+ "Stop loading PROJECTS.md at startup — projects moved to the project store",
60
+ find: () => startupFiles().filter(isProjectsFile),
61
+ remove() {
62
+ const settings = readJson<Json>(palSettings(), {});
63
+ const startup = settings.loadAtStartup as { files: string[] };
64
+ startup.files = startup.files.filter((file) => !isProjectsFile(file));
65
+ writeJson(palSettings(), settings);
66
+ },
67
+ };
68
+
69
+ const oldOpencodePlugin = () =>
70
+ resolve(platform.opencodeDir(), "plugins", "pai-plugin.ts");
71
+
72
+ const opencodePluginOldName: Leftover = {
73
+ id: "opencode-pai-plugin",
74
+ description: "Remove the opencode plugin under its old name, pai-plugin.ts",
75
+ find: () => (existsSync(oldOpencodePlugin()) ? [oldOpencodePlugin()] : []),
76
+ remove: () => unlinkSync(oldOpencodePlugin()),
77
+ };
78
+
79
+ const oldCopilotInstructions = () =>
80
+ resolve(platform.copilotDir(), "copilot-instructions.md");
81
+
82
+ function linksToAgentsMd(path: string): boolean {
83
+ try {
84
+ return lstatSync(path).isSymbolicLink() && readlinkSync(path).includes("AGENTS.md");
85
+ } catch {
86
+ return false;
87
+ }
88
+ }
89
+
90
+ const copilotInstructionsLink: Leftover = {
91
+ id: "copilot-instructions-link",
92
+ description:
93
+ "Remove the copilot-instructions.md link to AGENTS.md — Copilot now reads PAL's own instruction files",
94
+ find: () =>
95
+ linksToAgentsMd(oldCopilotInstructions()) ? [oldCopilotInstructions()] : [],
96
+ remove: () => unlinkSync(oldCopilotInstructions()),
97
+ };
98
+
99
+ const debugLog = () => resolve(paths.debug(), "debug.log");
100
+
101
+ const debugLogPrev: Leftover = {
102
+ id: "debug-log-prev",
103
+ description: "Rename the old single rotated debug log, debug.log.prev, to debug.log.1",
104
+ find: () => (existsSync(`${debugLog()}.prev`) ? [`${debugLog()}.prev`] : []),
105
+ remove() {
106
+ const prev = `${debugLog()}.prev`;
107
+ if (existsSync(`${debugLog()}.1`)) unlinkSync(prev);
108
+ else renameSync(prev, `${debugLog()}.1`);
109
+ },
110
+ };
111
+
112
+ export const LEFTOVERS: Leftover[] = [
113
+ claudeFileToolRules,
114
+ startupProjectsFile,
115
+ opencodePluginOldName,
116
+ copilotInstructionsLink,
117
+ debugLogPrev,
118
+ ];
@@ -1,13 +1,13 @@
1
1
  /**
2
- * pal cli migrate — versioned, non-destructive data migrations.
2
+ * pal cli migrate — the only place PAL knows about its own past.
3
3
  *
4
4
  * Each Migration has:
5
- * check() — returns whether this migration is needed (safe to call repeatedly)
6
- * run() — applies the migration; NEVER deletes source data
5
+ * check() — reads what is on disk, so it is safe to call on every install
6
+ * run() — applies it; user data is moved, never deleted, while leftovers
7
+ * PAL itself wrote and no longer reads are removed
7
8
  *
8
- * Add new migrations by appending to MIGRATIONS. Registry is ordered; migrations
9
- * run in declaration order. Doctor calls checkPendingMigrations() to surface
10
- * pending work without running anything.
9
+ * Retiring a setting, hook, file or name means adding a migration that removes
10
+ * its leftovers. Install runs pending migrations first; the doctor lists them.
11
11
  */
12
12
 
13
13
  import {
@@ -40,6 +40,7 @@ import {
40
40
  slugify,
41
41
  } from "../tools/knowledge/lib";
42
42
  import { readThreads, type Thread, writeThreads } from "../tools/lib/thread";
43
+ import { LEFTOVERS, type Leftover } from "./leftovers";
43
44
 
44
45
  // ── Types ─────────────────────────────────────────────────────────
45
46
 
@@ -715,6 +716,29 @@ const v7RetiredSettingsKeys: Migration = {
715
716
  },
716
717
  };
717
718
 
719
+ function leftoverMigration(leftover: Leftover): Migration {
720
+ return {
721
+ id: leftover.id,
722
+ description: leftover.description,
723
+ movesSources: true,
724
+ check() {
725
+ const found = leftover.find();
726
+ return { pending: found.length > 0, detail: found.join(", ") || undefined };
727
+ },
728
+ run(dryRun = false): MigrationResult {
729
+ const found = leftover.find();
730
+ if (found.length === 0) return { migrated: 0, skipped: 0, results: [] };
731
+ if (!dryRun) leftover.remove();
732
+ const verb = dryRun ? "would remove" : "removed";
733
+ return {
734
+ migrated: found.length,
735
+ skipped: 0,
736
+ results: [`${verb} ${found.join(", ")}`],
737
+ };
738
+ },
739
+ };
740
+ }
741
+
718
742
  const MIGRATIONS: Migration[] = [
719
743
  v1Projects,
720
744
  v2ThreadsToIsc,
@@ -723,6 +747,7 @@ const MIGRATIONS: Migration[] = [
723
747
  v5AttributionKeys,
724
748
  v6HistorySlugs,
725
749
  v7RetiredSettingsKeys,
750
+ ...LEFTOVERS.map(leftoverMigration),
726
751
  ];
727
752
 
728
753
  // ── Public API ────────────────────────────────────────────────────
@@ -733,6 +758,13 @@ interface PendingMigration {
733
758
  detail?: string;
734
759
  }
735
760
 
761
+ /** Install runs these first, so one install leaves nothing an older PAL wrote. */
762
+ export function applyPendingMigrations(): string[] {
763
+ return MIGRATIONS.filter((m) => m.check().pending).flatMap((m) =>
764
+ m.run(false).results.map((line) => `${m.id}: ${line}`)
765
+ );
766
+ }
767
+
736
768
  /** Returns migrations that have pending work. Used by doctor. */
737
769
  export function checkPendingMigrations(): PendingMigration[] {
738
770
  return MIGRATIONS.flatMap((m) => {
@@ -36,22 +36,10 @@ function timestamp(): string {
36
36
  * .MAX_ROTATED (oldest). Each rotation shifts .N-1 → .N, drops the oldest,
37
37
  * and renames the current log to .1. Total disk footprint bounded at
38
38
  * (MAX_ROTATED + 1) * MAX_LOG_SIZE ≈ 300KB.
39
- *
40
- * Migrates legacy .prev → .1 on first new rotation so existing history
41
- * survives the format change.
42
39
  */
43
40
  function rotateIfNeeded(path: string): void {
44
41
  try {
45
42
  if (!existsSync(path) || statSync(path).size <= MAX_LOG_SIZE) return;
46
- // Backward-compat migration: legacy .prev was the single old rotation file.
47
- const legacyPrev = `${path}.prev`;
48
- if (existsSync(legacyPrev) && !existsSync(`${path}.1`)) {
49
- try {
50
- renameSync(legacyPrev, `${path}.1`);
51
- } catch {
52
- /* ignore */
53
- }
54
- }
55
43
  // Drop the oldest, then shift .N-1 → .N for N from MAX_ROTATED down to 2.
56
44
  const oldest = `${path}.${MAX_ROTATED}`;
57
45
  if (existsSync(oldest)) {
@@ -99,7 +87,7 @@ function logFilesOldestFirst(): string[] {
99
87
  { length: MAX_ROTATED },
100
88
  (_, i) => `${path}.${MAX_ROTATED - i}`
101
89
  );
102
- return [...rotated, `${path}.prev`, path];
90
+ return [...rotated, path];
103
91
  }
104
92
 
105
93
  function readAllLogs(): string {
@@ -4,7 +4,7 @@
4
4
  * and the VS Code chat.instructionsFilesLocations entry.
5
5
  */
6
6
 
7
- import { copyFileSync, existsSync, lstatSync, readlinkSync, unlinkSync } from "node:fs";
7
+ import { copyFileSync, existsSync, unlinkSync } from "node:fs";
8
8
  import { resolve } from "node:path";
9
9
  import { platform } from "../../hooks/lib/paths";
10
10
  import {
@@ -63,22 +63,6 @@ log.success(
63
63
  `Removed ${removedInstructions.length} ~/.copilot/instructions/pal-*.instructions.md`
64
64
  );
65
65
 
66
- // --- Backward compat: remove old copilot-instructions.md symlink if present ---
67
- const legacyPath = resolve(COPILOT_DIR, "copilot-instructions.md");
68
- if (existsSync(legacyPath)) {
69
- try {
70
- if (
71
- lstatSync(legacyPath).isSymbolicLink() &&
72
- readlinkSync(legacyPath).includes("AGENTS.md")
73
- ) {
74
- unlinkSync(legacyPath);
75
- log.success("Removed legacy copilot-instructions.md symlink");
76
- }
77
- } catch {
78
- /* ignore */
79
- }
80
- }
81
-
82
66
  // --- Remove ~/.copilot/instructions entry from VS Code settings ---
83
67
  const vsSettingsPath = vscodeSettingsFile();
84
68
  if (vsSettingsPath && existsSync(vsSettingsPath)) {
@@ -211,7 +211,7 @@ export function mergeSettings(existing: Settings, template: Settings): Settings
211
211
  }
212
212
  }
213
213
 
214
- // Merge permissions.allow (deduplicate), then drop deprecated entries.
214
+ // Merge permissions.allow (deduplicate)
215
215
  if (template.permissions?.allow) {
216
216
  result.permissions ??= {};
217
217
  result.permissions.allow ??= [];
@@ -221,15 +221,6 @@ export function mergeSettings(existing: Settings, template: Settings): Settings
221
221
  }
222
222
  }
223
223
  }
224
- // Strip ineffective Grep(...)/Glob(...) rules left by older templates. Claude Code
225
- // governs Grep/Glob via Read(...) rules, so a path-scoped Grep()/Glob() entry never
226
- // matches and Claude Code warns about it on every prompt. Read(//*) already covers them.
227
- if (result.permissions?.allow) {
228
- result.permissions.allow = result.permissions.allow.filter(
229
- (perm) => !isIneffectiveFileToolRule(perm)
230
- );
231
- }
232
-
233
224
  // Merge skillOverrides (object with skill name keys, add if not present)
234
225
  if (template.skillOverrides && typeof template.skillOverrides === "object") {
235
226
  result.skillOverrides ??= {};
@@ -488,15 +479,6 @@ function isOutdatedPalHook(cmd: string, palCanonical: Set<string>): boolean {
488
479
  return palCanonical.has(canonicalPalCmd(cmd)) || runsRemovedPalHook(cmd);
489
480
  }
490
481
 
491
- /**
492
- * True for path-scoped Grep()/Glob() allow rules, which Claude Code cannot honor —
493
- * it resolves Grep/Glob permission through Read(...) rules, so these never match and
494
- * trigger a warning on every prompt. Bare "Grep"/"Glob" tool allows are left intact.
495
- */
496
- function isIneffectiveFileToolRule(perm: string): boolean {
497
- return /^(?:Grep|Glob)\(/.test(perm);
498
- }
499
-
500
482
  export function loadCodexHooksTemplate(
501
483
  templatePath: string,
502
484
  pkgRoot: string
@@ -697,21 +679,6 @@ export function scaffoldPalSettings(): void {
697
679
  copyFileSync(src, dst);
698
680
  log.info("Created pal-settings.json from template");
699
681
  }
700
-
701
- // Strip deprecated loadAtStartup.files entries from existing installs.
702
- // mergeSettings only adds, never removes — deprecated entries persist indefinitely otherwise.
703
- try {
704
- const raw = JSON.parse(readFileSync(dst, "utf-8"));
705
- const files: string[] = raw?.loadAtStartup?.files ?? [];
706
- const cleaned = files.filter((f: string) => !f.endsWith("PROJECTS.md"));
707
- if (cleaned.length !== files.length) {
708
- raw.loadAtStartup.files = cleaned;
709
- writeFileSync(dst, `${JSON.stringify(raw, null, 2)}\n`, "utf-8");
710
- log.info("Removed deprecated PROJECTS.md from loadAtStartup.files");
711
- }
712
- } catch {
713
- /* non-fatal — malformed settings left as-is */
714
- }
715
682
  }
716
683
 
717
684
  // --- PAL docs (modular context routing files) ---
@@ -3,14 +3,7 @@
3
3
  * Deploys plugin, installs skills, generates AGENTS.md.
4
4
  */
5
5
 
6
- import {
7
- existsSync,
8
- mkdirSync,
9
- readFileSync,
10
- statSync,
11
- unlinkSync,
12
- writeFileSync,
13
- } from "node:fs";
6
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
14
7
  import { resolve } from "node:path";
15
8
  import { palPkg, platform } from "../../hooks/lib/paths";
16
9
  import { getSemiStaticSources } from "../../hooks/lib/semi-static";
@@ -22,11 +15,7 @@ const OC_PLUGINS_DIR = resolve(OC_GLOBAL_DIR, "plugins");
22
15
 
23
16
  mkdirSync(OC_PLUGINS_DIR, { recursive: true });
24
17
 
25
- // --- 1. Deploy plugin (clean up legacy filename) ---
26
- const legacyPlugin = resolve(OC_PLUGINS_DIR, "pai-plugin.ts");
27
- if (existsSync(legacyPlugin)) {
28
- unlinkSync(legacyPlugin);
29
- }
18
+ // --- 1. Deploy plugin ---
30
19
  const pluginSrc = resolve(PKG_ROOT, "src", "targets", "opencode", "plugin.ts");
31
20
  const pluginDst = resolve(OC_PLUGINS_DIR, "pal-plugin.ts");
32
21
  // Embed PKG_ROOT as a hardcoded constant so no env config is needed