portable-agent-layer 0.64.0 → 0.65.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
@@ -32,7 +32,7 @@ With PAL, you can:
32
32
 
33
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
34
 
35
- - [Bun](https://bun.sh) >= 1.3.0
35
+ - [Bun](https://bun.sh) >= 1.4.0
36
36
  - At least one of: [Claude Code](https://claude.ai/code), [opencode](https://opencode.ai), [Cursor](https://cursor.com), [GitHub Copilot CLI](https://docs.github.com/en/copilot/github-copilot-in-the-cli), or [Codex](https://openai.com/index/introducing-codex/)
37
37
 
38
38
  ### Package mode (recommended)
@@ -3,7 +3,7 @@ import { createReadStream, constants as fsConstants, realpathSync } from "node:f
3
3
  import { access, readFile, stat, unlink, writeFile } from "node:fs/promises";
4
4
  import { createServer } from "node:http";
5
5
  import { extname, join, resolve } from "node:path";
6
- import { pathToFileURL } from "node:url";
6
+ import { fileURLToPath, pathToFileURL } from "node:url";
7
7
  import { PDFDocument } from "pdf-lib";
8
8
  import { chromium } from "playwright";
9
9
  const COLOR = {
@@ -220,7 +220,7 @@ function realResolve(p) {
220
220
  return resolve(p);
221
221
  }
222
222
  }
223
- const isMain = process.argv[1] && realResolve(process.argv[1]) === realResolve(new URL(import.meta.url).pathname);
223
+ const isMain = process.argv[1] && realResolve(process.argv[1]) === realResolve(fileURLToPath(import.meta.url));
224
224
  if (isMain) {
225
225
  await run();
226
226
  }
@@ -19,7 +19,7 @@ import { createReadStream, constants as fsConstants, realpathSync } from "node:f
19
19
  import { access, readFile, stat, unlink, writeFile } from "node:fs/promises";
20
20
  import { createServer, type Server } from "node:http";
21
21
  import { extname, join, resolve } from "node:path";
22
- import { pathToFileURL } from "node:url";
22
+ import { fileURLToPath, pathToFileURL } from "node:url";
23
23
  import { PDFDocument } from "pdf-lib";
24
24
  import { chromium } from "playwright";
25
25
 
@@ -315,6 +315,9 @@ async function run(argv: string[] = process.argv.slice(2)): Promise<void> {
315
315
  // Node ≥ 22.6 doesn't expose import.meta.main; gate on argv[1] instead.
316
316
  // Use realpathSync on both sides so symlinked skill paths (e.g. ~/.pal/skills →
317
317
  // PAL repo) match the resolved import.meta.url.
318
+ // fileURLToPath, never new URL().pathname: on Windows the latter yields
319
+ // "/C:/Users/..." — leading slash, forward slashes — which can never equal argv[1]'s
320
+ // "C:\Users\...", so the gate silently failed and run() never executed there.
318
321
  function realResolve(p: string): string {
319
322
  try {
320
323
  return realpathSync(resolve(p));
@@ -324,7 +327,7 @@ function realResolve(p: string): string {
324
327
  }
325
328
  const isMain =
326
329
  process.argv[1] &&
327
- realResolve(process.argv[1]) === realResolve(new URL(import.meta.url).pathname);
330
+ realResolve(process.argv[1]) === realResolve(fileURLToPath(import.meta.url));
328
331
  if (isMain) {
329
332
  await run();
330
333
  }
@@ -26,7 +26,7 @@ Take a screenshot of a running page and `Read` it into context so you can see th
26
26
  The bundled tool picks the best available local engine automatically:
27
27
 
28
28
  1. **System `playwright-cli`** (Microsoft's stateful agent CLI) — used when it is on `PATH` and no exact viewport/full-page is requested (its `screenshot` command can't set those).
29
- 2. **PAL-installed Playwright, launched via Node** — the cross-platform-safe path (Playwright's Chromium hangs under Bun on Windows). Honors `--viewport`, `--full-page`, and `--selector` precisely.
29
+ 2. **PAL-installed Playwright, launched in-process** — the precise path. Honors `--viewport`, `--full-page`, and `--selector` precisely.
30
30
 
31
31
  If neither engine is usable, the tool prints `NO_PLAYWRIGHT_CLI` and exits non-zero — only then use the **Playwright MCP** (tier 3) in step 4.
32
32
 
@@ -36,7 +36,7 @@ If neither engine is usable, the tool prints `NO_PLAYWRIGHT_CLI` and exits non-z
36
36
  2. Run the tool (it prints the absolute PNG path as its last stdout line):
37
37
 
38
38
  ```bash
39
- node ~/.pal/skills/playwright/tools/shot.mjs <url> \
39
+ bun ~/.pal/skills/playwright/tools/shot.ts <url> \
40
40
  [--viewport 1440x900] [--full-page] [--selector "<css>"] [-o <out.png>]
41
41
  ```
42
42
 
@@ -1,18 +1,17 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env bun
2
2
  // playwright skill tool: capture a screenshot of a URL and print its absolute path.
3
3
  //
4
4
  // Engine selection (see chooseTier):
5
5
  // Tier 1 — system `playwright-cli` binary (Microsoft's stateful agent CLI), when on
6
6
  // PATH and no exact viewport/full-page is requested.
7
- // Tier 2 — PAL-installed Playwright, launched via Node. (Playwright's chromium.launch
8
- // hangs under Bun on Windows — the same Node exception create-pdf relies on.)
7
+ // Tier 2 — PAL-installed Playwright, launched in-process.
9
8
  // If neither engine works, prints NO_PLAYWRIGHT_CLI on stderr and exits non-zero so the
10
9
  // caller (SKILL.md) can fall back to the Playwright MCP.
11
10
  //
12
- // pal-build:mjs ships as a compiled shot.mjs sibling (scripts/build-skill-tools.ts).
13
- // Run under Node via the compiled file (a .ts under node_modules can't be type-stripped;
14
- // plain .mjs needs no stripping and runs on every OS, keeping the Windows fallback):
15
- // node ~/.pal/skills/playwright/tools/shot.mjs <url> [opts]
11
+ // Runs under Bun on every OS:
12
+ // bun ~/.pal/skills/playwright/tools/shot.ts <url> [opts]
13
+ // It used to ship a Node-compiled .mjs sibling because chromium.launch hung under Bun on
14
+ // Windows; bun 1.4.0 fixes that, so the Node hop and the build step are gone.
16
15
 
17
16
  import { spawnSync } from "node:child_process";
18
17
  import { existsSync, mkdtempSync, rmSync } from "node:fs";
@@ -18,6 +18,9 @@ metadata:
18
18
  - "where are we"
19
19
  - "open ticket"
20
20
  - "open iscs"
21
+ - "file ticket"
22
+ - "file it as a ticket"
23
+ - "file a ticket"
21
24
  ---
22
25
 
23
26
  Manage the user's project registry. Each project lives at `~/.pal/memory/projects/{slug}/ISA.md`. Frontmatter holds operational state (next steps, blockers, handoff); the body holds ISA spec sections (Problem, Goal, Criteria, Context, Decisions, etc.). The Stop hook auto-touches `updated` whenever the cwd resolves into a registered project — just *being* in the project keeps it warm.
@@ -64,7 +67,7 @@ The body of each ISA.md holds spec sections. Use `update-section` to set them:
64
67
  | Decisions | `decisions` | Auto-managed by `add-decision`; dated bullet list |
65
68
  | Changelog | `changelog` | Archive of completed ISCs (`complete-isc` moves them here under a dated `### Archived` heading) plus any milestone notes |
66
69
 
67
- **ISC archive model.** `complete-isc` does not just check a box — it moves the ISC line out of Criteria and into the Changelog archive, so Criteria always reflects exactly the open work and never bloats the context loaded at session start. `list-isc <name>` shows open ISCs by default; pass `--closed` (archived only) or `--all` (open + archived) to read finished ones. `reopen-isc` pulls an archived ISC back into the open set. `prune-isc <name>` backfills legacy projects by sweeping any done ISCs still sitting in Criteria into the archive in one pass.
70
+ **ISC archive model.** `complete-isc` does not just check a box — it moves the ISC line out of Criteria and into the Changelog archive, so Criteria always reflects exactly the open work and never bloats the context loaded at session start. `list-isc <name>` shows open ISCs by default; pass `--closed` (archived only) or `--all` (open + archived) to read finished ones. `reopen-isc` pulls an archived or retired ISC back into the open set. `edit-isc <name> <id> "new text"` rewrites an ISC's wording in place, keeping its id and state — use it to sharpen a vague criterion instead of closing and refiling. `retire-isc <name> <id> [--by <id>]` closes an ISC that stopped being valid: it files under a `### Retired` heading as `[~]` rather than claiming the work was done, and `--by` records the superseding ISC. A retired ISC counts as neither open nor done, is listed with `--retired`, and keeps its id reserved so no future ISC can reuse it. `prune-isc <name>` backfills legacy projects by sweeping any done ISCs still sitting in Criteria into the archive in one pass.
68
71
 
69
72
  ## Routing
70
73
 
@@ -45,7 +45,8 @@
45
45
  },
46
46
  "attribution": {
47
47
  "commit": "",
48
- "pr": ""
48
+ "pr": "",
49
+ "sessionUrl": false
49
50
  },
50
51
  "showClearContextOnPlanAccept": true,
51
52
  "respectGitignore": true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.64.0",
3
+ "version": "0.65.0",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,7 +14,7 @@
14
14
  "LICENSE"
15
15
  ],
16
16
  "engines": {
17
- "bun": ">=1.3.0"
17
+ "bun": ">=1.4.0"
18
18
  },
19
19
  "keywords": [
20
20
  "ai",
@@ -34,7 +34,9 @@
34
34
  },
35
35
  "scripts": {
36
36
  "type-check": "tsc --noEmit",
37
- "test": "bun test",
37
+ "test": "bun test --randomize",
38
+ "test:mutate": "stryker run",
39
+ "test:mutate:diff": "bun .agents/scripts/mutate-diff.ts",
38
40
  "format": "biome format",
39
41
  "format-write": "biome format --write",
40
42
  "lint": "biome lint",
@@ -44,6 +46,10 @@
44
46
  "knip": "knip-bun",
45
47
  "klint": "klint",
46
48
  "jscpd": "jscpd --noTips",
49
+ "madge": "madge --circular --extensions ts --ts-config tsconfig.json src",
50
+ "lf": "bun .agents/scripts/check-lf.ts",
51
+ "lf:fix": "bun .agents/scripts/check-lf.ts --fix",
52
+ "secretlint": "secretlint \"**/*\"",
47
53
  "lint-staged": "lint-staged",
48
54
  "prepare": "bun .husky/install.mjs",
49
55
  "build:skill-tools": "bun run scripts/build-skill-tools.ts",
@@ -66,11 +72,14 @@
66
72
  "@biomejs/biome": "2.4.15",
67
73
  "@commitlint/cli": "21.0.1",
68
74
  "@commitlint/config-conventional": "21.0.1",
69
- "@konvert7/klint": "0.20.0",
75
+ "@hughescr/stryker-bun-runner": "1.3.8",
76
+ "@konvert7/klint": "0.34.0",
70
77
  "@opencode-ai/plugin": "latest",
78
+ "@secretlint/secretlint-rule-preset-recommend": "13.0.2",
71
79
  "@semantic-release/changelog": "^6.0.3",
72
80
  "@semantic-release/git": "^10.0.1",
73
81
  "@semantic-release/github": "^12.0.8",
82
+ "@stryker-mutator/core": "9.6.1",
74
83
  "@types/adm-zip": "^0.5.8",
75
84
  "@types/bun": "latest",
76
85
  "@types/node": "latest",
@@ -78,7 +87,9 @@
78
87
  "jscpd": "^4.2.3",
79
88
  "knip": "^6.14.1",
80
89
  "lint-staged": "17.0.5",
90
+ "madge": "8.0.0",
81
91
  "promptfoo": "0.121.14",
92
+ "secretlint": "13.0.2",
82
93
  "semantic-release": "^25.0.3",
83
94
  "typescript": "^5.9.3"
84
95
  },
package/src/cli/index.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  * uninstall [--claude] [--opencode] [--cursor] [--codex] Remove hooks/skills for targets
13
13
  * update Update PAL (git pull or npm update)
14
14
  * export [path] [--dry-run] Export user state to zip
15
- * import [path] [--dry-run] Import user state from zip
15
+ * import [path] [--dry-run] [--overwrite] Merge user state from zip
16
16
  * status Show current PAL configuration
17
17
  * doctor Check prerequisites and system health
18
18
  * usage Summarize token usage and cost
@@ -35,9 +35,17 @@ import {
35
35
  } from "node:fs";
36
36
  import { homedir } from "node:os";
37
37
  import { resolve } from "node:path";
38
+ import {
39
+ appendImportLog,
40
+ mergeArchive,
41
+ readManifest,
42
+ summarize,
43
+ } from "../hooks/lib/import-merge";
38
44
  import { inference, previewInferenceRoute } from "../hooks/lib/inference";
39
45
  import { DEBUG_LOG_MAX_ROTATED, logDebug } from "../hooks/lib/log";
46
+ import { ensureRegistered, writeRegistryEntry } from "../hooks/lib/machine";
40
47
  import { palHome, palPkg, paths, platform } from "../hooks/lib/paths";
48
+ import { auditBindings, describeBindingIssue } from "../hooks/lib/projects";
41
49
  import { hasRealContent, SETUP_STEPS, STEP_ORDER } from "../hooks/lib/setup";
42
50
  import { log } from "../targets/lib";
43
51
  import { checkPendingMigrations } from "./migrate";
@@ -274,7 +282,7 @@ function showHelp() {
274
282
  pal cli uninstall [--claude] [--opencode] [--cursor] [--codex] Remove hooks for targets
275
283
  pal cli update Update PAL (git pull or npm update)
276
284
  pal cli export [path] [--dry-run] Export state to zip
277
- pal cli import [path] [--dry-run] Import state from zip
285
+ pal cli import [path] [--dry-run] Merge state from zip (--overwrite to replace)
278
286
  pal cli status Show PAL configuration
279
287
  pal cli doctor [--probe-inference] Check prerequisites and health (--probe fires real inference per route)
280
288
  pal cli migrate [--list] [--dry-run] Run pending data migrations
@@ -845,6 +853,16 @@ function doctor(silent = false): DoctorResult {
845
853
  );
846
854
  }
847
855
 
856
+ // Project bindings — where each project lives on THIS machine
857
+ {
858
+ const issues = auditBindings();
859
+ if (issues.length === 0) {
860
+ ok("Project bindings healthy");
861
+ } else {
862
+ for (const issue of issues) warn(`Binding: ${describeBindingIssue(issue)}`);
863
+ }
864
+ }
865
+
848
866
  // Dependencies (PAL's own npm packages)
849
867
  const nodeModulesPath = resolve(palPkg(), "node_modules");
850
868
  existsSync(nodeModulesPath)
@@ -1293,6 +1311,7 @@ async function importState(args: string[]) {
1293
1311
 
1294
1312
  const home = palHome();
1295
1313
  const dryRun = args.includes("--dry-run");
1314
+ const overwrite = args.includes("--overwrite");
1296
1315
  const pathArg = args.find((a) => !a.startsWith("-"));
1297
1316
  logDebug("import", `start dryRun=${dryRun} pathArg=${pathArg ?? "(auto)"}`);
1298
1317
 
@@ -1369,16 +1388,83 @@ async function importState(args: string[]) {
1369
1388
  process.exit(0);
1370
1389
  }
1371
1390
 
1372
- logDebug("import", `zip=${zipPath} entries=${entries.length} dryRun=${dryRun}`);
1391
+ logDebug(
1392
+ "import",
1393
+ `zip=${zipPath} entries=${entries.length} dryRun=${dryRun} overwrite=${overwrite}`
1394
+ );
1373
1395
  if (dryRun) {
1374
- console.log(`Would import ${entries.length} files → ${home}\n`);
1396
+ console.log(
1397
+ `Would ${overwrite ? "overwrite with" : "merge"} ${entries.length} files → ${home}\n`
1398
+ );
1375
1399
  for (const e of entries) console.log(` ${e.entryName}`);
1376
- } else {
1400
+ return;
1401
+ }
1402
+
1403
+ if (overwrite) {
1377
1404
  zip.extractAllTo(home, true);
1378
- logDebug("import", `done extracted=${entries.length} to=${home}`);
1379
- console.log(`Imported ${entries.length} files → ${home}`);
1405
+ logDebug("import", `done overwrote=${entries.length} to=${home}`);
1406
+ console.log(`Imported ${entries.length} files → ${home} (overwrite)`);
1407
+ appendImportLog(home, {
1408
+ ts: new Date().toISOString(),
1409
+ archive: zipPath,
1410
+ mode: "overwrite",
1411
+ created: 0,
1412
+ merged: 0,
1413
+ identical: 0,
1414
+ conflicts: 0,
1415
+ skipped: 0,
1416
+ linesAdded: 0,
1417
+ quarantineDir: null,
1418
+ });
1380
1419
  log.info("Run 'pal cli install' to re-register hooks.");
1420
+ return;
1421
+ }
1422
+
1423
+ ensureRegistered(home);
1424
+ const quarantineDir = resolve(
1425
+ home,
1426
+ "backups",
1427
+ `import-conflicts-${new Date()
1428
+ .toISOString()
1429
+ .replace(/[-:T.]/g, "")
1430
+ .slice(0, 14)}`
1431
+ );
1432
+ const result = mergeArchive(
1433
+ entries.map((e) => ({ path: e.entryName, data: () => e.getData() })),
1434
+ home,
1435
+ quarantineDir
1436
+ );
1437
+ const source = readManifest(
1438
+ entries.map((e) => ({ path: e.entryName, data: () => e.getData() }))
1439
+ );
1440
+ if (source) {
1441
+ writeRegistryEntry({ id: source.machineId, label: source.label, os: source.os });
1442
+ console.log(`Source machine: ${source.label} (${source.machineId})`);
1443
+ }
1444
+
1445
+ appendImportLog(home, {
1446
+ ts: new Date().toISOString(),
1447
+ archive: zipPath,
1448
+ mode: "merge",
1449
+ created: result.created.length,
1450
+ merged: result.merged.length,
1451
+ identical: result.identical.length,
1452
+ conflicts: result.conflicts.length,
1453
+ skipped: result.skipped.length,
1454
+ linesAdded: result.linesAdded,
1455
+ quarantineDir: result.quarantineDir,
1456
+ sourceMachineId: source?.machineId ?? null,
1457
+ });
1458
+
1459
+ logDebug("import", `done merge ${summarize(result)} to=${home}`);
1460
+ console.log(`Imported → ${home}: ${summarize(result)}`);
1461
+ if (result.conflicts.length > 0) {
1462
+ log.warn(
1463
+ `${result.conflicts.length} file(s) diverged — local kept, incoming saved to ${result.quarantineDir}`
1464
+ );
1465
+ for (const c of result.conflicts) console.log(` ${c}`);
1381
1466
  }
1467
+ log.info("Run 'pal cli install' to re-register hooks.");
1382
1468
  }
1383
1469
 
1384
1470
  async function update() {
@@ -105,7 +105,7 @@ const v1Projects: Migration = {
105
105
  function nextIscId(criteria: string): number {
106
106
  const ids: number[] = [];
107
107
  for (const line of criteria.split("\n")) {
108
- const m = new RegExp(/^-\s+\[[ x]\]\s+ISC-(\d+):/i).exec(line);
108
+ const m = new RegExp(/^-\s+\[[ x~]\]\s+ISC-(\d+):/i).exec(line);
109
109
  if (m) ids.push(Number(m[1]));
110
110
  }
111
111
  return ids.length > 0 ? Math.max(...ids) + 1 : 1;
@@ -117,7 +117,9 @@ function pendingThreadsForProjects(): { thread: Thread; project: ProjectProgress
117
117
  const projects = readAllProjects();
118
118
  const results: { thread: Thread; project: ProjectProgress }[] = [];
119
119
  for (const thread of threads) {
120
- const project = projects.find((p) => resolve(p.path) === resolve(thread.cwd));
120
+ const project = projects.find(
121
+ (p) => p.path !== undefined && resolve(p.path) === resolve(thread.cwd)
122
+ );
121
123
  if (project) results.push({ thread, project });
122
124
  }
123
125
  return results;
@@ -362,7 +364,71 @@ const v3EntitiesToKnowledge: Migration = {
362
364
 
363
365
  // ── Registry ──────────────────────────────────────────────────────
364
366
 
365
- const MIGRATIONS: Migration[] = [v1Projects, v2ThreadsToIsc, v3EntitiesToKnowledge];
367
+ // ── v4: project paths out of records, into machine-local bindings ──
368
+
369
+ /** Records written before bindings existed carry their own `path:` in frontmatter. */
370
+ function recordsCarryingAPath(): string[] {
371
+ const base = paths.projectHistory();
372
+ if (!existsSync(base)) return [];
373
+ const out: string[] = [];
374
+ for (const slug of readdirSync(base)) {
375
+ const file = resolve(base, slug, "ISA.md");
376
+ if (!existsSync(file)) continue;
377
+ const frontmatter = readFileSync(file, "utf-8").split("---")[1] ?? "";
378
+ if (/^path:/m.test(frontmatter)) out.push(slug);
379
+ }
380
+ return out;
381
+ }
382
+
383
+ const v4PathsToBindings: Migration = {
384
+ id: "v4-paths-to-bindings",
385
+ description: "Move project paths out of records into machine-local bindings",
386
+
387
+ check() {
388
+ const stale = recordsCarryingAPath();
389
+ return {
390
+ pending: stale.length > 0,
391
+ detail:
392
+ stale.length > 0 ? `${stale.length} record(s) still store a path` : undefined,
393
+ };
394
+ },
395
+
396
+ // Reading a record and writing it straight back is the whole migration: the
397
+ // writer already routes the path to a binding, drops the field, and picks up
398
+ // the git remote. Nothing here reimplements that, so the two cannot drift.
399
+ run(dryRun = false): MigrationResult {
400
+ const stale = recordsCarryingAPath();
401
+ const results: string[] = [];
402
+ let migrated = 0;
403
+ let skipped = 0;
404
+
405
+ for (const slug of stale) {
406
+ const project = readProject(slug);
407
+ if (!project) {
408
+ skipped++;
409
+ results.push(`${slug}: unreadable, left untouched`);
410
+ continue;
411
+ }
412
+ if (dryRun) {
413
+ migrated++;
414
+ results.push(`${slug}: would move ${project.path ?? "(no path)"} to a binding`);
415
+ continue;
416
+ }
417
+ writeProject(project);
418
+ migrated++;
419
+ results.push(`${slug}: path moved to a binding`);
420
+ }
421
+
422
+ return { migrated, skipped, results };
423
+ },
424
+ };
425
+
426
+ const MIGRATIONS: Migration[] = [
427
+ v1Projects,
428
+ v2ThreadsToIsc,
429
+ v3EntitiesToKnowledge,
430
+ v4PathsToBindings,
431
+ ];
366
432
 
367
433
  // ── Public API ────────────────────────────────────────────────────
368
434
 
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Path anchors — `{proj:slug}/relative` instead of an absolute path.
3
+ *
4
+ * An absolute cwd stamp never matches across machines: same project,
5
+ * different mount, different username, different OS. An anchor replaces the
6
+ * absolute prefix with the project's registry slug, so resolution happens
7
+ * locally at read time — the same trick machine.ts uses for labels, applied
8
+ * to paths. Relocating a project (`project.ts set-path`) then fixes every
9
+ * memory that ever referenced it, because none of them stored the path.
10
+ */
11
+
12
+ import { relative, resolve, sep } from "node:path";
13
+ import type { ProjectProgress } from "./projects";
14
+ import {
15
+ projectPathOnThisMachine,
16
+ readAllProjects,
17
+ resolveProjectFromCwd,
18
+ } from "./projects";
19
+
20
+ const ANCHOR_RE = /^\{proj:([a-z0-9_-]+)\}(\/.*)?$/;
21
+
22
+ export function isAnchor(value: string): boolean {
23
+ return ANCHOR_RE.test(value);
24
+ }
25
+
26
+ /**
27
+ * Absolute path → `{proj:slug}/relative`, if it falls inside a registered
28
+ * project. A path outside every registered project passes through
29
+ * unchanged — most cwd stamps from ad hoc commands will never resolve to a
30
+ * project, and that is fine; they simply do not benefit yet.
31
+ */
32
+ export function encodeAnchor(
33
+ absPath: string,
34
+ projects: ProjectProgress[] = readAllProjects()
35
+ ): string {
36
+ const proj = resolveProjectFromCwd(absPath, projects);
37
+ if (!proj) return absPath;
38
+
39
+ // The same effective path the resolver matched on, not the record's own field:
40
+ // a bound project's path lives in bindings, so anchoring off proj.path directly
41
+ // would measure the relative segment against the wrong root.
42
+ const root = projectPathOnThisMachine(proj);
43
+ if (!root) return absPath;
44
+
45
+ const rel = relative(root, resolve(absPath));
46
+ if (rel.startsWith("..")) return absPath;
47
+
48
+ const relPosix = rel.split(sep).join("/");
49
+ return relPosix ? `{proj:${proj.name}}/${relPosix}` : `{proj:${proj.name}}`;
50
+ }
51
+
52
+ export type AnchorResolution =
53
+ | { state: "anchored"; path: string }
54
+ | { state: "plain"; path: string }
55
+ | { state: "unresolvable"; slug: string };
56
+
57
+ /**
58
+ * `{proj:slug}/relative` → absolute path on THIS machine, via the local
59
+ * registry. A plain (non-anchor) value is returned as-is — either an
60
+ * already-local absolute path, or a record captured before this feature
61
+ * shipped. There is no backfill, so pre-anchor records are handled exactly
62
+ * as they were before.
63
+ */
64
+ export function resolveAnchor(
65
+ value: string,
66
+ projects: ProjectProgress[] = readAllProjects()
67
+ ): AnchorResolution {
68
+ const match = ANCHOR_RE.exec(value);
69
+ if (!match) return { state: "plain", path: value };
70
+
71
+ const [, slug, rel] = match;
72
+ const proj = projects.find((p) => p.name === slug);
73
+ if (!proj) return { state: "unresolvable", slug };
74
+
75
+ const root = projectPathOnThisMachine(proj);
76
+ if (!root) return { state: "unresolvable", slug };
77
+ const path = rel ? resolve(root, `.${rel}`) : root;
78
+ return { state: "anchored", path };
79
+ }
80
+
81
+ /** Does `value` (anchored or plain) refer to `cwd` on this machine? */
82
+ export function anchorMatchesCwd(
83
+ value: string,
84
+ cwd: string,
85
+ projects: ProjectProgress[] = readAllProjects()
86
+ ): boolean {
87
+ const resolved = resolveAnchor(value, projects);
88
+ if (resolved.state === "unresolvable") return false;
89
+ return resolve(resolved.path) === resolve(cwd);
90
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Bindings — where each project lives on THIS machine.
3
+ *
4
+ * A project record answers two questions in one field today: what the project
5
+ * is (portable — name, goal, criteria, decisions) and where it sits on disk
6
+ * (true on exactly one machine). The second answer rides inside `memory/`,
7
+ * which is exported, so one machine's filesystem layout travels to every other
8
+ * machine as though it were a fact about the project.
9
+ *
10
+ * This module holds the second answer separately. Memory becomes the union of
11
+ * all work; bindings are one machine's intersection with its disk, so "that
12
+ * project is not checked out here" turns into an ordinary state rather than a
13
+ * dead path.
14
+ *
15
+ * `bindings.json` lives at the PAL_HOME root for the same reason `machine.json`
16
+ * does: export walks `telos`, `memory`, `skills` and `agents`, so anything
17
+ * under those would sync and defeat the point.
18
+ */
19
+
20
+ import { copyFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
21
+ import { homedir } from "node:os";
22
+ import { resolve } from "node:path";
23
+ import { palHome } from "./paths";
24
+
25
+ /** Project name → absolute path on this machine. */
26
+ export type Bindings = Record<string, string>;
27
+
28
+ export function bindingsFilePath(home: string = palHome()): string {
29
+ return resolve(home, "bindings.json");
30
+ }
31
+
32
+ /**
33
+ * Where the previous bindings are kept.
34
+ *
35
+ * Once a record stops storing its own path, this file is the only place a
36
+ * project's location lives, and it is deliberately excluded from exports — so
37
+ * losing it loses every location. One rolling copy of the last good content
38
+ * makes that recoverable by renaming a file, and never goes stale the way a
39
+ * one-off backup taken at migration time would.
40
+ */
41
+ export function bindingsBackupPath(home: string = palHome()): string {
42
+ return resolve(home, "bindings.backup.json");
43
+ }
44
+
45
+ function isBindingMap(value: unknown): value is Bindings {
46
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
47
+ return Object.values(value).every((v) => typeof v === "string");
48
+ }
49
+
50
+ /**
51
+ * A malformed or unreadable file reads as empty rather than throwing. Bindings
52
+ * are a lookup aid, so a corrupt one degrades to "nothing is bound here" — the
53
+ * same state a fresh machine starts in — instead of breaking every caller.
54
+ */
55
+ export function readBindings(home: string = palHome()): Bindings {
56
+ const file = bindingsFilePath(home);
57
+ if (!existsSync(file)) return {};
58
+ try {
59
+ const parsed: unknown = JSON.parse(readFileSync(file, "utf-8"));
60
+ if (!isBindingMap(parsed)) return {};
61
+ return parsed;
62
+ } catch {
63
+ return {};
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Seeding writes on the first project read, which makes an unsandboxed test a
69
+ * silent writer into the developer's own ~/.pal. The suite sets PAL_TEST_SANDBOX,
70
+ * so refuse there and name the file — a test that forgets to point PAL_HOME at a
71
+ * temp dir fails loudly instead of editing the machine running it.
72
+ */
73
+ function assertNotRealHomeDuringTests(home: string): void {
74
+ if (!process.env.PAL_TEST_SANDBOX) return;
75
+ if (resolve(home) !== resolve(homedir(), ".pal")) return;
76
+ throw new Error(
77
+ "Refusing to write bindings.json into the real ~/.pal during a test run. " +
78
+ "Point PAL_HOME at a temp directory in this test's setup."
79
+ );
80
+ }
81
+
82
+ export function writeBindings(bindings: Bindings, home: string = palHome()): void {
83
+ assertNotRealHomeDuringTests(home);
84
+ const file = bindingsFilePath(home);
85
+ // Only a differing, non-empty predecessor is worth keeping: backing up an
86
+ // identical file is noise, and backing up an empty one would let a bad write
87
+ // erase the copy that made it recoverable.
88
+ if (existsSync(file) && readFileSync(file, "utf-8").trim().length > 0) {
89
+ copyFileSync(file, bindingsBackupPath(home));
90
+ }
91
+ const sorted: Bindings = {};
92
+ for (const key of Object.keys(bindings).sort()) sorted[key] = bindings[key];
93
+ writeFileSync(bindingsFilePath(home), `${JSON.stringify(sorted, null, 2)}\n`);
94
+ }
95
+
96
+ /** The absolute path this machine has for `project`, or null when unbound. */
97
+ export function bindingFor(project: string, home: string = palHome()): string | null {
98
+ return readBindings(home)[project] ?? null;
99
+ }
100
+
101
+ /** Bind `project` to `path`, replacing any existing binding for it. */
102
+ export function writeBinding(
103
+ project: string,
104
+ path: string,
105
+ home: string = palHome()
106
+ ): void {
107
+ const bindings = readBindings(home);
108
+ bindings[project] = resolve(path);
109
+ writeBindings(bindings, home);
110
+ }
111
+
112
+ export function removeBinding(project: string, home: string = palHome()): void {
113
+ const bindings = readBindings(home);
114
+ if (!(project in bindings)) return;
115
+ delete bindings[project];
116
+ writeBindings(bindings, home);
117
+ }