portable-agent-layer 0.63.3 → 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 +20 -10
  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
@@ -12,6 +12,7 @@
12
12
  import { existsSync, readdirSync, readFileSync } from "node:fs";
13
13
  import { basename, extname, relative, resolve } from "node:path";
14
14
  import { palHome } from "../hooks/lib/paths";
15
+ import { declaredTriggers } from "../hooks/lib/skill-triggers";
15
16
 
16
17
  type Level = "pass" | "warn" | "error";
17
18
 
@@ -33,13 +34,20 @@ interface ParsedSkill {
33
34
  name: string | null;
34
35
  description: string | null;
35
36
  descriptionQuoted: boolean;
37
+ triggers: string[];
38
+ shipped: boolean;
39
+ license: string | null;
40
+ derivedFrom: string | null;
36
41
  body: string;
37
42
  }
38
43
 
44
+ const SHIPPED_SOURCE = "portable-agent-layer";
45
+
39
46
  const RESERVED_WORDS = ["anthropic", "claude"];
40
47
  const MAX_NAME = 64;
41
48
  const MAX_DESCRIPTION = 1024;
42
49
  const MAX_BODY_LINES = 500;
50
+ const MIN_TRIGGERS = 3;
43
51
 
44
52
  /** File extensions worth scanning for hardcoded paths (SKILL.md + its scripts). */
45
53
  const SCANNABLE_EXT = new Set([".md", ".ts", ".js", ".mjs", ".cjs", ".sh", ".py"]);
@@ -83,7 +91,16 @@ function findAbsolutePaths(skillDir: string): string[] {
83
91
  function parseSkill(content: string): ParsedSkill {
84
92
  const parts = content.split(/^---\s*$/m);
85
93
  if (parts.length < 3) {
86
- return { name: null, description: null, descriptionQuoted: false, body: content };
94
+ return {
95
+ name: null,
96
+ description: null,
97
+ descriptionQuoted: false,
98
+ triggers: [],
99
+ shipped: false,
100
+ license: null,
101
+ derivedFrom: null,
102
+ body: content,
103
+ };
87
104
  }
88
105
  const frontmatter = parts[1];
89
106
  const body = parts.slice(2).join("---");
@@ -95,7 +112,46 @@ function parseSkill(content: string): ParsedSkill {
95
112
  rawDescription.startsWith('"') &&
96
113
  rawDescription.endsWith('"');
97
114
  const description = descriptionQuoted ? rawDescription.slice(1, -1) : rawDescription;
98
- return { name, description, descriptionQuoted, body };
115
+ return {
116
+ name,
117
+ description,
118
+ descriptionQuoted,
119
+ triggers: declaredTriggers(frontmatter),
120
+ shipped: metadataField(frontmatter, "source") === SHIPPED_SOURCE,
121
+ license: topLevelField(frontmatter, "license"),
122
+ derivedFrom: metadataField(frontmatter, "derived-from"),
123
+ body,
124
+ };
125
+ }
126
+
127
+ /** Value of a top-level `key:` line in the frontmatter, unquoted. */
128
+ function topLevelField(frontmatter: string, key: string): string | null {
129
+ return (
130
+ new RegExp(String.raw`^${key}:\s*"?(.+?)"?\s*$`, "m").exec(frontmatter)?.[1] ?? null
131
+ );
132
+ }
133
+
134
+ /** Value of an indented `key:` line under the `metadata:` block, unquoted. */
135
+ function metadataField(frontmatter: string, key: string): string | null {
136
+ return (
137
+ new RegExp(String.raw`^[ \t]+${key}:\s*"?(.+?)"?\s*$`, "m").exec(frontmatter)?.[1] ??
138
+ null
139
+ );
140
+ }
141
+
142
+ /** Render triggers for a report line: `"a", "b"` or `"a" then "b"`. */
143
+ function quoteList(triggers: string[], separator: string): string {
144
+ return triggers.map((trigger) => `"${trigger}"`).join(separator);
145
+ }
146
+
147
+ /**
148
+ * The triggers every skill must declare first: its own name, then the
149
+ * de-hyphenated form a user would actually type. A single-word name has only
150
+ * the one form, and the parser dedupes anyway, so it requires just itself.
151
+ */
152
+ function leadTriggers(name: string): string[] {
153
+ const spaced = name.replaceAll("-", " ");
154
+ return spaced === name ? [name] : [name, spaced];
99
155
  }
100
156
 
101
157
  /** Remove fenced and inline code so prose checks don't trip on examples. */
@@ -134,9 +190,29 @@ export function lintSkill(skillDir: string): DoctorReport {
134
190
  `skill file is "${skillFile}" — must be exactly "SKILL.md" or the skill is silently ignored`
135
191
  );
136
192
 
137
- const { name, description, descriptionQuoted, body } = parseSkill(
138
- readFileSync(resolve(skillDir, skillFile), "utf-8")
139
- );
193
+ const {
194
+ name,
195
+ description,
196
+ descriptionQuoted,
197
+ triggers,
198
+ shipped,
199
+ license,
200
+ derivedFrom,
201
+ body,
202
+ } = parseSkill(readFileSync(resolve(skillDir, skillFile), "utf-8"));
203
+
204
+ // ── provenance (shipped skills only) ──
205
+ if (shipped) {
206
+ if (license) add("pass", "license", `licensed ${license}`);
207
+ else if (derivedFrom)
208
+ add("pass", "license", `unlicensed by design — derived from ${derivedFrom}`);
209
+ else
210
+ add(
211
+ "warn",
212
+ "license",
213
+ "shipped skill declares no license — add `license: MIT`, or `metadata.derived-from: <origin>` when the idea comes from another project"
214
+ );
215
+ }
140
216
 
141
217
  // The runtime keys a skill by its folder name; a mismatched frontmatter `name`
142
218
  // makes the skill silently fail to load.
@@ -218,6 +294,37 @@ export function lintSkill(skillDir: string): DoctorReport {
218
294
  : add("pass", "description.pov", "third person");
219
295
  }
220
296
 
297
+ // ── triggers ──
298
+ if (triggers.length === 0) {
299
+ add(
300
+ "warn",
301
+ "metadata.triggers",
302
+ "no metadata.triggers declared — add the words and phrases a prompt would contain so the prompt-time matcher can surface this skill; without them it falls back to keywords mined from the description"
303
+ );
304
+ } else if (triggers.length < MIN_TRIGGERS) {
305
+ add(
306
+ "warn",
307
+ "metadata.triggers",
308
+ `only ${triggers.length} trigger(s) declared — aim for at least ${MIN_TRIGGERS}, mostly multi-word phrases`
309
+ );
310
+ } else {
311
+ add("pass", "metadata.triggers", `${triggers.length} triggers declared`);
312
+ }
313
+
314
+ if (name && triggers.length > 0) {
315
+ const lead = leadTriggers(name);
316
+ const actual = triggers.slice(0, lead.length);
317
+ const wanted = quoteList(lead, " then ");
318
+ const found = quoteList(actual, ", ") || "nothing";
319
+ actual.join("\u0000") === lead.join("\u0000")
320
+ ? add("pass", "metadata.triggers.lead", `leads with ${wanted}`)
321
+ : add(
322
+ "warn",
323
+ "metadata.triggers.lead",
324
+ `triggers must lead with ${wanted} — found ${found}`
325
+ );
326
+ }
327
+
221
328
  // ── body ──
222
329
  const bodyLines = body.split("\n").length;
223
330
  bodyLines <= MAX_BODY_LINES
@@ -299,6 +406,24 @@ export function lintSkill(skillDir: string): DoctorReport {
299
406
  }
300
407
 
301
408
  /** Render a report as a human-readable string. */
409
+ /** One scannable line per skill for a whole-store run: verdict plus the checks that fired. */
410
+ export function formatSummary(r: DoctorReport): string {
411
+ const fired = (level: Level) =>
412
+ r.findings.filter((f) => f.level === level).map((f) => f.check);
413
+ // The folder name, not the frontmatter name — the folder is what the reader
414
+ // passes back to `pal cli skill doctor <name>`, and a mismatch between the two
415
+ // is itself one of the errors this line reports.
416
+ const name = basename(r.dir).padEnd(20);
417
+
418
+ if (r.errors > 0) {
419
+ return `✗ ${name} ${r.errors} error(s): ${fired("error").join(", ")}`;
420
+ }
421
+ if (r.warnings > 0) {
422
+ return `⚠ ${name} ${r.warnings} warning(s): ${fired("warn").join(", ")}`;
423
+ }
424
+ return `✓ ${name} clean`;
425
+ }
426
+
302
427
  export function formatReport(r: DoctorReport): string {
303
428
  const icon = { pass: "✓", warn: "⚠", error: "✗" } as const;
304
429
  const lines = [`skill-doctor: ${r.name ?? "(unparsed)"} — ${r.dir}`];
@@ -1,20 +0,0 @@
1
- ---
2
- name: review
3
- description: "Security-focused code review with severity ratings. Use when reviewing code for security issues, vulnerabilities, or OWASP concerns."
4
- argument-hint: [file or directory]
5
- ---
6
-
7
- When the user invokes /review <file, diff, or PR>:
8
-
9
- 1. Read the target code or diff in full
10
- 2. Analyze for:
11
- - **Security** — OWASP top 10, injection, auth, data exposure
12
- - **Logic** — edge cases, off-by-one, null handling, race conditions
13
- - **Performance** — N+1 queries, unnecessary allocations, blocking calls
14
- - **Style** — consistency with surrounding code (not your preferences)
15
- 3. Output findings grouped by severity:
16
- - CRITICAL — must fix before merge
17
- - WARNING — should fix, creates risk
18
- - SUGGESTION — nice to have
19
- 4. Each finding includes: file:line, what's wrong, concrete fix
20
- 5. End with a one-line verdict: APPROVE / REQUEST CHANGES / NEEDS DISCUSSION
@@ -1,16 +0,0 @@
1
- ---
2
- name: summarize
3
- description: "Structured summarization of documents, URLs, or conversations. Use when summarizing content, creating overviews, or distilling key points."
4
- argument-hint: <document, URL, or topic>
5
- ---
6
-
7
- Summarize $ARGUMENTS:
8
-
9
- 1. Fetch or read the target content
10
- 2. Produce:
11
- - **TLDR** (1 sentence)
12
- - **Key points** (5-7 bullets)
13
- - **Action items** (if any exist in the content)
14
- - **Notable quotes/data** (verbatim, with attribution)
15
- 3. Keep total output under 500 words
16
- 4. If the content is very long, note what was covered vs skipped