portable-agent-layer 0.59.0 → 0.60.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
@@ -88,6 +88,9 @@ pal cli status # check your setup
88
88
  | `pal cli knowledge` | Query & manage the knowledge store (search, graph, stats, hubs, find, show, add, ls, ingest) |
89
89
  | `pal cli skill link <name>` | Link a personal `~/.pal/skills/<name>/` into every installed agent so it is discoverable |
90
90
  | `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) |
91
+ | `pal cli subagent link <name>` | Install a personal `~/.pal/agents/<name>.md` (merged multi-platform definition) into every installed agent, split per platform |
92
+ | `pal cli subagent doctor <name>` | Evaluate a subagent definition against the authoring best practices (name/file match, per-platform blocks, model/tools/permission shape, shipped-name collision) |
93
+ | `pal cli subagent list` | List the user-authored subagents in `~/.pal/agents/` |
91
94
 
92
95
  ### Target flags
93
96
 
@@ -0,0 +1,50 @@
1
+ ---
2
+ name: subagent-author
3
+ description: Authors a new personal PAL subagent end-to-end — writes the merged multi-platform definition under ~/.pal/agents/, installs it into every agent, and runs the doctor. Invoked by the create-subagent skill to delegate the authoring to a flagship model.
4
+
5
+ claude:
6
+ tools: Bash, Read, Write, Edit, Grep, Glob
7
+ model: fable
8
+
9
+ opencode:
10
+ mode: subagent
11
+ permission:
12
+ read: allow
13
+ write: allow
14
+ edit: allow
15
+ bash: allow
16
+
17
+ cursor:
18
+ model: inherit
19
+ readonly: false
20
+ is_background: false
21
+ ---
22
+
23
+ You author a single new personal subagent for this user. You are handed a **subagent name** and a **description**, and interview answers about its model, tools, skills, and system prompt.
24
+
25
+ ## Steps
26
+
27
+ 1. Read the authoring guide — it is the single source of truth for the merged frontmatter schema and per-platform field rules:
28
+ ```bash
29
+ cat ~/.pal/skills/create-subagent/authoring-guide.md
30
+ ```
31
+ 2. Validate the name: lowercase-kebab, no spaces. It must not collide with an existing personal subagent (`pal cli subagent list`) or a shipped one. If it collides or is malformed, stop and report the conflict instead of overwriting.
32
+ 3. Write `~/.pal/agents/<name>.md` (create `~/.pal/agents/` if missing), populated per the guide's merged schema: global `name` + `description`, then only the platform blocks that matter (`claude:`, `opencode:`, `cursor:`, `copilot:`), then the system prompt body. Remember: `skills:` is Claude-only and preloads — for other agents, name intended skills in the body.
33
+ 4. Install it into every installed agent:
34
+ ```bash
35
+ pal cli subagent link <name>
36
+ ```
37
+ 5. Run the doctor and fix every `✗` error; weigh each `⚠`:
38
+ ```bash
39
+ pal cli subagent doctor <name>
40
+ ```
41
+ A name/file mismatch makes the subagent silently fail to load — never skip this.
42
+ 6. Hand-check what the doctor can't: trigger clarity (would the parent agent delegate from the description alone?), role discipline (one job), prompt concreteness, and least-privilege tool/permission fit.
43
+
44
+ ## Output
45
+
46
+ Return, concisely:
47
+ - The path of the created definition (`~/.pal/agents/<name>.md`).
48
+ - The agents it was installed into (from the `pal cli subagent link` output).
49
+ - The doctor's final result (errors resolved).
50
+ - A 2-3 sentence summary of the subagent's role and when to delegate to it.
@@ -0,0 +1,71 @@
1
+ ---
2
+ name: create-subagent
3
+ description: "Create a user-scoped subagent for every installed agent (Claude Code, opencode, Cursor, Copilot) from one merged definition, then install and run the doctor on it. Use when the user asks to create a subagent, add a custom agent, delegate a role to a specialized agent, or \"make a subagent that…\"."
4
+ argument-hint: <subagent name> <what it does + when to delegate>
5
+ ---
6
+
7
+ # Create a personal subagent
8
+
9
+ This authors a personal subagent into the user's own `~/.pal/agents/<name>.md` — a
10
+ single file with merged multi-platform frontmatter — then installs a per-platform
11
+ copy into every agent the user has installed, so it is immediately available as a
12
+ delegate. It does not touch the subagents PAL ships (those live in the repo).
13
+
14
+ The per-platform frontmatter schema and every field rule live in one place — read it
15
+ before writing anything:
16
+
17
+ ```bash
18
+ cat ~/.pal/skills/create-subagent/authoring-guide.md
19
+ ```
20
+
21
+ ## Workflow when invoked with `<name> <description>`
22
+
23
+ 1. Validate the name: lowercase-kebab, no spaces. It must not collide with an
24
+ existing personal subagent (`pal cli subagent list`) or a shipped one (the CLI
25
+ rejects shipped names on link — a reinstall would overwrite them).
26
+ 2. Interview the user for what the description leaves unspecified. Ask only what you
27
+ cannot reasonably infer, then confirm your draft:
28
+ - **model** — a flagship model or `inherit` the caller's model (can differ per agent).
29
+ - **tools** — which tools it may use (a comma list for Claude/Copilot; a permission
30
+ map for opencode). Default: inherit all tools.
31
+ - **skills** — skills to PRELOAD. Claude Code only; the other agents have no such
32
+ field (see the guide). Record intended skills in the body for the others.
33
+ - **system prompt** — the role and behavior. Draft it and confirm with the user.
34
+ 3. Check whether a flagship authoring model is configured for the current agent:
35
+ ```bash
36
+ pal cli subagent author-model
37
+ ```
38
+ 4. **If it prints a model** → delegate the authoring to the `subagent-author` subagent
39
+ (preconfigured to run on that flagship model). Hand it the name, description, and
40
+ interview answers; let it write `~/.pal/agents/<name>.md`, run `pal cli subagent
41
+ link`, and run the doctor. Relay its result.
42
+ 5. **If it prints nothing** → author the subagent inline yourself:
43
+ - Read `authoring-guide.md` (above) and follow the merged frontmatter schema.
44
+ - Write `~/.pal/agents/<name>.md` (create `~/.pal/agents/` if it is missing).
45
+ - Install it into every installed agent:
46
+ ```bash
47
+ pal cli subagent link <name>
48
+ ```
49
+ - Run the doctor and fix every `✗` error; weigh each `⚠`:
50
+ ```bash
51
+ pal cli subagent doctor <name>
52
+ ```
53
+
54
+ Either path: if only some agents are installed, `pal cli subagent link` writes only to
55
+ those — re-run it after installing another agent to propagate the subagent there.
56
+
57
+ ## Output format
58
+
59
+ After installing, return:
60
+ - The path of the created definition (`~/.pal/agents/<name>.md`).
61
+ - The agents it was installed into (from the `pal cli subagent link` output).
62
+ - The doctor's final result (errors resolved).
63
+ - A 2-3 sentence summary of the subagent's role and when the user should delegate to it.
64
+
65
+ ## When to use / Do NOT use
66
+
67
+ - **Use** when the user wants a reusable, specialized *agent* they delegate work to,
68
+ shared across their installed agents.
69
+ - **Do NOT use** for a personal *skill* — a workflow the current agent runs itself.
70
+ That is `create-skill`. A subagent is a separate agent you hand a task to; a skill is
71
+ an instruction set the current agent follows.
@@ -0,0 +1,87 @@
1
+ # Subagent authoring guide
2
+
3
+ The canonical rules for authoring a personal PAL subagent. Both the `create-subagent`
4
+ dispatcher and the delegated `subagent-author` subagent follow this file, so it is the
5
+ single source of truth for subagent quality and the exact frontmatter schema.
6
+
7
+ ## What a subagent is
8
+
9
+ A subagent is a **separate agent you delegate a scoped task to** — a reviewer, a
10
+ researcher, a refactorer. It has its own system prompt, its own tool access, and
11
+ optionally its own model. It is not a skill (a workflow the current agent runs). If the
12
+ user wants "a thing the assistant does," that is `create-skill`; if they want "another
13
+ agent I can hand work to," that is this.
14
+
15
+ ## One definition, every agent
16
+
17
+ A personal subagent is stored once as `~/.pal/agents/<name>.md` with **merged
18
+ multi-platform frontmatter**. On `pal cli subagent link <name>`, PAL splits it per
19
+ platform and writes a native file into each installed agent's agents directory. Global
20
+ fields (`name`, `description`) always ship; each platform block is un-indented into that
21
+ agent's frontmatter and the other blocks are stripped.
22
+
23
+ ```markdown
24
+ ---
25
+ name: <slug> # lowercase-kebab; MUST equal the file stem
26
+ description: "<what it does + WHEN to delegate to it>"
27
+ claude:
28
+ tools: Read, Grep, Bash # comma list; omit to inherit all tools
29
+ model: fable # a model alias/id, or `inherit`
30
+ skills: # OPTIONAL, Claude only — PRELOADS these skills
31
+ - some-skill
32
+ opencode:
33
+ mode: subagent # required so opencode treats it as a delegate
34
+ model: inherit
35
+ permission: # per-capability: allow | ask | deny
36
+ read: allow
37
+ write: allow
38
+ edit: allow
39
+ bash: allow
40
+ cursor:
41
+ model: inherit
42
+ readonly: false
43
+ is_background: false
44
+ copilot:
45
+ model: inherit
46
+ tools: read, edit # comma list
47
+ ---
48
+
49
+ <system prompt — second-person instructions to the subagent>
50
+ ```
51
+
52
+ Only include the platform blocks that matter. A block you omit means that agent installs
53
+ with just `name` + `description` (a valid, minimal subagent).
54
+
55
+ ## Per-platform field rules (verified, do not guess)
56
+
57
+ - **model** — `inherit` uses the caller's model. A flagship alias (`fable`, etc.) or a
58
+ full model id pins it. Unknown values are a doctor warning, not an error, because model
59
+ names drift.
60
+ - **tools** (Claude, Copilot) — a comma-separated allow-list. Omit to inherit all tools;
61
+ do not write an empty `tools:`.
62
+ - **opencode** — set `mode: subagent`. Access is a `permission:` map (`read`, `write`,
63
+ `edit`, `bash`, …) with values `allow` / `ask` / `deny`. opencode has **no** `tools`
64
+ allow-list field.
65
+ - **skills** — **Claude Code only**, and it *preloads* skills into the subagent's context;
66
+ it does not restrict them. opencode, Cursor, and Copilot have **no** `skills` frontmatter
67
+ field — emitting one there is ignored. If a subagent should lean on specific skills on
68
+ those agents, name them in the system prompt body instead.
69
+ - **cursor** — `readonly` and `is_background` are booleans (`true`/`false`).
70
+
71
+ ## What makes a good subagent
72
+
73
+ 1. **Pointed at the subagent, not the user.** The body is the subagent's system prompt —
74
+ second-person instructions to *it* ("You review…", "Return…"). Not a tutorial.
75
+ 2. **One job.** A subagent has a single role. "Reviews security AND writes migrations" is
76
+ two subagents.
77
+ 3. **A trigger in the description.** State *when to delegate* to it, in third person, with
78
+ the terms a model would match on — the parent agent uses this to decide to hand off.
79
+ 4. **Least privilege.** Grant only the tools/permissions the role needs. A read-only
80
+ reviewer should not have write/bash.
81
+
82
+ ## Hand-checks the doctor can't judge
83
+
84
+ - **Trigger clarity** — could the parent agent fail to delegate from the description alone?
85
+ - **Role discipline** — one job, one subagent.
86
+ - **Prompt concreteness** — the body tells the subagent exactly what to do and what to return.
87
+ - **Privilege fit** — tools/permissions match the role, nothing broader.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.59.0",
3
+ "version": "0.60.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
@@ -18,6 +18,8 @@
18
18
  * usage Summarize token usage and cost
19
19
  * skill link <name> Link a personal ~/.pal/skills/<name>/ into installed agents
20
20
  * skill doctor <name> Evaluate a skill against the authoring best practices
21
+ * subagent link <name> Install a personal ~/.pal/agents/<name>.md into installed agents
22
+ * subagent doctor <name> Evaluate a subagent against the authoring best practices
21
23
  * debug [on|off] Enable / disable verbose hook debug logging
22
24
  */
23
25
 
@@ -208,6 +210,12 @@ async function runCli(command: string | undefined, args: string[]) {
208
210
  if (code !== 0) process.exit(code);
209
211
  break;
210
212
  }
213
+ case "subagent": {
214
+ const { runSubagent } = await import("./subagent");
215
+ const code = await runSubagent(args);
216
+ if (code !== 0) process.exit(code);
217
+ break;
218
+ }
211
219
  case "skill": {
212
220
  const { runSkill } = await import("./skill");
213
221
  const code = await runSkill(args);
@@ -263,6 +271,10 @@ function showHelp() {
263
271
  pal cli skill link <name> Link a personal ~/.pal/skills/<name>/ into installed agents
264
272
  pal cli skill doctor <name> Evaluate a skill against the authoring best practices
265
273
  pal cli skill author-model Print the flagship model that authors skills for the active agent
274
+ pal cli subagent link <name> Install a personal ~/.pal/agents/<name>.md into installed agents
275
+ pal cli subagent doctor <name> Evaluate a subagent against the authoring best practices
276
+ pal cli subagent list List the user-authored subagents in ~/.pal/agents/
277
+ pal cli subagent author-model Print the flagship model that authors subagents for the active agent
266
278
  pal cli debug [on|off] Enable/disable verbose hook debug logging (persisted)
267
279
 
268
280
  Environment:
@@ -0,0 +1,75 @@
1
+ /**
2
+ * pal cli subagent — manage personal subagents under ~/.pal/agents/.
3
+ *
4
+ * pal cli subagent link <name> Install ~/.pal/agents/<name>.md into every
5
+ * installed agent (split per platform).
6
+ * pal cli subagent doctor <name> Evaluate ~/.pal/agents/<name>.md against the
7
+ * subagent-authoring best practices.
8
+ * pal cli subagent list List the user-authored subagents.
9
+ * pal cli subagent author-model Print the flagship model configured to author
10
+ * subagents for the active agent (empty if none).
11
+ */
12
+
13
+ import { getActiveAgent } from "../hooks/lib/agent";
14
+ import { flagshipAuthorModel } from "../hooks/lib/models";
15
+ import { installPersonalSubagent, listPersonalSubagents, log } from "../targets/lib";
16
+ import {
17
+ formatSubagentReport,
18
+ lintSubagent,
19
+ resolveSubagentFile,
20
+ } from "../tools/subagent-doctor";
21
+
22
+ export async function runSubagent(args: string[]): Promise<number> {
23
+ const [sub, name] = args;
24
+
25
+ if (sub === "author-model") {
26
+ const model = flagshipAuthorModel(getActiveAgent());
27
+ if (model) console.log(model);
28
+ return 0;
29
+ }
30
+
31
+ if (sub === "list") {
32
+ const names = listPersonalSubagents();
33
+ if (names.length === 0) {
34
+ log.info("No personal subagents in ~/.pal/agents/");
35
+ } else {
36
+ for (const n of names) console.log(n);
37
+ }
38
+ return 0;
39
+ }
40
+
41
+ if (sub === "doctor") {
42
+ if (!name) {
43
+ log.error("Usage: pal cli subagent doctor <name>");
44
+ return 1;
45
+ }
46
+ const report = lintSubagent(resolveSubagentFile(name));
47
+ console.log(formatSubagentReport(report));
48
+ return report.errors > 0 ? 1 : 0;
49
+ }
50
+
51
+ if (sub === "link") {
52
+ if (!name) {
53
+ log.error("Usage: pal cli subagent link <name>");
54
+ return 1;
55
+ }
56
+ try {
57
+ const installed = installPersonalSubagent(name);
58
+ if (installed.length === 0) {
59
+ log.warn(
60
+ `'${name}' installed into no agents (none installed yet). ` +
61
+ "Run 'pal cli install' first, then re-link."
62
+ );
63
+ } else {
64
+ log.success(`Installed '${name}' into: ${installed.join(", ")}`);
65
+ }
66
+ return 0;
67
+ } catch (e) {
68
+ log.error(e instanceof Error ? e.message : String(e));
69
+ return 1;
70
+ }
71
+ }
72
+
73
+ log.error("Usage: pal cli subagent <link|doctor|list|author-model> [name]");
74
+ return 1;
75
+ }
@@ -7,9 +7,11 @@
7
7
  * 2. last-handoff.json keyed by cwd
8
8
  * → read by loadHandoff() to surface "Pick Up Where You Left Off"
9
9
  *
10
- * Always overwrites — Stop is the source of truth for both. The LEARN-phase
11
- * handoff-note.ts tool may also write to last-handoff.json; whichever runs last wins,
12
- * but raw exchange is sufficient for continuity and costs nothing.
10
+ * last-exchange is always overwritten — Stop is its source of truth. last-handoff
11
+ * is NOT: a deliberate LEARN-phase note (written by handoff-note.ts with
12
+ * source:"deliberate") outranks this raw auto-snapshot, so we leave it intact
13
+ * while it is still fresh and in-progress. Otherwise the auto-snapshot would
14
+ * clobber the curated handoff the moment the session stopped (ISC-39).
13
15
  */
14
16
 
15
17
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
@@ -21,6 +23,18 @@ import { detectStatus } from "../lib/work-tracking";
21
23
 
22
24
  type ParsedMessage = { role: string; content: unknown };
23
25
 
26
+ /** Matches loadHandoff()'s staleness window in hooks/lib/context.ts. */
27
+ const HANDOFF_STALE_MS = 7 * 24 * 60 * 60 * 1000;
28
+
29
+ /** A fresh, in-progress deliberate note must not be overwritten by the auto-snapshot. */
30
+ function isProtectedHandoff(entry: unknown): boolean {
31
+ if (!entry || typeof entry !== "object") return false;
32
+ const e = entry as { source?: string; status?: string; timestamp?: string };
33
+ if (e.source !== "deliberate" || e.status !== "in-progress" || !e.timestamp)
34
+ return false;
35
+ return Date.now() - new Date(e.timestamp).getTime() <= HANDOFF_STALE_MS;
36
+ }
37
+
24
38
  export function persistLastExchange(
25
39
  messages: ParsedMessage[],
26
40
  sessionId: string,
@@ -45,26 +59,30 @@ export function persistLastExchange(
45
59
  writeFileSync(resolve(stateDir, `${sessionId}.json`), json, "utf-8");
46
60
  writeFileSync(resolve(stateDir, "latest.json"), json, "utf-8");
47
61
 
48
- // 2. Write last-handoff.json for "Pick Up Where You Left Off"
62
+ // 2. Write last-handoff.json for "Pick Up Where You Left Off" — unless a
63
+ // fresh deliberate note already owns this cwd (ISC-39).
49
64
  const handoffPath = resolve(paths.state(), "last-handoff.json");
50
65
  const existing: Record<string, unknown> = existsSync(handoffPath)
51
66
  ? JSON.parse(readFileSync(handoffPath, "utf-8"))
52
67
  : {};
53
- const title = (lastUser.slice(0, 80).replace(/\n/g, " ") || "Session").trim();
54
- const handoff = [
55
- lastUser ? `Last user message:\n${lastUser.slice(0, 500)}` : "",
56
- lastAssistant ? `\nLast assistant response:\n${lastAssistant.slice(0, 500)}` : "",
57
- ]
58
- .filter(Boolean)
59
- .join("");
60
- existing[cwd] = {
61
- timestamp: new Date().toISOString(),
62
- title,
63
- status: detectStatus(lastAssistant),
64
- handoff,
65
- artifacts: [],
66
- };
67
- writeFileSync(handoffPath, JSON.stringify(existing, null, 2), "utf-8");
68
+ if (!isProtectedHandoff(existing[cwd])) {
69
+ const title = (lastUser.slice(0, 80).replace(/\n/g, " ") || "Session").trim();
70
+ const handoff = [
71
+ lastUser ? `Last user message:\n${lastUser.slice(0, 500)}` : "",
72
+ lastAssistant ? `\nLast assistant response:\n${lastAssistant.slice(0, 500)}` : "",
73
+ ]
74
+ .filter(Boolean)
75
+ .join("");
76
+ existing[cwd] = {
77
+ timestamp: new Date().toISOString(),
78
+ title,
79
+ status: detectStatus(lastAssistant),
80
+ handoff,
81
+ artifacts: [],
82
+ source: "auto",
83
+ };
84
+ writeFileSync(handoffPath, JSON.stringify(existing, null, 2), "utf-8");
85
+ }
68
86
 
69
87
  logDebug(
70
88
  "persist-last-exchange",
@@ -8,8 +8,13 @@ import { relative, resolve } from "node:path";
8
8
  import AdmZip from "adm-zip";
9
9
  import { palHome } from "./paths";
10
10
 
11
- /** Directories within PAL_HOME that contain user state worth exporting. */
12
- const EXPORT_DIRS = ["telos", "memory"];
11
+ /**
12
+ * Directories within PAL_HOME that contain user state worth exporting.
13
+ * `skills` and `agents` hold user-authored personal skills and subagents.
14
+ * Shipped skills live in `skills/` only as symlinks back to the repo, and
15
+ * walkDir skips symlinks (see below) — so only real, user-owned files travel.
16
+ */
17
+ const EXPORT_DIRS = ["telos", "memory", "skills", "agents"];
13
18
 
14
19
  /** Subdirectories/files to skip during export. */
15
20
  const SKIP_PATTERNS = ["memory/downloads"];
@@ -30,6 +35,9 @@ function walkDir(dir: string, root: string): string[] {
30
35
 
31
36
  if (shouldSkip(relPath)) continue;
32
37
 
38
+ // Symlinks report neither isDirectory nor isFile, so they are skipped —
39
+ // this is what keeps shipped-skill symlinks (skills/<name> → repo) out of
40
+ // the export. Do not switch to stat()-based following.
33
41
  if (entry.isDirectory()) {
34
42
  files.push(...walkDir(fullPath, root));
35
43
  } else if (entry.isFile()) {
@@ -927,7 +927,7 @@ function extractAgentForPlatform(content: string, platform: AgentPlatform): stri
927
927
  for (const line of frontmatter.split("\n")) {
928
928
  if (!line.trim()) continue;
929
929
 
930
- const platformMatch = new RegExp(/^(claude|opencode|cursor):\s*$/).exec(line);
930
+ const platformMatch = new RegExp(/^(claude|opencode|cursor|copilot):\s*$/).exec(line);
931
931
  if (platformMatch) {
932
932
  currentPlatform = platformMatch[1] as AgentPlatform;
933
933
  continue;
@@ -1013,6 +1013,79 @@ export function removeAgentsFromCopilot(copilotAgentsDir: string): string[] {
1013
1013
  return uninstallAgents(copilotAgentsDir, "copilot");
1014
1014
  }
1015
1015
 
1016
+ // --- Personal subagents (user-authored, under ~/.pal/agents/) ---
1017
+
1018
+ /**
1019
+ * Store for user-authored subagents: ~/.pal/agents/<name>.md — one merged
1020
+ * multi-platform frontmatter file per subagent (same schema as assets/agents/).
1021
+ */
1022
+ const PAL_AGENTS_STORE = resolve(palHome(), "agents");
1023
+
1024
+ /**
1025
+ * Each installed agent and the native agents directory a personal subagent is
1026
+ * written into. An agent counts as installed when its agents directory already
1027
+ * exists (mirrors linkPersonalSkill's per-agent gate).
1028
+ */
1029
+ function personalSubagentTargets(): { agent: AgentPlatform; dir: string }[] {
1030
+ return [
1031
+ { agent: "claude", dir: resolve(platform.claudeDir(), "agents") },
1032
+ { agent: "opencode", dir: resolve(platform.opencodeDir(), "agents") },
1033
+ { agent: "cursor", dir: resolve(platform.cursorDir(), "agents") },
1034
+ { agent: "copilot", dir: resolve(platform.copilotDir(), "agents") },
1035
+ ];
1036
+ }
1037
+
1038
+ /** Names of the subagents PAL ships (assets/agents/*.md). */
1039
+ function shippedAgentNames(): Set<string> {
1040
+ const dir = assets.agents();
1041
+ if (!existsSync(dir)) return new Set();
1042
+ return new Set(
1043
+ readdirSync(dir)
1044
+ .filter((f) => f.endsWith(".md"))
1045
+ .map((f) => f.replace(/\.md$/, ""))
1046
+ );
1047
+ }
1048
+
1049
+ /** List the user-authored subagents in ~/.pal/agents/. */
1050
+ export function listPersonalSubagents(): string[] {
1051
+ if (!existsSync(PAL_AGENTS_STORE)) return [];
1052
+ return readdirSync(PAL_AGENTS_STORE)
1053
+ .filter((f) => f.endsWith(".md"))
1054
+ .map((f) => f.replace(/\.md$/, ""))
1055
+ .sort();
1056
+ }
1057
+
1058
+ /**
1059
+ * Install a personal subagent (~/.pal/agents/<name>.md) into every installed
1060
+ * agent by splitting its merged frontmatter per platform and writing the
1061
+ * result into that agent's native agents dir. Unlike personal skills (which are
1062
+ * symlinked), subagents are transformed-and-copied because each platform needs
1063
+ * its own frontmatter shape. Returns the agents it was installed into.
1064
+ */
1065
+ export function installPersonalSubagent(name: string): string[] {
1066
+ const src = resolve(PAL_AGENTS_STORE, `${name}.md`);
1067
+ if (!existsSync(src)) {
1068
+ throw new Error(`No subagent found at ${src}`);
1069
+ }
1070
+ if (shippedAgentNames().has(name)) {
1071
+ throw new Error(
1072
+ `'${name}' is a shipped PAL subagent — choose another name (a reinstall would overwrite it).`
1073
+ );
1074
+ }
1075
+ const content = readFileSync(src, "utf-8");
1076
+ const installed: string[] = [];
1077
+ for (const { agent, dir } of personalSubagentTargets()) {
1078
+ if (!existsSync(dir)) continue; // agent not installed
1079
+ writeFileSync(
1080
+ resolve(dir, `${name}.md`),
1081
+ extractAgentForPlatform(content, agent),
1082
+ "utf-8"
1083
+ );
1084
+ installed.push(agent);
1085
+ }
1086
+ return installed;
1087
+ }
1088
+
1016
1089
  /** Load and resolve the Copilot hooks template, substituting PKG_ROOT */
1017
1090
  export function loadCopilotHooksTemplate(templatePath: string, pkgRoot: string): unknown {
1018
1091
  const resolved = readFileSync(templatePath, "utf-8").replaceAll(
@@ -21,6 +21,7 @@ interface HandoffEntry {
21
21
  status: "in-progress" | "completed";
22
22
  handoff: string;
23
23
  artifacts: string[];
24
+ source: "deliberate" | "auto";
24
25
  }
25
26
 
26
27
  function handoffPath(): string {
@@ -56,6 +57,7 @@ function writeHandoffNote(
56
57
  status: done ? "completed" : "in-progress",
57
58
  handoff: text,
58
59
  artifacts: [],
60
+ source: "deliberate",
59
61
  };
60
62
  writeHandoffs(handoffs);
61
63
  return {
@@ -0,0 +1,414 @@
1
+ /**
2
+ * subagent-doctor — static evaluator for a personal subagent definition
3
+ * (~/.pal/agents/<name>.md) with merged multi-platform frontmatter. Checks only
4
+ * what is mechanically verifiable: name/description constraints, per-platform
5
+ * blocks (claude/opencode/cursor/copilot), model/tools/permission shape, body
6
+ * length, and absolute-path portability.
7
+ *
8
+ * Library: import { lintSubagent, formatSubagentReport } from ".../subagent-doctor"
9
+ * Script: bun src/tools/subagent-doctor.ts <file-or-name>
10
+ * (resolves a path, or a name under ~/.pal/agents/)
11
+ * CLI: pal cli subagent doctor <name> (see src/cli/subagent.ts)
12
+ */
13
+
14
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
15
+ import { basename, resolve } from "node:path";
16
+ import { assets, palHome } from "../hooks/lib/paths";
17
+
18
+ type Level = "pass" | "warn" | "error";
19
+
20
+ interface DoctorFinding {
21
+ level: Level;
22
+ check: string;
23
+ message: string;
24
+ }
25
+
26
+ export interface SubagentReport {
27
+ file: string;
28
+ name: string | null;
29
+ findings: DoctorFinding[];
30
+ errors: number;
31
+ warnings: number;
32
+ }
33
+
34
+ const AGENT_PLATFORMS = ["claude", "opencode", "cursor", "copilot"] as const;
35
+ type AgentPlatform = (typeof AGENT_PLATFORMS)[number];
36
+
37
+ interface ParsedSubagent {
38
+ hasFrontmatter: boolean;
39
+ name: string | null;
40
+ description: string | null;
41
+ descriptionQuoted: boolean;
42
+ global: string[];
43
+ platforms: Partial<Record<AgentPlatform, string[]>>;
44
+ body: string;
45
+ }
46
+
47
+ const RESERVED_WORDS = ["anthropic", "claude"];
48
+ const MAX_NAME = 64;
49
+ const MAX_DESCRIPTION = 1024;
50
+ const MAX_BODY_LINES = 500;
51
+
52
+ /** Model values PAL recognises across agents; anything else warns (naming drifts). */
53
+ const KNOWN_MODELS = ["inherit", "fable", "sonnet", "opus", "haiku"];
54
+ const PERMISSION_VALUES = ["allow", "ask", "deny"];
55
+ const OPENCODE_MODES = ["subagent", "primary", "all"];
56
+
57
+ /** Machine/user-specific absolute paths that will not survive an export. */
58
+ const ABSOLUTE_PATH_RE =
59
+ /(?:\/(?:Users|home)\/[A-Za-z0-9._-]+|\/root\/[A-Za-z0-9._-]|[A-Za-z]:\\Users\\[A-Za-z0-9._-]+)/;
60
+
61
+ /** Names of the subagents PAL ships — a personal subagent may not reuse one. */
62
+ function shippedAgentNames(): Set<string> {
63
+ const dir = assets.agents();
64
+ if (!existsSync(dir)) return new Set();
65
+ return new Set(
66
+ readdirSync(dir)
67
+ .filter((f) => f.endsWith(".md"))
68
+ .map((f) => f.replace(/\.md$/, ""))
69
+ );
70
+ }
71
+
72
+ /** Split a merged subagent .md into global fields, per-platform blocks, and body. */
73
+ function parseSubagent(content: string): ParsedSubagent {
74
+ const parts = content.split(/^---\s*$/m);
75
+ if (parts.length < 3) {
76
+ return {
77
+ hasFrontmatter: false,
78
+ name: null,
79
+ description: null,
80
+ descriptionQuoted: false,
81
+ global: [],
82
+ platforms: {},
83
+ body: content,
84
+ };
85
+ }
86
+ const frontmatter = parts[1];
87
+ const body = parts.slice(2).join("---");
88
+
89
+ const global: string[] = [];
90
+ const platforms: Partial<Record<AgentPlatform, string[]>> = {};
91
+ let current: AgentPlatform | null = null;
92
+
93
+ for (const line of frontmatter.split("\n")) {
94
+ if (!line.trim()) continue;
95
+ const pm = /^(claude|opencode|cursor|copilot):\s*$/.exec(line);
96
+ if (pm) {
97
+ current = pm[1] as AgentPlatform;
98
+ platforms[current] ??= [];
99
+ continue;
100
+ }
101
+ if (current) {
102
+ if (/^ {2}/.test(line)) {
103
+ platforms[current]?.push(line.slice(2));
104
+ continue;
105
+ }
106
+ current = null;
107
+ }
108
+ global.push(line);
109
+ }
110
+
111
+ const globalText = global.join("\n");
112
+ const name = /^name:\s*"?(.+?)"?\s*$/m.exec(globalText)?.[1] ?? null;
113
+ const rawDesc = /^description:[ \t]*(.*?)\s*$/m.exec(globalText)?.[1] ?? null;
114
+ const descriptionQuoted =
115
+ rawDesc !== null &&
116
+ rawDesc.length >= 2 &&
117
+ rawDesc.startsWith('"') &&
118
+ rawDesc.endsWith('"');
119
+ const description = descriptionQuoted ? rawDesc.slice(1, -1) : rawDesc;
120
+
121
+ return {
122
+ hasFrontmatter: true,
123
+ name,
124
+ description,
125
+ descriptionQuoted,
126
+ global,
127
+ platforms,
128
+ body,
129
+ };
130
+ }
131
+
132
+ /** Value of a single-line `key: value` field within an un-indented block. */
133
+ function fieldValue(lines: string[], key: string): string | null {
134
+ const re = new RegExp(String.raw`^${key}:[ \t]*(.*?)\s*$`);
135
+ for (const line of lines) {
136
+ const m = re.exec(line);
137
+ if (m) return m[1].replace(/^"(.*)"$/, "$1");
138
+ }
139
+ return null;
140
+ }
141
+
142
+ /** True when a block declares `key:` (with or without an inline value). */
143
+ function hasField(lines: string[], key: string): boolean {
144
+ return lines.some((l) => new RegExp(`^${key}:`).test(l));
145
+ }
146
+
147
+ function checkClaudeBlock(lines: string[], add: AddFinding): void {
148
+ const model = fieldValue(lines, "model");
149
+ if (model) validateModel(model, "claude", add);
150
+ const tools = fieldValue(lines, "tools");
151
+ if (tools !== null && tools.trim() === "") {
152
+ add("warn", "claude.tools", "`tools` is empty — omit it to inherit all tools");
153
+ }
154
+ }
155
+
156
+ function checkOpencodeBlock(lines: string[], add: AddFinding): void {
157
+ const mode = fieldValue(lines, "mode");
158
+ if (!mode) {
159
+ add(
160
+ "warn",
161
+ "opencode.mode",
162
+ "no `mode` — set `mode: subagent` so opencode treats it as a delegate"
163
+ );
164
+ } else if (!OPENCODE_MODES.includes(mode)) {
165
+ add(
166
+ "warn",
167
+ "opencode.mode",
168
+ `mode "${mode}" — expected one of ${OPENCODE_MODES.join(", ")}`
169
+ );
170
+ } else if (mode !== "subagent") {
171
+ add("warn", "opencode.mode", `mode is "${mode}", not "subagent" — intended?`);
172
+ }
173
+ const model = fieldValue(lines, "model");
174
+ if (model) validateModel(model, "opencode", add);
175
+ for (const line of lines) {
176
+ const perm = /^\s*(read|write|edit|bash|webfetch|skill):\s*"?([a-z-]+)"?\s*$/.exec(
177
+ line
178
+ );
179
+ if (perm && !PERMISSION_VALUES.includes(perm[2])) {
180
+ add(
181
+ "warn",
182
+ "opencode.permission",
183
+ `${perm[1]}: "${perm[2]}" — expected ${PERMISSION_VALUES.join("/")}`
184
+ );
185
+ }
186
+ }
187
+ }
188
+
189
+ function checkCursorBlock(lines: string[], add: AddFinding): void {
190
+ const model = fieldValue(lines, "model");
191
+ if (model) validateModel(model, "cursor", add);
192
+ for (const key of ["readonly", "is_background"]) {
193
+ const v = fieldValue(lines, key);
194
+ if (v !== null && v !== "true" && v !== "false") {
195
+ add("warn", `cursor.${key}`, `${key}: "${v}" — must be true or false`);
196
+ }
197
+ }
198
+ }
199
+
200
+ function checkCopilotBlock(lines: string[], add: AddFinding): void {
201
+ const model = fieldValue(lines, "model");
202
+ if (model) validateModel(model, "copilot", add);
203
+ }
204
+
205
+ function validateModel(model: string, platform: string, add: AddFinding): void {
206
+ const known = KNOWN_MODELS.includes(model) || /^(claude-|gpt-|o\d|gemini-)/.test(model);
207
+ if (!known) {
208
+ add(
209
+ "warn",
210
+ `${platform}.model`,
211
+ `model "${model}" is unrecognised — confirm it is a valid ${platform} model id/alias`
212
+ );
213
+ }
214
+ }
215
+
216
+ type AddFinding = (level: Level, check: string, message: string) => void;
217
+
218
+ /**
219
+ * Evaluate the subagent file at `file` (a merged multi-platform .md) and return
220
+ * a structured report. Loader-fatal problems surface as errors; best-practice
221
+ * nudges surface as warnings.
222
+ */
223
+ export function lintSubagent(file: string): SubagentReport {
224
+ const findings: DoctorFinding[] = [];
225
+ const add: AddFinding = (level, check, message) =>
226
+ findings.push({ level, check, message });
227
+
228
+ if (!existsSync(file)) {
229
+ add("error", "structure", `No subagent file at ${file}`);
230
+ return { file, name: null, findings, errors: 1, warnings: 0 };
231
+ }
232
+
233
+ const parsed = parseSubagent(readFileSync(file, "utf-8"));
234
+ if (!parsed.hasFrontmatter) {
235
+ add("error", "structure", "No frontmatter (--- … ---) block found");
236
+ return { file, name: null, findings, errors: 1, warnings: 0 };
237
+ }
238
+
239
+ const { name, description, descriptionQuoted, platforms, body } = parsed;
240
+
241
+ // ── name ──
242
+ const stem = basename(file).replace(/\.md$/, "");
243
+ if (!name) {
244
+ add("error", "name", "Missing `name` in frontmatter");
245
+ } else {
246
+ name === stem
247
+ ? add("pass", "name.file", `matches file name "${stem}"`)
248
+ : add(
249
+ "error",
250
+ "name.file",
251
+ `name "${name}" must equal the file name "${stem}" verbatim — otherwise the subagent is silently ignored`
252
+ );
253
+ name.length <= MAX_NAME
254
+ ? add("pass", "name.length", `${name.length}/${MAX_NAME} chars`)
255
+ : add("error", "name.length", `${name.length} chars exceeds ${MAX_NAME}`);
256
+ /^[a-z0-9-]+$/.test(name)
257
+ ? add("pass", "name.charset", "lowercase letters, numbers, hyphens only")
258
+ : add(
259
+ "error",
260
+ "name.charset",
261
+ `"${name}" must be lowercase a-z, 0-9, hyphens only`
262
+ );
263
+ const reserved = RESERVED_WORDS.find((w) => name.toLowerCase().includes(w));
264
+ reserved
265
+ ? add("error", "name.reserved", `contains reserved word "${reserved}"`)
266
+ : add("pass", "name.reserved", "no reserved words");
267
+ shippedAgentNames().has(name)
268
+ ? add(
269
+ "error",
270
+ "name.collision",
271
+ `"${name}" is a shipped PAL subagent — a reinstall would overwrite it; choose another name`
272
+ )
273
+ : add("pass", "name.collision", "does not collide with a shipped subagent");
274
+ }
275
+
276
+ // ── description ──
277
+ if (!description) {
278
+ add("error", "description", "Missing `description` in frontmatter");
279
+ } else {
280
+ description.length <= MAX_DESCRIPTION
281
+ ? add(
282
+ "pass",
283
+ "description.length",
284
+ `${description.length}/${MAX_DESCRIPTION} chars`
285
+ )
286
+ : add(
287
+ "error",
288
+ "description.length",
289
+ `${description.length} chars exceeds ${MAX_DESCRIPTION}`
290
+ );
291
+ descriptionQuoted
292
+ ? add("pass", "description.quoted", "value is wrapped in double quotes")
293
+ : add(
294
+ "warn",
295
+ "description.quoted",
296
+ 'value is not wrapped in double quotes — unquoted YAML mis-parses on colons and commas; wrap it in "..."'
297
+ );
298
+ /<[^>]+>/.test(description)
299
+ ? add(
300
+ "warn",
301
+ "description.xml",
302
+ "contains angle-bracket content — rephrase placeholders in prose"
303
+ )
304
+ : add("pass", "description.xml", "no XML tags");
305
+ /\bwhen\b/i.test(description)
306
+ ? add("pass", "description.trigger", "states when to delegate to the subagent")
307
+ : add(
308
+ "warn",
309
+ "description.trigger",
310
+ "no 'when to use' trigger — add 'Use when …' so the model knows when to delegate"
311
+ );
312
+ }
313
+
314
+ // ── platform blocks ──
315
+ const present = AGENT_PLATFORMS.filter((p) => (platforms[p]?.length ?? 0) > 0);
316
+ present.length > 0
317
+ ? add("pass", "platforms", `defines block(s): ${present.join(", ")}`)
318
+ : add(
319
+ "warn",
320
+ "platforms",
321
+ "no platform block — the subagent installs with only name/description; add a claude:/opencode:/cursor:/copilot: block to set model, tools, mode"
322
+ );
323
+
324
+ if (platforms.claude) checkClaudeBlock(platforms.claude, add);
325
+ if (platforms.opencode) checkOpencodeBlock(platforms.opencode, add);
326
+ if (platforms.cursor) checkCursorBlock(platforms.cursor, add);
327
+ if (platforms.copilot) checkCopilotBlock(platforms.copilot, add);
328
+
329
+ // ── skills field: Claude preloads it; the others have no such field ──
330
+ for (const p of ["opencode", "cursor", "copilot"] as const) {
331
+ if (platforms[p] && hasField(platforms[p] ?? [], "skills")) {
332
+ add(
333
+ "warn",
334
+ `${p}.skills`,
335
+ `${p} has no native \`skills\` frontmatter field — it will be ignored; record intended skills in the body instead`
336
+ );
337
+ }
338
+ }
339
+
340
+ // ── body ──
341
+ const bodyLines = body.split("\n").length;
342
+ bodyLines <= MAX_BODY_LINES
343
+ ? add("pass", "body.length", `${bodyLines}/${MAX_BODY_LINES} lines`)
344
+ : add(
345
+ "warn",
346
+ "body.length",
347
+ `${bodyLines} lines exceeds ${MAX_BODY_LINES} — trim the system prompt`
348
+ );
349
+ body.trim().length > 0
350
+ ? add("pass", "body.present", "has a system prompt")
351
+ : add(
352
+ "error",
353
+ "body.present",
354
+ "empty body — a subagent needs a system prompt after the frontmatter"
355
+ );
356
+
357
+ // ── absolute-path portability ──
358
+ const hits: string[] = [];
359
+ const allLines = readFileSync(file, "utf-8").split("\n");
360
+ for (let i = 0; i < allLines.length; i++) {
361
+ const m = ABSOLUTE_PATH_RE.exec(allLines[i]);
362
+ if (m) hits.push(`${i + 1} → ${m[0]}`);
363
+ }
364
+ const more = hits.length > 3 ? ` (+${hits.length - 3} more)` : "";
365
+ hits.length > 0
366
+ ? add(
367
+ "warn",
368
+ "paths.absolute",
369
+ `hardcoded machine-specific path(s): ${hits.slice(0, 3).join("; ")}${more} — prefer $HOME/~ or an env var`
370
+ )
371
+ : add("pass", "paths.absolute", "no machine-specific absolute paths");
372
+
373
+ const errors = findings.filter((f) => f.level === "error").length;
374
+ const warnings = findings.filter((f) => f.level === "warn").length;
375
+ return { file, name, findings, errors, warnings };
376
+ }
377
+
378
+ /** Render a subagent report as a human-readable string. */
379
+ export function formatSubagentReport(r: SubagentReport): string {
380
+ const icon = { pass: "✓", warn: "⚠", error: "✗" } as const;
381
+ const lines = [`subagent-doctor: ${r.name ?? "(unparsed)"} — ${r.file}`];
382
+ for (const f of r.findings) {
383
+ lines.push(` ${icon[f.level]} ${f.check}: ${f.message}`);
384
+ }
385
+ let verdict: string;
386
+ if (r.errors > 0) {
387
+ verdict = `FAIL — ${r.errors} error(s), ${r.warnings} warning(s)`;
388
+ } else if (r.warnings > 0) {
389
+ verdict = `OK with ${r.warnings} warning(s)`;
390
+ } else {
391
+ verdict = "PASS — all checks clean";
392
+ }
393
+ lines.push(` ${verdict}`);
394
+ return lines.join("\n");
395
+ }
396
+
397
+ /** Resolve a doctor argument to a subagent .md path (a path, or a name in the store). */
398
+ export function resolveSubagentFile(arg: string): string {
399
+ if (arg.endsWith(".md") && existsSync(resolve(arg))) return resolve(arg);
400
+ const direct = resolve(arg);
401
+ if (existsSync(direct) && direct.endsWith(".md")) return direct;
402
+ return resolve(palHome(), "agents", arg.endsWith(".md") ? arg : `${arg}.md`);
403
+ }
404
+
405
+ if (import.meta.main) {
406
+ const arg = process.argv[2];
407
+ if (!arg) {
408
+ console.error("Usage: bun src/tools/subagent-doctor.ts <file-or-name>");
409
+ process.exit(2);
410
+ }
411
+ const report = lintSubagent(resolveSubagentFile(arg));
412
+ console.log(formatSubagentReport(report));
413
+ process.exit(report.errors > 0 ? 1 : 0);
414
+ }