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 +1 -1
- package/assets/skills/consulting-report/tools/generate-pdf.mjs +2 -2
- package/assets/skills/consulting-report/tools/generate-pdf.ts +5 -2
- package/assets/skills/playwright/SKILL.md +2 -2
- package/assets/skills/playwright/tools/shot.ts +6 -7
- package/assets/skills/projects/SKILL.md +4 -1
- package/assets/templates/settings.claude.json +2 -1
- package/package.json +15 -4
- package/src/cli/index.ts +93 -7
- package/src/cli/migrate.ts +69 -3
- package/src/hooks/lib/anchor.ts +90 -0
- package/src/hooks/lib/bindings.ts +117 -0
- package/src/hooks/lib/export.ts +38 -1
- package/src/hooks/lib/import-merge.ts +220 -0
- package/src/hooks/lib/inference.ts +113 -72
- package/src/hooks/lib/machine.ts +176 -0
- package/src/hooks/lib/projects.ts +223 -15
- package/src/hooks/lib/relationship.ts +3 -1
- package/src/hooks/lib/remote.ts +58 -0
- package/src/hooks/lib/retrieval.ts +8 -2
- package/src/hooks/lib/signals.ts +2 -1
- package/src/hooks/lib/stop.ts +5 -2
- package/src/targets/lib.ts +79 -34
- package/src/tools/agent/algorithm-reflect.ts +45 -11
- package/src/tools/agent/project.ts +148 -23
- package/src/tools/agent/thread.ts +7 -2
- package/assets/skills/playwright/tools/shot-lib.mjs +0 -44
- package/assets/skills/playwright/tools/shot.mjs +0 -89
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.
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
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
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "portable-agent-layer",
|
|
3
|
-
"version": "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.
|
|
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
|
-
"@
|
|
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]
|
|
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]
|
|
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(
|
|
1391
|
+
logDebug(
|
|
1392
|
+
"import",
|
|
1393
|
+
`zip=${zipPath} entries=${entries.length} dryRun=${dryRun} overwrite=${overwrite}`
|
|
1394
|
+
);
|
|
1373
1395
|
if (dryRun) {
|
|
1374
|
-
console.log(
|
|
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
|
-
|
|
1400
|
+
return;
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
if (overwrite) {
|
|
1377
1404
|
zip.extractAllTo(home, true);
|
|
1378
|
-
logDebug("import", `done
|
|
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() {
|
package/src/cli/migrate.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
+
}
|