portable-agent-layer 0.59.0 → 0.60.1
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 +3 -0
- package/assets/agents/subagent-author.md +50 -0
- package/assets/skills/create-subagent/SKILL.md +71 -0
- package/assets/skills/create-subagent/authoring-guide.md +87 -0
- package/package.json +1 -1
- package/src/cli/index.ts +16 -1
- package/src/cli/subagent.ts +75 -0
- package/src/hooks/handlers/persist-last-exchange.ts +37 -19
- package/src/hooks/handlers/update-check.ts +10 -1
- package/src/hooks/lib/export.ts +10 -2
- package/src/hooks/lib/models.ts +22 -0
- package/src/targets/lib.ts +74 -1
- package/src/tools/agent/handoff-note.ts +2 -0
- package/src/tools/subagent-doctor.ts +414 -0
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
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:
|
|
@@ -1299,7 +1311,9 @@ async function importState(args: string[]) {
|
|
|
1299
1311
|
}
|
|
1300
1312
|
|
|
1301
1313
|
async function update() {
|
|
1302
|
-
const { checkForUpdate } = await import(
|
|
1314
|
+
const { checkForUpdate, clearUpdateCache } = await import(
|
|
1315
|
+
"../hooks/handlers/update-check"
|
|
1316
|
+
);
|
|
1303
1317
|
const result = await checkForUpdate(true);
|
|
1304
1318
|
|
|
1305
1319
|
log.info(`Current: ${result.current} (${result.mode} mode)`);
|
|
@@ -1339,6 +1353,7 @@ async function update() {
|
|
|
1339
1353
|
throw new Error(`Failed to read updated package.json: ${e}`);
|
|
1340
1354
|
}
|
|
1341
1355
|
log.success(`Updated: ${result.current} → ${newPkg.version}`);
|
|
1356
|
+
clearUpdateCache();
|
|
1342
1357
|
|
|
1343
1358
|
log.info("Reinstalling...");
|
|
1344
1359
|
await install(resolveTargets([]));
|
|
@@ -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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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",
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* Caches result in state/update-available.json. Checked at most once per hour.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
11
11
|
import { resolve } from "node:path";
|
|
12
12
|
import { logDebug } from "../lib/log";
|
|
13
13
|
import { ensureDir, palPkg, paths } from "../lib/paths";
|
|
@@ -179,6 +179,15 @@ export async function checkForUpdate(force = false): Promise<UpdateCache> {
|
|
|
179
179
|
return result;
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
/** Invalidate the cached update status — call after a successful update so the notice clears immediately. */
|
|
183
|
+
export function clearUpdateCache(): void {
|
|
184
|
+
try {
|
|
185
|
+
rmSync(cachePath(), { force: true });
|
|
186
|
+
} catch {
|
|
187
|
+
/* non-critical */
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
182
191
|
/** Read cached update status for greeting display. Returns null if no update. */
|
|
183
192
|
export function getUpdateNotice(): string | null {
|
|
184
193
|
try {
|
package/src/hooks/lib/export.ts
CHANGED
|
@@ -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
|
-
/**
|
|
12
|
-
|
|
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()) {
|
package/src/hooks/lib/models.ts
CHANGED
|
@@ -44,6 +44,20 @@ export const MODEL_PRICING: Record<
|
|
|
44
44
|
cacheWrite1h: 2,
|
|
45
45
|
cacheRead: 0.1,
|
|
46
46
|
},
|
|
47
|
+
[FABLE_MODEL]: {
|
|
48
|
+
input: 10,
|
|
49
|
+
output: 50,
|
|
50
|
+
cacheWrite5m: 12.5,
|
|
51
|
+
cacheWrite1h: 20,
|
|
52
|
+
cacheRead: 1,
|
|
53
|
+
},
|
|
54
|
+
"claude-opus-4-8": {
|
|
55
|
+
input: 5,
|
|
56
|
+
output: 25,
|
|
57
|
+
cacheWrite5m: 6.25,
|
|
58
|
+
cacheWrite1h: 10,
|
|
59
|
+
cacheRead: 0.5,
|
|
60
|
+
},
|
|
47
61
|
"claude-opus-4-7": {
|
|
48
62
|
input: 5,
|
|
49
63
|
output: 25,
|
|
@@ -58,6 +72,14 @@ export const MODEL_PRICING: Record<
|
|
|
58
72
|
cacheWrite1h: 10,
|
|
59
73
|
cacheRead: 0.5,
|
|
60
74
|
},
|
|
75
|
+
// Claude Sonnet 5 — introductory pricing through 2026-08-31; standard (3/15/3.75/6/0.30) applies from 2026-09-01.
|
|
76
|
+
"claude-sonnet-5": {
|
|
77
|
+
input: 2,
|
|
78
|
+
output: 10,
|
|
79
|
+
cacheWrite5m: 2.5,
|
|
80
|
+
cacheWrite1h: 4,
|
|
81
|
+
cacheRead: 0.2,
|
|
82
|
+
},
|
|
61
83
|
"claude-sonnet-4-6": {
|
|
62
84
|
input: 3,
|
|
63
85
|
output: 15,
|
package/src/targets/lib.ts
CHANGED
|
@@ -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
|
+
}
|