portable-agent-layer 0.47.0 → 0.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -86,6 +86,7 @@ pal cli status # check your setup
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
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) |
89
90
 
90
91
  ### Target flags
91
92
 
@@ -57,7 +57,12 @@ The `description` should state **both what the skill does and when to invoke it*
57
57
  pal cli skill link <name>
58
58
  ```
59
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. Validate before declaring done:
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:
61
66
  - **Trigger clarity** — could a model decide *not* to invoke this from the description alone? If so, tighten it.
62
67
  - **Step concreteness** — every step has a verb and an object.
63
68
  - **Output specification** — the caller knows what they get back.
@@ -165,6 +165,18 @@ Refine criteria if the pressure test reveals gaps. Add criteria for uncovered fa
165
165
  - Decide execution order — what's serial, what can parallelize
166
166
  - **Extended+:** use EnterPlanMode for user alignment before executing
167
167
 
168
+ **Grounding Gate — verify the premises before EXECUTE.**
169
+
170
+ Don't just *list* what must be true — actively confirm the load-bearing facts the criteria rest on. For each premise (a reported behavior, a provider/API contract, an external tool's CLI, a file's contents, a claim about system state):
171
+ - **Reproduce** the reported behavior before designing a fix — no fix on an unreproduced bug.
172
+ - **Fetch the authoritative source** (live docs, the actual file, `gh api`) before asserting how something works.
173
+ - **Probe the dependency/contract** (tool availability, MCP account, env var, DB connectivity, exact CLI) before building on it.
174
+ - **Confirm one concrete artifact** (a commit, a shebang, a line of output) behind any external claim.
175
+
176
+ No criterion may rest on an unverified premise. If a premise can't be verified now, mark it explicitly as an assumption and add a criterion to check it during EXECUTE. After 2 failed attempts at the same sub-problem, stop and re-ground — re-run OBSERVE rather than iterating on a bad premise.
177
+
178
+ Output: `🧭 GROUNDING: [premises verified, or assumptions flagged]`
179
+
168
180
  ### ━━━ ⚡ EXECUTE ━━━ 3/5
169
181
 
170
182
  Do the work. Invoke selected capabilities via tool calls.
@@ -217,9 +229,11 @@ Focus: reasoning approach, problem decomposition, anticipation, blind spots.
217
229
 
218
230
  ```bash
219
231
  bun ~/.pal/tools/algorithm-reflect.ts --task "description" --criteria N --passed N --failed N --sentiment 1-10 \
220
- --q1 "self reflection" --q2 "algorithm reflection" --q3 "AI reflection"
232
+ --q1 "self reflection" --q2 "algorithm reflection" --q3 "AI reflection" --scope general
221
233
  ```
222
234
 
235
+ Set `--scope task-specific` when the Q2 idea is bound to this one task (e.g. a criterion that only matters for the specific file, API, or dataset you were handling) and would not generalize to the algorithm; use `general` (the default) for reusable structural improvements. This keeps the algorithm-update synthesis focused on changes worth folding into ALGORITHM.md.
236
+
223
237
  **3. Relationship note** — write one Session entry capturing what was done this session:
224
238
 
225
239
  ```bash
@@ -316,6 +330,7 @@ Only write if the insight is **genuine and reusable** — not every session prod
316
330
  ━━━ 🧠 PLAN ━━━ 2/5
317
331
  🧠 RISKS: [risks]
318
332
  🧠 PREMORTEM: [failure modes]
333
+ 🧭 GROUNDING: [premises verified, or assumptions flagged]
319
334
  📐 APPROACH: [execution plan]
320
335
 
321
336
  ━━━ ⚡ EXECUTE ━━━ 3/5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.47.0",
3
+ "version": "0.48.0",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli/index.ts CHANGED
@@ -17,6 +17,7 @@
17
17
  * doctor Check prerequisites and system health
18
18
  * usage Summarize token usage and cost
19
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
20
21
  */
21
22
 
22
23
  import { spawnSync } from "node:child_process";
@@ -242,6 +243,7 @@ function showHelp() {
242
243
  pal cli knowledge <sub> [args] Query & manage the knowledge store
243
244
  (search · graph · stats · hubs · find · show · add · ls)
244
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
245
247
 
246
248
  Environment:
247
249
  PAL_HOME Override user state directory (default: ~/.pal or repo root)
package/src/cli/skill.ts CHANGED
@@ -1,15 +1,30 @@
1
1
  /**
2
2
  * pal cli skill — manage personal skills under ~/.pal/skills/.
3
3
  *
4
- * pal cli skill link <name> Link an existing ~/.pal/skills/<name>/ into
5
- * every installed agent so it is discoverable.
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.
6
8
  */
7
9
 
10
+ import { resolve } from "node:path";
11
+ import { palHome } from "../hooks/lib/paths";
8
12
  import { linkPersonalSkill, log } from "../targets/lib";
13
+ import { formatReport, lintSkill } from "../tools/skill-doctor";
9
14
 
10
15
  export async function runSkill(args: string[]): Promise<number> {
11
16
  const [sub, name] = args;
12
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
+
13
28
  if (sub === "link") {
14
29
  if (!name) {
15
30
  log.error("Usage: pal cli skill link <name>");
@@ -35,6 +50,6 @@ export async function runSkill(args: string[]): Promise<number> {
35
50
  }
36
51
  }
37
52
 
38
- log.error("Usage: pal cli skill link <name>");
53
+ log.error("Usage: pal cli skill <link|doctor> <name>");
39
54
  return 1;
40
55
  }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Algorithm-review cadence — decides when to nudge the MAINTAINER to run an
3
+ * `/algorithm-update` session (fold accumulated Q2 reflections into ALGORITHM.md).
4
+ *
5
+ * Maintainer-only by construction: the nudge fires solely in a PAL repo checkout,
6
+ * detected by the presence of the repo-only `.agents/skills/algorithm-update`
7
+ * skill. That path never ships to downstream installs — package.json `files`
8
+ * lists only `src/` and `assets/`, so `.agents/` is absent everywhere except a
9
+ * source checkout. Downstream users therefore never see this nudge.
10
+ */
11
+
12
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
13
+ import { resolve } from "node:path";
14
+ import { readReflections } from "./learning-store";
15
+ import { palPkg, paths } from "./paths";
16
+
17
+ const NUDGE_MIN_COUNT = 25;
18
+ const NUDGE_MIN_DAYS = 7;
19
+ const DAY_MS = 86_400_000;
20
+
21
+ function markFile(): string {
22
+ return resolve(paths.state(), "algorithm-review.json");
23
+ }
24
+
25
+ export function readReviewMark(): string | null {
26
+ try {
27
+ const parsed = JSON.parse(readFileSync(markFile(), "utf-8"));
28
+ return typeof parsed.lastReviewedTs === "string" ? parsed.lastReviewedTs : null;
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ export function writeReviewMark(ts: string): void {
35
+ writeFileSync(markFile(), `${JSON.stringify({ lastReviewedTs: ts }, null, 2)}\n`);
36
+ }
37
+
38
+ /** True only in a maintainer repo checkout — the repo-only update skill is present. */
39
+ export function isMaintainerEnv(): boolean {
40
+ return existsSync(
41
+ resolve(palPkg(), ".agents", "skills", "algorithm-update", "SKILL.md")
42
+ );
43
+ }
44
+
45
+ /** Count reflections newer than the last review mark (all of them, if never reviewed). */
46
+ export function countUnreviewed(mark: string | null = readReviewMark()): number {
47
+ const reflections = readReflections(paths.reflectionsFile());
48
+ if (!mark) return reflections.length;
49
+ const cutoff = new Date(mark).getTime();
50
+ return reflections.filter((r) => r.ts && new Date(r.ts).getTime() > cutoff).length;
51
+ }
52
+
53
+ export interface ReviewNudge {
54
+ count: number;
55
+ sinceDays: number;
56
+ since: string | null;
57
+ }
58
+
59
+ /**
60
+ * Nudge data when BOTH thresholds are met (≥25 new AND ≥7 days) in a maintainer
61
+ * env, else null. `now` is injected for testability.
62
+ */
63
+ export function algorithmReviewNudge(now: Date): ReviewNudge | null {
64
+ if (!isMaintainerEnv()) return null;
65
+ const mark = readReviewMark();
66
+ const count = countUnreviewed(mark);
67
+ if (count < NUDGE_MIN_COUNT) return null;
68
+ const sinceDays = mark
69
+ ? Math.floor((now.getTime() - new Date(mark).getTime()) / DAY_MS)
70
+ : Number.POSITIVE_INFINITY;
71
+ if (sinceDays < NUDGE_MIN_DAYS) return null;
72
+ return { count, sinceDays, since: mark };
73
+ }
74
+
75
+ /** Formatted nudge section for the session reminder, or "" when it shouldn't fire. */
76
+ export function loadAlgorithmReviewNudge(now: Date = new Date()): string {
77
+ const n = algorithmReviewNudge(now);
78
+ if (!n) return "";
79
+ const since = n.since ? new Date(n.since).toISOString().slice(0, 10) : "the start";
80
+ const age = Number.isFinite(n.sinceDays) ? `, ${n.sinceDays}d` : "";
81
+ return [
82
+ "## Algorithm Review Due",
83
+ `🔧 ${n.count} new algorithm reflections since ${since}${age} — run \`/algorithm-update\` to fold the recurring ones into ALGORITHM.md.`,
84
+ ].join("\n");
85
+ }
@@ -6,6 +6,7 @@
6
6
  import { existsSync, readFileSync } from "node:fs";
7
7
  import { homedir } from "node:os";
8
8
  import { resolve } from "node:path";
9
+ import { loadAlgorithmReviewNudge } from "./algorithm-review";
9
10
  import { readLearnings } from "./learning-store";
10
11
  import { loadOpinionContext } from "./opinions";
11
12
  import { paths } from "./paths";
@@ -312,9 +313,12 @@ export function buildSystemReminder(opts: { agent?: AgentTarget } = {}): string
312
313
  ? loadSessionIntelligence()
313
314
  : "";
314
315
  const handoff = settings.isEnabled("handoff") ? loadHandoff() : "";
316
+ // Maintainer-only: self-gates to a repo checkout, "" for everyone else.
317
+ const algoReview = loadAlgorithmReviewNudge();
315
318
  const parts: string[] = [];
316
319
  if (startup) parts.push(startup);
317
320
  if (handoff) parts.push(handoff);
321
+ if (algoReview) parts.push(algoReview);
318
322
  if (selfModel) parts.push(selfModel);
319
323
  if (wisdom) parts.push(wisdom);
320
324
  if (opinions) parts.push(opinions);
@@ -158,3 +158,48 @@ export function readLearnings(baseDir: string, limit?: number): LearningEntry[]
158
158
 
159
159
  return entries;
160
160
  }
161
+
162
+ // ── Reflections ──
163
+
164
+ export interface ReflectionEntry {
165
+ ts: string;
166
+ cwd: string;
167
+ task: string;
168
+ sentiment: number;
169
+ q1: string;
170
+ q2: string;
171
+ q3: string;
172
+ /** Whether the improvement ideas generalize to the algorithm, or are task-bound. */
173
+ scope: "general" | "task-specific";
174
+ }
175
+
176
+ /**
177
+ * Read algorithm reflections from the JSONL store, newest first. Each line is one
178
+ * reflection produced at the end of an Algorithm run (q1 = what I'd do differently,
179
+ * q2 = algorithm improvement, q3 = AI-level insight).
180
+ */
181
+ export function readReflections(file: string, limit?: number): ReflectionEntry[] {
182
+ if (!existsSync(file)) return [];
183
+ const entries: ReflectionEntry[] = [];
184
+ for (const line of readFileSync(file, "utf-8").split("\n")) {
185
+ if (!line.trim()) continue;
186
+ try {
187
+ const o = JSON.parse(line);
188
+ entries.push({
189
+ ts: o.timestamp || "",
190
+ cwd: o.cwd || "",
191
+ task: o.task || "",
192
+ sentiment: typeof o.sentiment === "number" ? o.sentiment : 0,
193
+ q1: o.q1 || "",
194
+ q2: o.q2 || "",
195
+ q3: o.q3 || "",
196
+ // Untagged (e.g. the existing backlog) defaults to general — the common case.
197
+ scope: o.scope === "task-specific" ? "task-specific" : "general",
198
+ });
199
+ } catch {
200
+ /* skip malformed line */
201
+ }
202
+ }
203
+ entries.reverse(); // JSONL is appended chronologically → newest first
204
+ return limit ? entries.slice(0, limit) : entries;
205
+ }
@@ -50,6 +50,9 @@ export const paths = {
50
50
  knowledge: () => ensureDir(home("memory", "knowledge")),
51
51
  knowledgeDomain: (d: string) => ensureDir(home("memory", "knowledge", d)),
52
52
  failures: () => ensureDir(home("memory", "learning", "failures")),
53
+ reflections: () => ensureDir(home("memory", "learning", "reflections")),
54
+ reflectionsFile: () =>
55
+ home("memory", "learning", "reflections", "algorithm-reflections.jsonl"),
53
56
  retrievalIndex: () => home("memory", "learning", ".retrieval-index.json"),
54
57
  progress: () => ensureDir(home("memory", "state", "progress")),
55
58
  projectHistory: () => ensureDir(home("memory", "projects")),
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Retrieval index — single JSON file with per-doc term-frequency vectors and a global
3
- * document-frequency table over the failures + wisdom-frames corpus.
3
+ * document-frequency table over the failures + wisdom-frames + reflections corpus.
4
4
  *
5
5
  * Built once on first read, rebuilt in the background when source dirs change.
6
6
  * Read by the UserPromptSubmit retrieval handler; written by ensureIndex (sync bootstrap)
@@ -11,17 +11,18 @@ import { spawn } from "node:child_process";
11
11
  import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
12
12
  import { resolve } from "node:path";
13
13
  import { SIMILARITY_THRESHOLD } from "./graduation";
14
- import { readFailures } from "./learning-store";
14
+ import { readFailures, readReflections } from "./learning-store";
15
15
  import { logDebug, logError } from "./log";
16
16
  import { palPkg, paths } from "./paths";
17
17
  import { similarity, tokenize } from "./text-similarity";
18
18
  import { readFramesForRetrieval } from "./wisdom";
19
19
 
20
- const INDEX_VERSION = 1;
20
+ // v2: added "reflection" source — bump invalidates v1 caches so they rebuild with it.
21
+ const INDEX_VERSION = 2;
21
22
 
22
23
  export interface IndexedDoc {
23
24
  id: string;
24
- source: "failure" | "wisdom";
25
+ source: "failure" | "wisdom" | "reflection";
25
26
  path: string;
26
27
  rating: number;
27
28
  ts: string;
@@ -144,6 +145,32 @@ export function buildIndex(): RetrievalIndex {
144
145
  });
145
146
  }
146
147
 
148
+ // Reflections — each Algorithm run's self-observations. q1 ("what I'd do
149
+ // differently") is the actionable lesson; q2/q3 add process and reasoning signal.
150
+ for (const r of readReflections(paths.reflectionsFile())) {
151
+ const tokens = buildDocTokens([
152
+ { text: r.q1, weight: 3 },
153
+ { text: r.task, weight: 2 },
154
+ { text: r.q2, weight: 2 },
155
+ { text: r.q3, weight: 1 },
156
+ ]);
157
+ if (tokens.length === 0) continue;
158
+ const { tf, len } = buildTermFreq(tokens);
159
+ for (const term of Object.keys(tf)) df[term] = (df[term] ?? 0) + 1;
160
+ docs.push({
161
+ id: `reflect:${r.ts}`,
162
+ source: "reflection",
163
+ path: paths.reflectionsFile(),
164
+ rating: r.sentiment,
165
+ ts: r.ts,
166
+ tf,
167
+ len,
168
+ displayPrinciple: r.q1,
169
+ displayContext: r.task,
170
+ cwd: r.cwd || undefined,
171
+ });
172
+ }
173
+
147
174
  return {
148
175
  version: INDEX_VERSION,
149
176
  builtAt: new Date().toISOString(),
@@ -173,7 +200,7 @@ export function readIndex(): RetrievalIndex | null {
173
200
  export function isStale(index: RetrievalIndex): boolean {
174
201
  try {
175
202
  const builtMs = new Date(index.builtAt).getTime();
176
- for (const dir of [paths.failures(), paths.wisdom()]) {
203
+ for (const dir of [paths.failures(), paths.wisdom(), paths.reflections()]) {
177
204
  if (!existsSync(dir)) continue;
178
205
  if (statSync(dir).mtimeMs > builtMs) return true;
179
206
  }
@@ -131,7 +131,8 @@ function formatLine(s: ScoredDoc): string {
131
131
  return `- ${tag} ${principle} (CRYSTAL ${s.doc.rating}%)`;
132
132
  }
133
133
  const ago = formatAgo(s.doc.ts);
134
- const meta = ago ? `rating ${s.doc.rating}/10, ${ago}` : `rating ${s.doc.rating}/10`;
134
+ const kind = s.doc.source === "reflection" ? "reflection" : "rating";
135
+ const meta = ago ? `${kind} ${s.doc.rating}/10, ${ago}` : `${kind} ${s.doc.rating}/10`;
135
136
  return `- ${tag} ${principle} (${meta})`;
136
137
  }
137
138
 
@@ -9,7 +9,7 @@
9
9
  import { existsSync, readdirSync, readFileSync } from "node:fs";
10
10
  import { resolve } from "node:path";
11
11
  import { parse } from "./frontmatter";
12
- import { readFailures } from "./learning-store";
12
+ import { type FailureEntry, readFailures } from "./learning-store";
13
13
  import { loadOpinionContext } from "./opinions";
14
14
  import { palHome, paths } from "./paths";
15
15
  import { readFramePrinciples } from "./wisdom";
@@ -100,16 +100,39 @@ export function loadSynthesisRecommendations(): string {
100
100
  }
101
101
  }
102
102
 
103
- /** Build the 5 most recent failure lessons as an avoid-list. */
103
+ /**
104
+ * Rank failures for injection: same-project (cwd match) first, then by recency.
105
+ * Project-relevant lessons are never crowded out by more-recent other-project
106
+ * ones — fixing the case where (e.g.) a web-app's cache lessons dominate a CLI
107
+ * session purely because they were logged last. Returns the top `limit`.
108
+ */
109
+ export function rankFailures(
110
+ entries: FailureEntry[],
111
+ cwd: string,
112
+ limit = 5
113
+ ): FailureEntry[] {
114
+ return [...entries]
115
+ .sort((a, b) => {
116
+ const am = a.cwd === cwd;
117
+ const bm = b.cwd === cwd;
118
+ if (am !== bm) return am ? -1 : 1;
119
+ return (b.ts || "").localeCompare(a.ts || ""); // recency desc within a group
120
+ })
121
+ .slice(0, limit);
122
+ }
123
+
124
+ /** Build the failure avoid-list, prioritized by relevance to the current project. */
104
125
  export function loadFailurePatterns(): string {
105
126
  try {
106
- const entries = readFailures(paths.failures(), 5);
107
- if (entries.length === 0) return "";
127
+ const cwd = process.cwd();
128
+ const all = readFailures(paths.failures());
129
+ if (all.length === 0) return "";
108
130
 
109
- const lines = entries.map((e) => {
131
+ const lines = rankFailures(all, cwd).map((e) => {
110
132
  const label = e.rating ? `[${e.rating}/10]` : "";
133
+ const tag = e.cwd === cwd ? "[project]" : "[other]";
111
134
  const text = e.principle || e.context;
112
- return `- ${label} ${text}`.trim();
135
+ return `- ${label} ${tag} ${text}`.trim();
113
136
  });
114
137
 
115
138
  return ["## Lessons from Recent Failures — Apply These Now", ...lines].join("\n");
@@ -12,8 +12,7 @@
12
12
  * --q3 "Missed the implicit constraint about cross-platform"
13
13
  */
14
14
 
15
- import { appendFileSync, existsSync, mkdirSync } from "node:fs";
16
- import { resolve } from "node:path";
15
+ import { appendFileSync } from "node:fs";
17
16
  import { parseArgs } from "node:util";
18
17
  import { paths } from "../../hooks/lib/paths";
19
18
 
@@ -30,14 +29,15 @@ interface AlgorithmReflection {
30
29
  q1: string;
31
30
  q2: string;
32
31
  q3: string;
32
+ /** general = the improvement ideas generalize to the algorithm; task-specific = bound to this task. */
33
+ scope: "general" | "task-specific";
33
34
  }
34
35
 
35
36
  // ── Core ──
36
37
 
37
38
  function reflectionsPath(): string {
38
- const dir = resolve(paths.learning(), "reflections");
39
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
40
- return resolve(dir, "algorithm-reflections.jsonl");
39
+ paths.reflections(); // ensures the directory exists
40
+ return paths.reflectionsFile();
41
41
  }
42
42
 
43
43
  function appendReflection(reflection: AlgorithmReflection): {
@@ -70,6 +70,7 @@ function run() {
70
70
  q1: { type: "string" },
71
71
  q2: { type: "string" },
72
72
  q3: { type: "string" },
73
+ scope: { type: "string" },
73
74
  help: { type: "boolean", short: "h" },
74
75
  },
75
76
  });
@@ -91,6 +92,7 @@ Arguments:
91
92
  --q1 Q1 — Self: what I'd do differently
92
93
  --q2 Q2 — Algorithm: structural improvement
93
94
  --q3 Q3 — AI: reasoning blind spot
95
+ --scope general (default) | task-specific — is the algorithm idea reusable or task-bound?
94
96
 
95
97
  Output: algorithm-reflections.jsonl in memory/learning/reflections/
96
98
  `);
@@ -113,6 +115,9 @@ Output: algorithm-reflections.jsonl in memory/learning/reflections/
113
115
  q1: values.q1,
114
116
  q2: values.q2,
115
117
  q3: values.q3,
118
+ // Default to general (the ~94% case); only "task-specific" suppresses it
119
+ // from algorithm-update clustering.
120
+ scope: values.scope === "task-specific" ? "task-specific" : "general",
116
121
  };
117
122
 
118
123
  const result = appendReflection(reflection);
@@ -0,0 +1,218 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * algorithm-synthesize — surface the Q2 field ("what a smarter algorithm would
4
+ * have done") across algorithm reflections, pre-sorted into candidate areas, to
5
+ * drive the periodic algorithm-update session: the loop from collected
6
+ * reflections back into ALGORITHM.md itself.
7
+ *
8
+ * IMPORTANT — the keyword BUCKETS are a *heuristic pre-sort hint only*, biased to
9
+ * the current model's English phrasing. They are NOT the clustering authority:
10
+ * another model wording the same idea differently ("double-check the assumption"
11
+ * vs "verify the premise") will not match. The real clustering is semantic, done
12
+ * by the model reading this report in the session. Therefore the tool NEVER drops
13
+ * data — every unmatched Q2 is surfaced in full under "Unbucketed", so nothing is
14
+ * invisible even when (especially when) the keyword sort misses or mis-sorts it.
15
+ *
16
+ * Library: import { synthesizeAlgorithm, formatAlgorithmReport } from ".../algorithm-synthesize"
17
+ * Script: bun src/tools/agent/algorithm-synthesize.ts [--since <ISO>] [--json]
18
+ */
19
+
20
+ import { parseArgs } from "node:util";
21
+ import { readReflections } from "../../hooks/lib/learning-store";
22
+ import { paths } from "../../hooks/lib/paths";
23
+
24
+ /** Algorithm areas a Q2 idea can target, mapped to ALGORITHM.md structure. */
25
+ const BUCKETS: { key: string; label: string; patterns: RegExp }[] = [
26
+ {
27
+ key: "verify-gate",
28
+ label: "Establish grounding before acting (verify a fact / prerequisite first)",
29
+ patterns:
30
+ /pre-?check|pre-?mortem|premise|primary source|verif\w+|reproduce|before (writ|cod|propos|assert|implement|build|run|any|attempt|fetch|explor)|dependency scan|sanity-check|confirm .* before|prerequisite|gate (in|before)|trace .* first|check\w* .* before|anchor on|upfront|connectivity before|resolve .* before|triangulat|contradiction-detection|credibility gate|escalation|after \d+ (failed|iteration)/i,
31
+ },
32
+ {
33
+ key: "criteria",
34
+ label: "Criteria quality (atomicity, splitting, coverage, anti-criteria)",
35
+ patterns: /criteri|atomic|anti-criter|split .* criter|a .* criterion|enumerate/i,
36
+ },
37
+ {
38
+ key: "capability",
39
+ label: "Capability selection (plan mode, subagents, parallelism, research)",
40
+ patterns:
41
+ /capabilit|plan ?mode|askuser|sub-?agent|parallel|in one (agent|batch)|research|worktree/i,
42
+ },
43
+ {
44
+ key: "read-first",
45
+ label: "Read existing code/docs first (prior-artifact / context scan)",
46
+ patterns:
47
+ /read (the|existing|both)|inspect|prior-?artifact|existing (code|repo|hook|target)|grep|survey .* (before|first)/i,
48
+ },
49
+ {
50
+ key: "phase-structure",
51
+ label: "Phase structure (OBSERVE / PLAN / EXECUTE / VERIFY / LEARN ordering)",
52
+ patterns:
53
+ /observe|\bplan phase\b|execute|\bverify phase\b|\blearn phase\b|between (observe|plan|execute|verify)|new phase|a .* phase\b/i,
54
+ },
55
+ {
56
+ key: "scope",
57
+ label: "Scope & altitude (minimal change, avoid over-engineering)",
58
+ patterns: /scope|altitude|minimal|over-?eng|too (broad|wide|much)|narrow/i,
59
+ },
60
+ ];
61
+
62
+ interface AlgorithmBucket {
63
+ key: string;
64
+ label: string;
65
+ count: number;
66
+ avgSentiment: number;
67
+ projects: number;
68
+ quotes: string[];
69
+ }
70
+
71
+ export interface AlgorithmSynthesis {
72
+ /** Count of general Q2s clustered (task-specific ones are surfaced separately). */
73
+ total: number;
74
+ since: string | null;
75
+ buckets: AlgorithmBucket[];
76
+ /** Count of general Q2s the keyword pre-sort matched no bucket for. */
77
+ unbucketed: number;
78
+ /** Full deduped text of every unbucketed general Q2 — the safety net, never just a count. */
79
+ unbucketedQuotes: string[];
80
+ /** Count of Q2s tagged task-specific (excluded from clustering, kept for review). */
81
+ taskSpecific: number;
82
+ /** Full deduped text of task-specific Q2s — surfaced, never dropped. */
83
+ taskSpecificQuotes: string[];
84
+ }
85
+
86
+ function projectOf(cwd: string): string {
87
+ return cwd.split("/").filter(Boolean).pop() ?? "";
88
+ }
89
+
90
+ /**
91
+ * Cluster Q2 reflections into candidate algorithm-improvement areas. A Q2 may
92
+ * land in multiple buckets (ideas often span areas); `unbucketed` counts Q2s
93
+ * that matched none, so blind spots in the bucket set stay visible.
94
+ */
95
+ export function synthesizeAlgorithm(since?: Date): AlgorithmSynthesis {
96
+ const all = readReflections(paths.reflectionsFile());
97
+ const filtered = since ? all.filter((r) => r.ts && new Date(r.ts) >= since) : all;
98
+ const withQ2 = filtered.filter((r) => r.q2.trim());
99
+ // Cluster only general ideas; task-specific ones don't generalize to the
100
+ // algorithm, so surface them separately rather than polluting the buckets.
101
+ const q2s = withQ2.filter((r) => r.scope !== "task-specific");
102
+ const taskSpecificQuotes = [
103
+ ...new Set(withQ2.filter((r) => r.scope === "task-specific").map((r) => r.q2.trim())),
104
+ ];
105
+
106
+ const acc = new Map<
107
+ string,
108
+ { sentiments: number[]; projects: Set<string>; quotes: string[] }
109
+ >();
110
+ for (const b of BUCKETS) {
111
+ acc.set(b.key, { sentiments: [], projects: new Set(), quotes: [] });
112
+ }
113
+
114
+ let unbucketed = 0;
115
+ const unbucketedQuotes: string[] = [];
116
+ for (const r of q2s) {
117
+ const matched = BUCKETS.filter((b) => b.patterns.test(r.q2));
118
+ if (matched.length === 0) {
119
+ unbucketed += 1;
120
+ unbucketedQuotes.push(r.q2.trim());
121
+ continue;
122
+ }
123
+ for (const b of matched) {
124
+ const a = acc.get(b.key);
125
+ if (!a) continue;
126
+ a.sentiments.push(r.sentiment);
127
+ if (r.cwd) a.projects.add(projectOf(r.cwd));
128
+ a.quotes.push(r.q2.trim());
129
+ }
130
+ }
131
+
132
+ const buckets: AlgorithmBucket[] = BUCKETS.map((b) => {
133
+ const a = acc.get(b.key);
134
+ const sentiments = a?.sentiments ?? [];
135
+ const avg = sentiments.length
136
+ ? sentiments.reduce((s, n) => s + n, 0) / sentiments.length
137
+ : 0;
138
+ // Full deduped membership, longest (most specific) first. Not capped here —
139
+ // --json carries every member so nothing is lost; the human report caps the
140
+ // display per bucket. Zero-loss is the contract.
141
+ const quotes = [...new Set(a?.quotes ?? [])].sort((x, y) => y.length - x.length);
142
+ return {
143
+ key: b.key,
144
+ label: b.label,
145
+ count: sentiments.length,
146
+ avgSentiment: Math.round(avg * 10) / 10,
147
+ projects: a?.projects.size ?? 0,
148
+ quotes,
149
+ };
150
+ })
151
+ .filter((b) => b.count > 0)
152
+ .sort((x, y) => y.count - x.count);
153
+
154
+ return {
155
+ total: q2s.length,
156
+ since: since ? since.toISOString() : null,
157
+ buckets,
158
+ unbucketed,
159
+ unbucketedQuotes: [...new Set(unbucketedQuotes)],
160
+ taskSpecific: taskSpecificQuotes.length,
161
+ taskSpecificQuotes,
162
+ };
163
+ }
164
+
165
+ /** Render the synthesis as a markdown candidate-changes report. */
166
+ export function formatAlgorithmReport(s: AlgorithmSynthesis): string {
167
+ const sinceLabel = s.since ? ` since ${s.since.slice(0, 10)}` : " (all time)";
168
+ const tsLabel = s.taskSpecific > 0 ? ` (+${s.taskSpecific} task-specific, below)` : "";
169
+ const lines = [
170
+ "# Algorithm Update — Candidate Changes",
171
+ `Source: ${s.total} general Q2 reflections${sinceLabel}${tsLabel}`,
172
+ "",
173
+ "> The buckets below are a keyword pre-sort hint (biased to current wording).",
174
+ "> Cluster semantically yourself — and read the Unbucketed section in full,",
175
+ "> since that is where wording the heuristic missed lands.",
176
+ "",
177
+ "## Pre-sorted candidate areas",
178
+ ];
179
+ s.buckets.forEach((b, i) => {
180
+ lines.push(
181
+ `### ${i + 1}. ${b.label}`,
182
+ `${b.count} reflections · avg sentiment ${b.avgSentiment}/10 · ${b.projects} project(s)`,
183
+ ""
184
+ );
185
+ for (const q of b.quotes.slice(0, 5)) lines.push(`- ${q}`);
186
+ if (b.quotes.length > 5) lines.push(`- _…+${b.quotes.length - 5} more (see --json)_`);
187
+ lines.push("");
188
+ });
189
+ lines.push(
190
+ `## Unbucketed — ${s.unbucketed} general Q2s the pre-sort missed (cluster these by hand)`,
191
+ ""
192
+ );
193
+ for (const q of s.unbucketedQuotes) lines.push(`- ${q}`);
194
+ if (s.taskSpecific > 0) {
195
+ lines.push(
196
+ "",
197
+ `## Task-specific — ${s.taskSpecific} Q2s tagged not-generalizable (review, don't fold into ALGORITHM.md)`,
198
+ ""
199
+ );
200
+ for (const q of s.taskSpecificQuotes) lines.push(`- ${q}`);
201
+ }
202
+ return lines.join("\n");
203
+ }
204
+
205
+ if (import.meta.main) {
206
+ const { values } = parseArgs({
207
+ args: process.argv.slice(2),
208
+ options: {
209
+ since: { type: "string" },
210
+ json: { type: "boolean", default: false },
211
+ },
212
+ });
213
+ const since = values.since ? new Date(values.since) : undefined;
214
+ const result = synthesizeAlgorithm(since);
215
+ console.log(
216
+ values.json ? JSON.stringify(result, null, 2) : formatAlgorithmReport(result)
217
+ );
218
+ }
@@ -0,0 +1,254 @@
1
+ /**
2
+ * skill-doctor — static evaluator for a SKILL.md against Anthropic's
3
+ * skill-authoring best practices. Checks only what is mechanically verifiable:
4
+ * name/description constraints, body length, point-of-view, and reference depth.
5
+ *
6
+ * Library: import { lintSkill, formatReport } from ".../skill-doctor"
7
+ * Script: bun src/tools/skill-doctor.ts <skill-dir-or-name>
8
+ * (resolves a path, or a name under ~/.pal/skills/)
9
+ * CLI: pal cli skill doctor <name> (see src/cli/skill.ts)
10
+ */
11
+
12
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
13
+ import { basename, resolve } from "node:path";
14
+ import { palHome } from "../hooks/lib/paths";
15
+
16
+ type Level = "pass" | "warn" | "error";
17
+
18
+ interface DoctorFinding {
19
+ level: Level;
20
+ check: string;
21
+ message: string;
22
+ }
23
+
24
+ export interface DoctorReport {
25
+ dir: string;
26
+ name: string | null;
27
+ findings: DoctorFinding[];
28
+ errors: number;
29
+ warnings: number;
30
+ }
31
+
32
+ interface ParsedSkill {
33
+ name: string | null;
34
+ description: string | null;
35
+ body: string;
36
+ }
37
+
38
+ const RESERVED_WORDS = ["anthropic", "claude"];
39
+ const MAX_NAME = 64;
40
+ const MAX_DESCRIPTION = 1024;
41
+ const MAX_BODY_LINES = 500;
42
+
43
+ /** Split a SKILL.md into frontmatter fields and body. */
44
+ function parseSkill(content: string): ParsedSkill {
45
+ const parts = content.split(/^---\s*$/m);
46
+ if (parts.length < 3) {
47
+ return { name: null, description: null, body: content };
48
+ }
49
+ const frontmatter = parts[1];
50
+ const body = parts.slice(2).join("---");
51
+ const name = /^name:\s*"?(.+?)"?\s*$/m.exec(frontmatter)?.[1] ?? null;
52
+ const description = /^description:\s*"?(.+?)"?\s*$/m.exec(frontmatter)?.[1] ?? null;
53
+ return { name, description, body };
54
+ }
55
+
56
+ /** Remove fenced and inline code so prose checks don't trip on examples. */
57
+ function stripCode(s: string): string {
58
+ return s.replace(/```[\s\S]*?```/g, "").replace(/`[^`]*`/g, "");
59
+ }
60
+
61
+ /**
62
+ * Evaluate the skill at `skillDir` (a folder containing SKILL.md) and return a
63
+ * structured report. Hard rules from Anthropic's validation surface as errors;
64
+ * best-practice nudges surface as warnings.
65
+ */
66
+ export function lintSkill(skillDir: string): DoctorReport {
67
+ const findings: DoctorFinding[] = [];
68
+ const add = (level: Level, check: string, message: string) =>
69
+ findings.push({ level, check, message });
70
+
71
+ if (!existsSync(skillDir)) {
72
+ add("error", "structure", `No skill directory at ${skillDir}`);
73
+ return { dir: skillDir, name: null, findings, errors: 1, warnings: 0 };
74
+ }
75
+
76
+ // The runtime only loads a file named exactly `SKILL.md`. On case-insensitive
77
+ // filesystems (macOS, Windows) `existsSync` would accept `skill.md`, so match
78
+ // the real on-disk entry, not a case-folded path.
79
+ const skillFile = readdirSync(skillDir).find((e) => e.toLowerCase() === "skill.md");
80
+ if (!skillFile) {
81
+ add("error", "structure", `No SKILL.md found in ${skillDir}`);
82
+ return { dir: skillDir, name: null, findings, errors: 1, warnings: 0 };
83
+ }
84
+ skillFile === "SKILL.md"
85
+ ? add("pass", "file.name", "skill file is named SKILL.md")
86
+ : add(
87
+ "error",
88
+ "file.name",
89
+ `skill file is "${skillFile}" — must be exactly "SKILL.md" or the skill is silently ignored`
90
+ );
91
+
92
+ const { name, description, body } = parseSkill(
93
+ readFileSync(resolve(skillDir, skillFile), "utf-8")
94
+ );
95
+
96
+ // The runtime keys a skill by its folder name; a mismatched frontmatter `name`
97
+ // makes the skill silently fail to load.
98
+ const folder = basename(skillDir);
99
+ if (name) {
100
+ name === folder
101
+ ? add("pass", "name.folder", `matches folder "${folder}"`)
102
+ : add(
103
+ "error",
104
+ "name.folder",
105
+ `name "${name}" must equal the folder name "${folder}" verbatim — otherwise the skill is silently ignored`
106
+ );
107
+ }
108
+
109
+ // ── name ──
110
+ if (!name) {
111
+ add("error", "name", "Missing `name` in frontmatter");
112
+ } else {
113
+ name.length <= MAX_NAME
114
+ ? add("pass", "name.length", `${name.length}/${MAX_NAME} chars`)
115
+ : add("error", "name.length", `${name.length} chars exceeds ${MAX_NAME}`);
116
+ /^[a-z0-9-]+$/.test(name)
117
+ ? add("pass", "name.charset", "lowercase letters, numbers, hyphens only")
118
+ : add(
119
+ "error",
120
+ "name.charset",
121
+ `"${name}" must be lowercase a-z, 0-9, hyphens only`
122
+ );
123
+ const reserved = RESERVED_WORDS.find((w) => name.toLowerCase().includes(w));
124
+ reserved
125
+ ? add("error", "name.reserved", `contains reserved word "${reserved}"`)
126
+ : add("pass", "name.reserved", "no reserved words");
127
+ }
128
+
129
+ // ── description ──
130
+ if (!description) {
131
+ add("error", "description", "Missing `description` in frontmatter");
132
+ } else {
133
+ description.length <= MAX_DESCRIPTION
134
+ ? add(
135
+ "pass",
136
+ "description.length",
137
+ `${description.length}/${MAX_DESCRIPTION} chars`
138
+ )
139
+ : add(
140
+ "error",
141
+ "description.length",
142
+ `${description.length} chars exceeds ${MAX_DESCRIPTION}`
143
+ );
144
+ /<[^>]+>/.test(description)
145
+ ? add(
146
+ "warn",
147
+ "description.xml",
148
+ "contains angle-bracket content — Anthropic disallows XML tags; rephrase placeholders like <x> in prose"
149
+ )
150
+ : add("pass", "description.xml", "no XML tags");
151
+ /\bwhen/i.test(description)
152
+ ? add("pass", "description.trigger", "states when to use the skill")
153
+ : add(
154
+ "warn",
155
+ "description.trigger",
156
+ "no 'when to use' trigger — add 'Use when …' so the dispatcher can match it"
157
+ );
158
+ /\b(I can|I['’]ll|I will|I help|you can|you will|you could|you['’]ll)\b/i.test(
159
+ description
160
+ )
161
+ ? add(
162
+ "warn",
163
+ "description.pov",
164
+ "reads first/second person — write descriptions in third person (e.g. 'Processes…', 'Generates…')"
165
+ )
166
+ : add("pass", "description.pov", "third person");
167
+ }
168
+
169
+ // ── body ──
170
+ const bodyLines = body.split("\n").length;
171
+ bodyLines <= MAX_BODY_LINES
172
+ ? add("pass", "body.length", `${bodyLines}/${MAX_BODY_LINES} lines`)
173
+ : add(
174
+ "warn",
175
+ "body.length",
176
+ `${bodyLines} lines exceeds ${MAX_BODY_LINES} — split into reference files`
177
+ );
178
+
179
+ const prose = stripCode(body);
180
+ /\b(I['’]m|I will|I['’]ll|in my experience|my workflow|I wrote|I created)\b/i.test(
181
+ prose
182
+ )
183
+ ? add(
184
+ "warn",
185
+ "body.pov",
186
+ "body uses first-person author voice — write it as second-person instructions to the assistant"
187
+ )
188
+ : add("pass", "body.pov", "instructional voice");
189
+
190
+ // ── reference depth (one level deep) ──
191
+ const linkRe = /\[[^\]]+\]\(([^)]+\.md)\)/g;
192
+ const hasMdLink = /\[[^\]]+\]\([^)]+\.md\)/;
193
+ let nested = false;
194
+ for (const m of body.matchAll(linkRe)) {
195
+ const target = m[1];
196
+ if (/^https?:/.test(target)) continue;
197
+ const targetPath = resolve(skillDir, target);
198
+ if (!existsSync(targetPath)) continue;
199
+ const refContent = readFileSync(targetPath, "utf-8");
200
+ if (hasMdLink.test(refContent)) {
201
+ nested = true;
202
+ add(
203
+ "warn",
204
+ "references.depth",
205
+ `${target} links to further .md files — keep references one level deep from SKILL.md`
206
+ );
207
+ }
208
+ }
209
+ if (!nested) add("pass", "references.depth", "references are one level deep");
210
+
211
+ // ── windows-style paths ──
212
+ /\b[\w.-]+\\[\w.-]+\.(py|ts|js|sh|md|json)\b/.test(body)
213
+ ? add("warn", "paths", "Windows-style backslash path found — use forward slashes")
214
+ : add("pass", "paths", "forward-slash paths");
215
+
216
+ const errors = findings.filter((f) => f.level === "error").length;
217
+ const warnings = findings.filter((f) => f.level === "warn").length;
218
+ return { dir: skillDir, name, findings, errors, warnings };
219
+ }
220
+
221
+ /** Render a report as a human-readable string. */
222
+ export function formatReport(r: DoctorReport): string {
223
+ const icon = { pass: "✓", warn: "⚠", error: "✗" } as const;
224
+ const lines = [`skill-doctor: ${r.name ?? "(unparsed)"} — ${r.dir}`];
225
+ for (const f of r.findings) {
226
+ lines.push(` ${icon[f.level]} ${f.check}: ${f.message}`);
227
+ }
228
+ let verdict: string;
229
+ if (r.errors > 0) {
230
+ verdict = `FAIL — ${r.errors} error(s), ${r.warnings} warning(s)`;
231
+ } else if (r.warnings > 0) {
232
+ verdict = `OK with ${r.warnings} warning(s)`;
233
+ } else {
234
+ verdict = "PASS — all checks clean";
235
+ }
236
+ lines.push(` ${verdict}`);
237
+ return lines.join("\n");
238
+ }
239
+
240
+ if (import.meta.main) {
241
+ const arg = process.argv[2];
242
+ if (!arg) {
243
+ console.error("Usage: bun src/tools/skill-doctor.ts <skill-dir-or-name>");
244
+ process.exit(2);
245
+ }
246
+ let dir = resolve(arg);
247
+ if (!existsSync(resolve(dir, "SKILL.md"))) {
248
+ const byName = resolve(palHome(), "skills", arg);
249
+ if (existsSync(resolve(byName, "SKILL.md"))) dir = byName;
250
+ }
251
+ const report = lintSkill(dir);
252
+ console.log(formatReport(report));
253
+ process.exit(report.errors > 0 ? 1 : 0);
254
+ }