portable-agent-layer 0.46.0 → 0.48.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.
package/README.md CHANGED
@@ -85,6 +85,8 @@ pal cli status # check your setup
85
85
  | `pal cli migrate` | Run pending data migrations (non-destructive) |
86
86
  | `pal cli usage` | Summarize token usage and estimated cost |
87
87
  | `pal cli knowledge` | Query & manage the knowledge store (search, graph, stats, hubs, find, show, add, ls, ingest) |
88
+ | `pal cli skill link <name>` | Link a personal `~/.pal/skills/<name>/` into every installed agent so it is discoverable |
89
+ | `pal cli skill doctor <name>` | Evaluate a skill against the authoring best practices (folder/file-name match, name, description, body length, point-of-view, reference depth) |
88
90
 
89
91
  ### Target flags
90
92
 
@@ -1,69 +1,77 @@
1
1
  ---
2
2
  name: create-skill
3
- description: Scaffold a new PAL skill from a description. Use when creating a new skill, adding a capability, or building a custom command. Enforces general, assistant-facing, personally-clean skill writing.
3
+ description: Create a new personal skill for this user, scaffolded into their own ~/.pal/skills/ and linked into every installed agent. Use when the user asks to create a skill, add a capability, build a custom command, or "make a skill that…".
4
4
  argument-hint: <skill name> <skill description>
5
5
  ---
6
6
 
7
- ## What makes a good PAL skill
7
+ # Create a personal skill
8
8
 
9
- Before writing any skill, internalize these three rules. A skill that violates them gets re-written, not committed.
9
+ This scaffolds a personal skill into the user's own `~/.pal/skills/<name>/` and links it into every installed agent so it is immediately discoverable.
10
10
 
11
- 1. **Pointed at the assistant, not the user.** A skill is *instructions you (the assistant) follow*. Write in second person addressing yourself ("read X", "extract Y", "output Z"). Prescriptive verbs, deterministic flow. It is **not** a user tutorial, marketing blurb, or README. The user reads `README.md`; you read `SKILL.md`.
11
+ ## What makes a good skill
12
12
 
13
- 2. **Zero personal information.** No usernames, real names, employer names, project codenames, dataset names, hostnames, absolute home paths (`C:\Users\someone\…`), email addresses, or anecdotes about the author's own work. A skill must be reusable by any user. If the rule is "don't expand `~` on Windows cmd," that is the rule — don't add "because $username's primary shell is cmd." Personal context belongs in the user's private memory, never in the skill body.
13
+ 1. **Pointed at the assistant, not the user.** A skill is *instructions you (the assistant) follow*. Write in second person addressing yourself ("read X", "run Y", "output Z"). Prescriptive verbs, deterministic flow not a tutorial or marketing blurb.
14
14
 
15
- 3. **General, not user-specific.** A skill describes a *workflow class* (e.g. "build a deck," "summarize a PDF," "lint a python file"), not a single user's habits. Use placeholders (`<deck-dir>`, `<input.pdf>`), not hardcoded paths. If the workflow only makes sense for one person, it is a memory entry or a personal hook, not a skill.
15
+ 2. **One skill, one job.** A skill describes a single workflow. If it needs branches like "for case A do X, for case B do Y," it is two skills.
16
+
17
+ 3. **Concise and concrete.** Assume the model is already smart — only add what it doesn't already know. Every step has a verb and an object; no "as needed" or "appropriately." Keep the SKILL.md body well under 500 lines; push long reference material into sibling files linked one level deep.
18
+
19
+ A personal skill **may** contain this user's own context — their paths, project names, preferences, conventions. That is the point of a personal skill.
16
20
 
17
21
  ## Skill anatomy
18
22
 
19
23
  ```markdown
20
24
  ---
21
25
  name: <slug> # the slash-command name; lowercase-kebab
22
- description: <one sentence trigger> # WHEN to invoke (used by the dispatcher), not WHAT it does
26
+ description: <what it does + WHEN to invoke> # the dispatcher matches on this
23
27
  argument-hint: <args> # optional; how the user passes input
24
28
  ---
25
29
 
26
30
  ## Overview / Workflow
27
31
 
28
- Numbered steps the assistant follows on invocation. Each step is concrete:
29
- read this file, run this command, ask this question, output this format.
32
+ Numbered, concrete steps the assistant follows on invocation.
30
33
 
31
34
  ## Output format
32
35
 
33
- Exactly what the assistant returns to the user. Specify structure if the
34
- caller will parse it; specify tone if the caller is a human.
36
+ Exactly what the assistant returns structure if it will be parsed, tone if a human reads it.
35
37
 
36
38
  ## When to use / Do NOT use
37
39
 
38
- Two short lists. The "do not" list disambiguates this skill from neighbours
39
- that would otherwise also match the user's request.
40
+ Two short lists; the "do not" list disambiguates this skill from neighbours.
40
41
  ```
41
42
 
42
- ## Workflow when the user invokes `/create-skill <name> <description>`
43
-
44
- 1. Sanity-check the name and description against the three rules above. If the description leaks personal info ("a skill for me to clean my Notion db"), rewrite it to the general form ("clean a Notion database via the API") before scaffolding.
45
- 2. Create `assets/skills/<name>/SKILL.md` in the repo (the canonical source). The installed copy at `~/.pal/skills/<name>/SKILL.md` may be a junction to this path — verify the user's setup before assuming.
46
- 3. Populate the SKILL.md from the anatomy above. Required fields: `name`, `description`, body sections (Workflow, Output format, When to use). Add `argument-hint` if the skill takes arguments.
47
- 4. If the skill needs runtime tooling (TypeScript, scripts, vendored assets), scaffold a `tools/` subdir alongside SKILL.md. Otherwise leave the skill markdown-only.
48
- 5. Validate before declaring done:
49
- - **Trigger clarity** — could a model decide *not* to invoke this skill from the description alone? If yes, tighten the description.
50
- - **Step concreteness** — every step has a verb and an object; no "as needed" or "appropriately."
51
- - **Output specification** — caller knows what they get back.
52
- - **Scope discipline** one skill, one job. If the skill needs section headers like "for case A do X, for case B do Y," it is two skills.
53
- - **Personal-info scan** — grep the new SKILL.md for usernames, real names, absolute home paths, employer or project codenames; remove any hits.
54
- - **Generality test** — could a stranger with the same workflow need use this unchanged? If no, factor the user-specific bits into memory.
55
-
56
- ## Anti-patterns to refuse
57
-
58
- - A SKILL.md whose description starts with "I want…" or "My …" that's a journal entry, not a skill.
59
- - Hardcoded paths under `C:\Users\<name>\…` or `/Users/<name>/…` — use `~` (and document the cmd.exe `%USERPROFILE%` alternative if Windows is in scope).
60
- - Brand- or company-specific defaults baked into the skill body. Default brand colors, footer strings, etc. belong in *templates* (user data) or *config*, never in the skill.
61
- - A description longer than ~30 words, or one that explains the implementation rather than the trigger.
62
- - Skills that duplicate an existing skill's trigger surface. Read the existing skills index before scaffolding.
43
+ The `description` should state **both what the skill does and when to invoke it**, in third person, with the trigger terms a model would match on. A vague description ("helps with documents") will not trigger reliably.
44
+
45
+ ## Workflow when invoked with `<name> <description>`
46
+
47
+ 1. Validate the name: lowercase-kebab, no spaces, not colliding with an existing skill (check `~/.pal/skills/` and the active skill list).
48
+ 2. Confirm the trigger with the user if the description is ambiguous about *when* the skill should fire.
49
+ 3. Create the directory and SKILL.md at the user's PAL home:
50
+ ```bash
51
+ mkdir -p ~/.pal/skills/<name>
52
+ ```
53
+ Write `~/.pal/skills/<name>/SKILL.md` populated from the anatomy above (frontmatter + Workflow + Output format + When to use).
54
+ 4. If the skill needs runtime tooling, scaffold a `tools/` subdir alongside SKILL.md and write the scripts there.
55
+ 5. Link the new skill into every installed agent so it is discoverable:
56
+ ```bash
57
+ pal cli skill link <name>
58
+ ```
59
+ This creates the per-skill discovery symlink in each installed agent's skills directory (Claude Code, Cursor, Copilot, Codex); opencode discovers it automatically via `~/.pal/skills/`.
60
+ 6. Run the doctor and resolve every error it reports:
61
+ ```bash
62
+ pal cli skill doctor <name>
63
+ ```
64
+ It checks the mechanical rules (folder/file-name match, name length/charset, description length, point-of-view, body length, reference depth). Fix all `✗` errors; weigh each `⚠` warning. A name/folder mismatch or a misnamed file makes the skill silently fail to load, so never skip this.
65
+ 7. Validate the rest by hand — the doctor can't judge these:
66
+ - **Trigger clarity** — could a model decide *not* to invoke this from the description alone? If so, tighten it.
67
+ - **Step concreteness** — every step has a verb and an object.
68
+ - **Output specification** — the caller knows what they get back.
69
+ - **Scope discipline** — one skill, one job.
63
70
 
64
71
  ## Output format
65
72
 
66
73
  After scaffolding, return:
67
- - The path of the created `SKILL.md`.
74
+ - The path of the created `SKILL.md` (under `~/.pal/skills/<name>/`).
75
+ - The agents it was linked into (from the `pal cli skill link` output).
68
76
  - A 2-3 sentence summary of the trigger and workflow.
69
- - Any anti-pattern violations you caught and corrected during scaffolding (so the user learns the rule).
77
+ - How to invoke it (its `/<name>` slash command or trigger phrase).
@@ -13,7 +13,7 @@
13
13
  // node --experimental-strip-types ~/.pal/skills/playwright/tools/shot.ts <url> [opts]
14
14
 
15
15
  import { spawnSync } from "node:child_process";
16
- import { existsSync } from "node:fs";
16
+ import { existsSync, mkdtempSync, rmSync } from "node:fs";
17
17
  import { tmpdir } from "node:os";
18
18
  import { join, resolve } from "node:path";
19
19
  import { chooseTier, parseArgs, type ShotOptions } from "./shot-lib.ts";
@@ -27,13 +27,23 @@ function playwrightCliAvailable(): boolean {
27
27
  }
28
28
 
29
29
  function runViaCli(opts: ShotOptions, out: string): boolean {
30
- const open = spawnSync("playwright-cli", ["open", opts.url], { stdio: "inherit" });
31
- if (open.status !== 0) return false;
32
- const args = ["screenshot", `--filename=${out}`];
33
- if (opts.selector) args.push(opts.selector);
34
- const shot = spawnSync("playwright-cli", args, { stdio: "inherit" });
35
- spawnSync("playwright-cli", ["close"], { stdio: "ignore" });
36
- return shot.status === 0 && existsSync(out);
30
+ // playwright-cli writes a `.playwright-cli/` session dir (logs + page snapshots)
31
+ // into its cwd. Run it inside a throwaway temp dir so nothing lands in the user's
32
+ // repo, then remove it. `out` is already absolute, so the screenshot is unaffected.
33
+ // Cross-platform: os.tmpdir + fs.mkdtemp/rm — no shell, no platform-specific paths.
34
+ const work = mkdtempSync(join(tmpdir(), "pal-pwcli-"));
35
+ const run = (args: string[], quiet = false) =>
36
+ spawnSync("playwright-cli", args, { stdio: quiet ? "ignore" : "inherit", cwd: work });
37
+ try {
38
+ if (run(["open", opts.url]).status !== 0) return false;
39
+ const args = ["screenshot", `--filename=${out}`];
40
+ if (opts.selector) args.push(opts.selector);
41
+ const shot = run(args);
42
+ run(["close"], true);
43
+ return shot.status === 0 && existsSync(out);
44
+ } finally {
45
+ rmSync(work, { recursive: true, force: true });
46
+ }
37
47
  }
38
48
 
39
49
  // "unavailable" = the engine itself can't run (package or Chromium missing) → MCP fallback.
@@ -165,6 +165,18 @@ Refine criteria if the pressure test reveals gaps. Add criteria for uncovered fa
165
165
  - Decide execution order — what's serial, what can parallelize
166
166
  - **Extended+:** use EnterPlanMode for user alignment before executing
167
167
 
168
+ **Grounding Gate — verify the premises before EXECUTE.**
169
+
170
+ Don't just *list* what must be true — actively confirm the load-bearing facts the criteria rest on. For each premise (a reported behavior, a provider/API contract, an external tool's CLI, a file's contents, a claim about system state):
171
+ - **Reproduce** the reported behavior before designing a fix — no fix on an unreproduced bug.
172
+ - **Fetch the authoritative source** (live docs, the actual file, `gh api`) before asserting how something works.
173
+ - **Probe the dependency/contract** (tool availability, MCP account, env var, DB connectivity, exact CLI) before building on it.
174
+ - **Confirm one concrete artifact** (a commit, a shebang, a line of output) behind any external claim.
175
+
176
+ No criterion may rest on an unverified premise. If a premise can't be verified now, mark it explicitly as an assumption and add a criterion to check it during EXECUTE. After 2 failed attempts at the same sub-problem, stop and re-ground — re-run OBSERVE rather than iterating on a bad premise.
177
+
178
+ Output: `🧭 GROUNDING: [premises verified, or assumptions flagged]`
179
+
168
180
  ### ━━━ ⚡ EXECUTE ━━━ 3/5
169
181
 
170
182
  Do the work. Invoke selected capabilities via tool calls.
@@ -217,9 +229,11 @@ Focus: reasoning approach, problem decomposition, anticipation, blind spots.
217
229
 
218
230
  ```bash
219
231
  bun ~/.pal/tools/algorithm-reflect.ts --task "description" --criteria N --passed N --failed N --sentiment 1-10 \
220
- --q1 "self reflection" --q2 "algorithm reflection" --q3 "AI reflection"
232
+ --q1 "self reflection" --q2 "algorithm reflection" --q3 "AI reflection" --scope general
221
233
  ```
222
234
 
235
+ Set `--scope task-specific` when the Q2 idea is bound to this one task (e.g. a criterion that only matters for the specific file, API, or dataset you were handling) and would not generalize to the algorithm; use `general` (the default) for reusable structural improvements. This keeps the algorithm-update synthesis focused on changes worth folding into ALGORITHM.md.
236
+
223
237
  **3. Relationship note** — write one Session entry capturing what was done this session:
224
238
 
225
239
  ```bash
@@ -316,6 +330,7 @@ Only write if the insight is **genuine and reusable** — not every session prod
316
330
  ━━━ 🧠 PLAN ━━━ 2/5
317
331
  🧠 RISKS: [risks]
318
332
  🧠 PREMORTEM: [failure modes]
333
+ 🧭 GROUNDING: [premises verified, or assumptions flagged]
319
334
  📐 APPROACH: [execution plan]
320
335
 
321
336
  ━━━ ⚡ EXECUTE ━━━ 3/5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.46.0",
3
+ "version": "0.48.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
@@ -16,6 +16,8 @@
16
16
  * status Show current PAL configuration
17
17
  * doctor Check prerequisites and system health
18
18
  * usage Summarize token usage and cost
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
19
21
  */
20
22
 
21
23
  import { spawnSync } from "node:child_process";
@@ -192,6 +194,12 @@ async function runCli(command: string | undefined, args: string[]) {
192
194
  if (code !== 0) process.exit(code);
193
195
  break;
194
196
  }
197
+ case "skill": {
198
+ const { runSkill } = await import("./skill");
199
+ const code = await runSkill(args);
200
+ if (code !== 0) process.exit(code);
201
+ break;
202
+ }
195
203
  case "--help":
196
204
  case "-h":
197
205
  case "help":
@@ -234,6 +242,8 @@ function showHelp() {
234
242
  pal cli usage Summarize token usage and cost
235
243
  pal cli knowledge <sub> [args] Query & manage the knowledge store
236
244
  (search · graph · stats · hubs · find · show · add · ls)
245
+ pal cli skill link <name> Link a personal ~/.pal/skills/<name>/ into installed agents
246
+ pal cli skill doctor <name> Evaluate a skill against the authoring best practices
237
247
 
238
248
  Environment:
239
249
  PAL_HOME Override user state directory (default: ~/.pal or repo root)
@@ -0,0 +1,55 @@
1
+ /**
2
+ * pal cli skill — manage personal skills under ~/.pal/skills/.
3
+ *
4
+ * pal cli skill link <name> Link an existing ~/.pal/skills/<name>/ into
5
+ * every installed agent so it is discoverable.
6
+ * pal cli skill doctor <name> Evaluate ~/.pal/skills/<name>/ against the
7
+ * skill-authoring best practices.
8
+ */
9
+
10
+ import { resolve } from "node:path";
11
+ import { palHome } from "../hooks/lib/paths";
12
+ import { linkPersonalSkill, log } from "../targets/lib";
13
+ import { formatReport, lintSkill } from "../tools/skill-doctor";
14
+
15
+ export async function runSkill(args: string[]): Promise<number> {
16
+ const [sub, name] = args;
17
+
18
+ if (sub === "doctor") {
19
+ if (!name) {
20
+ log.error("Usage: pal cli skill doctor <name>");
21
+ return 1;
22
+ }
23
+ const report = lintSkill(resolve(palHome(), "skills", name));
24
+ console.log(formatReport(report));
25
+ return report.errors > 0 ? 1 : 0;
26
+ }
27
+
28
+ if (sub === "link") {
29
+ if (!name) {
30
+ log.error("Usage: pal cli skill link <name>");
31
+ return 1;
32
+ }
33
+ try {
34
+ const linked = linkPersonalSkill(name);
35
+ if (linked.length === 0) {
36
+ log.warn(
37
+ `'${name}' linked to no per-skill agents (none installed). ` +
38
+ "It is still discoverable by opencode via ~/.pal/skills/."
39
+ );
40
+ } else {
41
+ log.success(
42
+ `Linked '${name}' into: ${linked.join(", ")} ` +
43
+ "(opencode: auto via ~/.pal/skills/)"
44
+ );
45
+ }
46
+ return 0;
47
+ } catch (e) {
48
+ log.error(e instanceof Error ? e.message : String(e));
49
+ return 1;
50
+ }
51
+ }
52
+
53
+ log.error("Usage: pal cli skill <link|doctor> <name>");
54
+ return 1;
55
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Algorithm-review cadence — decides when to nudge the MAINTAINER to run an
3
+ * `/algorithm-update` session (fold accumulated Q2 reflections into ALGORITHM.md).
4
+ *
5
+ * Maintainer-only by construction: the nudge fires solely in a PAL repo checkout,
6
+ * detected by the presence of the repo-only `.agents/skills/algorithm-update`
7
+ * skill. That path never ships to downstream installs — package.json `files`
8
+ * lists only `src/` and `assets/`, so `.agents/` is absent everywhere except a
9
+ * source checkout. Downstream users therefore never see this nudge.
10
+ */
11
+
12
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
13
+ import { resolve } from "node:path";
14
+ import { readReflections } from "./learning-store";
15
+ import { palPkg, paths } from "./paths";
16
+
17
+ const NUDGE_MIN_COUNT = 25;
18
+ const NUDGE_MIN_DAYS = 7;
19
+ const DAY_MS = 86_400_000;
20
+
21
+ function markFile(): string {
22
+ return resolve(paths.state(), "algorithm-review.json");
23
+ }
24
+
25
+ export function readReviewMark(): string | null {
26
+ try {
27
+ const parsed = JSON.parse(readFileSync(markFile(), "utf-8"));
28
+ return typeof parsed.lastReviewedTs === "string" ? parsed.lastReviewedTs : null;
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ export function writeReviewMark(ts: string): void {
35
+ writeFileSync(markFile(), `${JSON.stringify({ lastReviewedTs: ts }, null, 2)}\n`);
36
+ }
37
+
38
+ /** True only in a maintainer repo checkout — the repo-only update skill is present. */
39
+ export function isMaintainerEnv(): boolean {
40
+ return existsSync(
41
+ resolve(palPkg(), ".agents", "skills", "algorithm-update", "SKILL.md")
42
+ );
43
+ }
44
+
45
+ /** Count reflections newer than the last review mark (all of them, if never reviewed). */
46
+ export function countUnreviewed(mark: string | null = readReviewMark()): number {
47
+ const reflections = readReflections(paths.reflectionsFile());
48
+ if (!mark) return reflections.length;
49
+ const cutoff = new Date(mark).getTime();
50
+ return reflections.filter((r) => r.ts && new Date(r.ts).getTime() > cutoff).length;
51
+ }
52
+
53
+ export interface ReviewNudge {
54
+ count: number;
55
+ sinceDays: number;
56
+ since: string | null;
57
+ }
58
+
59
+ /**
60
+ * Nudge data when BOTH thresholds are met (≥25 new AND ≥7 days) in a maintainer
61
+ * env, else null. `now` is injected for testability.
62
+ */
63
+ export function algorithmReviewNudge(now: Date): ReviewNudge | null {
64
+ if (!isMaintainerEnv()) return null;
65
+ const mark = readReviewMark();
66
+ const count = countUnreviewed(mark);
67
+ if (count < NUDGE_MIN_COUNT) return null;
68
+ const sinceDays = mark
69
+ ? Math.floor((now.getTime() - new Date(mark).getTime()) / DAY_MS)
70
+ : Number.POSITIVE_INFINITY;
71
+ if (sinceDays < NUDGE_MIN_DAYS) return null;
72
+ return { count, sinceDays, since: mark };
73
+ }
74
+
75
+ /** Formatted nudge section for the session reminder, or "" when it shouldn't fire. */
76
+ export function loadAlgorithmReviewNudge(now: Date = new Date()): string {
77
+ const n = algorithmReviewNudge(now);
78
+ if (!n) return "";
79
+ const since = n.since ? new Date(n.since).toISOString().slice(0, 10) : "the start";
80
+ const age = Number.isFinite(n.sinceDays) ? `, ${n.sinceDays}d` : "";
81
+ return [
82
+ "## Algorithm Review Due",
83
+ `🔧 ${n.count} new algorithm reflections since ${since}${age} — run \`/algorithm-update\` to fold the recurring ones into ALGORITHM.md.`,
84
+ ].join("\n");
85
+ }
@@ -6,6 +6,7 @@
6
6
  import { existsSync, readFileSync } from "node:fs";
7
7
  import { homedir } from "node:os";
8
8
  import { resolve } from "node:path";
9
+ import { loadAlgorithmReviewNudge } from "./algorithm-review";
9
10
  import { readLearnings } from "./learning-store";
10
11
  import { loadOpinionContext } from "./opinions";
11
12
  import { paths } from "./paths";
@@ -312,9 +313,12 @@ export function buildSystemReminder(opts: { agent?: AgentTarget } = {}): string
312
313
  ? loadSessionIntelligence()
313
314
  : "";
314
315
  const handoff = settings.isEnabled("handoff") ? loadHandoff() : "";
316
+ // Maintainer-only: self-gates to a repo checkout, "" for everyone else.
317
+ const algoReview = loadAlgorithmReviewNudge();
315
318
  const parts: string[] = [];
316
319
  if (startup) parts.push(startup);
317
320
  if (handoff) parts.push(handoff);
321
+ if (algoReview) parts.push(algoReview);
318
322
  if (selfModel) parts.push(selfModel);
319
323
  if (wisdom) parts.push(wisdom);
320
324
  if (opinions) parts.push(opinions);
@@ -158,3 +158,48 @@ export function readLearnings(baseDir: string, limit?: number): LearningEntry[]
158
158
 
159
159
  return entries;
160
160
  }
161
+
162
+ // ── Reflections ──
163
+
164
+ export interface ReflectionEntry {
165
+ ts: string;
166
+ cwd: string;
167
+ task: string;
168
+ sentiment: number;
169
+ q1: string;
170
+ q2: string;
171
+ q3: string;
172
+ /** Whether the improvement ideas generalize to the algorithm, or are task-bound. */
173
+ scope: "general" | "task-specific";
174
+ }
175
+
176
+ /**
177
+ * Read algorithm reflections from the JSONL store, newest first. Each line is one
178
+ * reflection produced at the end of an Algorithm run (q1 = what I'd do differently,
179
+ * q2 = algorithm improvement, q3 = AI-level insight).
180
+ */
181
+ export function readReflections(file: string, limit?: number): ReflectionEntry[] {
182
+ if (!existsSync(file)) return [];
183
+ const entries: ReflectionEntry[] = [];
184
+ for (const line of readFileSync(file, "utf-8").split("\n")) {
185
+ if (!line.trim()) continue;
186
+ try {
187
+ const o = JSON.parse(line);
188
+ entries.push({
189
+ ts: o.timestamp || "",
190
+ cwd: o.cwd || "",
191
+ task: o.task || "",
192
+ sentiment: typeof o.sentiment === "number" ? o.sentiment : 0,
193
+ q1: o.q1 || "",
194
+ q2: o.q2 || "",
195
+ q3: o.q3 || "",
196
+ // Untagged (e.g. the existing backlog) defaults to general — the common case.
197
+ scope: o.scope === "task-specific" ? "task-specific" : "general",
198
+ });
199
+ } catch {
200
+ /* skip malformed line */
201
+ }
202
+ }
203
+ entries.reverse(); // JSONL is appended chronologically → newest first
204
+ return limit ? entries.slice(0, limit) : entries;
205
+ }
@@ -50,6 +50,9 @@ export const paths = {
50
50
  knowledge: () => ensureDir(home("memory", "knowledge")),
51
51
  knowledgeDomain: (d: string) => ensureDir(home("memory", "knowledge", d)),
52
52
  failures: () => ensureDir(home("memory", "learning", "failures")),
53
+ reflections: () => ensureDir(home("memory", "learning", "reflections")),
54
+ reflectionsFile: () =>
55
+ home("memory", "learning", "reflections", "algorithm-reflections.jsonl"),
53
56
  retrievalIndex: () => home("memory", "learning", ".retrieval-index.json"),
54
57
  progress: () => ensureDir(home("memory", "state", "progress")),
55
58
  projectHistory: () => ensureDir(home("memory", "projects")),
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Retrieval index — single JSON file with per-doc term-frequency vectors and a global
3
- * document-frequency table over the failures + wisdom-frames corpus.
3
+ * document-frequency table over the failures + wisdom-frames + reflections corpus.
4
4
  *
5
5
  * Built once on first read, rebuilt in the background when source dirs change.
6
6
  * Read by the UserPromptSubmit retrieval handler; written by ensureIndex (sync bootstrap)
@@ -11,17 +11,18 @@ import { spawn } from "node:child_process";
11
11
  import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
12
12
  import { resolve } from "node:path";
13
13
  import { SIMILARITY_THRESHOLD } from "./graduation";
14
- import { readFailures } from "./learning-store";
14
+ import { readFailures, readReflections } from "./learning-store";
15
15
  import { logDebug, logError } from "./log";
16
16
  import { palPkg, paths } from "./paths";
17
17
  import { similarity, tokenize } from "./text-similarity";
18
18
  import { readFramesForRetrieval } from "./wisdom";
19
19
 
20
- const INDEX_VERSION = 1;
20
+ // v2: added "reflection" source — bump invalidates v1 caches so they rebuild with it.
21
+ const INDEX_VERSION = 2;
21
22
 
22
23
  export interface IndexedDoc {
23
24
  id: string;
24
- source: "failure" | "wisdom";
25
+ source: "failure" | "wisdom" | "reflection";
25
26
  path: string;
26
27
  rating: number;
27
28
  ts: string;
@@ -144,6 +145,32 @@ export function buildIndex(): RetrievalIndex {
144
145
  });
145
146
  }
146
147
 
148
+ // Reflections — each Algorithm run's self-observations. q1 ("what I'd do
149
+ // differently") is the actionable lesson; q2/q3 add process and reasoning signal.
150
+ for (const r of readReflections(paths.reflectionsFile())) {
151
+ const tokens = buildDocTokens([
152
+ { text: r.q1, weight: 3 },
153
+ { text: r.task, weight: 2 },
154
+ { text: r.q2, weight: 2 },
155
+ { text: r.q3, weight: 1 },
156
+ ]);
157
+ if (tokens.length === 0) continue;
158
+ const { tf, len } = buildTermFreq(tokens);
159
+ for (const term of Object.keys(tf)) df[term] = (df[term] ?? 0) + 1;
160
+ docs.push({
161
+ id: `reflect:${r.ts}`,
162
+ source: "reflection",
163
+ path: paths.reflectionsFile(),
164
+ rating: r.sentiment,
165
+ ts: r.ts,
166
+ tf,
167
+ len,
168
+ displayPrinciple: r.q1,
169
+ displayContext: r.task,
170
+ cwd: r.cwd || undefined,
171
+ });
172
+ }
173
+
147
174
  return {
148
175
  version: INDEX_VERSION,
149
176
  builtAt: new Date().toISOString(),
@@ -173,7 +200,7 @@ export function readIndex(): RetrievalIndex | null {
173
200
  export function isStale(index: RetrievalIndex): boolean {
174
201
  try {
175
202
  const builtMs = new Date(index.builtAt).getTime();
176
- for (const dir of [paths.failures(), paths.wisdom()]) {
203
+ for (const dir of [paths.failures(), paths.wisdom(), paths.reflections()]) {
177
204
  if (!existsSync(dir)) continue;
178
205
  if (statSync(dir).mtimeMs > builtMs) return true;
179
206
  }
@@ -131,7 +131,8 @@ function formatLine(s: ScoredDoc): string {
131
131
  return `- ${tag} ${principle} (CRYSTAL ${s.doc.rating}%)`;
132
132
  }
133
133
  const ago = formatAgo(s.doc.ts);
134
- const meta = ago ? `rating ${s.doc.rating}/10, ${ago}` : `rating ${s.doc.rating}/10`;
134
+ const kind = s.doc.source === "reflection" ? "reflection" : "rating";
135
+ const meta = ago ? `${kind} ${s.doc.rating}/10, ${ago}` : `${kind} ${s.doc.rating}/10`;
135
136
  return `- ${tag} ${principle} (${meta})`;
136
137
  }
137
138
 
@@ -9,7 +9,7 @@
9
9
  import { existsSync, readdirSync, readFileSync } from "node:fs";
10
10
  import { resolve } from "node:path";
11
11
  import { parse } from "./frontmatter";
12
- import { readFailures } from "./learning-store";
12
+ import { type FailureEntry, readFailures } from "./learning-store";
13
13
  import { loadOpinionContext } from "./opinions";
14
14
  import { palHome, paths } from "./paths";
15
15
  import { readFramePrinciples } from "./wisdom";
@@ -100,16 +100,39 @@ export function loadSynthesisRecommendations(): string {
100
100
  }
101
101
  }
102
102
 
103
- /** Build the 5 most recent failure lessons as an avoid-list. */
103
+ /**
104
+ * Rank failures for injection: same-project (cwd match) first, then by recency.
105
+ * Project-relevant lessons are never crowded out by more-recent other-project
106
+ * ones — fixing the case where (e.g.) a web-app's cache lessons dominate a CLI
107
+ * session purely because they were logged last. Returns the top `limit`.
108
+ */
109
+ export function rankFailures(
110
+ entries: FailureEntry[],
111
+ cwd: string,
112
+ limit = 5
113
+ ): FailureEntry[] {
114
+ return [...entries]
115
+ .sort((a, b) => {
116
+ const am = a.cwd === cwd;
117
+ const bm = b.cwd === cwd;
118
+ if (am !== bm) return am ? -1 : 1;
119
+ return (b.ts || "").localeCompare(a.ts || ""); // recency desc within a group
120
+ })
121
+ .slice(0, limit);
122
+ }
123
+
124
+ /** Build the failure avoid-list, prioritized by relevance to the current project. */
104
125
  export function loadFailurePatterns(): string {
105
126
  try {
106
- const entries = readFailures(paths.failures(), 5);
107
- if (entries.length === 0) return "";
127
+ const cwd = process.cwd();
128
+ const all = readFailures(paths.failures());
129
+ if (all.length === 0) return "";
108
130
 
109
- const lines = entries.map((e) => {
131
+ const lines = rankFailures(all, cwd).map((e) => {
110
132
  const label = e.rating ? `[${e.rating}/10]` : "";
133
+ const tag = e.cwd === cwd ? "[project]" : "[other]";
111
134
  const text = e.principle || e.context;
112
- return `- ${label} ${text}`.trim();
135
+ return `- ${label} ${tag} ${text}`.trim();
113
136
  });
114
137
 
115
138
  return ["## Lessons from Recent Failures — Apply These Now", ...lines].join("\n");
@@ -506,6 +506,46 @@ export function copySkills(claudeSkillsDir: string): number {
506
506
  return count;
507
507
  }
508
508
 
509
+ /**
510
+ * Agent skills directories that need a per-skill discovery link.
511
+ *
512
+ * opencode is intentionally absent: it discovers the whole ~/.pal/skills/ tree
513
+ * via the ~/.agents/skills → ~/.pal/skills symlink, so any personal skill is
514
+ * picked up without a per-skill link.
515
+ */
516
+ function perSkillAgentDirs(): { agent: string; dir: string }[] {
517
+ return [
518
+ { agent: "claude", dir: resolve(platform.claudeDir(), "skills") },
519
+ { agent: "cursor", dir: resolve(platform.cursorDir(), "skills") },
520
+ { agent: "copilot", dir: resolve(platform.copilotDir(), "skills") },
521
+ { agent: "codex", dir: resolve(platform.codexDir(), "skills") },
522
+ ];
523
+ }
524
+
525
+ /**
526
+ * Link a personal skill that already lives at ~/.pal/skills/<name>/ into every
527
+ * installed agent's skills directory so the agent discovers it. Mirrors the
528
+ * per-skill discovery link copySkills() creates for shipped skills.
529
+ *
530
+ * An agent counts as installed when its skills directory already exists.
531
+ * Returns the agents a discovery link was created for (opencode excluded — it
532
+ * is covered by the whole-dir ~/.agents/skills link).
533
+ */
534
+ export function linkPersonalSkill(name: string): string[] {
535
+ const palLink = resolve(PAL_SKILLS_DIR, name);
536
+ if (!existsSync(resolve(palLink, "SKILL.md"))) {
537
+ throw new Error(`No skill found at ${palLink}/SKILL.md`);
538
+ }
539
+ const linkType = process.platform === "win32" ? "junction" : "dir";
540
+ const linked: string[] = [];
541
+ for (const { agent, dir } of perSkillAgentDirs()) {
542
+ if (!existsSync(dir)) continue; // agent not installed
543
+ ensureSymlink(resolve(dir, name), palLink, linkType);
544
+ linked.push(agent);
545
+ }
546
+ return linked;
547
+ }
548
+
509
549
  /** Create or update a symlink/junction, replacing any non-symlink entry. */
510
550
  function ensureSymlink(link: string, target: string, type: "dir" | "junction"): void {
511
551
  try {
@@ -12,8 +12,7 @@
12
12
  * --q3 "Missed the implicit constraint about cross-platform"
13
13
  */
14
14
 
15
- import { appendFileSync, existsSync, mkdirSync } from "node:fs";
16
- import { resolve } from "node:path";
15
+ import { appendFileSync } from "node:fs";
17
16
  import { parseArgs } from "node:util";
18
17
  import { paths } from "../../hooks/lib/paths";
19
18
 
@@ -30,14 +29,15 @@ interface AlgorithmReflection {
30
29
  q1: string;
31
30
  q2: string;
32
31
  q3: string;
32
+ /** general = the improvement ideas generalize to the algorithm; task-specific = bound to this task. */
33
+ scope: "general" | "task-specific";
33
34
  }
34
35
 
35
36
  // ── Core ──
36
37
 
37
38
  function reflectionsPath(): string {
38
- const dir = resolve(paths.learning(), "reflections");
39
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
40
- return resolve(dir, "algorithm-reflections.jsonl");
39
+ paths.reflections(); // ensures the directory exists
40
+ return paths.reflectionsFile();
41
41
  }
42
42
 
43
43
  function appendReflection(reflection: AlgorithmReflection): {
@@ -70,6 +70,7 @@ function run() {
70
70
  q1: { type: "string" },
71
71
  q2: { type: "string" },
72
72
  q3: { type: "string" },
73
+ scope: { type: "string" },
73
74
  help: { type: "boolean", short: "h" },
74
75
  },
75
76
  });
@@ -91,6 +92,7 @@ Arguments:
91
92
  --q1 Q1 — Self: what I'd do differently
92
93
  --q2 Q2 — Algorithm: structural improvement
93
94
  --q3 Q3 — AI: reasoning blind spot
95
+ --scope general (default) | task-specific — is the algorithm idea reusable or task-bound?
94
96
 
95
97
  Output: algorithm-reflections.jsonl in memory/learning/reflections/
96
98
  `);
@@ -113,6 +115,9 @@ Output: algorithm-reflections.jsonl in memory/learning/reflections/
113
115
  q1: values.q1,
114
116
  q2: values.q2,
115
117
  q3: values.q3,
118
+ // Default to general (the ~94% case); only "task-specific" suppresses it
119
+ // from algorithm-update clustering.
120
+ scope: values.scope === "task-specific" ? "task-specific" : "general",
116
121
  };
117
122
 
118
123
  const result = appendReflection(reflection);
@@ -0,0 +1,218 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * algorithm-synthesize — surface the Q2 field ("what a smarter algorithm would
4
+ * have done") across algorithm reflections, pre-sorted into candidate areas, to
5
+ * drive the periodic algorithm-update session: the loop from collected
6
+ * reflections back into ALGORITHM.md itself.
7
+ *
8
+ * IMPORTANT — the keyword BUCKETS are a *heuristic pre-sort hint only*, biased to
9
+ * the current model's English phrasing. They are NOT the clustering authority:
10
+ * another model wording the same idea differently ("double-check the assumption"
11
+ * vs "verify the premise") will not match. The real clustering is semantic, done
12
+ * by the model reading this report in the session. Therefore the tool NEVER drops
13
+ * data — every unmatched Q2 is surfaced in full under "Unbucketed", so nothing is
14
+ * invisible even when (especially when) the keyword sort misses or mis-sorts it.
15
+ *
16
+ * Library: import { synthesizeAlgorithm, formatAlgorithmReport } from ".../algorithm-synthesize"
17
+ * Script: bun src/tools/agent/algorithm-synthesize.ts [--since <ISO>] [--json]
18
+ */
19
+
20
+ import { parseArgs } from "node:util";
21
+ import { readReflections } from "../../hooks/lib/learning-store";
22
+ import { paths } from "../../hooks/lib/paths";
23
+
24
+ /** Algorithm areas a Q2 idea can target, mapped to ALGORITHM.md structure. */
25
+ const BUCKETS: { key: string; label: string; patterns: RegExp }[] = [
26
+ {
27
+ key: "verify-gate",
28
+ label: "Establish grounding before acting (verify a fact / prerequisite first)",
29
+ patterns:
30
+ /pre-?check|pre-?mortem|premise|primary source|verif\w+|reproduce|before (writ|cod|propos|assert|implement|build|run|any|attempt|fetch|explor)|dependency scan|sanity-check|confirm .* before|prerequisite|gate (in|before)|trace .* first|check\w* .* before|anchor on|upfront|connectivity before|resolve .* before|triangulat|contradiction-detection|credibility gate|escalation|after \d+ (failed|iteration)/i,
31
+ },
32
+ {
33
+ key: "criteria",
34
+ label: "Criteria quality (atomicity, splitting, coverage, anti-criteria)",
35
+ patterns: /criteri|atomic|anti-criter|split .* criter|a .* criterion|enumerate/i,
36
+ },
37
+ {
38
+ key: "capability",
39
+ label: "Capability selection (plan mode, subagents, parallelism, research)",
40
+ patterns:
41
+ /capabilit|plan ?mode|askuser|sub-?agent|parallel|in one (agent|batch)|research|worktree/i,
42
+ },
43
+ {
44
+ key: "read-first",
45
+ label: "Read existing code/docs first (prior-artifact / context scan)",
46
+ patterns:
47
+ /read (the|existing|both)|inspect|prior-?artifact|existing (code|repo|hook|target)|grep|survey .* (before|first)/i,
48
+ },
49
+ {
50
+ key: "phase-structure",
51
+ label: "Phase structure (OBSERVE / PLAN / EXECUTE / VERIFY / LEARN ordering)",
52
+ patterns:
53
+ /observe|\bplan phase\b|execute|\bverify phase\b|\blearn phase\b|between (observe|plan|execute|verify)|new phase|a .* phase\b/i,
54
+ },
55
+ {
56
+ key: "scope",
57
+ label: "Scope & altitude (minimal change, avoid over-engineering)",
58
+ patterns: /scope|altitude|minimal|over-?eng|too (broad|wide|much)|narrow/i,
59
+ },
60
+ ];
61
+
62
+ interface AlgorithmBucket {
63
+ key: string;
64
+ label: string;
65
+ count: number;
66
+ avgSentiment: number;
67
+ projects: number;
68
+ quotes: string[];
69
+ }
70
+
71
+ export interface AlgorithmSynthesis {
72
+ /** Count of general Q2s clustered (task-specific ones are surfaced separately). */
73
+ total: number;
74
+ since: string | null;
75
+ buckets: AlgorithmBucket[];
76
+ /** Count of general Q2s the keyword pre-sort matched no bucket for. */
77
+ unbucketed: number;
78
+ /** Full deduped text of every unbucketed general Q2 — the safety net, never just a count. */
79
+ unbucketedQuotes: string[];
80
+ /** Count of Q2s tagged task-specific (excluded from clustering, kept for review). */
81
+ taskSpecific: number;
82
+ /** Full deduped text of task-specific Q2s — surfaced, never dropped. */
83
+ taskSpecificQuotes: string[];
84
+ }
85
+
86
+ function projectOf(cwd: string): string {
87
+ return cwd.split("/").filter(Boolean).pop() ?? "";
88
+ }
89
+
90
+ /**
91
+ * Cluster Q2 reflections into candidate algorithm-improvement areas. A Q2 may
92
+ * land in multiple buckets (ideas often span areas); `unbucketed` counts Q2s
93
+ * that matched none, so blind spots in the bucket set stay visible.
94
+ */
95
+ export function synthesizeAlgorithm(since?: Date): AlgorithmSynthesis {
96
+ const all = readReflections(paths.reflectionsFile());
97
+ const filtered = since ? all.filter((r) => r.ts && new Date(r.ts) >= since) : all;
98
+ const withQ2 = filtered.filter((r) => r.q2.trim());
99
+ // Cluster only general ideas; task-specific ones don't generalize to the
100
+ // algorithm, so surface them separately rather than polluting the buckets.
101
+ const q2s = withQ2.filter((r) => r.scope !== "task-specific");
102
+ const taskSpecificQuotes = [
103
+ ...new Set(withQ2.filter((r) => r.scope === "task-specific").map((r) => r.q2.trim())),
104
+ ];
105
+
106
+ const acc = new Map<
107
+ string,
108
+ { sentiments: number[]; projects: Set<string>; quotes: string[] }
109
+ >();
110
+ for (const b of BUCKETS) {
111
+ acc.set(b.key, { sentiments: [], projects: new Set(), quotes: [] });
112
+ }
113
+
114
+ let unbucketed = 0;
115
+ const unbucketedQuotes: string[] = [];
116
+ for (const r of q2s) {
117
+ const matched = BUCKETS.filter((b) => b.patterns.test(r.q2));
118
+ if (matched.length === 0) {
119
+ unbucketed += 1;
120
+ unbucketedQuotes.push(r.q2.trim());
121
+ continue;
122
+ }
123
+ for (const b of matched) {
124
+ const a = acc.get(b.key);
125
+ if (!a) continue;
126
+ a.sentiments.push(r.sentiment);
127
+ if (r.cwd) a.projects.add(projectOf(r.cwd));
128
+ a.quotes.push(r.q2.trim());
129
+ }
130
+ }
131
+
132
+ const buckets: AlgorithmBucket[] = BUCKETS.map((b) => {
133
+ const a = acc.get(b.key);
134
+ const sentiments = a?.sentiments ?? [];
135
+ const avg = sentiments.length
136
+ ? sentiments.reduce((s, n) => s + n, 0) / sentiments.length
137
+ : 0;
138
+ // Full deduped membership, longest (most specific) first. Not capped here —
139
+ // --json carries every member so nothing is lost; the human report caps the
140
+ // display per bucket. Zero-loss is the contract.
141
+ const quotes = [...new Set(a?.quotes ?? [])].sort((x, y) => y.length - x.length);
142
+ return {
143
+ key: b.key,
144
+ label: b.label,
145
+ count: sentiments.length,
146
+ avgSentiment: Math.round(avg * 10) / 10,
147
+ projects: a?.projects.size ?? 0,
148
+ quotes,
149
+ };
150
+ })
151
+ .filter((b) => b.count > 0)
152
+ .sort((x, y) => y.count - x.count);
153
+
154
+ return {
155
+ total: q2s.length,
156
+ since: since ? since.toISOString() : null,
157
+ buckets,
158
+ unbucketed,
159
+ unbucketedQuotes: [...new Set(unbucketedQuotes)],
160
+ taskSpecific: taskSpecificQuotes.length,
161
+ taskSpecificQuotes,
162
+ };
163
+ }
164
+
165
+ /** Render the synthesis as a markdown candidate-changes report. */
166
+ export function formatAlgorithmReport(s: AlgorithmSynthesis): string {
167
+ const sinceLabel = s.since ? ` since ${s.since.slice(0, 10)}` : " (all time)";
168
+ const tsLabel = s.taskSpecific > 0 ? ` (+${s.taskSpecific} task-specific, below)` : "";
169
+ const lines = [
170
+ "# Algorithm Update — Candidate Changes",
171
+ `Source: ${s.total} general Q2 reflections${sinceLabel}${tsLabel}`,
172
+ "",
173
+ "> The buckets below are a keyword pre-sort hint (biased to current wording).",
174
+ "> Cluster semantically yourself — and read the Unbucketed section in full,",
175
+ "> since that is where wording the heuristic missed lands.",
176
+ "",
177
+ "## Pre-sorted candidate areas",
178
+ ];
179
+ s.buckets.forEach((b, i) => {
180
+ lines.push(
181
+ `### ${i + 1}. ${b.label}`,
182
+ `${b.count} reflections · avg sentiment ${b.avgSentiment}/10 · ${b.projects} project(s)`,
183
+ ""
184
+ );
185
+ for (const q of b.quotes.slice(0, 5)) lines.push(`- ${q}`);
186
+ if (b.quotes.length > 5) lines.push(`- _…+${b.quotes.length - 5} more (see --json)_`);
187
+ lines.push("");
188
+ });
189
+ lines.push(
190
+ `## Unbucketed — ${s.unbucketed} general Q2s the pre-sort missed (cluster these by hand)`,
191
+ ""
192
+ );
193
+ for (const q of s.unbucketedQuotes) lines.push(`- ${q}`);
194
+ if (s.taskSpecific > 0) {
195
+ lines.push(
196
+ "",
197
+ `## Task-specific — ${s.taskSpecific} Q2s tagged not-generalizable (review, don't fold into ALGORITHM.md)`,
198
+ ""
199
+ );
200
+ for (const q of s.taskSpecificQuotes) lines.push(`- ${q}`);
201
+ }
202
+ return lines.join("\n");
203
+ }
204
+
205
+ if (import.meta.main) {
206
+ const { values } = parseArgs({
207
+ args: process.argv.slice(2),
208
+ options: {
209
+ since: { type: "string" },
210
+ json: { type: "boolean", default: false },
211
+ },
212
+ });
213
+ const since = values.since ? new Date(values.since) : undefined;
214
+ const result = synthesizeAlgorithm(since);
215
+ console.log(
216
+ values.json ? JSON.stringify(result, null, 2) : formatAlgorithmReport(result)
217
+ );
218
+ }
@@ -0,0 +1,254 @@
1
+ /**
2
+ * skill-doctor — static evaluator for a SKILL.md against Anthropic's
3
+ * skill-authoring best practices. Checks only what is mechanically verifiable:
4
+ * name/description constraints, body length, point-of-view, and reference depth.
5
+ *
6
+ * Library: import { lintSkill, formatReport } from ".../skill-doctor"
7
+ * Script: bun src/tools/skill-doctor.ts <skill-dir-or-name>
8
+ * (resolves a path, or a name under ~/.pal/skills/)
9
+ * CLI: pal cli skill doctor <name> (see src/cli/skill.ts)
10
+ */
11
+
12
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
13
+ import { basename, resolve } from "node:path";
14
+ import { palHome } from "../hooks/lib/paths";
15
+
16
+ type Level = "pass" | "warn" | "error";
17
+
18
+ interface DoctorFinding {
19
+ level: Level;
20
+ check: string;
21
+ message: string;
22
+ }
23
+
24
+ export interface DoctorReport {
25
+ dir: string;
26
+ name: string | null;
27
+ findings: DoctorFinding[];
28
+ errors: number;
29
+ warnings: number;
30
+ }
31
+
32
+ interface ParsedSkill {
33
+ name: string | null;
34
+ description: string | null;
35
+ body: string;
36
+ }
37
+
38
+ const RESERVED_WORDS = ["anthropic", "claude"];
39
+ const MAX_NAME = 64;
40
+ const MAX_DESCRIPTION = 1024;
41
+ const MAX_BODY_LINES = 500;
42
+
43
+ /** Split a SKILL.md into frontmatter fields and body. */
44
+ function parseSkill(content: string): ParsedSkill {
45
+ const parts = content.split(/^---\s*$/m);
46
+ if (parts.length < 3) {
47
+ return { name: null, description: null, body: content };
48
+ }
49
+ const frontmatter = parts[1];
50
+ const body = parts.slice(2).join("---");
51
+ const name = /^name:\s*"?(.+?)"?\s*$/m.exec(frontmatter)?.[1] ?? null;
52
+ const description = /^description:\s*"?(.+?)"?\s*$/m.exec(frontmatter)?.[1] ?? null;
53
+ return { name, description, body };
54
+ }
55
+
56
+ /** Remove fenced and inline code so prose checks don't trip on examples. */
57
+ function stripCode(s: string): string {
58
+ return s.replace(/```[\s\S]*?```/g, "").replace(/`[^`]*`/g, "");
59
+ }
60
+
61
+ /**
62
+ * Evaluate the skill at `skillDir` (a folder containing SKILL.md) and return a
63
+ * structured report. Hard rules from Anthropic's validation surface as errors;
64
+ * best-practice nudges surface as warnings.
65
+ */
66
+ export function lintSkill(skillDir: string): DoctorReport {
67
+ const findings: DoctorFinding[] = [];
68
+ const add = (level: Level, check: string, message: string) =>
69
+ findings.push({ level, check, message });
70
+
71
+ if (!existsSync(skillDir)) {
72
+ add("error", "structure", `No skill directory at ${skillDir}`);
73
+ return { dir: skillDir, name: null, findings, errors: 1, warnings: 0 };
74
+ }
75
+
76
+ // The runtime only loads a file named exactly `SKILL.md`. On case-insensitive
77
+ // filesystems (macOS, Windows) `existsSync` would accept `skill.md`, so match
78
+ // the real on-disk entry, not a case-folded path.
79
+ const skillFile = readdirSync(skillDir).find((e) => e.toLowerCase() === "skill.md");
80
+ if (!skillFile) {
81
+ add("error", "structure", `No SKILL.md found in ${skillDir}`);
82
+ return { dir: skillDir, name: null, findings, errors: 1, warnings: 0 };
83
+ }
84
+ skillFile === "SKILL.md"
85
+ ? add("pass", "file.name", "skill file is named SKILL.md")
86
+ : add(
87
+ "error",
88
+ "file.name",
89
+ `skill file is "${skillFile}" — must be exactly "SKILL.md" or the skill is silently ignored`
90
+ );
91
+
92
+ const { name, description, body } = parseSkill(
93
+ readFileSync(resolve(skillDir, skillFile), "utf-8")
94
+ );
95
+
96
+ // The runtime keys a skill by its folder name; a mismatched frontmatter `name`
97
+ // makes the skill silently fail to load.
98
+ const folder = basename(skillDir);
99
+ if (name) {
100
+ name === folder
101
+ ? add("pass", "name.folder", `matches folder "${folder}"`)
102
+ : add(
103
+ "error",
104
+ "name.folder",
105
+ `name "${name}" must equal the folder name "${folder}" verbatim — otherwise the skill is silently ignored`
106
+ );
107
+ }
108
+
109
+ // ── name ──
110
+ if (!name) {
111
+ add("error", "name", "Missing `name` in frontmatter");
112
+ } else {
113
+ name.length <= MAX_NAME
114
+ ? add("pass", "name.length", `${name.length}/${MAX_NAME} chars`)
115
+ : add("error", "name.length", `${name.length} chars exceeds ${MAX_NAME}`);
116
+ /^[a-z0-9-]+$/.test(name)
117
+ ? add("pass", "name.charset", "lowercase letters, numbers, hyphens only")
118
+ : add(
119
+ "error",
120
+ "name.charset",
121
+ `"${name}" must be lowercase a-z, 0-9, hyphens only`
122
+ );
123
+ const reserved = RESERVED_WORDS.find((w) => name.toLowerCase().includes(w));
124
+ reserved
125
+ ? add("error", "name.reserved", `contains reserved word "${reserved}"`)
126
+ : add("pass", "name.reserved", "no reserved words");
127
+ }
128
+
129
+ // ── description ──
130
+ if (!description) {
131
+ add("error", "description", "Missing `description` in frontmatter");
132
+ } else {
133
+ description.length <= MAX_DESCRIPTION
134
+ ? add(
135
+ "pass",
136
+ "description.length",
137
+ `${description.length}/${MAX_DESCRIPTION} chars`
138
+ )
139
+ : add(
140
+ "error",
141
+ "description.length",
142
+ `${description.length} chars exceeds ${MAX_DESCRIPTION}`
143
+ );
144
+ /<[^>]+>/.test(description)
145
+ ? add(
146
+ "warn",
147
+ "description.xml",
148
+ "contains angle-bracket content — Anthropic disallows XML tags; rephrase placeholders like <x> in prose"
149
+ )
150
+ : add("pass", "description.xml", "no XML tags");
151
+ /\bwhen/i.test(description)
152
+ ? add("pass", "description.trigger", "states when to use the skill")
153
+ : add(
154
+ "warn",
155
+ "description.trigger",
156
+ "no 'when to use' trigger — add 'Use when …' so the dispatcher can match it"
157
+ );
158
+ /\b(I can|I['’]ll|I will|I help|you can|you will|you could|you['’]ll)\b/i.test(
159
+ description
160
+ )
161
+ ? add(
162
+ "warn",
163
+ "description.pov",
164
+ "reads first/second person — write descriptions in third person (e.g. 'Processes…', 'Generates…')"
165
+ )
166
+ : add("pass", "description.pov", "third person");
167
+ }
168
+
169
+ // ── body ──
170
+ const bodyLines = body.split("\n").length;
171
+ bodyLines <= MAX_BODY_LINES
172
+ ? add("pass", "body.length", `${bodyLines}/${MAX_BODY_LINES} lines`)
173
+ : add(
174
+ "warn",
175
+ "body.length",
176
+ `${bodyLines} lines exceeds ${MAX_BODY_LINES} — split into reference files`
177
+ );
178
+
179
+ const prose = stripCode(body);
180
+ /\b(I['’]m|I will|I['’]ll|in my experience|my workflow|I wrote|I created)\b/i.test(
181
+ prose
182
+ )
183
+ ? add(
184
+ "warn",
185
+ "body.pov",
186
+ "body uses first-person author voice — write it as second-person instructions to the assistant"
187
+ )
188
+ : add("pass", "body.pov", "instructional voice");
189
+
190
+ // ── reference depth (one level deep) ──
191
+ const linkRe = /\[[^\]]+\]\(([^)]+\.md)\)/g;
192
+ const hasMdLink = /\[[^\]]+\]\([^)]+\.md\)/;
193
+ let nested = false;
194
+ for (const m of body.matchAll(linkRe)) {
195
+ const target = m[1];
196
+ if (/^https?:/.test(target)) continue;
197
+ const targetPath = resolve(skillDir, target);
198
+ if (!existsSync(targetPath)) continue;
199
+ const refContent = readFileSync(targetPath, "utf-8");
200
+ if (hasMdLink.test(refContent)) {
201
+ nested = true;
202
+ add(
203
+ "warn",
204
+ "references.depth",
205
+ `${target} links to further .md files — keep references one level deep from SKILL.md`
206
+ );
207
+ }
208
+ }
209
+ if (!nested) add("pass", "references.depth", "references are one level deep");
210
+
211
+ // ── windows-style paths ──
212
+ /\b[\w.-]+\\[\w.-]+\.(py|ts|js|sh|md|json)\b/.test(body)
213
+ ? add("warn", "paths", "Windows-style backslash path found — use forward slashes")
214
+ : add("pass", "paths", "forward-slash paths");
215
+
216
+ const errors = findings.filter((f) => f.level === "error").length;
217
+ const warnings = findings.filter((f) => f.level === "warn").length;
218
+ return { dir: skillDir, name, findings, errors, warnings };
219
+ }
220
+
221
+ /** Render a report as a human-readable string. */
222
+ export function formatReport(r: DoctorReport): string {
223
+ const icon = { pass: "✓", warn: "⚠", error: "✗" } as const;
224
+ const lines = [`skill-doctor: ${r.name ?? "(unparsed)"} — ${r.dir}`];
225
+ for (const f of r.findings) {
226
+ lines.push(` ${icon[f.level]} ${f.check}: ${f.message}`);
227
+ }
228
+ let verdict: string;
229
+ if (r.errors > 0) {
230
+ verdict = `FAIL — ${r.errors} error(s), ${r.warnings} warning(s)`;
231
+ } else if (r.warnings > 0) {
232
+ verdict = `OK with ${r.warnings} warning(s)`;
233
+ } else {
234
+ verdict = "PASS — all checks clean";
235
+ }
236
+ lines.push(` ${verdict}`);
237
+ return lines.join("\n");
238
+ }
239
+
240
+ if (import.meta.main) {
241
+ const arg = process.argv[2];
242
+ if (!arg) {
243
+ console.error("Usage: bun src/tools/skill-doctor.ts <skill-dir-or-name>");
244
+ process.exit(2);
245
+ }
246
+ let dir = resolve(arg);
247
+ if (!existsSync(resolve(dir, "SKILL.md"))) {
248
+ const byName = resolve(palHome(), "skills", arg);
249
+ if (existsSync(resolve(byName, "SKILL.md"))) dir = byName;
250
+ }
251
+ const report = lintSkill(dir);
252
+ console.log(formatReport(report));
253
+ process.exit(report.errors > 0 ? 1 : 0);
254
+ }