portable-agent-layer 0.47.0 → 0.49.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 +1 -0
- package/assets/STATUSLINE.md +102 -0
- package/assets/skills/create-skill/SKILL.md +6 -1
- package/assets/statusline.ps1 +142 -0
- package/assets/statusline.sh +167 -0
- package/assets/templates/PAL/ALGORITHM.md +16 -1
- package/assets/templates/settings.claude.json +21 -0
- package/package.json +1 -1
- package/src/cli/index.ts +2 -0
- package/src/cli/skill.ts +18 -3
- 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 +5 -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/claude/install.ts +9 -1
- package/src/targets/claude/uninstall.ts +9 -1
- package/src/targets/lib.ts +216 -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
|
@@ -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
|
+
}
|