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 +2 -0
- package/assets/skills/create-skill/SKILL.md +44 -36
- package/assets/skills/playwright/tools/shot.ts +18 -8
- package/assets/templates/PAL/ALGORITHM.md +16 -1
- package/package.json +1 -1
- package/src/cli/index.ts +10 -0
- package/src/cli/skill.ts +55 -0
- package/src/hooks/lib/algorithm-review.ts +85 -0
- package/src/hooks/lib/context.ts +4 -0
- package/src/hooks/lib/learning-store.ts +45 -0
- package/src/hooks/lib/paths.ts +3 -0
- package/src/hooks/lib/retrieval-index.ts +32 -5
- package/src/hooks/lib/retrieval.ts +2 -1
- package/src/hooks/lib/semi-static.ts +29 -6
- package/src/targets/lib.ts +40 -0
- package/src/tools/agent/algorithm-reflect.ts +10 -5
- package/src/tools/agent/algorithm-synthesize.ts +218 -0
- package/src/tools/skill-doctor.ts +254 -0
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:
|
|
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
|
-
|
|
7
|
+
# Create a personal skill
|
|
8
8
|
|
|
9
|
-
|
|
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
|
-
|
|
11
|
+
## What makes a good skill
|
|
12
12
|
|
|
13
|
-
|
|
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
|
-
|
|
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: <
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
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
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)
|
package/src/cli/skill.ts
ADDED
|
@@ -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
|
+
}
|
package/src/hooks/lib/context.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/hooks/lib/paths.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
107
|
-
|
|
127
|
+
const cwd = process.cwd();
|
|
128
|
+
const all = readFailures(paths.failures());
|
|
129
|
+
if (all.length === 0) return "";
|
|
108
130
|
|
|
109
|
-
const lines =
|
|
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");
|
package/src/targets/lib.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
39
|
-
|
|
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
|
+
}
|