portable-agent-layer 0.63.2 → 0.64.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 (43) hide show
  1. package/README.md +7 -3
  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/council/SKILL.md +32 -0
  7. package/assets/skills/create-pdf/SKILL.md +13 -0
  8. package/assets/skills/create-skill/SKILL.md +14 -2
  9. package/assets/skills/create-skill/authoring-guide.md +10 -1
  10. package/assets/skills/create-subagent/SKILL.md +22 -4
  11. package/assets/skills/{research → deep-research}/SKILL.md +32 -1
  12. package/assets/skills/entities/SKILL.md +10 -0
  13. package/assets/skills/extract-wisdom/SKILL.md +12 -0
  14. package/assets/skills/first-principles/SKILL.md +8 -0
  15. package/assets/skills/frontend-design/SKILL.md +14 -0
  16. package/assets/skills/fyzz-chat-api/SKILL.md +10 -0
  17. package/assets/skills/humanize/SKILL.md +13 -1
  18. package/assets/skills/opinion/SKILL.md +11 -0
  19. package/assets/skills/pal-analyze/SKILL.md +11 -0
  20. package/assets/skills/pal-reflect/SKILL.md +10 -0
  21. package/assets/skills/playwright/SKILL.md +13 -0
  22. package/assets/skills/presentation/SKILL.md +12 -0
  23. package/assets/skills/projects/SKILL.md +16 -0
  24. package/assets/skills/reflect/SKILL.md +13 -0
  25. package/assets/skills/telos/SKILL.md +12 -0
  26. package/assets/skills/think/SKILL.md +9 -0
  27. package/assets/templates/PAL/SYSTEM_ARCHITECTURE.md +3 -0
  28. package/assets/templates/pal-settings.json +1 -0
  29. package/package.json +1 -1
  30. package/src/cli/index.ts +2 -2
  31. package/src/cli/skill.ts +47 -3
  32. package/src/hooks/handlers/inject-retrieval.ts +41 -27
  33. package/src/hooks/lib/readme-sync.ts +30 -10
  34. package/src/hooks/lib/skill-match.ts +129 -0
  35. package/src/hooks/lib/skill-triggers.ts +82 -0
  36. package/src/targets/lib.ts +60 -3
  37. package/src/targets/opencode/plugin.ts +2 -6
  38. package/src/tools/skill-doctor.ts +130 -5
  39. package/assets/skills/review/SKILL.md +0 -20
  40. package/assets/skills/summarize/SKILL.md +0 -16
  41. /package/assets/skills/{research → deep-research}/tools/gemini-search.ts +0 -0
  42. /package/assets/skills/{research → deep-research}/tools/grok-search.ts +0 -0
  43. /package/assets/skills/{research → deep-research}/tools/perplexity-search.ts +0 -0
@@ -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": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.63.2",
3
+ "version": "0.64.0",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli/index.ts CHANGED
@@ -17,7 +17,7 @@
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
@@ -283,7 +283,7 @@ function showHelp() {
283
283
  pal cli knowledge <sub> [args] Query & manage the knowledge store
284
284
  (search · graph · stats · hubs · find · show · add · ls)
285
285
  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
286
+ pal cli skill doctor <name|--all> Evaluate one skill, or every installed skill
287
287
  pal cli skill author-model Print the flagship model that authors skills for the active agent
288
288
  pal cli subagent link <name> Install a personal ~/.pal/agents/<name>.md into installed agents
289
289
  pal cli subagent doctor <name> Evaluate a subagent against the authoring best practices
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
  }
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * Called from UserPromptOrchestrator. Reads the retrieval index, ranks the prompt
5
5
  * against the corpus, prints a `<system-reminder>` block to stdout (Claude Code
6
- * prepends UserPromptSubmit hook stdout to the prompt). Fail-closed: any error or
7
- * timeout produces empty output, never blocks the prompt.
6
+ * prepends UserPromptSubmit hook stdout to the prompt). Fail-closed: any error
7
+ * produces empty output, never blocks the prompt.
8
8
  */
9
9
 
10
10
  import { isCodex, isCursor } from "../lib/agent";
@@ -12,35 +12,41 @@ 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
- const TIMEOUT_MS = 250;
18
+ const BUDGET_MS = 250;
18
19
 
19
- function withTimeout<T>(work: () => T, ms: number): Promise<T | null> {
20
- return new Promise((resolve) => {
21
- const timer = setTimeout(() => resolve(null), ms);
22
- try {
23
- const result = work();
24
- clearTimeout(timer);
25
- resolve(result);
26
- } catch (err) {
27
- clearTimeout(timer);
28
- logError("inject-retrieval", err);
29
- resolve(null);
20
+ /** Run sync work on the prompt path, containing any throw. A synchronous call cannot
21
+ * be preempted on a single thread, so the budget is measured and logged, never
22
+ * enforced — an overrun still returns its result rather than being discarded.
23
+ * @lintignore exported for test/inject-retrieval.test.ts */
24
+ export function withinBudget<T>(work: () => T, ms: number): T | null {
25
+ const started = performance.now();
26
+ try {
27
+ return work();
28
+ } catch (err) {
29
+ logError("inject-retrieval", err);
30
+ return null;
31
+ } finally {
32
+ const elapsed = performance.now() - started;
33
+ if (elapsed > ms) {
34
+ logDebug("inject-retrieval", `over budget: ${elapsed.toFixed(0)}ms > ${ms}ms`);
30
35
  }
31
- });
36
+ }
32
37
  }
33
38
 
34
- /** 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 */
35
41
  export async function getRetrievalReminder(prompt: string): Promise<string | null> {
36
42
  if (!prompt?.trim()) return null;
37
43
  if (!isEnabled("learningInjection")) return null;
38
44
 
39
- const result = await withTimeout(() => {
45
+ const result = withinBudget(() => {
40
46
  const index = ensureIndex();
41
47
  if (index.corpusSize === 0) return null;
42
48
  return runRetrieval(prompt, index, process.cwd());
43
- }, TIMEOUT_MS);
49
+ }, BUDGET_MS);
44
50
 
45
51
  if (!result?.reminder) return null;
46
52
 
@@ -73,15 +79,23 @@ function writeForAgent(reminder: string): void {
73
79
  }
74
80
  }
75
81
 
76
- /** Gather all prompt-time context — prior-lesson retrieval + contextual steering —
77
- * merge into a single payload, and do the one per-agent write. Returns the combined
78
- * reminder that was injected, or null if there was nothing to inject. */
82
+ /** Merge every prompt-time source — contextual 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. */
79
97
  export async function injectPromptContext(prompt: string): Promise<string | null> {
80
- const retrieval = await getRetrievalReminder(prompt);
81
- const steering = getSteeringReminder(prompt);
82
- const parts = [steering, retrieval].filter((p): p is string => Boolean(p));
83
- if (parts.length === 0) return null;
84
- const combined = parts.join("\n\n");
85
- writeForAgent(combined);
98
+ const combined = await getPromptContext(prompt);
99
+ if (combined) writeForAgent(combined);
86
100
  return combined;
87
101
  }
@@ -79,15 +79,30 @@ function extractEnvVars(): string[] {
79
79
  return [...vars];
80
80
  }
81
81
 
82
- /** Extract skill names from assets/skills/ */
83
- function extractSkillNames(): string[] {
84
- const pkg = palPkg();
85
- const skillsDir = resolve(pkg, "assets", "skills");
82
+ /**
83
+ * Names of the skills PAL ships — one directory per skill, each holding a
84
+ * SKILL.md. Reading the directory rather than loose `.md` files matters: the
85
+ * folder-per-skill layout is what the runtime loads, and matching on files
86
+ * silently yields nothing, which makes every skill check pass vacuously.
87
+ */
88
+ export function shippedSkillNames(): string[] {
89
+ const skillsDir = resolve(palPkg(), "assets", "skills");
86
90
  if (!existsSync(skillsDir)) return [];
87
91
 
88
- return readdirSync(skillsDir)
89
- .filter((f) => f.endsWith(".md"))
90
- .map((f) => f.replace(/\.md$/, ""));
92
+ return readdirSync(skillsDir, { withFileTypes: true })
93
+ .filter((e) => e.isDirectory() && existsSync(resolve(skillsDir, e.name, "SKILL.md")))
94
+ .map((e) => e.name)
95
+ .sort();
96
+ }
97
+
98
+ /**
99
+ * Skill names the README's "## Skills" table claims PAL ships. Scoped to that
100
+ * one section: other tables list commands and agents in the same row shape, and
101
+ * matching them would report every command as a retired skill.
102
+ */
103
+ function documentedSkillNames(readme: string): string[] {
104
+ const section = /^## Skills$([\s\S]*?)(?=^## )/m.exec(readme)?.[1] ?? "";
105
+ return Array.from(section.matchAll(/^\|\s*`([a-z0-9-]+)`\s*\|/gm), (m) => m[1]);
91
106
  }
92
107
 
93
108
  /** Validate that README.md documents all code surfaces. */
@@ -118,12 +133,17 @@ export function validateReadmeSync(): SyncResult {
118
133
  }
119
134
  }
120
135
 
121
- // Check skills — just verify the count is mentioned or each name appears
122
- const skills = extractSkillNames();
123
- const undocumentedSkills = skills.filter((name) => !readme.includes(name));
136
+ // Check skills — every shipped skill has a row, and no row outlives its skill
137
+ const shipped = shippedSkillNames();
138
+ const undocumentedSkills = shipped.filter((name) => !readme.includes(name));
124
139
  if (undocumentedSkills.length > 0) {
125
140
  issues.push(`Skills not documented in README: ${undocumentedSkills.join(", ")}`);
126
141
  }
127
142
 
143
+ const retired = documentedSkillNames(readme).filter((name) => !shipped.includes(name));
144
+ if (retired.length > 0) {
145
+ issues.push(`README documents skills that no longer ship: ${retired.join(", ")}`);
146
+ }
147
+
128
148
  return { ok: issues.length === 0, issues };
129
149
  }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Skill matching — deterministic trigger lookup that names the skills a prompt
3
+ * probably wants, injected at prompt time.
4
+ *
5
+ * A skill declares its own `metadata.triggers` in SKILL.md; `generateSkillIndex`
6
+ * copies them into skill-index.json. Here the prompt and every trigger are
7
+ * normalized the same way (lowercase, punctuation to spaces, space-padded), so a
8
+ * plain substring test is already a whole-word test and multi-word phrases work
9
+ * without a regex. Pure + fail-open, like the steering classifier it rides with.
10
+ */
11
+
12
+ import { existsSync, readFileSync } from "node:fs";
13
+ import { resolve } from "node:path";
14
+ import { paths } from "./paths";
15
+ import { isEnabled } from "./settings";
16
+
17
+ interface SkillIndexEntry {
18
+ name: string;
19
+ description: string;
20
+ triggers?: string[];
21
+ }
22
+
23
+ export interface SkillIndex {
24
+ skills: Record<string, SkillIndexEntry>;
25
+ }
26
+
27
+ export interface SkillMatch {
28
+ name: string;
29
+ description: string;
30
+ score: number;
31
+ matched: string[];
32
+ }
33
+
34
+ const MAX_MATCHES = 3;
35
+ const MAX_SKILL_BYTES = 700;
36
+ const MAX_DESCRIPTION_CHARS = 100;
37
+ const PHRASE_WEIGHT = 3;
38
+ const WORD_WEIGHT = 1;
39
+
40
+ /** Lowercase, punctuation to spaces, space-padded — so `includes` tests whole words. */
41
+ function normalize(text: string): string {
42
+ return ` ${text
43
+ .toLowerCase()
44
+ .replace(/[^a-z0-9]+/g, " ")
45
+ .trim()} `;
46
+ }
47
+
48
+ function readSkillIndex(): SkillIndex | null {
49
+ const path = resolve(paths.state(), "skill-index.json");
50
+ if (!existsSync(path)) return null;
51
+ try {
52
+ return JSON.parse(readFileSync(path, "utf-8")) as SkillIndex;
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ function weightOf(trigger: string): number {
59
+ return trigger.trim().includes(" ") ? PHRASE_WEIGHT : WORD_WEIGHT;
60
+ }
61
+
62
+ /** Rank the indexed skills whose triggers appear in the prompt, best first. */
63
+ export function matchSkills(prompt: string, index: SkillIndex): SkillMatch[] {
64
+ const haystack = normalize(prompt);
65
+ if (haystack.trim() === "") return [];
66
+
67
+ const matches: SkillMatch[] = [];
68
+ for (const entry of Object.values(index.skills ?? {})) {
69
+ let score = 0;
70
+ const matched: string[] = [];
71
+ for (const trigger of entry.triggers ?? []) {
72
+ const needle = normalize(trigger);
73
+ if (needle.trim() === "" || !haystack.includes(needle)) continue;
74
+ score += weightOf(needle);
75
+ matched.push(trigger);
76
+ }
77
+ if (score > 0)
78
+ matches.push({ name: entry.name, description: entry.description, score, matched });
79
+ }
80
+
81
+ return matches
82
+ .sort((a, b) => b.score - a.score || a.name.localeCompare(b.name))
83
+ .slice(0, MAX_MATCHES);
84
+ }
85
+
86
+ function summarize(description: string): string {
87
+ if (description.length <= MAX_DESCRIPTION_CHARS) return description;
88
+ return `${description.slice(0, MAX_DESCRIPTION_CHARS).trimEnd()}…`;
89
+ }
90
+
91
+ function line(match: SkillMatch): string {
92
+ const matched = match.matched.map((trigger) => `"${trigger}"`).join(", ");
93
+ return `- ${match.name} — ${summarize(match.description)} (matched: ${matched})`;
94
+ }
95
+
96
+ /** Build the skill-match <system-reminder> for a prompt, or null if nothing matches. */
97
+ export function getSkillReminder(prompt: string): string | null {
98
+ if (!isEnabled("skillMatching")) return null;
99
+ if (!prompt?.trim()) return null;
100
+
101
+ const index = readSkillIndex();
102
+ if (!index) return null;
103
+
104
+ let matches: SkillMatch[];
105
+ try {
106
+ matches = matchSkills(prompt, index);
107
+ } catch {
108
+ return null; // fail-open: never block a prompt on a matcher error
109
+ }
110
+ if (matches.length === 0) return null;
111
+
112
+ const lines: string[] = [];
113
+ let budget = MAX_SKILL_BYTES;
114
+ for (const match of matches) {
115
+ const rendered = line(match);
116
+ const cost = Buffer.byteLength(rendered);
117
+ if (cost > budget) break; // byte-cap: drop the overflow tail, keep top matches
118
+ lines.push(rendered);
119
+ budget -= cost;
120
+ }
121
+ if (lines.length === 0) return null;
122
+
123
+ return [
124
+ "<system-reminder>",
125
+ "Potential matching skills: these matched trigger words in your prompt. Invoke one with the Skill tool if it fits the request; ignore them if none do.",
126
+ ...lines,
127
+ "</system-reminder>",
128
+ ].join("\n");
129
+ }
@@ -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
+ }
@@ -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
 
@@ -775,6 +777,10 @@ export function copySkills(claudeSkillsDir: string): number {
775
777
  const linkType = process.platform === "win32" ? "junction" : "dir";
776
778
  let count = 0;
777
779
 
780
+ for (const name of pruneStaleSkillLinks(claudeSkillsDir)) {
781
+ log.info(`Removed stale skill link: ${name}`);
782
+ }
783
+
778
784
  for (const name of readdirSync(skillsDir)) {
779
785
  const srcDir = resolve(skillsDir, name);
780
786
  if (!existsSync(resolve(srcDir, "SKILL.md"))) continue;
@@ -797,6 +803,55 @@ export function copySkills(claudeSkillsDir: string): number {
797
803
  return count;
798
804
  }
799
805
 
806
+ /** True when `link` is a symlink whose target no longer exists. */
807
+ function isDanglingSymlink(link: string): boolean {
808
+ try {
809
+ return lstatSync(link).isSymbolicLink() && !existsSync(link);
810
+ } catch {
811
+ return false;
812
+ }
813
+ }
814
+
815
+ /** True when the symlink at `link` points at `root` or somewhere beneath it. */
816
+ function symlinkPointsInto(link: string, root: string): boolean {
817
+ try {
818
+ const target = resolve(dirname(link), readlinkSync(link));
819
+ return target === root || target.startsWith(root + sep);
820
+ } catch {
821
+ return false;
822
+ }
823
+ }
824
+
825
+ /**
826
+ * Remove discovery links left behind when a shipped skill is renamed or
827
+ * retired. Ownership is read from where a link points, not from metadata:
828
+ * a dead link has no SKILL.md to read, but its target path still says
829
+ * whether PAL created it.
830
+ *
831
+ * ~/.pal/skills/<name> → pruned when dangling and pointing into assets/skills/
832
+ * <agent>/skills/<name> → pruned when dangling and pointing into ~/.pal/skills/
833
+ *
834
+ * Personal skills are real directories, so they are never candidates, and a
835
+ * user's own symlinks to anywhere else are left alone even when broken.
836
+ */
837
+ function pruneStaleSkillLinks(agentSkillsDir: string): string[] {
838
+ const ownedTrees = [
839
+ { dir: PAL_SKILLS_DIR, root: assets.skills() },
840
+ { dir: agentSkillsDir, root: PAL_SKILLS_DIR },
841
+ ];
842
+ const removed: string[] = [];
843
+ for (const { dir, root } of ownedTrees) {
844
+ if (!existsSync(dir)) continue;
845
+ for (const name of readdirSync(dir)) {
846
+ const link = resolve(dir, name);
847
+ if (!isDanglingSymlink(link) || !symlinkPointsInto(link, root)) continue;
848
+ unlinkSync(link);
849
+ removed.push(name);
850
+ }
851
+ }
852
+ return removed;
853
+ }
854
+
800
855
  /**
801
856
  * Agent skills directories that need a per-skill discovery link.
802
857
  *
@@ -1319,7 +1374,7 @@ interface SkillIndex {
1319
1374
  skills: Record<string, SkillIndexEntry>;
1320
1375
  }
1321
1376
 
1322
- /** Extract trigger keywords from a skill description */
1377
+ /** Fallback triggers for a skill that declares none: keywords mined from its description. */
1323
1378
  function extractTriggers(description: string): string[] {
1324
1379
  // Extract "Use when ..." phrases and key terms
1325
1380
  const triggers = new Set<string>();
@@ -1378,10 +1433,12 @@ export function generateSkillIndex(): number {
1378
1433
  const skillName = nameMatch[1].trim();
1379
1434
  const description = descMatch?.[1]?.trim() ?? "";
1380
1435
 
1436
+ const declared = declaredTriggers(fm);
1437
+
1381
1438
  index.skills[skillName] = {
1382
1439
  name: skillName,
1383
1440
  description,
1384
- triggers: extractTriggers(description),
1441
+ triggers: declared.length > 0 ? declared : extractTriggers(description),
1385
1442
  };
1386
1443
  index.totalSkills++;
1387
1444
  } catch {
@@ -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([