portable-agent-layer 0.63.3 → 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.
Files changed (65) hide show
  1. package/README.md +8 -4
  2. package/assets/schema/pal-settings.schema.json +4 -0
  3. package/assets/skills/analyze-pdf/SKILL.md +11 -0
  4. package/assets/skills/analyze-youtube/SKILL.md +12 -0
  5. package/assets/skills/consulting-report/SKILL.md +9 -0
  6. package/assets/skills/consulting-report/tools/generate-pdf.mjs +2 -2
  7. package/assets/skills/consulting-report/tools/generate-pdf.ts +5 -2
  8. package/assets/skills/council/SKILL.md +32 -0
  9. package/assets/skills/create-pdf/SKILL.md +13 -0
  10. package/assets/skills/create-skill/SKILL.md +14 -2
  11. package/assets/skills/create-skill/authoring-guide.md +10 -1
  12. package/assets/skills/create-subagent/SKILL.md +22 -4
  13. package/assets/skills/{research → deep-research}/SKILL.md +32 -1
  14. package/assets/skills/entities/SKILL.md +10 -0
  15. package/assets/skills/extract-wisdom/SKILL.md +12 -0
  16. package/assets/skills/first-principles/SKILL.md +8 -0
  17. package/assets/skills/frontend-design/SKILL.md +14 -0
  18. package/assets/skills/fyzz-chat-api/SKILL.md +10 -0
  19. package/assets/skills/humanize/SKILL.md +13 -1
  20. package/assets/skills/opinion/SKILL.md +11 -0
  21. package/assets/skills/pal-analyze/SKILL.md +11 -0
  22. package/assets/skills/pal-reflect/SKILL.md +10 -0
  23. package/assets/skills/playwright/SKILL.md +15 -2
  24. package/assets/skills/playwright/tools/shot.ts +6 -7
  25. package/assets/skills/presentation/SKILL.md +12 -0
  26. package/assets/skills/projects/SKILL.md +20 -1
  27. package/assets/skills/reflect/SKILL.md +13 -0
  28. package/assets/skills/telos/SKILL.md +12 -0
  29. package/assets/skills/think/SKILL.md +9 -0
  30. package/assets/templates/PAL/SYSTEM_ARCHITECTURE.md +3 -0
  31. package/assets/templates/pal-settings.json +1 -0
  32. package/assets/templates/settings.claude.json +2 -1
  33. package/package.json +15 -4
  34. package/src/cli/index.ts +95 -9
  35. package/src/cli/migrate.ts +69 -3
  36. package/src/cli/skill.ts +47 -3
  37. package/src/hooks/handlers/inject-retrieval.ts +20 -10
  38. package/src/hooks/lib/anchor.ts +90 -0
  39. package/src/hooks/lib/bindings.ts +117 -0
  40. package/src/hooks/lib/export.ts +38 -1
  41. package/src/hooks/lib/import-merge.ts +220 -0
  42. package/src/hooks/lib/inference.ts +113 -72
  43. package/src/hooks/lib/machine.ts +176 -0
  44. package/src/hooks/lib/projects.ts +223 -15
  45. package/src/hooks/lib/readme-sync.ts +30 -10
  46. package/src/hooks/lib/relationship.ts +3 -1
  47. package/src/hooks/lib/remote.ts +58 -0
  48. package/src/hooks/lib/retrieval.ts +8 -2
  49. package/src/hooks/lib/signals.ts +2 -1
  50. package/src/hooks/lib/skill-match.ts +129 -0
  51. package/src/hooks/lib/skill-triggers.ts +82 -0
  52. package/src/hooks/lib/stop.ts +5 -2
  53. package/src/targets/lib.ts +137 -35
  54. package/src/targets/opencode/plugin.ts +2 -6
  55. package/src/tools/agent/algorithm-reflect.ts +45 -11
  56. package/src/tools/agent/project.ts +148 -23
  57. package/src/tools/agent/thread.ts +7 -2
  58. package/src/tools/skill-doctor.ts +130 -5
  59. package/assets/skills/playwright/tools/shot-lib.mjs +0 -44
  60. package/assets/skills/playwright/tools/shot.mjs +0 -89
  61. package/assets/skills/review/SKILL.md +0 -20
  62. package/assets/skills/summarize/SKILL.md +0 -16
  63. /package/assets/skills/{research → deep-research}/tools/gemini-search.ts +0 -0
  64. /package/assets/skills/{research → deep-research}/tools/grok-search.ts +0 -0
  65. /package/assets/skills/{research → deep-research}/tools/perplexity-search.ts +0 -0
@@ -1,7 +1,19 @@
1
1
  ---
2
2
  name: presentation
3
+ license: MIT
3
4
  description: "Build branded HTML presentations from markdown using Reveal.js. Multi-template registry per user (each template = brand color, logo, fonts, footer, aspect). Per-deck workflow: scaffold → edit one markdown file per slide in slides/ → build → present. Output: a per-deck subdir with a self-contained HTML and a concatenated markdown sibling. 14 layouts including data-display patterns (big-stat, metric-grid). Use when creating slide decks, talks, workshop slides, lectures, or pitch decks."
4
5
  argument-hint: <deck-dir> to build, OR `setup-template` to add a brand template, OR `new <deck-dir> --template <name>` to scaffold a deck, OR `list-templates`
6
+ metadata:
7
+ source: portable-agent-layer
8
+ triggers:
9
+ - "presentation"
10
+ - "slide deck"
11
+ - "slides"
12
+ - "pitch deck"
13
+ - "reveal.js"
14
+ - "workshop deck"
15
+ - "talk slides"
16
+ - "ppt"
5
17
  ---
6
18
 
7
19
  ## Overview
@@ -1,7 +1,26 @@
1
1
  ---
2
2
  name: projects
3
+ license: MIT
3
4
  description: "Project context management. PROACTIVE — use when the user references a project (by name or as \"this repo\", \"current work\"), asks to add/update/complete a project, says \"store under a named project\", \"track this\", \"what am I working on\", \"my projects\", \"my priorities\". Also triggered by ISC/ISA terminology — ISC stands for Ideal State Criteria, a project's verifiable done-conditions stored as `ISC-N:` lines in the Criteria section of its ISA.md spec. Phrases like \"ISC\", \"open/opening the ISC\", \"open ISCs\" (unfinished criteria), \"create/creating a ticket for a project\", \"new ticket\", or \"project ticket\" all map here: use `list-isc` to read them, `add-isc` to create one, `complete-isc`/`reopen-isc` to change status."
4
5
  argument-hint: [list | create | resume | add-next | add-blocker | add-decision | add-handoff | update-section | criteria | isa-init | complete | archive | pause]
6
+ metadata:
7
+ source: portable-agent-layer
8
+ triggers:
9
+ - "projects"
10
+ - "isc"
11
+ - "isa"
12
+ - "my projects"
13
+ - "what am i working on"
14
+ - "my priorities"
15
+ - "track this"
16
+ - "resume the project"
17
+ - "resume project"
18
+ - "where are we"
19
+ - "open ticket"
20
+ - "open iscs"
21
+ - "file ticket"
22
+ - "file it as a ticket"
23
+ - "file a ticket"
5
24
  ---
6
25
 
7
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.
@@ -48,7 +67,7 @@ The body of each ISA.md holds spec sections. Use `update-section` to set them:
48
67
  | Decisions | `decisions` | Auto-managed by `add-decision`; dated bullet list |
49
68
  | Changelog | `changelog` | Archive of completed ISCs (`complete-isc` moves them here under a dated `### Archived` heading) plus any milestone notes |
50
69
 
51
- **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.
52
71
 
53
72
  ## Routing
54
73
 
@@ -1,7 +1,20 @@
1
1
  ---
2
2
  name: reflect
3
+ license: MIT
3
4
  description: "Diagnose why a PAL behavior did not trigger as expected — trace hooks, instructions, and logic to find the gap. Use when a hook, skill, or automation didn't fire or behaved unexpectedly."
4
5
  argument-hint: <what went wrong>
6
+ metadata:
7
+ source: portable-agent-layer
8
+ triggers:
9
+ - "reflect"
10
+ - "didn't fire"
11
+ - "didn't trigger"
12
+ - "didn't run"
13
+ - "why didn't"
14
+ - "not triggering"
15
+ - "never fired"
16
+ - "should have fired"
17
+ - "behaved unexpectedly"
5
18
  ---
6
19
 
7
20
  When the user invokes `/reflect [optional: description of what didn't happen]`:
@@ -2,6 +2,18 @@
2
2
  name: telos
3
3
  description: "Personal context management. Use when discussing goals, beliefs, challenges, identity, updating telos, life context, changing a goal, what do I believe, current obstacles, mission, or strategies."
4
4
  argument-hint: [area to view or update]
5
+ metadata:
6
+ source: portable-agent-layer
7
+ derived-from: https://github.com/danielmiessler/LifeOS
8
+ triggers:
9
+ - "telos"
10
+ - "my goals"
11
+ - "my beliefs"
12
+ - "my mission"
13
+ - "life context"
14
+ - "current challenges"
15
+ - "what do i believe"
16
+ - "my strategies"
5
17
  ---
6
18
 
7
19
  Manage the user's TELOS files — from Greek τέλος (télos), meaning end/purpose/goal. The persistent personal context that orients PAL around who the user is and what they're working toward.
@@ -2,6 +2,15 @@
2
2
  name: think
3
3
  description: "Thinking mode router — selects the right analytical approach for a question. Use when thinking through a problem, analyzing deeply, brainstorming ideas, debating options, decomposing to root cause, challenging assumptions, or exploring from multiple perspectives."
4
4
  argument-hint: <question or topic>
5
+ metadata:
6
+ source: portable-agent-layer
7
+ derived-from: https://github.com/danielmiessler/LifeOS
8
+ triggers:
9
+ - "think"
10
+ - "brainstorm"
11
+ - "analyze deeply"
12
+ - "think about this"
13
+ - "mental model"
5
14
  ---
6
15
 
7
16
  Route $ARGUMENTS to the right thinking mode based on intent. Detect the mode from context — do NOT ask.
@@ -213,6 +213,9 @@ Brief description.
213
213
 
214
214
  ┌─────────────────────┐
215
215
  │ User Prompt Submit │──► UserPromptOrchestrator.ts
216
+ │ │ - Prompt context (one merged system-reminder):
217
+ │ │ contextual steering, skill trigger matches,
218
+ │ │ prior-lesson retrieval
216
219
  │ │ - Rating capture (explicit/implicit)
217
220
  │ │ - Session naming (first prompt)
218
221
  └─────────────────────┘
@@ -27,6 +27,7 @@
27
27
  "handoff": true,
28
28
  "selfModel": true,
29
29
  "contextualSteering": true,
30
+ "skillMatching": true,
30
31
  "steeringTestReport": false
31
32
  },
32
33
  "steering": {
@@ -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.63.3",
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,12 +12,12 @@
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
19
19
  * skill link <name> Link a personal ~/.pal/skills/<name>/ into installed agents
20
- * skill doctor <name> Evaluate a skill against the authoring best practices
20
+ * skill doctor <name|--all> Evaluate one skill, or every installed skill, against the authoring best practices
21
21
  * subagent link <name> Install a personal ~/.pal/agents/<name>.md into installed agents
22
22
  * subagent doctor <name> Evaluate a subagent against the authoring best practices
23
23
  * debug [on|off] Enable / disable verbose hook debug logging
@@ -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
@@ -283,7 +291,7 @@ function showHelp() {
283
291
  pal cli knowledge <sub> [args] Query & manage the knowledge store
284
292
  (search · graph · stats · hubs · find · show · add · ls)
285
293
  pal cli skill link <name> Link a personal ~/.pal/skills/<name>/ into installed agents
286
- pal cli skill doctor <name> Evaluate a skill against the authoring best practices
294
+ pal cli skill doctor <name|--all> Evaluate one skill, or every installed skill
287
295
  pal cli skill author-model Print the flagship model that authors skills for the active agent
288
296
  pal cli subagent link <name> Install a personal ~/.pal/agents/<name>.md into installed agents
289
297
  pal cli subagent doctor <name> Evaluate a subagent against the authoring best practices
@@ -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
 
package/src/cli/skill.ts CHANGED
@@ -5,16 +5,59 @@
5
5
  * every installed agent so it is discoverable.
6
6
  * pal cli skill doctor <name> Evaluate ~/.pal/skills/<name>/ against the
7
7
  * skill-authoring best practices.
8
+ * pal cli skill doctor --all Evaluate every installed skill, one line each.
8
9
  * pal cli skill author-model Print the flagship model configured to author
9
10
  * skills for the active agent (empty if none).
10
11
  */
11
12
 
13
+ import { existsSync, readdirSync, statSync } from "node:fs";
12
14
  import { resolve } from "node:path";
13
15
  import { getActiveAgent } from "../hooks/lib/agent";
14
16
  import { flagshipAuthorModel } from "../hooks/lib/models";
15
17
  import { palHome } from "../hooks/lib/paths";
16
18
  import { linkPersonalSkill, log } from "../targets/lib";
17
- import { formatReport, lintSkill } from "../tools/skill-doctor";
19
+ import { formatReport, formatSummary, lintSkill } from "../tools/skill-doctor";
20
+
21
+ /** Entry names under ~/.pal/skills/, sorted; dangling links included. */
22
+ function skillEntries(dir: string): string[] {
23
+ if (!existsSync(dir)) return [];
24
+ return readdirSync(dir).sort();
25
+ }
26
+
27
+ /** A listed entry that does not exist can only be a symlink whose target is gone. */
28
+ function isDanglingLink(path: string): boolean {
29
+ return !existsSync(path);
30
+ }
31
+
32
+ /** Lint every installed skill, one summary line each. Exits 1 if any has errors. */
33
+ function doctorAll(): number {
34
+ const dir = resolve(palHome(), "skills");
35
+ const entries = skillEntries(dir);
36
+
37
+ for (const name of entries.filter((n) => isDanglingLink(resolve(dir, n)))) {
38
+ log.warn(`Skipped ${name}: link target is gone — run 'pal cli install' to prune it`);
39
+ }
40
+
41
+ const names = entries.filter((n) => {
42
+ const path = resolve(dir, n);
43
+ return !isDanglingLink(path) && statSync(path).isDirectory();
44
+ });
45
+ if (names.length === 0) {
46
+ log.warn(`No skills found in ${dir}`);
47
+ return 0;
48
+ }
49
+
50
+ const reports = names.map((n) => lintSkill(resolve(dir, n)));
51
+ for (const report of reports) console.log(formatSummary(report));
52
+
53
+ const failing = reports.filter((r) => r.errors > 0).length;
54
+ const warning = reports.filter((r) => r.errors === 0 && r.warnings > 0).length;
55
+ const clean = reports.length - failing - warning;
56
+ console.log(
57
+ `\n${reports.length} skills — ${failing} failing, ${warning} with warnings, ${clean} clean`
58
+ );
59
+ return failing > 0 ? 1 : 0;
60
+ }
18
61
 
19
62
  export async function runSkill(args: string[]): Promise<number> {
20
63
  const [sub, name] = args;
@@ -26,8 +69,9 @@ export async function runSkill(args: string[]): Promise<number> {
26
69
  }
27
70
 
28
71
  if (sub === "doctor") {
72
+ if (name === "--all") return doctorAll();
29
73
  if (!name) {
30
- log.error("Usage: pal cli skill doctor <name>");
74
+ log.error("Usage: pal cli skill doctor <name|--all>");
31
75
  return 1;
32
76
  }
33
77
  const report = lintSkill(resolve(palHome(), "skills", name));
@@ -60,6 +104,6 @@ export async function runSkill(args: string[]): Promise<number> {
60
104
  }
61
105
  }
62
106
 
63
- log.error("Usage: pal cli skill <link|doctor|author-model> [name]");
107
+ log.error("Usage: pal cli skill <link|doctor|author-model> [name|--all]");
64
108
  return 1;
65
109
  }
@@ -12,6 +12,7 @@ import { logDebug, logError } from "../lib/log";
12
12
  import { runRetrieval } from "../lib/retrieval";
13
13
  import { ensureIndex } from "../lib/retrieval-index";
14
14
  import { isEnabled } from "../lib/settings";
15
+ import { getSkillReminder } from "../lib/skill-match";
15
16
  import { getSteeringReminder } from "../lib/steering";
16
17
 
17
18
  const BUDGET_MS = 250;
@@ -35,7 +36,8 @@ export function withinBudget<T>(work: () => T, ms: number): T | null {
35
36
  }
36
37
  }
37
38
 
38
- /** Returns the retrieval reminder string, or null if nothing to inject. @lintignore dynamically imported by opencode plugin */
39
+ /** Returns the retrieval reminder string, or null if nothing to inject.
40
+ * @lintignore exercised directly by test/inject-retrieval.test.ts */
39
41
  export async function getRetrievalReminder(prompt: string): Promise<string | null> {
40
42
  if (!prompt?.trim()) return null;
41
43
  if (!isEnabled("learningInjection")) return null;
@@ -77,15 +79,23 @@ function writeForAgent(reminder: string): void {
77
79
  }
78
80
  }
79
81
 
80
- /** Gather all prompt-time contextprior-lesson retrieval + contextual steering —
81
- * merge into a single payload, and do the one per-agent write. Returns the combined
82
- * reminder that was injected, or null if there was nothing to inject. */
82
+ /** Merge every prompt-time sourcecontextual steering, skill matches, prior-lesson
83
+ * retrieval into one payload, or null when none of them produced anything.
84
+ * @lintignore dynamically imported by opencode plugin */
85
+ export async function getPromptContext(prompt: string): Promise<string | null> {
86
+ const parts = [
87
+ getSteeringReminder(prompt),
88
+ getSkillReminder(prompt),
89
+ await getRetrievalReminder(prompt),
90
+ ].filter((p): p is string => Boolean(p));
91
+
92
+ return parts.length > 0 ? parts.join("\n\n") : null;
93
+ }
94
+
95
+ /** Gather all prompt-time context and do the one per-agent write. Returns the
96
+ * combined reminder that was injected, or null if there was nothing to inject. */
83
97
  export async function injectPromptContext(prompt: string): Promise<string | null> {
84
- const retrieval = await getRetrievalReminder(prompt);
85
- const steering = getSteeringReminder(prompt);
86
- const parts = [steering, retrieval].filter((p): p is string => Boolean(p));
87
- if (parts.length === 0) return null;
88
- const combined = parts.join("\n\n");
89
- writeForAgent(combined);
98
+ const combined = await getPromptContext(prompt);
99
+ if (combined) writeForAgent(combined);
90
100
  return combined;
91
101
  }