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
@@ -24,6 +24,7 @@
24
24
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
25
25
  import { resolve } from "node:path";
26
26
  import { parseArgs } from "node:util";
27
+ import { writeBinding } from "../../hooks/lib/bindings";
27
28
  import { paths } from "../../hooks/lib/paths";
28
29
  import {
29
30
  defaultSlug,
@@ -32,6 +33,7 @@ import {
32
33
  legacyJsonToProgress,
33
34
  type ProjectProgress,
34
35
  type ProjectStatus,
36
+ proposeBinding,
35
37
  readAllProjects,
36
38
  readProject,
37
39
  writeProject,
@@ -132,14 +134,28 @@ function cmdResume(args: string[]): void {
132
134
  if (!name) fail("Usage: resume <name>");
133
135
  const { criteria, changelog, ...project } = requireProject(name);
134
136
  const iscs = parseIscs(criteria ?? "");
135
- const openIscs = iscs.filter((i) => !i.checked);
136
- const done = iscs.filter((i) => i.checked).length + parseIscs(changelog ?? "").length;
137
+ const archived = parseIscs(changelog ?? "");
138
+ const openIscs = iscs.filter((i) => i.status === "open");
139
+ const all = [...iscs, ...archived];
140
+ const done = all.filter((i) => i.status === "done").length;
141
+ const retired = all.filter((i) => i.status === "retired").length;
142
+ // Resuming a project PAL cannot locate is the natural moment to offer a
143
+ // binding: the user just named this project, so the suggestion is wanted rather
144
+ // than volunteered. It is only ever a command — nothing binds on its own.
145
+ const unlocatable = !project.path || !existsSync(project.path);
146
+ const binding = unlocatable
147
+ ? proposeBinding({ ...project, criteria, changelog })
148
+ : null;
149
+
137
150
  ok({
138
151
  project: {
139
152
  ...project,
140
153
  open_iscs: openIscs.map((i) => ({ id: i.id, title: iscTitle(i.text) })),
141
- isc_summary: { open: openIscs.length, done },
154
+ isc_summary: { open: openIscs.length, done, retired },
142
155
  },
156
+ ...(unlocatable
157
+ ? { binding: binding ?? { state: "unbound", confidence: "none" } }
158
+ : {}),
143
159
  });
144
160
  }
145
161
 
@@ -206,12 +222,15 @@ function addHandoff(name: string, text: string): void {
206
222
 
207
223
  // ── set-path ──────────────────────────────────────────────────────
208
224
 
225
+ // Where a project lives is machine-local, so this writes a binding rather than a
226
+ // field on the record. Unlike the save path it does not require the directory to
227
+ // exist yet: naming where a repo is about to be cloned is a legitimate use.
209
228
  function cmdSetPath(args: string[]): void {
210
229
  const [name, ...rest] = args;
211
230
  if (!name || rest.length === 0) fail("Usage: set-path <name> <new-path>");
212
231
  const newPath = resolve(rest.join(" ").trim());
213
232
  const p = requireProject(name);
214
- p.path = newPath;
233
+ writeBinding(p.name, newPath);
215
234
  p.updated = now();
216
235
  writeProject(p);
217
236
  ok({ updated: true, name, path: newPath });
@@ -350,17 +369,34 @@ function cmdRm(args: string[]): void {
350
369
 
351
370
  // ── ISC helpers ──────────────────────────────────────────────────
352
371
 
372
+ // Three states, not two: a retired ISC is one that stopped being valid, which the
373
+ // record must not report as completed work. The box character is the storage form
374
+ // and the id stays in it, so a retired line keeps reserving its id in nextIscId.
375
+ type IscStatus = "open" | "done" | "retired";
376
+
377
+ const ISC_BOX: Record<IscStatus, string> = {
378
+ open: "[ ]",
379
+ done: "[x]",
380
+ retired: "[~]",
381
+ };
382
+
383
+ function statusFromBox(box: string): IscStatus {
384
+ if (box.toLowerCase() === "x") return "done";
385
+ if (box === "~") return "retired";
386
+ return "open";
387
+ }
388
+
353
389
  interface Isc {
354
390
  id: number;
355
391
  text: string;
356
- checked: boolean;
392
+ status: IscStatus;
357
393
  }
358
394
 
359
395
  function parseIscs(criteria: string): Isc[] {
360
396
  const out: Isc[] = [];
361
397
  for (const line of criteria.split("\n")) {
362
- const m = new RegExp(/^-\s+\[( |x)\]\s+ISC-(\d+):\s+(.+)$/i).exec(line);
363
- if (m) out.push({ id: Number(m[2]), text: m[3].trim(), checked: m[1] === "x" });
398
+ const m = new RegExp(/^-\s+\[( |x|~)\]\s+ISC-(\d+):\s+(.+)$/i).exec(line);
399
+ if (m) out.push({ id: Number(m[2]), text: m[3].trim(), status: statusFromBox(m[1]) });
364
400
  }
365
401
  return out;
366
402
  }
@@ -387,7 +423,7 @@ function removeIscLine(
387
423
  ): { line: string | null; rest: string } {
388
424
  const lines = section.split("\n");
389
425
  const idx = lines.findIndex((l) =>
390
- new RegExp(String.raw`^-\s+\[[ x]\]\s+ISC-${id}:`).test(l)
426
+ new RegExp(String.raw`^-\s+\[[ x~]\]\s+ISC-${id}:`).test(l)
391
427
  );
392
428
  if (idx === -1) return { line: null, rest: section };
393
429
  const [line] = lines.splice(idx, 1);
@@ -417,8 +453,12 @@ function dropEmptyArchiveHeadings(changelog: string): string {
417
453
  .trim();
418
454
  }
419
455
 
420
- function archiveLine(changelog: string | undefined, doneLine: string): string {
421
- const heading = `### Archived ${new Date().toISOString().slice(0, 10)}`;
456
+ function archiveLine(
457
+ changelog: string | undefined,
458
+ doneLine: string,
459
+ kind: "Archived" | "Retired" = "Archived"
460
+ ): string {
461
+ const heading = `### ${kind} ${new Date().toISOString().slice(0, 10)}`;
422
462
  const base = (changelog ?? "").trim();
423
463
  if (base.includes(heading)) return `${base}\n${doneLine}`;
424
464
  return base ? `${base}\n\n${heading}\n${doneLine}` : `${heading}\n${doneLine}`;
@@ -472,7 +512,7 @@ function cmdReopenIsc(args: string[]): void {
472
512
  const id = Number(args[1] ?? fail("Usage: reopen-isc <name> <id>"));
473
513
  if (!Number.isInteger(id) || id < 1) fail("ISC id must be a positive integer");
474
514
  const p = requireProject(name);
475
- if (parseIscs(p.criteria ?? "").some((i) => i.id === id && !i.checked)) {
515
+ if (parseIscs(p.criteria ?? "").some((i) => i.id === id && i.status === "open")) {
476
516
  ok({ checked: false, id, alreadyOpen: true });
477
517
  return;
478
518
  }
@@ -484,16 +524,17 @@ function cmdReopenIsc(args: string[]): void {
484
524
  if (removed.line) p.criteria = removed.rest;
485
525
  }
486
526
  if (!removed.line) fail(`ISC-${id} not found in project "${name}"`);
487
- const openLine = removed.line.replace(/\[x\]/i, "[ ]");
527
+ const openLine = removed.line.replace(/\[[x~]\]/i, "[ ]");
488
528
  p.criteria = p.criteria ? `${p.criteria.trimEnd()}\n${openLine}` : openLine;
489
529
  p.updated = now();
490
530
  writeProject(p);
491
531
  ok({ checked: false, id });
492
532
  }
493
533
 
494
- function selectIscs(open: Isc[], done: Isc[], flags: Set<string>): Isc[] {
495
- if (flags.has("--all")) return [...open, ...done];
534
+ function selectIscs(open: Isc[], done: Isc[], retired: Isc[], flags: Set<string>): Isc[] {
535
+ if (flags.has("--all")) return [...open, ...done, ...retired];
496
536
  if (flags.has("--closed")) return done;
537
+ if (flags.has("--retired")) return retired;
497
538
  return open;
498
539
  }
499
540
 
@@ -501,17 +542,20 @@ function cmdListIsc(args: string[]): void {
501
542
  const flags = new Set(args.filter((a) => a.startsWith("--")));
502
543
  const name =
503
544
  args.find((a) => !a.startsWith("--")) ??
504
- fail("Usage: list-isc <name> [--all | --closed]");
545
+ fail("Usage: list-isc <name> [--all | --closed | --retired]");
505
546
  const p = requireProject(name);
506
547
  const criteria = parseIscs(p.criteria ?? "");
507
- const open = criteria.filter((i) => !i.checked);
508
- const done = [...criteria.filter((i) => i.checked), ...parseIscs(p.changelog ?? "")];
548
+ const all = [...criteria, ...parseIscs(p.changelog ?? "")];
549
+ const open = all.filter((i) => i.status === "open");
550
+ const done = all.filter((i) => i.status === "done");
551
+ const retired = all.filter((i) => i.status === "retired");
509
552
  ok({
510
553
  name,
511
- total: open.length + done.length,
554
+ total: open.length + done.length + retired.length,
512
555
  open: open.length,
513
556
  done: done.length,
514
- iscs: selectIscs(open, done, flags),
557
+ retired: retired.length,
558
+ iscs: selectIscs(open, done, retired, flags),
515
559
  });
516
560
  }
517
561
 
@@ -526,7 +570,80 @@ function cmdShowIsc(args: string[]): void {
526
570
  (i) => i.id === id
527
571
  );
528
572
  if (!isc) fail(`ISC-${id} not found in project "${name}".`);
529
- ok({ name, id: isc.id, status: isc.checked ? "closed" : "open", text: isc.text });
573
+ ok({ name, id: isc.id, status: isc.status, text: isc.text });
574
+ }
575
+
576
+ // retire-isc closes an ISC that stopped being valid, which complete-isc cannot say:
577
+ // completing files it as done work. The line moves to the Changelog under its own
578
+ // heading as [~], so it still reserves its id and never reads as finished.
579
+ function cmdRetireIsc(args: string[]): void {
580
+ const positional = args.filter((a) => !a.startsWith("--"));
581
+ const name = positional[0];
582
+ const id = Number(positional[1]);
583
+ if (!name || !Number.isInteger(id) || id < 1) {
584
+ fail("Usage: retire-isc <name> <id> [--by <supersedingId>]");
585
+ }
586
+ const byIndex = args.indexOf("--by");
587
+ const by = byIndex === -1 ? null : Number(args[byIndex + 1]);
588
+ if (byIndex !== -1 && (!Number.isInteger(by) || (by ?? 0) < 1)) {
589
+ fail("--by expects a positive ISC id");
590
+ }
591
+ const p = requireProject(name);
592
+ if (parseIscs(p.changelog ?? "").some((i) => i.id === id && i.status === "retired")) {
593
+ ok({ retired: true, id, alreadyRetired: true });
594
+ return;
595
+ }
596
+ const { line, rest } = removeIscLine(p.criteria ?? "", id);
597
+ if (!line) fail(`ISC-${id} not found in project "${name}"`);
598
+ const suffix = by ? ` (superseded by ISC-${by})` : "";
599
+ p.criteria = rest;
600
+ p.changelog = archiveLine(
601
+ p.changelog,
602
+ `${line.replace(/\[[ x]\]/i, "[~]")}${suffix}`,
603
+ "Retired"
604
+ );
605
+ p.updated = now();
606
+ writeProject(p);
607
+ ok({ retired: true, id, supersededBy: by, archived: true });
608
+ }
609
+
610
+ // edit-isc rewrites one ISC's text in place, keeping its id and open/done state.
611
+ // The id never leaves the record, so nextIscId still reserves it. Returns the
612
+ // previous text because the ISA files carry no version history of their own.
613
+ function cmdEditIsc(args: string[]): void {
614
+ const name = args[0];
615
+ const id = Number(args[1]);
616
+ const text = args.slice(2).join(" ").trim();
617
+ if (!name || !Number.isInteger(id) || id < 1 || !text) {
618
+ fail('Usage: edit-isc <name> <id> "new text"');
619
+ }
620
+ const p = requireProject(name);
621
+ const inCriteria = parseIscs(p.criteria ?? "").find((i) => i.id === id);
622
+ const isc = inCriteria ?? parseIscs(p.changelog ?? "").find((i) => i.id === id);
623
+ if (!isc) fail(`ISC-${id} not found in project "${name}".`);
624
+
625
+ const box = ISC_BOX[isc.status];
626
+ const rewrite = (section: string) =>
627
+ section
628
+ .split("\n")
629
+ .map((l) =>
630
+ new RegExp(String.raw`^-\s+\[[ x~]\]\s+ISC-${id}:`, "i").test(l)
631
+ ? `- ${box} ISC-${id}: ${text}`
632
+ : l
633
+ )
634
+ .join("\n");
635
+
636
+ if (inCriteria) p.criteria = rewrite(p.criteria ?? "");
637
+ else p.changelog = rewrite(p.changelog ?? "");
638
+ p.updated = now();
639
+ writeProject(p);
640
+ ok({
641
+ edited: true,
642
+ id,
643
+ status: isc.status,
644
+ previous: isc.text,
645
+ text,
646
+ });
530
647
  }
531
648
 
532
649
  // Backfill: sweep any done ISCs still sitting in Criteria (legacy projects, or
@@ -534,7 +651,7 @@ function cmdShowIsc(args: string[]): void {
534
651
  function cmdPruneIsc(args: string[]): void {
535
652
  const name = args[0] ?? fail("Usage: prune-isc <name>");
536
653
  const p = requireProject(name);
537
- const done = parseIscs(p.criteria ?? "").filter((i) => i.checked);
654
+ const done = parseIscs(p.criteria ?? "").filter((i) => i.status !== "open");
538
655
  for (const isc of done) {
539
656
  const { line, rest } = removeIscLine(p.criteria ?? "", isc.id);
540
657
  if (!line) continue;
@@ -545,7 +662,7 @@ function cmdPruneIsc(args: string[]): void {
545
662
  p.updated = now();
546
663
  writeProject(p);
547
664
  }
548
- const openLeft = parseIscs(p.criteria ?? "").filter((i) => !i.checked).length;
665
+ const openLeft = parseIscs(p.criteria ?? "").filter((i) => i.status === "open").length;
549
666
  ok({ pruned: done.length, name, remaining_open: openLeft });
550
667
  }
551
668
 
@@ -628,8 +745,10 @@ Commands:
628
745
  add-isc <name> "title" append a new open ISC to Criteria
629
746
  complete-isc <name> <id> mark ISC-N as done
630
747
  reopen-isc <name> <id> reopen ISC-N (mark not done)
631
- list-isc <name> [--all | --closed] list open ISCs (default); --all or --closed for done
748
+ list-isc <name> [--all | --closed | --retired] list open ISCs (default); --all, --closed, or --retired
632
749
  show-isc <name> <id> print one ISC's full text
750
+ edit-isc <name> <id> "new text" rewrite ISC-N's text, keeping its id and state
751
+ retire-isc <name> <id> [--by <id>] close ISC-N as no longer valid, not as done
633
752
  prune-isc <name> archive done ISCs from Criteria into the Changelog
634
753
  isa-init <name> mark project as ISA-initialized
635
754
  scaffold-task-isa <title> create a one-shot task ISA in memory/work/
@@ -725,6 +844,12 @@ function run(): void {
725
844
  case "show-isc":
726
845
  cmdShowIsc(rest);
727
846
  return;
847
+ case "retire-isc":
848
+ cmdRetireIsc(rest);
849
+ return;
850
+ case "edit-isc":
851
+ cmdEditIsc(rest);
852
+ return;
728
853
  case "prune-isc":
729
854
  cmdPruneIsc(rest);
730
855
  return;
@@ -14,6 +14,8 @@
14
14
  import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
15
15
  import { resolve } from "node:path";
16
16
  import { parseArgs } from "node:util";
17
+ import { encodeAnchor } from "../../hooks/lib/anchor";
18
+ import { loadMachine } from "../../hooks/lib/machine";
17
19
  import { ensureDir, paths } from "../../hooks/lib/paths";
18
20
  import { emit } from "../lib/emit";
19
21
 
@@ -22,6 +24,7 @@ import { emit } from "../lib/emit";
22
24
  export interface Thread {
23
25
  id: string;
24
26
  cwd: string;
27
+ m: string;
25
28
  title: string;
26
29
  context: string;
27
30
  status: "open" | "resolved";
@@ -62,10 +65,12 @@ export function writeThreads(threads: Thread[]): void {
62
65
 
63
66
  // ── Operations ──
64
67
 
65
- function addThread(title: string, context: string): Thread {
68
+ /** Exported so the cwd-anchor and origin-stamp wiring is directly testable. */
69
+ export function addThread(title: string, context: string): Thread {
66
70
  const thread: Thread = {
67
71
  id: generateId(),
68
- cwd: process.cwd(),
72
+ cwd: encodeAnchor(process.cwd()),
73
+ m: loadMachine().id,
69
74
  title,
70
75
  context,
71
76
  status: "open",
@@ -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,44 +0,0 @@
1
- const USAGE = "usage: shot.ts <url> [-o <file>] [--viewport WxH] [--full-page] [--selector <css>] [--wait <ms>]";
2
- export function parseArgs(argv) {
3
- let url = "";
4
- let out = "";
5
- let viewport;
6
- let fullPage = false;
7
- let selector;
8
- let waitMs;
9
- for (let i = 0;i < argv.length; i++) {
10
- const a = argv[i];
11
- if (a === "-o" || a === "--out")
12
- out = argv[++i] ?? "";
13
- else if (a === "--viewport") {
14
- const m = /^(\d+)[x,](\d+)$/.exec(argv[++i] ?? "");
15
- if (!m)
16
- throw new Error("--viewport expects WxH, e.g. 1440x900");
17
- viewport = { width: Number(m[1]), height: Number(m[2]) };
18
- } else if (a === "--full-page")
19
- fullPage = true;
20
- else if (a === "--selector")
21
- selector = argv[++i];
22
- else if (a === "--wait") {
23
- const n = Number(argv[++i]);
24
- if (!Number.isFinite(n))
25
- throw new Error("--wait expects a number of milliseconds");
26
- waitMs = n;
27
- } else if (!a.startsWith("-") && !url)
28
- url = a;
29
- else
30
- throw new Error(`unknown argument: ${a}
31
- ${USAGE}`);
32
- }
33
- if (!url)
34
- throw new Error(`a URL is required
35
- ${USAGE}`);
36
- return { url, out, viewport, fullPage, selector, waitMs };
37
- }
38
- export function chooseTier(opts) {
39
- if (!opts.cliAvailable)
40
- return "node";
41
- if (opts.viewport || opts.fullPage)
42
- return "node";
43
- return "cli";
44
- }
@@ -1,89 +0,0 @@
1
- import { spawnSync } from "node:child_process";
2
- import { existsSync, mkdtempSync, rmSync } from "node:fs";
3
- import { tmpdir } from "node:os";
4
- import { join, resolve } from "node:path";
5
- import { chooseTier, parseArgs } from "./shot-lib.mjs";
6
- function playwrightCliAvailable() {
7
- try {
8
- return spawnSync("playwright-cli", ["--version"], { stdio: "ignore" }).status === 0;
9
- } catch {
10
- return false;
11
- }
12
- }
13
- function runViaCli(opts, out) {
14
- const work = mkdtempSync(join(tmpdir(), "pal-pwcli-"));
15
- const run = (args, quiet = false) => spawnSync("playwright-cli", args, { stdio: quiet ? "ignore" : "inherit", cwd: work });
16
- try {
17
- if (run(["open", opts.url]).status !== 0)
18
- return false;
19
- const args = ["screenshot", `--filename=${out}`];
20
- if (opts.selector)
21
- args.push(opts.selector);
22
- const shot = run(args);
23
- run(["close"], true);
24
- return shot.status === 0 && existsSync(out);
25
- } finally {
26
- rmSync(work, { recursive: true, force: true });
27
- }
28
- }
29
- async function runViaNode(opts, out) {
30
- let chromium;
31
- try {
32
- ({ chromium } = await import("playwright"));
33
- } catch {
34
- return "unavailable";
35
- }
36
- let browser;
37
- try {
38
- browser = await chromium.launch();
39
- } catch (e) {
40
- console.error(`chromium launch failed: ${e.message}`);
41
- return "unavailable";
42
- }
43
- try {
44
- const page = await browser.newPage(opts.viewport ? { viewport: opts.viewport } : {});
45
- await page.goto(opts.url, { waitUntil: "networkidle" });
46
- if (opts.waitMs)
47
- await page.waitForTimeout(opts.waitMs);
48
- if (opts.selector)
49
- await page.locator(opts.selector).screenshot({ path: out });
50
- else
51
- await page.screenshot({ path: out, fullPage: opts.fullPage });
52
- return existsSync(out) ? "ok" : "error";
53
- } catch (e) {
54
- console.error(`screenshot failed: ${e.message}`);
55
- return "error";
56
- } finally {
57
- await browser.close();
58
- }
59
- }
60
- async function main() {
61
- let opts;
62
- try {
63
- opts = parseArgs(process.argv.slice(2));
64
- } catch (e) {
65
- console.error(e.message);
66
- process.exit(2);
67
- }
68
- const out = opts.out ? resolve(opts.out) : join(tmpdir(), `pal-shot-${Date.now()}.png`);
69
- const tier = chooseTier({
70
- cliAvailable: playwrightCliAvailable(),
71
- viewport: opts.viewport,
72
- fullPage: opts.fullPage
73
- });
74
- if (tier === "cli" && runViaCli(opts, out)) {
75
- console.log(out);
76
- return;
77
- }
78
- const result = await runViaNode(opts, out);
79
- if (result === "ok") {
80
- console.log(out);
81
- return;
82
- }
83
- if (result === "unavailable") {
84
- console.error("NO_PLAYWRIGHT_CLI");
85
- process.exit(3);
86
- }
87
- process.exit(1);
88
- }
89
- await main();
@@ -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