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
@@ -0,0 +1,82 @@
1
+ /**
2
+ * SKILL.md trigger declarations — the words and phrases a prompt carries when it
3
+ * wants a given skill.
4
+ *
5
+ * `metadata` is the only frontmatter key Anthropic's skill spec reserves for
6
+ * third-party tooling, so triggers live under it; a top-level `triggers:` key
7
+ * fails skill packaging with an unexpected-key error. Shared by the skill-index
8
+ * generator (which publishes them) and the skill doctor (which warns when a
9
+ * skill declares none).
10
+ */
11
+
12
+ /** Indented lines belonging to the frontmatter `metadata:` map, or [] when absent. */
13
+ function metadataBlock(frontmatter: string): string[] {
14
+ const lines = frontmatter.split("\n");
15
+ const start = lines.findIndex((line) => /^metadata:\s*$/.test(line));
16
+ if (start === -1) return [];
17
+
18
+ const block: string[] = [];
19
+ for (const line of lines.slice(start + 1)) {
20
+ if (line.trim() === "") continue;
21
+ if (!/^\s/.test(line)) break;
22
+ block.push(line);
23
+ }
24
+ return block;
25
+ }
26
+
27
+ /** Normalize one authored trigger: unquote, collapse whitespace, lowercase. */
28
+ function normalizeTrigger(raw: string): string {
29
+ return raw
30
+ .trim()
31
+ .replace(/^["']|["']$/g, "")
32
+ .replace(/\s+/g, " ")
33
+ .trim()
34
+ .toLowerCase();
35
+ }
36
+
37
+ /** Split a YAML flow sequence — `["a", "b"]` or `[a, b]` — into its items. */
38
+ function splitFlowSequence(value: string): string[] {
39
+ try {
40
+ const parsed: unknown = JSON.parse(value);
41
+ if (Array.isArray(parsed)) return parsed.map((item) => String(item));
42
+ } catch {
43
+ /* not strict JSON — fall through to the permissive split */
44
+ }
45
+ return value.slice(1, -1).split(",");
46
+ }
47
+
48
+ /** Items of a YAML block sequence: the `- item` lines directly under a key. */
49
+ function blockSequenceItems(lines: string[]): string[] {
50
+ const items: string[] = [];
51
+ for (const line of lines) {
52
+ const item = /^\s*-\s+(.*)$/.exec(line);
53
+ if (!item) break;
54
+ items.push(item[1]);
55
+ }
56
+ return items;
57
+ }
58
+
59
+ /**
60
+ * Author-declared `metadata.triggers` — the words and phrases a prompt is matched
61
+ * against. Accepts either YAML shape:
62
+ *
63
+ * metadata: | metadata:
64
+ * triggers: | triggers: ["make a deck", "slides"]
65
+ * - make a deck |
66
+ * - slides |
67
+ *
68
+ * `metadata` is the only frontmatter key Anthropic's skill spec reserves for
69
+ * third-party tooling; a top-level `triggers:` key fails skill packaging.
70
+ */
71
+ export function declaredTriggers(frontmatter: string): string[] {
72
+ const block = metadataBlock(frontmatter);
73
+ const at = block.findIndex((line) => /^\s*triggers:/.test(line));
74
+ if (at === -1) return [];
75
+
76
+ const inline = /^\s*triggers:\s*(\S.*)$/.exec(block[at])?.[1];
77
+ const raw = inline
78
+ ? splitFlowSequence(inline)
79
+ : blockSequenceItems(block.slice(at + 1));
80
+
81
+ return [...new Set(raw.map(normalizeTrigger).filter(Boolean))];
82
+ }
@@ -3,6 +3,7 @@
3
3
  * Used by StopOrchestrator.ts (Claude Code) and opencode plugin.
4
4
  */
5
5
 
6
+ import { randomUUID } from "node:crypto";
6
7
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
7
8
  import { mkdtemp, rename, writeFile } from "node:fs/promises";
8
9
  import { tmpdir } from "node:os";
@@ -189,8 +190,10 @@ async function detachFailurePrinciple(transcript: string): Promise<void> {
189
190
  // Rename to claim the pending file atomically — prevents two Stop hooks
190
191
  // racing on the same low rating (opencode notably fires session.idle AND
191
192
  // session.diff concurrently, so runStopHandlers runs twice in parallel).
192
- const claimedDir = await mkdtemp(resolve(tmpdir(), "pal-pending-"));
193
- const claimedPath = resolve(claimedDir, "pending.json");
193
+ // The claim stays inside the state directory: rename fails with EXDEV across
194
+ // devices, and the OS temp dir is on another volume often enough to matter.
195
+ const claimId: string = randomUUID();
196
+ const claimedPath = resolve(paths.state(), `pending-failure.${claimId}.json`);
194
197
  try {
195
198
  await rename(pendingPath, claimedPath);
196
199
  } catch (err) {
@@ -10,14 +10,16 @@ import {
10
10
  mkdirSync,
11
11
  readdirSync,
12
12
  readFileSync,
13
+ readlinkSync,
13
14
  rmSync,
14
15
  symlinkSync,
15
16
  unlinkSync,
16
17
  writeFileSync,
17
18
  } from "node:fs";
18
19
  import { homedir } from "node:os";
19
- import { resolve } from "node:path";
20
+ import { dirname, resolve, sep } from "node:path";
20
21
  import { assets, palHome, platform } from "../hooks/lib/paths";
22
+ import { declaredTriggers } from "../hooks/lib/skill-triggers";
21
23
 
22
24
  // --- Colored logging ---
23
25
 
@@ -48,14 +50,20 @@ export function writeJson(path: string, data: unknown): void {
48
50
  /** Public PAL repository — the link surfaced in commit/PR co-author credits. */
49
51
  export const PAL_REPO_URL = "https://github.com/kovrichard/portable-agent-layer";
50
52
 
51
- /** Build the commit footer (bare URL, autolinks on GitHub) and PR body line (markdown link). */
53
+ /**
54
+ * Build the commit footer (bare URL, autolinks on GitHub) and PR body line
55
+ * (markdown link). `sessionUrl` is off because Claude Code otherwise appends a
56
+ * claude.ai session link to commits made from web or Remote Control sessions,
57
+ * which puts a link to a private transcript in a public history.
58
+ */
52
59
  export function buildAttributionText(
53
60
  name: string,
54
61
  repoUrl: string = PAL_REPO_URL
55
- ): { commit: string; pr: string } {
62
+ ): { commit: string; pr: string; sessionUrl: false } {
56
63
  return {
57
64
  commit: `Co-authored by ${name} · ${repoUrl}`,
58
65
  pr: `Co-authored by [${name}](${repoUrl})`,
66
+ sessionUrl: false,
59
67
  };
60
68
  }
61
69
 
@@ -73,7 +81,7 @@ export function applyAttribution(
73
81
  result.attribution = buildAttributionText(opts.name, opts.repoUrl);
74
82
  result.includeCoAuthoredBy = false;
75
83
  } else {
76
- result.attribution = { commit: "", pr: "" };
84
+ result.attribution = { commit: "", pr: "", sessionUrl: false };
77
85
  if (result.includeCoAuthoredBy === false) delete result.includeCoAuthoredBy;
78
86
  }
79
87
  return result;
@@ -691,8 +699,8 @@ export function scaffoldPalSettings(): void {
691
699
 
692
700
  // --- PAL docs (modular context routing files) ---
693
701
 
694
- const PAL_DOCS_DIR = resolve(palHome(), "docs");
695
- const PAL_TOOLS_DIR = resolve(palHome(), "tools");
702
+ const palDocsDir = () => resolve(palHome(), "docs");
703
+ const palToolsDir = () => resolve(palHome(), "tools");
696
704
 
697
705
  /**
698
706
  * Install PAL system docs into ~/.pal/docs/.
@@ -703,19 +711,19 @@ export function copyPalDocs(): number {
703
711
  const srcDir = assets.palDocs();
704
712
  if (!existsSync(srcDir)) return 0;
705
713
 
706
- mkdirSync(PAL_DOCS_DIR, { recursive: true });
714
+ mkdirSync(palDocsDir(), { recursive: true });
707
715
  let count = 0;
708
716
 
709
717
  for (const file of readdirSync(srcDir).filter((f) => f.endsWith(".md"))) {
710
718
  const src = resolve(srcDir, file);
711
- const dst = resolve(PAL_DOCS_DIR, file);
719
+ const dst = resolve(palDocsDir(), file);
712
720
  copyFileSync(src, dst);
713
721
  count++;
714
722
  }
715
723
 
716
724
  // ~/.pal/tools/ → repo agent tools
717
725
  const linkType = process.platform === "win32" ? "junction" : "dir";
718
- ensureSymlink(PAL_TOOLS_DIR, assets.agentTools(), linkType);
726
+ ensureSymlink(palToolsDir(), assets.agentTools(), linkType);
719
727
 
720
728
  return count;
721
729
  }
@@ -724,13 +732,13 @@ export function copyPalDocs(): number {
724
732
  export function removePalDocs(): void {
725
733
  // Remove tools symlink
726
734
  try {
727
- unlinkSync(PAL_TOOLS_DIR);
735
+ unlinkSync(palToolsDir());
728
736
  } catch {
729
737
  /* gone */
730
738
  }
731
- if (!existsSync(PAL_DOCS_DIR)) return;
739
+ if (!existsSync(palDocsDir())) return;
732
740
  try {
733
- rmSync(PAL_DOCS_DIR, { recursive: true });
741
+ rmSync(palDocsDir(), { recursive: true });
734
742
  log.info("Removed ~/.pal/docs/");
735
743
  } catch {
736
744
  /* gone */
@@ -739,7 +747,7 @@ export function removePalDocs(): void {
739
747
 
740
748
  // --- Skills ---
741
749
 
742
- const PAL_SKILLS_DIR = resolve(palHome(), "skills");
750
+ const palSkillsDir = () => resolve(palHome(), "skills");
743
751
 
744
752
  /**
745
753
  * Run one step of a bulk install, naming it only when it fails.
@@ -770,16 +778,20 @@ export function copySkills(claudeSkillsDir: string): number {
770
778
  const skillsDir = assets.skills();
771
779
  if (!existsSync(skillsDir)) return 0;
772
780
 
773
- mkdirSync(PAL_SKILLS_DIR, { recursive: true });
781
+ mkdirSync(palSkillsDir(), { recursive: true });
774
782
  mkdirSync(claudeSkillsDir, { recursive: true });
775
783
  const linkType = process.platform === "win32" ? "junction" : "dir";
776
784
  let count = 0;
777
785
 
786
+ for (const name of pruneStaleSkillLinks(claudeSkillsDir)) {
787
+ log.info(`Removed stale skill link: ${name}`);
788
+ }
789
+
778
790
  for (const name of readdirSync(skillsDir)) {
779
791
  const srcDir = resolve(skillsDir, name);
780
792
  if (!existsSync(resolve(srcDir, "SKILL.md"))) continue;
781
793
 
782
- const palLink = resolve(PAL_SKILLS_DIR, name);
794
+ const palLink = resolve(palSkillsDir(), name);
783
795
  const claudeLink = resolve(claudeSkillsDir, name);
784
796
  const linked = reportOnlyOnFailure(`skill ${name}`, () => {
785
797
  // ~/.pal/skills/<name> → <repo>/assets/skills/<name>
@@ -792,11 +804,60 @@ export function copySkills(claudeSkillsDir: string): number {
792
804
 
793
805
  // ~/.agents/skills/ → ~/.pal/skills/
794
806
  mkdirSync(platform.agentsDir(), { recursive: true });
795
- ensureSymlink(resolve(platform.agentsDir(), "skills"), PAL_SKILLS_DIR, linkType);
807
+ ensureSymlink(resolve(platform.agentsDir(), "skills"), palSkillsDir(), linkType);
796
808
 
797
809
  return count;
798
810
  }
799
811
 
812
+ /** True when `link` is a symlink whose target no longer exists. */
813
+ function isDanglingSymlink(link: string): boolean {
814
+ try {
815
+ return lstatSync(link).isSymbolicLink() && !existsSync(link);
816
+ } catch {
817
+ return false;
818
+ }
819
+ }
820
+
821
+ /** True when the symlink at `link` points at `root` or somewhere beneath it. */
822
+ function symlinkPointsInto(link: string, root: string): boolean {
823
+ try {
824
+ const target = resolve(dirname(link), readlinkSync(link));
825
+ return target === root || target.startsWith(root + sep);
826
+ } catch {
827
+ return false;
828
+ }
829
+ }
830
+
831
+ /**
832
+ * Remove discovery links left behind when a shipped skill is renamed or
833
+ * retired. Ownership is read from where a link points, not from metadata:
834
+ * a dead link has no SKILL.md to read, but its target path still says
835
+ * whether PAL created it.
836
+ *
837
+ * ~/.pal/skills/<name> → pruned when dangling and pointing into assets/skills/
838
+ * <agent>/skills/<name> → pruned when dangling and pointing into ~/.pal/skills/
839
+ *
840
+ * Personal skills are real directories, so they are never candidates, and a
841
+ * user's own symlinks to anywhere else are left alone even when broken.
842
+ */
843
+ function pruneStaleSkillLinks(agentSkillsDir: string): string[] {
844
+ const ownedTrees = [
845
+ { dir: palSkillsDir(), root: assets.skills() },
846
+ { dir: agentSkillsDir, root: palSkillsDir() },
847
+ ];
848
+ const removed: string[] = [];
849
+ for (const { dir, root } of ownedTrees) {
850
+ if (!existsSync(dir)) continue;
851
+ for (const name of readdirSync(dir)) {
852
+ const link = resolve(dir, name);
853
+ if (!isDanglingSymlink(link) || !symlinkPointsInto(link, root)) continue;
854
+ unlinkSync(link);
855
+ removed.push(name);
856
+ }
857
+ }
858
+ return removed;
859
+ }
860
+
800
861
  /**
801
862
  * Agent skills directories that need a per-skill discovery link.
802
863
  *
@@ -823,7 +884,7 @@ function perSkillAgentDirs(): { agent: string; dir: string }[] {
823
884
  * is covered by the whole-dir ~/.agents/skills link).
824
885
  */
825
886
  export function linkPersonalSkill(name: string): string[] {
826
- const palLink = resolve(PAL_SKILLS_DIR, name);
887
+ const palLink = resolve(palSkillsDir(), name);
827
888
  if (!existsSync(resolve(palLink, "SKILL.md"))) {
828
889
  throw new Error(`No skill found at ${palLink}/SKILL.md`);
829
890
  }
@@ -837,8 +898,47 @@ export function linkPersonalSkill(name: string): string[] {
837
898
  return linked;
838
899
  }
839
900
 
901
+ /**
902
+ * The agent config trees PAL writes into when no env override is set. These are
903
+ * the developer's own installed agents, so a test that forgets to point the
904
+ * PAL_*_DIR vars at a sandbox silently rewires their real setup.
905
+ */
906
+ function realAgentRoots(): string[] {
907
+ const h = homedir();
908
+ return [
909
+ resolve(h, ".pal"),
910
+ resolve(h, ".claude"),
911
+ resolve(h, ".cursor"),
912
+ resolve(h, ".copilot"),
913
+ resolve(h, ".codex"),
914
+ resolve(h, ".agents"),
915
+ resolve(h, ".config", "opencode"),
916
+ ];
917
+ }
918
+
919
+ /**
920
+ * Under `bun test` (PAL_TEST_SANDBOX, set by the test preload and inherited by
921
+ * spawned CLIs), refuse any link that would land in a real agent tree. Tests
922
+ * sandbox PAL_HOME far more reliably than they sandbox the per-agent dirs, and
923
+ * the failure is otherwise invisible: the suite passes while the developer's
924
+ * own agents accumulate links into a deleted test directory.
925
+ */
926
+ function assertInsideTestSandbox(link: string): void {
927
+ if (!process.env.PAL_TEST_SANDBOX) return;
928
+ const escaped = realAgentRoots().find(
929
+ (root) => link === root || link.startsWith(root + sep)
930
+ );
931
+ if (!escaped) return;
932
+ throw new Error(
933
+ `Refusing to write ${link}: outside the test sandbox (${escaped} is a real agent directory). ` +
934
+ "Point PAL_CLAUDE_DIR, PAL_CURSOR_DIR, PAL_COPILOT_DIR, PAL_CODEX_DIR, " +
935
+ "PAL_OPENCODE_DIR and PAL_AGENTS_DIR at a temp directory in this test."
936
+ );
937
+ }
938
+
840
939
  /** Create or update a symlink/junction, replacing any non-symlink entry. */
841
940
  function ensureSymlink(link: string, target: string, type: "dir" | "junction"): void {
941
+ assertInsideTestSandbox(link);
842
942
  try {
843
943
  const st = lstatSync(link);
844
944
  if (st.isSymbolicLink()) return; // already a symlink, leave it
@@ -886,7 +986,7 @@ export function removeSkills(claudeSkillsDir: string): string[] {
886
986
  for (const name of readdirSync(skillsDir)) {
887
987
  if (!existsSync(resolve(skillsDir, name, "SKILL.md"))) continue;
888
988
 
889
- for (const link of [resolve(PAL_SKILLS_DIR, name), resolve(claudeSkillsDir, name)]) {
989
+ for (const link of [resolve(palSkillsDir(), name), resolve(claudeSkillsDir, name)]) {
890
990
  try {
891
991
  unlinkSync(link);
892
992
  } catch {
@@ -909,14 +1009,14 @@ export function removeSkills(claudeSkillsDir: string): string[] {
909
1009
 
910
1010
  // --- Agents ---
911
1011
 
912
- const CLAUDE_AGENTS_DIR = resolve(platform.claudeDir(), "agents");
1012
+ const claudeAgentsDir = () => resolve(platform.claudeDir(), "agents");
913
1013
 
914
1014
  /**
915
1015
  * Install PAL agent definitions into ~/.claude/agents/.
916
1016
  * Always overwrites — engine-managed, not user-editable.
917
1017
  */
918
1018
  export function copyAgents(): number {
919
- return installAgents(CLAUDE_AGENTS_DIR, "claude");
1019
+ return installAgents(claudeAgentsDir(), "claude");
920
1020
  }
921
1021
 
922
1022
  /** Remove PAL agents from ~/.claude/agents/ */
@@ -926,7 +1026,7 @@ export function removeAgents(): string[] {
926
1026
 
927
1027
  const removed: string[] = [];
928
1028
  for (const file of readdirSync(agentsDir).filter((f) => f.endsWith(".md"))) {
929
- const dst = resolve(CLAUDE_AGENTS_DIR, file);
1029
+ const dst = resolve(claudeAgentsDir(), file);
930
1030
  if (existsSync(dst)) {
931
1031
  unlinkSync(dst);
932
1032
  const name = file.replace(/\.md$/, "");
@@ -939,9 +1039,9 @@ export function removeAgents(): string[] {
939
1039
 
940
1040
  /** Count agent .md files in ~/.claude/agents/ */
941
1041
  export function countAgents(): number {
942
- if (!existsSync(CLAUDE_AGENTS_DIR)) return 0;
1042
+ if (!existsSync(claudeAgentsDir())) return 0;
943
1043
  try {
944
- return readdirSync(CLAUDE_AGENTS_DIR).filter((f) => f.endsWith(".md")).length;
1044
+ return readdirSync(claudeAgentsDir()).filter((f) => f.endsWith(".md")).length;
945
1045
  } catch {
946
1046
  return 0;
947
1047
  }
@@ -1077,7 +1177,7 @@ export function removeAgentsFromCopilot(copilotAgentsDir: string): string[] {
1077
1177
  * Store for user-authored subagents: ~/.pal/agents/<name>.md — one merged
1078
1178
  * multi-platform frontmatter file per subagent (same schema as assets/agents/).
1079
1179
  */
1080
- const PAL_AGENTS_STORE = resolve(palHome(), "agents");
1180
+ const palAgentsStore = () => resolve(palHome(), "agents");
1081
1181
 
1082
1182
  /**
1083
1183
  * Each installed agent and the native agents directory a personal subagent is
@@ -1106,8 +1206,8 @@ function shippedAgentNames(): Set<string> {
1106
1206
 
1107
1207
  /** List the user-authored subagents in ~/.pal/agents/. */
1108
1208
  export function listPersonalSubagents(): string[] {
1109
- if (!existsSync(PAL_AGENTS_STORE)) return [];
1110
- return readdirSync(PAL_AGENTS_STORE)
1209
+ if (!existsSync(palAgentsStore())) return [];
1210
+ return readdirSync(palAgentsStore())
1111
1211
  .filter((f) => f.endsWith(".md"))
1112
1212
  .map((f) => f.replace(/\.md$/, ""))
1113
1213
  .sort();
@@ -1121,7 +1221,7 @@ export function listPersonalSubagents(): string[] {
1121
1221
  * its own frontmatter shape. Returns the agents it was installed into.
1122
1222
  */
1123
1223
  export function installPersonalSubagent(name: string): string[] {
1124
- const src = resolve(PAL_AGENTS_STORE, `${name}.md`);
1224
+ const src = resolve(palAgentsStore(), `${name}.md`);
1125
1225
  if (!existsSync(src)) {
1126
1226
  throw new Error(`No subagent found at ${src}`);
1127
1227
  }
@@ -1319,7 +1419,7 @@ interface SkillIndex {
1319
1419
  skills: Record<string, SkillIndexEntry>;
1320
1420
  }
1321
1421
 
1322
- /** Extract trigger keywords from a skill description */
1422
+ /** Fallback triggers for a skill that declares none: keywords mined from its description. */
1323
1423
  function extractTriggers(description: string): string[] {
1324
1424
  // Extract "Use when ..." phrases and key terms
1325
1425
  const triggers = new Set<string>();
@@ -1353,7 +1453,7 @@ function extractTriggers(description: string): string[] {
1353
1453
  * Called during install after skills are symlinked.
1354
1454
  */
1355
1455
  export function generateSkillIndex(): number {
1356
- if (!existsSync(PAL_SKILLS_DIR)) return 0;
1456
+ if (!existsSync(palSkillsDir())) return 0;
1357
1457
 
1358
1458
  const index: SkillIndex = {
1359
1459
  generated: new Date().toISOString(),
@@ -1361,8 +1461,8 @@ export function generateSkillIndex(): number {
1361
1461
  skills: {},
1362
1462
  };
1363
1463
 
1364
- for (const name of readdirSync(PAL_SKILLS_DIR)) {
1365
- const skillMd = resolve(PAL_SKILLS_DIR, name, "SKILL.md");
1464
+ for (const name of readdirSync(palSkillsDir())) {
1465
+ const skillMd = resolve(palSkillsDir(), name, "SKILL.md");
1366
1466
  if (!existsSync(skillMd)) continue;
1367
1467
 
1368
1468
  try {
@@ -1378,10 +1478,12 @@ export function generateSkillIndex(): number {
1378
1478
  const skillName = nameMatch[1].trim();
1379
1479
  const description = descMatch?.[1]?.trim() ?? "";
1380
1480
 
1481
+ const declared = declaredTriggers(fm);
1482
+
1381
1483
  index.skills[skillName] = {
1382
1484
  name: skillName,
1383
1485
  description,
1384
- triggers: extractTriggers(description),
1486
+ triggers: declared.length > 0 ? declared : extractTriggers(description),
1385
1487
  };
1386
1488
  index.totalSkills++;
1387
1489
  } catch {
@@ -1399,10 +1501,10 @@ export function generateSkillIndex(): number {
1399
1501
 
1400
1502
  /** Count skill subdirectories in ~/.pal/skills/ */
1401
1503
  export function countSkills(): number {
1402
- if (!existsSync(PAL_SKILLS_DIR)) return 0;
1504
+ if (!existsSync(palSkillsDir())) return 0;
1403
1505
  try {
1404
- return readdirSync(PAL_SKILLS_DIR).filter((f) =>
1405
- existsSync(resolve(PAL_SKILLS_DIR, f, "SKILL.md"))
1506
+ return readdirSync(palSkillsDir()).filter((f) =>
1507
+ existsSync(resolve(palSkillsDir(), f, "SKILL.md"))
1406
1508
  ).length;
1407
1509
  } catch {
1408
1510
  return 0;
@@ -63,11 +63,9 @@ const PALPlugin: Plugin = async ({ directory, client }: PluginInput) => {
63
63
  const { captureRating } = await lib<typeof import("../../hooks/handlers/rating")>(
64
64
  "../handlers/rating.ts"
65
65
  );
66
- const { getRetrievalReminder } = await lib<
66
+ const { getPromptContext } = await lib<
67
67
  typeof import("../../hooks/handlers/inject-retrieval")
68
68
  >("../handlers/inject-retrieval.ts");
69
- const { getSteeringReminder } =
70
- await lib<typeof import("../../hooks/lib/steering")>("steering.ts");
71
69
 
72
70
  function partsToText(parts: Array<Record<string, unknown>>): string {
73
71
  return parts
@@ -163,9 +161,7 @@ const PALPlugin: Plugin = async ({ directory, client }: PluginInput) => {
163
161
  const text = partsToText(output.parts ?? []);
164
162
  if (!text.trim()) return;
165
163
 
166
- const retrieval = await getRetrievalReminder(text);
167
- const steering = getSteeringReminder(text);
168
- const injectedText = [steering, retrieval].filter(Boolean).join("\n\n");
164
+ const injectedText = (await getPromptContext(text)) ?? "";
169
165
  logPromptSnapshot(text, injectedText || null);
170
166
 
171
167
  await Promise.allSettled([
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env bun
2
+
2
3
  /**
3
4
  * AlgorithmReflect — Append structured algorithm reflections to JSONL.
4
5
  *
@@ -14,6 +15,8 @@
14
15
 
15
16
  import { appendFileSync } from "node:fs";
16
17
  import { parseArgs } from "node:util";
18
+ import { encodeAnchor } from "../../hooks/lib/anchor";
19
+ import { loadMachine } from "../../hooks/lib/machine";
17
20
  import { paths } from "../../hooks/lib/paths";
18
21
  import { emit } from "../lib/emit";
19
22
 
@@ -22,6 +25,7 @@ import { emit } from "../lib/emit";
22
25
  interface AlgorithmReflection {
23
26
  timestamp: string;
24
27
  cwd: string;
28
+ m: string;
25
29
  task: string;
26
30
  criteria_count: number;
27
31
  criteria_passed: number;
@@ -41,6 +45,40 @@ function reflectionsPath(): string {
41
45
  return paths.reflectionsFile();
42
46
  }
43
47
 
48
+ /**
49
+ * Assemble a reflection record from CLI-style input, stamping the current
50
+ * cwd (anchored) and this machine's id. Exported so the stamping logic is
51
+ * directly testable without going through argv parsing.
52
+ */
53
+ export function buildReflection(input: {
54
+ task: string;
55
+ q1: string;
56
+ q2: string;
57
+ q3: string;
58
+ criteria_count?: number;
59
+ criteria_passed?: number;
60
+ criteria_failed?: number;
61
+ sentiment?: number;
62
+ scope?: string;
63
+ }): AlgorithmReflection {
64
+ return {
65
+ timestamp: new Date().toISOString(),
66
+ cwd: encodeAnchor(process.cwd()),
67
+ m: loadMachine().id,
68
+ task: input.task,
69
+ criteria_count: input.criteria_count ?? 0,
70
+ criteria_passed: input.criteria_passed ?? 0,
71
+ criteria_failed: input.criteria_failed ?? 0,
72
+ sentiment: Math.max(1, Math.min(10, input.sentiment ?? 5)),
73
+ q1: input.q1,
74
+ q2: input.q2,
75
+ q3: input.q3,
76
+ // Default to general (the ~94% case); only "task-specific" suppresses it
77
+ // from algorithm-update clustering.
78
+ scope: input.scope === "task-specific" ? "task-specific" : "general",
79
+ };
80
+ }
81
+
44
82
  function appendReflection(reflection: AlgorithmReflection): {
45
83
  success: boolean;
46
84
  message: string;
@@ -105,21 +143,17 @@ Output: algorithm-reflections.jsonl in memory/learning/reflections/
105
143
  process.exit(1);
106
144
  }
107
145
 
108
- const reflection: AlgorithmReflection = {
109
- timestamp: new Date().toISOString(),
110
- cwd: process.cwd(),
146
+ const reflection = buildReflection({
111
147
  task: values.task,
112
- criteria_count: parseInt(values.criteria || "0", 10),
113
- criteria_passed: parseInt(values.passed || "0", 10),
114
- criteria_failed: parseInt(values.failed || "0", 10),
115
- sentiment: Math.max(1, Math.min(10, parseInt(values.sentiment || "5", 10))),
116
148
  q1: values.q1,
117
149
  q2: values.q2,
118
150
  q3: values.q3,
119
- // Default to general (the ~94% case); only "task-specific" suppresses it
120
- // from algorithm-update clustering.
121
- scope: values.scope === "task-specific" ? "task-specific" : "general",
122
- };
151
+ criteria_count: parseInt(values.criteria || "0", 10),
152
+ criteria_passed: parseInt(values.passed || "0", 10),
153
+ criteria_failed: parseInt(values.failed || "0", 10),
154
+ sentiment: parseInt(values.sentiment || "5", 10),
155
+ scope: values.scope,
156
+ });
123
157
 
124
158
  const result = appendReflection(reflection);
125
159
  emit.ok(result.message);