portable-agent-layer 0.71.0 → 0.72.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/package.json +1 -1
- package/src/cli/migrate.ts +1 -1
- package/src/cli/skill.ts +1 -1
- package/src/hooks/CompactRecover.ts +28 -86
- package/src/hooks/LedgerUnapplied.ts +3 -28
- package/src/hooks/LoadContext.ts +33 -60
- package/src/hooks/SecurityValidator.ts +16 -109
- package/src/hooks/handlers/failure-principle.ts +19 -44
- package/src/hooks/handlers/session-intelligence.ts +13 -70
- package/src/hooks/lib/capture-store.ts +103 -0
- package/src/hooks/lib/compact-recall.ts +89 -0
- package/src/hooks/lib/failure-principle.ts +98 -0
- package/src/hooks/lib/ledger-hook.ts +35 -0
- package/src/hooks/lib/ledger.ts +48 -1
- package/src/hooks/lib/security-gate.ts +159 -0
- package/src/hooks/lib/session-context.ts +74 -0
- package/src/tools/agent/algorithm-reflect.ts +28 -97
- package/src/tools/agent/analyze.ts +19 -120
- package/src/tools/agent/handoff-note.ts +29 -77
- package/src/tools/agent/project.ts +13 -134
- package/src/tools/agent/relationship-note.ts +27 -46
- package/src/tools/agent/synthesize.ts +1 -1
- package/src/tools/agent/thread.ts +43 -123
- package/src/tools/control-room/data.ts +2 -2
- package/src/tools/control-room/matrix.ts +1 -1
- package/src/tools/control-room/ui/ledger.tsx +2 -1
- package/src/tools/ledger/view.ts +3 -0
- package/src/tools/lib/algorithm-reflect.ts +84 -0
- package/src/tools/lib/analyze-report.ts +120 -0
- package/src/tools/lib/handoff-note.ts +88 -0
- package/src/tools/lib/note-flags.ts +59 -0
- package/src/tools/lib/project-isc.ts +151 -0
- package/src/tools/lib/relationship-reflect.ts +402 -0
- package/src/tools/lib/self-model.ts +499 -0
- package/src/tools/lib/session-usage.ts +216 -0
- package/src/tools/lib/skill-doctor.ts +457 -0
- package/src/tools/lib/thread.ts +119 -0
- package/src/tools/lib/token-report.ts +173 -0
- package/src/tools/lib/transcript-usage.ts +42 -0
- package/src/tools/lib/usage-buckets.ts +329 -0
- package/src/tools/relationship-reflect.ts +48 -412
- package/src/tools/self-model.ts +76 -558
- package/src/tools/session-summary.ts +8 -215
- package/src/tools/skill-doctor.ts +9 -444
- package/src/tools/token-cost.ts +18 -428
|
@@ -3,227 +3,20 @@
|
|
|
3
3
|
* Designed to be called from the `pal` wrapper script.
|
|
4
4
|
*
|
|
5
5
|
* Usage: bun run tools/session-summary.ts --session <sessionId>
|
|
6
|
+
*
|
|
7
|
+
* The reading, the arithmetic and the formatting are in lib/session-usage.ts.
|
|
6
8
|
*/
|
|
7
9
|
|
|
8
|
-
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
9
|
-
import { homedir } from "node:os";
|
|
10
|
-
import { resolve } from "node:path";
|
|
11
10
|
import { parseArgs } from "node:util";
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
// ── Types ──
|
|
15
|
-
|
|
16
|
-
interface Usage {
|
|
17
|
-
input: number;
|
|
18
|
-
output: number;
|
|
19
|
-
cacheWrite5m: number;
|
|
20
|
-
cacheWrite1h: number;
|
|
21
|
-
cacheRead: number;
|
|
22
|
-
cost: number;
|
|
23
|
-
calls: number;
|
|
24
|
-
models: Set<string>;
|
|
25
|
-
durationMs: number;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
// ── Core Functions ──
|
|
29
|
-
|
|
30
|
-
function findSessionFile(
|
|
31
|
-
sessionId: string,
|
|
32
|
-
claudeDir: string
|
|
33
|
-
): { filepath: string; project: string } | null {
|
|
34
|
-
if (!existsSync(claudeDir)) return null;
|
|
35
|
-
|
|
36
|
-
const projectDirs = readdirSync(claudeDir, { withFileTypes: true }).filter((d) =>
|
|
37
|
-
d.isDirectory()
|
|
38
|
-
);
|
|
39
|
-
|
|
40
|
-
for (const dir of projectDirs) {
|
|
41
|
-
const projPath = resolve(claudeDir, dir.name);
|
|
42
|
-
const projName = dir.name.split("-").pop() ?? dir.name;
|
|
43
|
-
|
|
44
|
-
const directFile = resolve(projPath, `${sessionId}.jsonl`);
|
|
45
|
-
if (existsSync(directFile)) {
|
|
46
|
-
return { filepath: directFile, project: projName };
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// Fallback: scan most recently modified files
|
|
51
|
-
let latest: { filepath: string; project: string; mtime: number } | null = null;
|
|
52
|
-
|
|
53
|
-
for (const dir of readdirSync(claudeDir, { withFileTypes: true }).filter((d) =>
|
|
54
|
-
d.isDirectory()
|
|
55
|
-
)) {
|
|
56
|
-
const projPath = resolve(claudeDir, dir.name);
|
|
57
|
-
const projName = dir.name.split("-").pop() ?? dir.name;
|
|
58
|
-
|
|
59
|
-
let files: string[];
|
|
60
|
-
try {
|
|
61
|
-
files = readdirSync(projPath).filter((f) => f.endsWith(".jsonl"));
|
|
62
|
-
} catch {
|
|
63
|
-
continue;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
for (const file of files) {
|
|
67
|
-
const filepath = resolve(projPath, file);
|
|
68
|
-
try {
|
|
69
|
-
const mtime = Bun.file(filepath).lastModified;
|
|
70
|
-
if (!latest || mtime > latest.mtime) {
|
|
71
|
-
latest = { filepath, project: projName, mtime };
|
|
72
|
-
}
|
|
73
|
-
} catch {
|
|
74
|
-
/* skip */
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
return latest;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function parseSession(filepath: string, sessionId: string): Usage {
|
|
83
|
-
const usage: Usage = {
|
|
84
|
-
input: 0,
|
|
85
|
-
output: 0,
|
|
86
|
-
cacheWrite5m: 0,
|
|
87
|
-
cacheWrite1h: 0,
|
|
88
|
-
cacheRead: 0,
|
|
89
|
-
cost: 0,
|
|
90
|
-
calls: 0,
|
|
91
|
-
models: new Set(),
|
|
92
|
-
durationMs: 0,
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
const content = readFileSync(filepath, "utf-8");
|
|
96
|
-
let firstTs = "";
|
|
97
|
-
let lastTs = "";
|
|
98
|
-
|
|
99
|
-
for (const line of content.split("\n")) {
|
|
100
|
-
if (!line) continue;
|
|
101
|
-
|
|
102
|
-
try {
|
|
103
|
-
const d = JSON.parse(line) as {
|
|
104
|
-
type?: string;
|
|
105
|
-
timestamp?: string;
|
|
106
|
-
sessionId?: string;
|
|
107
|
-
message?: {
|
|
108
|
-
model?: string;
|
|
109
|
-
usage?: {
|
|
110
|
-
input_tokens?: number;
|
|
111
|
-
output_tokens?: number;
|
|
112
|
-
cache_creation_input_tokens?: number;
|
|
113
|
-
cache_read_input_tokens?: number;
|
|
114
|
-
cache_creation?: {
|
|
115
|
-
ephemeral_5m_input_tokens?: number;
|
|
116
|
-
ephemeral_1h_input_tokens?: number;
|
|
117
|
-
};
|
|
118
|
-
};
|
|
119
|
-
};
|
|
120
|
-
};
|
|
121
|
-
|
|
122
|
-
if (d.sessionId !== sessionId) continue;
|
|
123
|
-
|
|
124
|
-
if (d.timestamp) {
|
|
125
|
-
firstTs ??= d.timestamp;
|
|
126
|
-
lastTs = d.timestamp;
|
|
127
|
-
}
|
|
11
|
+
import { claudeProjectsDir, sessionSummary } from "./lib/session-usage";
|
|
128
12
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
const model = d.message?.model;
|
|
132
|
-
if (!u || !model) continue;
|
|
133
|
-
|
|
134
|
-
const input = u.input_tokens ?? 0;
|
|
135
|
-
const output = u.output_tokens ?? 0;
|
|
136
|
-
const cr = u.cache_read_input_tokens ?? 0;
|
|
137
|
-
const cw5m = u.cache_creation?.ephemeral_5m_input_tokens;
|
|
138
|
-
const cw1h = u.cache_creation?.ephemeral_1h_input_tokens;
|
|
139
|
-
const hasBreakdown = cw5m !== undefined || cw1h !== undefined;
|
|
140
|
-
const cacheWrite5m = hasBreakdown
|
|
141
|
-
? (cw5m ?? 0)
|
|
142
|
-
: (u.cache_creation_input_tokens ?? 0);
|
|
143
|
-
const cacheWrite1h = cw1h ?? 0;
|
|
144
|
-
|
|
145
|
-
usage.cost += costOfUsage(model, {
|
|
146
|
-
input,
|
|
147
|
-
output,
|
|
148
|
-
cacheWrite5m,
|
|
149
|
-
cacheWrite1h,
|
|
150
|
-
cacheRead: cr,
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
usage.input += input;
|
|
154
|
-
usage.output += output;
|
|
155
|
-
usage.cacheWrite5m += cacheWrite5m;
|
|
156
|
-
usage.cacheWrite1h += cacheWrite1h;
|
|
157
|
-
usage.cacheRead += cr;
|
|
158
|
-
usage.calls++;
|
|
159
|
-
usage.models.add(model);
|
|
160
|
-
} catch {
|
|
161
|
-
/* skip */
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
if (firstTs && lastTs) {
|
|
166
|
-
usage.durationMs = new Date(lastTs).getTime() - new Date(firstTs).getTime();
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
return usage;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// ── Format helpers ──
|
|
173
|
-
|
|
174
|
-
function fmtTokens(n: number): string {
|
|
175
|
-
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
176
|
-
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
|
177
|
-
return String(n);
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
function fmtCost(n: number): string {
|
|
181
|
-
if (n >= 1) return `$${n.toFixed(2)}`;
|
|
182
|
-
return `$${n.toFixed(4)}`;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
function fmtDuration(ms: number): string {
|
|
186
|
-
const mins = Math.floor(ms / 60_000);
|
|
187
|
-
if (mins < 1) return "<1m";
|
|
188
|
-
if (mins < 60) return `${mins}m`;
|
|
189
|
-
const hrs = Math.floor(mins / 60);
|
|
190
|
-
const rem = mins % 60;
|
|
191
|
-
return rem > 0 ? `${hrs}h ${rem}m` : `${hrs}h`;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
// ── CLI ──
|
|
195
|
-
|
|
196
|
-
function run() {
|
|
197
|
-
const { values: args } = parseArgs({
|
|
13
|
+
if (import.meta.main) {
|
|
14
|
+
const { values } = parseArgs({
|
|
198
15
|
options: { session: { type: "string" } },
|
|
199
16
|
strict: false,
|
|
200
17
|
});
|
|
201
18
|
|
|
202
|
-
const sessionId = typeof
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
const claudeDir = resolve(homedir(), ".claude", "projects");
|
|
206
|
-
const file = findSessionFile(sessionId, claudeDir);
|
|
207
|
-
if (!file) process.exit(0);
|
|
208
|
-
|
|
209
|
-
const usage = parseSession(file.filepath, sessionId);
|
|
210
|
-
if (usage.calls === 0) process.exit(0);
|
|
211
|
-
|
|
212
|
-
const totalTokens =
|
|
213
|
-
usage.input +
|
|
214
|
-
usage.output +
|
|
215
|
-
usage.cacheWrite5m +
|
|
216
|
-
usage.cacheWrite1h +
|
|
217
|
-
usage.cacheRead;
|
|
218
|
-
const model = [...usage.models].map((m) => m.replace("claude-", "")).join(", ");
|
|
219
|
-
|
|
220
|
-
const dim = "\x1b[2m";
|
|
221
|
-
const reset = "\x1b[0m";
|
|
222
|
-
const cyan = "\x1b[36m";
|
|
223
|
-
|
|
224
|
-
console.log(
|
|
225
|
-
`\n${dim}Session: ${file.project} · ${model} · ${fmtDuration(usage.durationMs)} · ${fmtTokens(totalTokens)} tokens · ${usage.calls} calls · ${cyan}${fmtCost(usage.cost)}${reset}`
|
|
226
|
-
);
|
|
19
|
+
const sessionId = typeof values.session === "string" ? values.session : "";
|
|
20
|
+
const summary = sessionSummary(sessionId, claudeProjectsDir());
|
|
21
|
+
if (summary) console.log(summary);
|
|
227
22
|
}
|
|
228
|
-
|
|
229
|
-
if (import.meta.main) run();
|
|
@@ -1,446 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
1
2
|
/**
|
|
2
3
|
* skill-doctor — static evaluator for a SKILL.md against Anthropic's
|
|
3
|
-
* skill-authoring best practices.
|
|
4
|
-
* name/description constraints, body length, point-of-view, and reference depth.
|
|
4
|
+
* skill-authoring best practices.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
13
|
-
import { basename, extname, relative, resolve } from "node:path";
|
|
14
|
-
import { palHome } from "../hooks/lib/paths";
|
|
15
|
-
import { declaredTriggers } from "../hooks/lib/skill-triggers";
|
|
16
|
-
|
|
17
|
-
type Level = "pass" | "warn" | "error";
|
|
18
|
-
|
|
19
|
-
interface DoctorFinding {
|
|
20
|
-
level: Level;
|
|
21
|
-
check: string;
|
|
22
|
-
message: string;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export interface DoctorReport {
|
|
26
|
-
dir: string;
|
|
27
|
-
name: string | null;
|
|
28
|
-
findings: DoctorFinding[];
|
|
29
|
-
errors: number;
|
|
30
|
-
warnings: number;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
interface ParsedSkill {
|
|
34
|
-
name: string | null;
|
|
35
|
-
description: string | null;
|
|
36
|
-
descriptionQuoted: boolean;
|
|
37
|
-
triggers: string[];
|
|
38
|
-
shipped: boolean;
|
|
39
|
-
license: string | null;
|
|
40
|
-
derivedFrom: string | null;
|
|
41
|
-
body: string;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const SHIPPED_SOURCE = "portable-agent-layer";
|
|
45
|
-
|
|
46
|
-
const RESERVED_WORDS = ["anthropic", "claude"];
|
|
47
|
-
const MAX_NAME = 64;
|
|
48
|
-
const MAX_DESCRIPTION = 1024;
|
|
49
|
-
const MAX_BODY_LINES = 500;
|
|
50
|
-
const MIN_TRIGGERS = 3;
|
|
51
|
-
|
|
52
|
-
/** File extensions worth scanning for hardcoded paths (SKILL.md + its scripts). */
|
|
53
|
-
const SCANNABLE_EXT = new Set([".md", ".ts", ".js", ".mjs", ".cjs", ".sh", ".py"]);
|
|
54
|
-
|
|
55
|
-
/** Machine/user-specific absolute paths that will not survive an export to
|
|
56
|
-
* another machine or user: POSIX home dirs and Windows user profiles. Portable
|
|
57
|
-
* forms ($HOME, ~, %USERPROFILE%, env vars) are deliberately not matched. */
|
|
58
|
-
const ABSOLUTE_PATH_RE =
|
|
59
|
-
/(?:\/(?:Users|home)\/[A-Za-z0-9._-]+|\/root\/[A-Za-z0-9._-]|[A-Za-z]:\\Users\\[A-Za-z0-9._-]+)/;
|
|
60
|
-
|
|
61
|
-
/** Collect SKILL.md and sibling script files, skipping vendored/VCS trees. */
|
|
62
|
-
function collectSkillFiles(skillDir: string): string[] {
|
|
63
|
-
const out: string[] = [];
|
|
64
|
-
const walk = (dir: string) => {
|
|
65
|
-
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
66
|
-
if (entry.name === "node_modules" || entry.name.startsWith(".")) continue;
|
|
67
|
-
const full = resolve(dir, entry.name);
|
|
68
|
-
if (entry.isDirectory()) walk(full);
|
|
69
|
-
else if (entry.isFile() && SCANNABLE_EXT.has(extname(entry.name))) out.push(full);
|
|
70
|
-
}
|
|
71
|
-
};
|
|
72
|
-
walk(skillDir);
|
|
73
|
-
return out;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** Find machine-specific absolute paths across a skill's files. */
|
|
77
|
-
function findAbsolutePaths(skillDir: string): string[] {
|
|
78
|
-
const hits: string[] = [];
|
|
79
|
-
for (const file of collectSkillFiles(skillDir)) {
|
|
80
|
-
const rel = relative(skillDir, file).replaceAll("\\", "/");
|
|
81
|
-
const lines = readFileSync(file, "utf-8").split("\n");
|
|
82
|
-
for (let i = 0; i < lines.length; i++) {
|
|
83
|
-
const m = ABSOLUTE_PATH_RE.exec(lines[i]);
|
|
84
|
-
if (m) hits.push(`${rel}:${i + 1} → ${m[0]}`);
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
return hits;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/** Split a SKILL.md into frontmatter fields and body. */
|
|
91
|
-
function parseSkill(content: string): ParsedSkill {
|
|
92
|
-
const parts = content.split(/^---\s*$/m);
|
|
93
|
-
if (parts.length < 3) {
|
|
94
|
-
return {
|
|
95
|
-
name: null,
|
|
96
|
-
description: null,
|
|
97
|
-
descriptionQuoted: false,
|
|
98
|
-
triggers: [],
|
|
99
|
-
shipped: false,
|
|
100
|
-
license: null,
|
|
101
|
-
derivedFrom: null,
|
|
102
|
-
body: content,
|
|
103
|
-
};
|
|
104
|
-
}
|
|
105
|
-
const frontmatter = parts[1];
|
|
106
|
-
const body = parts.slice(2).join("---");
|
|
107
|
-
const name = /^name:\s*"?(.+?)"?\s*$/m.exec(frontmatter)?.[1] ?? null;
|
|
108
|
-
const rawDescription = /^description:[ \t]*(.*?)\s*$/m.exec(frontmatter)?.[1] ?? null;
|
|
109
|
-
const descriptionQuoted =
|
|
110
|
-
rawDescription !== null &&
|
|
111
|
-
rawDescription.length >= 2 &&
|
|
112
|
-
rawDescription.startsWith('"') &&
|
|
113
|
-
rawDescription.endsWith('"');
|
|
114
|
-
const description = descriptionQuoted ? rawDescription.slice(1, -1) : rawDescription;
|
|
115
|
-
return {
|
|
116
|
-
name,
|
|
117
|
-
description,
|
|
118
|
-
descriptionQuoted,
|
|
119
|
-
triggers: declaredTriggers(frontmatter),
|
|
120
|
-
shipped: metadataField(frontmatter, "source") === SHIPPED_SOURCE,
|
|
121
|
-
license: topLevelField(frontmatter, "license"),
|
|
122
|
-
derivedFrom: metadataField(frontmatter, "derived-from"),
|
|
123
|
-
body,
|
|
124
|
-
};
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/** Value of a top-level `key:` line in the frontmatter, unquoted. */
|
|
128
|
-
function topLevelField(frontmatter: string, key: string): string | null {
|
|
129
|
-
return (
|
|
130
|
-
new RegExp(String.raw`^${key}:\s*"?(.+?)"?\s*$`, "m").exec(frontmatter)?.[1] ?? null
|
|
131
|
-
);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/** Value of an indented `key:` line under the `metadata:` block, unquoted. */
|
|
135
|
-
function metadataField(frontmatter: string, key: string): string | null {
|
|
136
|
-
return (
|
|
137
|
-
new RegExp(String.raw`^[ \t]+${key}:\s*"?(.+?)"?\s*$`, "m").exec(frontmatter)?.[1] ??
|
|
138
|
-
null
|
|
139
|
-
);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/** Render triggers for a report line: `"a", "b"` or `"a" then "b"`. */
|
|
143
|
-
function quoteList(triggers: string[], separator: string): string {
|
|
144
|
-
return triggers.map((trigger) => `"${trigger}"`).join(separator);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* The triggers every skill must declare first: its own name, then the
|
|
149
|
-
* de-hyphenated form a user would actually type. A single-word name has only
|
|
150
|
-
* the one form, and the parser dedupes anyway, so it requires just itself.
|
|
6
|
+
* Usage: bun src/tools/skill-doctor.ts <skill-dir-or-name>
|
|
7
|
+
* (resolves a path, or a name under ~/.pal/skills/)
|
|
8
|
+
*
|
|
9
|
+
* The checks live in src/tools/lib/skill-doctor.ts; this file only turns argv
|
|
10
|
+
* into a report and a report into an exit code.
|
|
151
11
|
*/
|
|
152
|
-
function leadTriggers(name: string): string[] {
|
|
153
|
-
const spaced = name.replaceAll("-", " ");
|
|
154
|
-
return spaced === name ? [name] : [name, spaced];
|
|
155
|
-
}
|
|
156
12
|
|
|
157
|
-
|
|
158
|
-
function stripCode(s: string): string {
|
|
159
|
-
return s.replace(/```[\s\S]*?```/g, "").replace(/`[^`]*`/g, "");
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* Evaluate the skill at `skillDir` (a folder containing SKILL.md) and return a
|
|
164
|
-
* structured report. Hard rules from Anthropic's validation surface as errors;
|
|
165
|
-
* best-practice nudges surface as warnings.
|
|
166
|
-
*/
|
|
167
|
-
export function lintSkill(skillDir: string): DoctorReport {
|
|
168
|
-
const findings: DoctorFinding[] = [];
|
|
169
|
-
const add = (level: Level, check: string, message: string) =>
|
|
170
|
-
findings.push({ level, check, message });
|
|
171
|
-
|
|
172
|
-
if (!existsSync(skillDir)) {
|
|
173
|
-
add("error", "structure", `No skill directory at ${skillDir}`);
|
|
174
|
-
return { dir: skillDir, name: null, findings, errors: 1, warnings: 0 };
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// The runtime only loads a file named exactly `SKILL.md`. On case-insensitive
|
|
178
|
-
// filesystems (macOS, Windows) `existsSync` would accept `skill.md`, so match
|
|
179
|
-
// the real on-disk entry, not a case-folded path.
|
|
180
|
-
const skillFile = readdirSync(skillDir).find((e) => e.toLowerCase() === "skill.md");
|
|
181
|
-
if (!skillFile) {
|
|
182
|
-
add("error", "structure", `No SKILL.md found in ${skillDir}`);
|
|
183
|
-
return { dir: skillDir, name: null, findings, errors: 1, warnings: 0 };
|
|
184
|
-
}
|
|
185
|
-
skillFile === "SKILL.md"
|
|
186
|
-
? add("pass", "file.name", "skill file is named SKILL.md")
|
|
187
|
-
: add(
|
|
188
|
-
"error",
|
|
189
|
-
"file.name",
|
|
190
|
-
`skill file is "${skillFile}" — must be exactly "SKILL.md" or the skill is silently ignored`
|
|
191
|
-
);
|
|
192
|
-
|
|
193
|
-
const {
|
|
194
|
-
name,
|
|
195
|
-
description,
|
|
196
|
-
descriptionQuoted,
|
|
197
|
-
triggers,
|
|
198
|
-
shipped,
|
|
199
|
-
license,
|
|
200
|
-
derivedFrom,
|
|
201
|
-
body,
|
|
202
|
-
} = parseSkill(readFileSync(resolve(skillDir, skillFile), "utf-8"));
|
|
203
|
-
|
|
204
|
-
// ── provenance (shipped skills only) ──
|
|
205
|
-
if (shipped) {
|
|
206
|
-
if (license) add("pass", "license", `licensed ${license}`);
|
|
207
|
-
else if (derivedFrom)
|
|
208
|
-
add("pass", "license", `unlicensed by design — derived from ${derivedFrom}`);
|
|
209
|
-
else
|
|
210
|
-
add(
|
|
211
|
-
"warn",
|
|
212
|
-
"license",
|
|
213
|
-
"shipped skill declares no license — add `license: MIT`, or `metadata.derived-from: <origin>` when the idea comes from another project"
|
|
214
|
-
);
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
// The runtime keys a skill by its folder name; a mismatched frontmatter `name`
|
|
218
|
-
// makes the skill silently fail to load.
|
|
219
|
-
const folder = basename(skillDir);
|
|
220
|
-
if (name) {
|
|
221
|
-
name === folder
|
|
222
|
-
? add("pass", "name.folder", `matches folder "${folder}"`)
|
|
223
|
-
: add(
|
|
224
|
-
"error",
|
|
225
|
-
"name.folder",
|
|
226
|
-
`name "${name}" must equal the folder name "${folder}" verbatim — otherwise the skill is silently ignored`
|
|
227
|
-
);
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
// ── name ──
|
|
231
|
-
if (!name) {
|
|
232
|
-
add("error", "name", "Missing `name` in frontmatter");
|
|
233
|
-
} else {
|
|
234
|
-
name.length <= MAX_NAME
|
|
235
|
-
? add("pass", "name.length", `${name.length}/${MAX_NAME} chars`)
|
|
236
|
-
: add("error", "name.length", `${name.length} chars exceeds ${MAX_NAME}`);
|
|
237
|
-
/^[a-z0-9-]+$/.test(name)
|
|
238
|
-
? add("pass", "name.charset", "lowercase letters, numbers, hyphens only")
|
|
239
|
-
: add(
|
|
240
|
-
"error",
|
|
241
|
-
"name.charset",
|
|
242
|
-
`"${name}" must be lowercase a-z, 0-9, hyphens only`
|
|
243
|
-
);
|
|
244
|
-
const reserved = RESERVED_WORDS.find((w) => name.toLowerCase().includes(w));
|
|
245
|
-
reserved
|
|
246
|
-
? add("error", "name.reserved", `contains reserved word "${reserved}"`)
|
|
247
|
-
: add("pass", "name.reserved", "no reserved words");
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
// ── description ──
|
|
251
|
-
if (!description) {
|
|
252
|
-
add("error", "description", "Missing `description` in frontmatter");
|
|
253
|
-
} else {
|
|
254
|
-
description.length <= MAX_DESCRIPTION
|
|
255
|
-
? add(
|
|
256
|
-
"pass",
|
|
257
|
-
"description.length",
|
|
258
|
-
`${description.length}/${MAX_DESCRIPTION} chars`
|
|
259
|
-
)
|
|
260
|
-
: add(
|
|
261
|
-
"error",
|
|
262
|
-
"description.length",
|
|
263
|
-
`${description.length} chars exceeds ${MAX_DESCRIPTION}`
|
|
264
|
-
);
|
|
265
|
-
descriptionQuoted
|
|
266
|
-
? add("pass", "description.quoted", "value is wrapped in double quotes")
|
|
267
|
-
: add(
|
|
268
|
-
"warn",
|
|
269
|
-
"description.quoted",
|
|
270
|
-
'value is not wrapped in double quotes — unquoted YAML mis-parses on colons, commas, and quotes; wrap it in "..." (escaping any inner " as \\")'
|
|
271
|
-
);
|
|
272
|
-
/<[^>]+>/.test(description)
|
|
273
|
-
? add(
|
|
274
|
-
"warn",
|
|
275
|
-
"description.xml",
|
|
276
|
-
"contains angle-bracket content — Anthropic disallows XML tags; rephrase placeholders like <x> in prose"
|
|
277
|
-
)
|
|
278
|
-
: add("pass", "description.xml", "no XML tags");
|
|
279
|
-
/\bwhen/i.test(description)
|
|
280
|
-
? add("pass", "description.trigger", "states when to use the skill")
|
|
281
|
-
: add(
|
|
282
|
-
"warn",
|
|
283
|
-
"description.trigger",
|
|
284
|
-
"no 'when to use' trigger — add 'Use when …' so the dispatcher can match it"
|
|
285
|
-
);
|
|
286
|
-
/\b(I can|I['’]ll|I will|I help|you can|you will|you could|you['’]ll)\b/i.test(
|
|
287
|
-
description
|
|
288
|
-
)
|
|
289
|
-
? add(
|
|
290
|
-
"warn",
|
|
291
|
-
"description.pov",
|
|
292
|
-
"reads first/second person — write descriptions in third person (e.g. 'Processes…', 'Generates…')"
|
|
293
|
-
)
|
|
294
|
-
: add("pass", "description.pov", "third person");
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
// ── triggers ──
|
|
298
|
-
if (triggers.length === 0) {
|
|
299
|
-
add(
|
|
300
|
-
"warn",
|
|
301
|
-
"metadata.triggers",
|
|
302
|
-
"no metadata.triggers declared — add the words and phrases a prompt would contain so the prompt-time matcher can surface this skill; without them it falls back to keywords mined from the description"
|
|
303
|
-
);
|
|
304
|
-
} else if (triggers.length < MIN_TRIGGERS) {
|
|
305
|
-
add(
|
|
306
|
-
"warn",
|
|
307
|
-
"metadata.triggers",
|
|
308
|
-
`only ${triggers.length} trigger(s) declared — aim for at least ${MIN_TRIGGERS}, mostly multi-word phrases`
|
|
309
|
-
);
|
|
310
|
-
} else {
|
|
311
|
-
add("pass", "metadata.triggers", `${triggers.length} triggers declared`);
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
if (name && triggers.length > 0) {
|
|
315
|
-
const lead = leadTriggers(name);
|
|
316
|
-
const actual = triggers.slice(0, lead.length);
|
|
317
|
-
const wanted = quoteList(lead, " then ");
|
|
318
|
-
const found = quoteList(actual, ", ") || "nothing";
|
|
319
|
-
actual.join("\u0000") === lead.join("\u0000")
|
|
320
|
-
? add("pass", "metadata.triggers.lead", `leads with ${wanted}`)
|
|
321
|
-
: add(
|
|
322
|
-
"warn",
|
|
323
|
-
"metadata.triggers.lead",
|
|
324
|
-
`triggers must lead with ${wanted} — found ${found}`
|
|
325
|
-
);
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
// ── body ──
|
|
329
|
-
const bodyLines = body.split("\n").length;
|
|
330
|
-
bodyLines <= MAX_BODY_LINES
|
|
331
|
-
? add("pass", "body.length", `${bodyLines}/${MAX_BODY_LINES} lines`)
|
|
332
|
-
: add(
|
|
333
|
-
"warn",
|
|
334
|
-
"body.length",
|
|
335
|
-
`${bodyLines} lines exceeds ${MAX_BODY_LINES} — split into reference files`
|
|
336
|
-
);
|
|
337
|
-
|
|
338
|
-
const prose = stripCode(body);
|
|
339
|
-
/\b(I['’]m|I will|I['’]ll|in my experience|my workflow|I wrote|I created)\b/i.test(
|
|
340
|
-
prose
|
|
341
|
-
)
|
|
342
|
-
? add(
|
|
343
|
-
"warn",
|
|
344
|
-
"body.pov",
|
|
345
|
-
"body uses first-person author voice — write it as second-person instructions to the assistant"
|
|
346
|
-
)
|
|
347
|
-
: add("pass", "body.pov", "instructional voice");
|
|
348
|
-
|
|
349
|
-
// ── reference depth (one level deep) ──
|
|
350
|
-
const linkRe = /\[[^\]]+\]\(([^)]+\.md)\)/g;
|
|
351
|
-
const skillFilePath = resolve(skillDir, skillFile);
|
|
352
|
-
// A reference back to the entry SKILL.md is a return-link, not a deeper chain.
|
|
353
|
-
const isDeeperRef = (target: string) =>
|
|
354
|
-
!/^https?:/.test(target) && resolve(skillDir, target) !== skillFilePath;
|
|
355
|
-
let nested = false;
|
|
356
|
-
for (const m of body.matchAll(linkRe)) {
|
|
357
|
-
const target = m[1];
|
|
358
|
-
if (!isDeeperRef(target)) continue;
|
|
359
|
-
const targetPath = resolve(skillDir, target);
|
|
360
|
-
if (!existsSync(targetPath)) continue;
|
|
361
|
-
const refContent = readFileSync(targetPath, "utf-8");
|
|
362
|
-
const onward = [...refContent.matchAll(linkRe)]
|
|
363
|
-
.map((mm) => mm[1])
|
|
364
|
-
.filter(isDeeperRef);
|
|
365
|
-
if (onward.length > 0) {
|
|
366
|
-
nested = true;
|
|
367
|
-
add(
|
|
368
|
-
"warn",
|
|
369
|
-
"references.depth",
|
|
370
|
-
`${target} links to further .md files — keep references one level deep from SKILL.md`
|
|
371
|
-
);
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
if (!nested) add("pass", "references.depth", "references are one level deep");
|
|
375
|
-
|
|
376
|
-
// ── windows-style paths ──
|
|
377
|
-
// Skip lines that are deliberate Windows examples (cmd.exe / %ENV% paths) —
|
|
378
|
-
// a cross-platform skill may legitimately document the Windows invocation.
|
|
379
|
-
const winPathRe = /\b[\w.-]+\\[\w.-]+\.(py|ts|js|sh|md|json)\b/;
|
|
380
|
-
const isIntentionalWindows = (line: string) =>
|
|
381
|
-
/%[A-Z_]+%/.test(line) || /cmd\.exe/i.test(line);
|
|
382
|
-
body.split("\n").some((l) => winPathRe.test(l) && !isIntentionalWindows(l))
|
|
383
|
-
? add("warn", "paths", "Windows-style backslash path found — use forward slashes")
|
|
384
|
-
: add("pass", "paths", "forward-slash paths");
|
|
385
|
-
|
|
386
|
-
// ── machine-specific absolute paths (portability) ──
|
|
387
|
-
// A personal skill MAY legitimately hardcode a machine-specific path (e.g. a
|
|
388
|
-
// cloud-mount vault), so this is a warning, never an error — it flags paths
|
|
389
|
-
// that will not survive being exported to another machine or user.
|
|
390
|
-
const absHits = findAbsolutePaths(skillDir);
|
|
391
|
-
if (absHits.length > 0) {
|
|
392
|
-
const shown = absHits.slice(0, 3).join("; ");
|
|
393
|
-
const more = absHits.length > 3 ? ` (+${absHits.length - 3} more)` : "";
|
|
394
|
-
add(
|
|
395
|
-
"warn",
|
|
396
|
-
"paths.absolute",
|
|
397
|
-
`hardcoded absolute path(s) that won't be portable across machines: ${shown}${more} — prefer $HOME/~ or an env var, or ignore if this is an intentional machine-specific mount`
|
|
398
|
-
);
|
|
399
|
-
} else {
|
|
400
|
-
add("pass", "paths.absolute", "no machine-specific absolute paths");
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
const errors = findings.filter((f) => f.level === "error").length;
|
|
404
|
-
const warnings = findings.filter((f) => f.level === "warn").length;
|
|
405
|
-
return { dir: skillDir, name, findings, errors, warnings };
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
/** Render a report as a human-readable string. */
|
|
409
|
-
/** One scannable line per skill for a whole-store run: verdict plus the checks that fired. */
|
|
410
|
-
export function formatSummary(r: DoctorReport): string {
|
|
411
|
-
const fired = (level: Level) =>
|
|
412
|
-
r.findings.filter((f) => f.level === level).map((f) => f.check);
|
|
413
|
-
// The folder name, not the frontmatter name — the folder is what the reader
|
|
414
|
-
// passes back to `pal cli skill doctor <name>`, and a mismatch between the two
|
|
415
|
-
// is itself one of the errors this line reports.
|
|
416
|
-
const name = basename(r.dir).padEnd(20);
|
|
417
|
-
|
|
418
|
-
if (r.errors > 0) {
|
|
419
|
-
return `✗ ${name} ${r.errors} error(s): ${fired("error").join(", ")}`;
|
|
420
|
-
}
|
|
421
|
-
if (r.warnings > 0) {
|
|
422
|
-
return `⚠ ${name} ${r.warnings} warning(s): ${fired("warn").join(", ")}`;
|
|
423
|
-
}
|
|
424
|
-
return `✓ ${name} clean`;
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
export function formatReport(r: DoctorReport): string {
|
|
428
|
-
const icon = { pass: "✓", warn: "⚠", error: "✗" } as const;
|
|
429
|
-
const lines = [`skill-doctor: ${r.name ?? "(unparsed)"} — ${r.dir}`];
|
|
430
|
-
for (const f of r.findings) {
|
|
431
|
-
lines.push(` ${icon[f.level]} ${f.check}: ${f.message}`);
|
|
432
|
-
}
|
|
433
|
-
let verdict: string;
|
|
434
|
-
if (r.errors > 0) {
|
|
435
|
-
verdict = `FAIL — ${r.errors} error(s), ${r.warnings} warning(s)`;
|
|
436
|
-
} else if (r.warnings > 0) {
|
|
437
|
-
verdict = `OK with ${r.warnings} warning(s)`;
|
|
438
|
-
} else {
|
|
439
|
-
verdict = "PASS — all checks clean";
|
|
440
|
-
}
|
|
441
|
-
lines.push(` ${verdict}`);
|
|
442
|
-
return lines.join("\n");
|
|
443
|
-
}
|
|
13
|
+
import { formatReport, lintSkill, resolveSkillDir } from "./lib/skill-doctor";
|
|
444
14
|
|
|
445
15
|
if (import.meta.main) {
|
|
446
16
|
const arg = process.argv[2];
|
|
@@ -448,12 +18,7 @@ if (import.meta.main) {
|
|
|
448
18
|
console.error("Usage: bun src/tools/skill-doctor.ts <skill-dir-or-name>");
|
|
449
19
|
process.exit(2);
|
|
450
20
|
}
|
|
451
|
-
|
|
452
|
-
if (!existsSync(resolve(dir, "SKILL.md"))) {
|
|
453
|
-
const byName = resolve(palHome(), "skills", arg);
|
|
454
|
-
if (existsSync(resolve(byName, "SKILL.md"))) dir = byName;
|
|
455
|
-
}
|
|
456
|
-
const report = lintSkill(dir);
|
|
21
|
+
const report = lintSkill(resolveSkillDir(arg));
|
|
457
22
|
console.log(formatReport(report));
|
|
458
23
|
process.exit(report.errors > 0 ? 1 : 0);
|
|
459
24
|
}
|