clementine-agent 1.18.107 → 1.18.108

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.
@@ -1,57 +1,63 @@
1
1
  /**
2
- * Skill store — Phase A (read-only) of the Skills-First redesign.
2
+ * Skill store — Phase A / A.5 of the Skills-First redesign.
3
3
  *
4
- * Discovers skill .md files from two locations and parses their
5
- * frontmatter into the Skill type. Phase A surfaces what's already on
6
- * disk; Phase B adds editing + testing; Phase C wires runtime invocation.
4
+ * Anthropic-compatible skill loader. Walks two skill directories and
5
+ * accepts both layouts:
7
6
  *
8
- * Discovery order:
9
- * 1. ~/.clementine/vault/00-System/skills/<name>.md (global)
10
- * 2. <work_dir>/.clementine/skills/<name>.md (per-project)
7
+ * 1. **Folder form** (Anthropic spec): <dir>/<skill-name>/SKILL.md
8
+ * A capital-S SKILL.md is the entry point. Sibling .md files and
9
+ * a scripts/ subdirectory are surfaced as bundled files.
11
10
  *
12
- * Per-project files win on name collision — they override global skills
13
- * for that project. The dashboard surfaces both pools and tags each
14
- * skill with its scope so the user can see which one will resolve.
11
+ * 2. **Flat form** (Clementine legacy): <dir>/<skill-name>.md
12
+ * A single .md file with frontmatter + body. Bundled files
13
+ * unsupported. Used by the 12 pre-redesign skill files we already
14
+ * have on disk.
15
15
  *
16
- * Schema detection: a file is `v1` when its frontmatter declares any of
17
- * inputs / tools.allow / tools.deny / dataSources / stateKeys / success.
18
- * Otherwise (only legacy fields like title / triggers / toolsUsed) it's
19
- * `legacy` and the dashboard shows a migration badge.
16
+ * Discovery directories (per-project wins on name collision):
17
+ * - global: $CLEMENTINE_HOME/vault/00-System/skills/
18
+ * - per-project: <work_dir>/.clementine/skills/
20
19
  *
21
- * Used-by join: Phase A reads the `skills:` array on CronJobDefinition
22
- * (the existing field) to populate Skill.usedByTriggers. Phase C will
23
- * extend this to read the new top-level `skill:` field on the trigger.
20
+ * Phase A is read-only. Phase B adds editing + a "Test this skill"
21
+ * runner. Phase C wires runtime invocation. Phase E migrates legacy
22
+ * crons folder-form skills.
24
23
  */
25
- import type { Skill, SkillScope, CronJobDefinition } from '../types.js';
24
+ import type { Skill, SkillScope, SkillValidationWarning, CronJobDefinition } from '../types.js';
25
+ /** Run Anthropic-spec validations on a parsed skill. Errors are spec
26
+ * violations (skill would be rejected by the Anthropic API); warnings
27
+ * are best-practice hints (still loadable). Findings render in the
28
+ * dashboard's detail pane. */
29
+ export declare function validateSkill(skill: Skill): SkillValidationWarning[];
26
30
  interface ParseResult {
27
31
  skill: Skill;
28
32
  /** Set when the file existed but couldn't be parsed (bad YAML, etc.).
29
- * We still surface the file with a fallback frontmatter so the user
30
- * can see which one needs fixing. */
33
+ * We still surface the skill with synthesized frontmatter so the
34
+ * dashboard can render the offending file with an error banner. */
31
35
  parseError?: string;
32
36
  }
33
- /** Parse a single skill file. Returns a Skill record even when the
34
- * frontmatter is malformed — the dashboard renders the parse error
35
- * in-pane so the user can fix it without leaving the UI. */
37
+ /** Parse a flat-form skill file (single .md). */
36
38
  export declare function parseSkillFile(filePath: string, scope: SkillScope): ParseResult;
39
+ /** Parse a folder-form skill (Anthropic spec: <name>/SKILL.md plus optional
40
+ * bundled files). The folder name is the canonical skill identifier. */
41
+ export declare function parseSkillFolder(folderPath: string, scope: SkillScope): ParseResult;
37
42
  export interface ListSkillsOptions {
38
- /** Optional per-project work_dir to also scan. Per-project skills
39
- * override global skills with the same filename. */
43
+ /** Optional per-project work_dir to scan. Per-project skills shadow
44
+ * global ones with the same identifier. */
40
45
  projectWorkDir?: string;
41
- /** Optional cron jobs list when provided, the loader populates the
42
- * usedByTriggers field on each skill via the existing skills[] array
43
- * on CronJobDefinition (Phase A's join). */
46
+ /** Optional cron jobs for the usedByTriggers join (via skills[]). */
44
47
  jobs?: CronJobDefinition[];
45
48
  }
46
- /** Top-level discovery API. Returns the merged list of skills across
47
- * global + per-project pools, with per-project taking precedence on
48
- * name collision. usedByTriggers is populated when jobs are passed in. */
49
+ /** Top-level discovery API. Merges global + per-project pools, with
50
+ * per-project taking precedence. Populates usedByTriggers when jobs
51
+ * are passed. Returned list is sorted alphabetically by name. */
49
52
  export declare function listSkills(opts?: ListSkillsOptions): Skill[];
50
- /** Get a single skill by name, with the same global/project precedence
51
- * as listSkills. Returns null if neither pool has the skill. */
53
+ /** Get one skill by name, applying per-project precedence. Returns
54
+ * null when neither pool has the skill. */
52
55
  export declare function getSkill(name: string, opts?: ListSkillsOptions): Skill | null;
53
- /** Test-only: where the loader looked. Useful in unit tests + the
54
- * dashboard's diagnostics surface. */
56
+ /** Read one bundled file's contents used by Phase B's preview pane.
57
+ * Defends against directory traversal: rejects paths that escape the
58
+ * skill folder. */
59
+ export declare function readBundledFile(skill: Skill, relPath: string): string | null;
60
+ /** Diagnostics for the dashboard — expose where the loader looked. */
55
61
  export declare function _skillDirsForDiagnostics(workDir?: string): {
56
62
  global: string;
57
63
  project: string | null;
@@ -1,125 +1,146 @@
1
1
  /**
2
- * Skill store — Phase A (read-only) of the Skills-First redesign.
2
+ * Skill store — Phase A / A.5 of the Skills-First redesign.
3
3
  *
4
- * Discovers skill .md files from two locations and parses their
5
- * frontmatter into the Skill type. Phase A surfaces what's already on
6
- * disk; Phase B adds editing + testing; Phase C wires runtime invocation.
4
+ * Anthropic-compatible skill loader. Walks two skill directories and
5
+ * accepts both layouts:
7
6
  *
8
- * Discovery order:
9
- * 1. ~/.clementine/vault/00-System/skills/<name>.md (global)
10
- * 2. <work_dir>/.clementine/skills/<name>.md (per-project)
7
+ * 1. **Folder form** (Anthropic spec): <dir>/<skill-name>/SKILL.md
8
+ * A capital-S SKILL.md is the entry point. Sibling .md files and
9
+ * a scripts/ subdirectory are surfaced as bundled files.
11
10
  *
12
- * Per-project files win on name collision — they override global skills
13
- * for that project. The dashboard surfaces both pools and tags each
14
- * skill with its scope so the user can see which one will resolve.
11
+ * 2. **Flat form** (Clementine legacy): <dir>/<skill-name>.md
12
+ * A single .md file with frontmatter + body. Bundled files
13
+ * unsupported. Used by the 12 pre-redesign skill files we already
14
+ * have on disk.
15
15
  *
16
- * Schema detection: a file is `v1` when its frontmatter declares any of
17
- * inputs / tools.allow / tools.deny / dataSources / stateKeys / success.
18
- * Otherwise (only legacy fields like title / triggers / toolsUsed) it's
19
- * `legacy` and the dashboard shows a migration badge.
16
+ * Discovery directories (per-project wins on name collision):
17
+ * - global: $CLEMENTINE_HOME/vault/00-System/skills/
18
+ * - per-project: <work_dir>/.clementine/skills/
20
19
  *
21
- * Used-by join: Phase A reads the `skills:` array on CronJobDefinition
22
- * (the existing field) to populate Skill.usedByTriggers. Phase C will
23
- * extend this to read the new top-level `skill:` field on the trigger.
20
+ * Phase A is read-only. Phase B adds editing + a "Test this skill"
21
+ * runner. Phase C wires runtime invocation. Phase E migrates legacy
22
+ * crons folder-form skills.
24
23
  */
25
24
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
26
25
  import os from 'node:os';
27
26
  import path from 'node:path';
28
27
  import matter from 'gray-matter';
29
- /** Resolve the global skills directory from CLEMENTINE_HOME (or default). */
28
+ // ── Path resolution (lazy reads CLEMENTINE_HOME on each call) ──────
30
29
  function globalSkillsDir() {
31
30
  const base = process.env.CLEMENTINE_HOME || path.join(os.homedir(), '.clementine');
32
31
  return path.join(base, 'vault', '00-System', 'skills');
33
32
  }
34
- /** Resolve a per-project skills directory. Returns null if work_dir is
35
- * empty or doesn't have a .clementine/skills/ child. */
36
33
  function projectSkillsDir(workDir) {
37
34
  if (!workDir)
38
35
  return null;
39
36
  const dir = path.join(workDir, '.clementine', 'skills');
40
37
  return existsSync(dir) ? dir : null;
41
38
  }
42
- /** Strip backup files (.bak), hidden files, and directories. */
43
- function isSkillFile(name) {
44
- if (name.startsWith('.'))
45
- return false;
46
- if (!name.endsWith('.md'))
47
- return false;
48
- if (name.endsWith('.bak'))
49
- return false;
50
- if (name.endsWith('.bak.md'))
51
- return false;
52
- return true;
53
- }
54
- /** Skill name is the filename without extension. We don't trust the
55
- * frontmatter's `name:` field as the canonical identifier because
56
- * different files could collide on it; the filename is what the loader
57
- * joins on. The frontmatter `name:` is preserved as a display alias. */
58
- function nameFromFile(file) {
59
- return path.basename(file, '.md');
39
+ // ── Anthropic spec validations ────────────────────────────────────────
40
+ const NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
41
+ const RESERVED_NAMES = new Set(['anthropic', 'claude']);
42
+ const NAME_MAX_LEN = 64;
43
+ const DESCRIPTION_MAX_LEN = 1024;
44
+ const BODY_LINE_LIMIT_WARN = 500;
45
+ /** Run Anthropic-spec validations on a parsed skill. Errors are spec
46
+ * violations (skill would be rejected by the Anthropic API); warnings
47
+ * are best-practice hints (still loadable). Findings render in the
48
+ * dashboard's detail pane. */
49
+ export function validateSkill(skill) {
50
+ const out = [];
51
+ const fm = skill.frontmatter;
52
+ // Name validation (Anthropic spec).
53
+ if (!fm.name) {
54
+ out.push({ severity: 'error', field: 'name', message: 'name is required' });
55
+ }
56
+ else {
57
+ if (fm.name.length > NAME_MAX_LEN) {
58
+ out.push({ severity: 'error', field: 'name', message: `name exceeds ${NAME_MAX_LEN} chars (got ${fm.name.length})` });
59
+ }
60
+ if (!NAME_PATTERN.test(fm.name)) {
61
+ out.push({ severity: 'error', field: 'name', message: 'name must be lowercase letters, numbers, and hyphens only' });
62
+ }
63
+ const lower = fm.name.toLowerCase();
64
+ if (RESERVED_NAMES.has(lower) || lower.includes('anthropic') || lower.includes('claude')) {
65
+ // Anthropic forbids these words anywhere in the name.
66
+ out.push({ severity: 'error', field: 'name', message: 'name cannot contain reserved words "anthropic" or "claude"' });
67
+ }
68
+ }
69
+ // Description validation (Anthropic spec).
70
+ if (!fm.description || !fm.description.trim()) {
71
+ out.push({ severity: 'warning', field: 'description', message: 'description is required by Anthropic spec — add one so the skill can be discovered' });
72
+ }
73
+ else if (fm.description.length > DESCRIPTION_MAX_LEN) {
74
+ out.push({ severity: 'error', field: 'description', message: `description exceeds ${DESCRIPTION_MAX_LEN} chars (got ${fm.description.length})` });
75
+ }
76
+ else if (/<\w+/i.test(fm.description)) {
77
+ out.push({ severity: 'error', field: 'description', message: 'description cannot contain XML tags' });
78
+ }
79
+ // Body length (best-practice hint).
80
+ const bodyLines = (skill.body.match(/\n/g)?.length ?? 0) + 1;
81
+ if (bodyLines > BODY_LINE_LIMIT_WARN) {
82
+ out.push({ severity: 'warning', field: 'body', message: `body is ${bodyLines} lines — Anthropic recommends under ${BODY_LINE_LIMIT_WARN}; split into bundled files (FORMS.md, reference.md, etc.)` });
83
+ }
84
+ // Layout hint: flat skills can't bundle scripts or sibling references.
85
+ if (skill.layout === 'flat' && skill.schemaVersion !== 'legacy') {
86
+ out.push({ severity: 'warning', field: 'layout', message: 'consider folder form (<skill-name>/SKILL.md) so you can bundle scripts and reference files later' });
87
+ }
88
+ return out;
60
89
  }
61
- /** Detect whether a frontmatter object uses the v1 schema or the
62
- * pre-redesign legacy shape. Phase A renders this as a badge so users
63
- * can see which skills need migration in Phase B. */
64
- function detectSchemaVersion(fm) {
65
- const v1Markers = ['inputs', 'dataSources', 'stateKeys', 'success', 'limits'];
66
- if (v1Markers.some((k) => k in fm))
67
- return 'v1';
68
- const tools = fm.tools;
69
- if (tools && (Array.isArray(tools.allow) || Array.isArray(tools.deny)))
70
- return 'v1';
71
- return 'legacy';
90
+ // ── Frontmatter parsing ───────────────────────────────────────────────
91
+ /** Detect which of the three schema variants this frontmatter is.
92
+ *
93
+ * - 'clementine' if the `clementine:` namespace is present
94
+ * - 'legacy' if any of the pre-redesign top-level fields are present
95
+ * (title / triggers / toolsUsed / useCount) AND no clementine
96
+ * - 'anthropic' otherwise (just name + description, the canonical case)
97
+ */
98
+ function detectSchemaVersion(raw) {
99
+ if (raw.clementine && typeof raw.clementine === 'object' && !Array.isArray(raw.clementine)) {
100
+ return 'clementine';
101
+ }
102
+ const legacyMarkers = ['title', 'triggers', 'toolsUsed', 'useCount', 'source'];
103
+ if (legacyMarkers.some((k) => k in raw))
104
+ return 'legacy';
105
+ return 'anthropic';
72
106
  }
73
- /** Coerce a parsed YAML object into the SkillFrontmatter shape. We
74
- * accept both the v1 fields and the legacy fields side-by-side; the
75
- * caller's schemaVersion check tells the dashboard which is which. */
76
- function coerceFrontmatter(raw, fileBasename) {
77
- const fm = {
78
- // Identifier ALWAYS the filename (without .md). The frontmatter's
79
- // `name:` field is intentionally ignored to avoid two skills colliding
80
- // on it. Users wanting a friendly display string can set `title:`
81
- // instead, which Phase B's editor surfaces as the heading.
82
- name: fileBasename,
83
- };
84
- if (typeof raw.description === 'string')
85
- fm.description = raw.description;
86
- // v1 inputs — JSON Schema map keyed by field name.
87
- if (raw.inputs && typeof raw.inputs === 'object' && !Array.isArray(raw.inputs)) {
88
- fm.inputs = raw.inputs;
107
+ function coerceClementineExtensions(raw) {
108
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
109
+ return undefined;
110
+ const r = raw;
111
+ const out = {};
112
+ if (r.inputs && typeof r.inputs === 'object' && !Array.isArray(r.inputs)) {
113
+ out.inputs = r.inputs;
89
114
  }
90
- // tools.allow / tools.deny
91
- if (raw.tools && typeof raw.tools === 'object' && !Array.isArray(raw.tools)) {
92
- const t = raw.tools;
115
+ if (r.tools && typeof r.tools === 'object' && !Array.isArray(r.tools)) {
116
+ const t = r.tools;
93
117
  const policy = {};
94
118
  if (Array.isArray(t.allow))
95
119
  policy.allow = t.allow.map(String);
96
120
  if (Array.isArray(t.deny))
97
121
  policy.deny = t.deny.map(String);
98
122
  if (policy.allow || policy.deny)
99
- fm.tools = policy;
123
+ out.tools = policy;
100
124
  }
101
- if (Array.isArray(raw.dataSources)) {
102
- fm.dataSources = raw.dataSources
103
- .filter((d) => !!d && typeof d === 'object')
104
- .map((d) => ({
105
- kind: String(d.kind || 'unknown'),
106
- purpose: String(d.purpose || ''),
107
- }));
125
+ if (Array.isArray(r.dataSources)) {
126
+ out.dataSources = r.dataSources
127
+ .filter((d) => !!d && typeof d === 'object' && !Array.isArray(d))
128
+ .map((d) => ({ kind: String(d.kind || 'unknown'), purpose: String(d.purpose || '') }));
108
129
  }
109
- if (Array.isArray(raw.stateKeys))
110
- fm.stateKeys = raw.stateKeys.map(String);
111
- if (raw.success && typeof raw.success === 'object' && !Array.isArray(raw.success)) {
112
- const s = raw.success;
130
+ if (Array.isArray(r.stateKeys))
131
+ out.stateKeys = r.stateKeys.map(String);
132
+ if (r.success && typeof r.success === 'object' && !Array.isArray(r.success)) {
133
+ const s = r.success;
113
134
  const success = {};
114
135
  if (s.schema && typeof s.schema === 'object')
115
136
  success.schema = s.schema;
116
137
  if (typeof s.criterion === 'string')
117
138
  success.criterion = s.criterion;
118
139
  if (success.schema || success.criterion)
119
- fm.success = success;
140
+ out.success = success;
120
141
  }
121
- if (raw.limits && typeof raw.limits === 'object' && !Array.isArray(raw.limits)) {
122
- const l = raw.limits;
142
+ if (r.limits && typeof r.limits === 'object' && !Array.isArray(r.limits)) {
143
+ const l = r.limits;
123
144
  const limits = {};
124
145
  if (typeof l.maxTurns === 'number')
125
146
  limits.maxTurns = l.maxTurns;
@@ -128,19 +149,31 @@ function coerceFrontmatter(raw, fileBasename) {
128
149
  if (typeof l.timeoutSeconds === 'number')
129
150
  limits.timeoutSeconds = l.timeoutSeconds;
130
151
  if (Object.keys(limits).length > 0)
131
- fm.limits = limits;
152
+ out.limits = limits;
132
153
  }
133
- if (typeof raw.version === 'number')
134
- fm.version = raw.version;
135
- if (typeof raw.createdAt === 'string')
136
- fm.createdAt = raw.createdAt;
137
- if (typeof raw.updatedAt === 'string')
138
- fm.updatedAt = raw.updatedAt;
139
- if (typeof raw.lastUsed === 'string')
140
- fm.lastUsed = raw.lastUsed;
141
- if (typeof raw.lastTestPass === 'string')
142
- fm.lastTestPass = raw.lastTestPass;
143
- // Legacy fields (preserved as-is for the migration UI).
154
+ if (typeof r.version === 'number')
155
+ out.version = r.version;
156
+ if (typeof r.createdAt === 'string')
157
+ out.createdAt = r.createdAt;
158
+ if (typeof r.updatedAt === 'string')
159
+ out.updatedAt = r.updatedAt;
160
+ if (typeof r.lastUsed === 'string')
161
+ out.lastUsed = r.lastUsed;
162
+ if (typeof r.lastTestPass === 'string')
163
+ out.lastTestPass = r.lastTestPass;
164
+ return Object.keys(out).length > 0 ? out : undefined;
165
+ }
166
+ /** Coerce raw YAML object → SkillFrontmatter. Filename always wins for
167
+ * identity (frontmatter `name:` is ignored to prevent two skills from
168
+ * colliding); we treat the YAML name as a display alias only. */
169
+ function coerceFrontmatter(raw, fileBasename) {
170
+ const fm = { name: fileBasename };
171
+ if (typeof raw.description === 'string')
172
+ fm.description = raw.description;
173
+ const ext = coerceClementineExtensions(raw.clementine);
174
+ if (ext)
175
+ fm.clementine = ext;
176
+ // Legacy top-level fields preserved for the migration UI.
144
177
  if (typeof raw.title === 'string')
145
178
  fm.title = raw.title;
146
179
  if (Array.isArray(raw.triggers))
@@ -153,58 +186,175 @@ function coerceFrontmatter(raw, fileBasename) {
153
186
  fm.useCount = raw.useCount;
154
187
  return fm;
155
188
  }
156
- /** Parse a single skill file. Returns a Skill record even when the
157
- * frontmatter is malformed — the dashboard renders the parse error
158
- * in-pane so the user can fix it without leaving the UI. */
189
+ // ── File / folder helpers ─────────────────────────────────────────────
190
+ function isLoadableSkillFile(name) {
191
+ if (name.startsWith('.'))
192
+ return false;
193
+ if (!name.endsWith('.md'))
194
+ return false;
195
+ if (name.endsWith('.bak'))
196
+ return false;
197
+ if (name.endsWith('.bak.md'))
198
+ return false;
199
+ return true;
200
+ }
201
+ function classifyBundledFile(relPath) {
202
+ if (relPath.endsWith('.md'))
203
+ return 'markdown';
204
+ if (relPath.startsWith('scripts/'))
205
+ return 'script';
206
+ if (/\.(py|js|ts|sh|rb)$/.test(relPath))
207
+ return 'script';
208
+ return 'other';
209
+ }
210
+ /** Walk a skill folder (recursively shallow — one level into scripts/)
211
+ * and return non-SKILL.md files as bundled artifacts. Skips hidden
212
+ * files, .bak duplicates, and the SKILL.md itself. */
213
+ function discoverBundledFiles(skillFolder) {
214
+ const out = [];
215
+ const walk = (dir, relPrefix) => {
216
+ let entries;
217
+ try {
218
+ entries = readdirSync(dir);
219
+ }
220
+ catch {
221
+ return;
222
+ }
223
+ for (const entry of entries) {
224
+ if (entry.startsWith('.'))
225
+ continue;
226
+ const abs = path.join(dir, entry);
227
+ let st;
228
+ try {
229
+ st = statSync(abs);
230
+ }
231
+ catch {
232
+ continue;
233
+ }
234
+ const rel = relPrefix ? `${relPrefix}/${entry}` : entry;
235
+ if (st.isDirectory()) {
236
+ // Only descend one level — avoids surprise when a skill bundles
237
+ // node_modules or similar. Convention: scripts/, reference/.
238
+ if (relPrefix)
239
+ continue;
240
+ walk(abs, rel);
241
+ continue;
242
+ }
243
+ if (!st.isFile())
244
+ continue;
245
+ // Skip the entry-point file itself + .bak duplicates.
246
+ if (rel === 'SKILL.md')
247
+ continue;
248
+ if (entry.endsWith('.bak') || entry.endsWith('.bak.md'))
249
+ continue;
250
+ out.push({
251
+ relPath: rel,
252
+ absPath: abs,
253
+ kind: classifyBundledFile(rel),
254
+ sizeBytes: st.size,
255
+ });
256
+ }
257
+ };
258
+ walk(skillFolder, '');
259
+ // Sort deterministically: top-level files first, then scripts/, then
260
+ // alphabetical within each group.
261
+ out.sort((a, b) => {
262
+ const aTop = !a.relPath.includes('/');
263
+ const bTop = !b.relPath.includes('/');
264
+ if (aTop !== bTop)
265
+ return aTop ? -1 : 1;
266
+ return a.relPath.localeCompare(b.relPath);
267
+ });
268
+ return out;
269
+ }
270
+ function emptySkill(filePath, basename, scope, layout) {
271
+ return {
272
+ frontmatter: { name: basename },
273
+ body: '',
274
+ filePath,
275
+ scope,
276
+ layout,
277
+ schemaVersion: 'legacy',
278
+ bundledFiles: [],
279
+ usedByTriggers: [],
280
+ validation: [],
281
+ };
282
+ }
283
+ /** Parse a flat-form skill file (single .md). */
159
284
  export function parseSkillFile(filePath, scope) {
160
- const basename = nameFromFile(filePath);
285
+ const basename = path.basename(filePath, '.md');
161
286
  let raw;
162
287
  try {
163
288
  raw = readFileSync(filePath, 'utf-8');
164
289
  }
165
290
  catch (err) {
166
- return {
167
- skill: emptySkill(filePath, basename, scope),
168
- parseError: 'failed to read: ' + String(err),
169
- };
291
+ return { skill: emptySkill(filePath, basename, scope, 'flat'), parseError: 'failed to read: ' + String(err) };
170
292
  }
171
293
  let parsed;
172
294
  try {
173
295
  parsed = matter(raw);
174
296
  }
175
297
  catch (err) {
176
- return {
177
- skill: { ...emptySkill(filePath, basename, scope), body: raw },
178
- parseError: 'YAML parse error: ' + String(err),
179
- };
298
+ const skill = { ...emptySkill(filePath, basename, scope, 'flat'), body: raw };
299
+ return { skill, parseError: 'YAML parse error: ' + String(err) };
180
300
  }
181
301
  const data = parsed.data;
182
- const fm = coerceFrontmatter(data, basename);
183
- const schemaVersion = detectSchemaVersion(data);
184
- return {
185
- skill: {
186
- frontmatter: fm,
187
- body: parsed.content || '',
188
- filePath,
189
- scope,
190
- schemaVersion,
191
- usedByTriggers: [],
192
- },
302
+ const skill = {
303
+ frontmatter: coerceFrontmatter(data, basename),
304
+ body: parsed.content || '',
305
+ filePath,
306
+ scope,
307
+ layout: 'flat',
308
+ schemaVersion: detectSchemaVersion(data),
309
+ bundledFiles: [],
310
+ usedByTriggers: [],
311
+ validation: [],
193
312
  };
313
+ skill.validation = validateSkill(skill);
314
+ return { skill };
194
315
  }
195
- function emptySkill(filePath, basename, scope) {
196
- return {
197
- frontmatter: { name: basename },
198
- body: '',
199
- filePath,
316
+ /** Parse a folder-form skill (Anthropic spec: <name>/SKILL.md plus optional
317
+ * bundled files). The folder name is the canonical skill identifier. */
318
+ export function parseSkillFolder(folderPath, scope) {
319
+ const basename = path.basename(folderPath);
320
+ const entryPoint = path.join(folderPath, 'SKILL.md');
321
+ if (!existsSync(entryPoint)) {
322
+ return {
323
+ skill: emptySkill(entryPoint, basename, scope, 'folder'),
324
+ parseError: 'no SKILL.md in folder',
325
+ };
326
+ }
327
+ let raw;
328
+ try {
329
+ raw = readFileSync(entryPoint, 'utf-8');
330
+ }
331
+ catch (err) {
332
+ return { skill: emptySkill(entryPoint, basename, scope, 'folder'), parseError: 'failed to read SKILL.md: ' + String(err) };
333
+ }
334
+ let parsed;
335
+ try {
336
+ parsed = matter(raw);
337
+ }
338
+ catch (err) {
339
+ const skill = { ...emptySkill(entryPoint, basename, scope, 'folder'), body: raw };
340
+ return { skill, parseError: 'YAML parse error in SKILL.md: ' + String(err) };
341
+ }
342
+ const data = parsed.data;
343
+ const skill = {
344
+ frontmatter: coerceFrontmatter(data, basename),
345
+ body: parsed.content || '',
346
+ filePath: entryPoint,
200
347
  scope,
201
- schemaVersion: 'legacy',
348
+ layout: 'folder',
349
+ schemaVersion: detectSchemaVersion(data),
350
+ bundledFiles: discoverBundledFiles(folderPath),
202
351
  usedByTriggers: [],
352
+ validation: [],
203
353
  };
354
+ skill.validation = validateSkill(skill);
355
+ return { skill };
204
356
  }
205
- /** List skills in a directory, returning Skill records (not just paths)
206
- * so callers can immediately render them. Tolerates missing dirs and
207
- * unreadable files — best-effort. */
357
+ // ── Discovery (top-level API) ────────────────────────────────────────
208
358
  function listSkillsInDir(dir, scope) {
209
359
  if (!existsSync(dir))
210
360
  return [];
@@ -217,24 +367,34 @@ function listSkillsInDir(dir, scope) {
217
367
  }
218
368
  const out = [];
219
369
  for (const entry of entries) {
220
- if (!isSkillFile(entry))
370
+ if (entry.startsWith('.'))
221
371
  continue;
222
372
  const fullPath = path.join(dir, entry);
373
+ let st;
223
374
  try {
224
- const stat = statSync(fullPath);
225
- if (!stat.isFile())
226
- continue;
375
+ st = statSync(fullPath);
227
376
  }
228
377
  catch {
229
378
  continue;
230
379
  }
231
- out.push(parseSkillFile(fullPath, scope).skill);
380
+ if (st.isDirectory()) {
381
+ // Folder-form skill. Must contain SKILL.md (case-sensitive,
382
+ // Anthropic spec). Skip folders that don't (e.g. accidental
383
+ // dirs like 'auto/' that exist in current vault).
384
+ if (!existsSync(path.join(fullPath, 'SKILL.md')))
385
+ continue;
386
+ out.push(parseSkillFolder(fullPath, scope).skill);
387
+ continue;
388
+ }
389
+ if (st.isFile() && isLoadableSkillFile(entry)) {
390
+ out.push(parseSkillFile(fullPath, scope).skill);
391
+ }
232
392
  }
233
393
  return out;
234
394
  }
235
- /** Top-level discovery API. Returns the merged list of skills across
236
- * global + per-project pools, with per-project taking precedence on
237
- * name collision. usedByTriggers is populated when jobs are passed in. */
395
+ /** Top-level discovery API. Merges global + per-project pools, with
396
+ * per-project taking precedence. Populates usedByTriggers when jobs
397
+ * are passed. Returned list is sorted alphabetically by name. */
238
398
  export function listSkills(opts = {}) {
239
399
  const globalSkills = listSkillsInDir(globalSkillsDir(), 'global');
240
400
  const projectSkills = opts.projectWorkDir
@@ -243,14 +403,11 @@ export function listSkills(opts = {}) {
243
403
  return pdir ? listSkillsInDir(pdir, 'project') : [];
244
404
  })()
245
405
  : [];
246
- // Build a map keyed by basename so per-project entries override global.
247
406
  const merged = new Map();
248
407
  for (const s of globalSkills)
249
408
  merged.set(s.frontmatter.name, s);
250
409
  for (const s of projectSkills)
251
410
  merged.set(s.frontmatter.name, s);
252
- // Used-by join from cron jobs' skills[] array. Same skill referenced by
253
- // multiple jobs accumulates them in order.
254
411
  if (opts.jobs && opts.jobs.length > 0) {
255
412
  for (const job of opts.jobs) {
256
413
  if (!Array.isArray(job.skills))
@@ -262,52 +419,60 @@ export function listSkills(opts = {}) {
262
419
  }
263
420
  }
264
421
  }
265
- // Sorted alphabetically — predictable rendering, no need for the
266
- // dashboard to re-sort. Per-project always sorts at the same key as
267
- // the global version it replaced.
268
422
  return [...merged.values()].sort((a, b) => a.frontmatter.name.localeCompare(b.frontmatter.name));
269
423
  }
270
- /** Get a single skill by name, with the same global/project precedence
271
- * as listSkills. Returns null if neither pool has the skill. */
424
+ /** Get one skill by name, applying per-project precedence. Returns
425
+ * null when neither pool has the skill. */
272
426
  export function getSkill(name, opts = {}) {
273
- // Per-project first (precedence).
427
+ // Per-project first (precedence), then global.
428
+ const tryDir = (dir, scope) => {
429
+ const folder = path.join(dir, name);
430
+ if (existsSync(folder) && existsSync(path.join(folder, 'SKILL.md'))) {
431
+ return parseSkillFolder(folder, scope).skill;
432
+ }
433
+ const flat = path.join(dir, name + '.md');
434
+ if (existsSync(flat)) {
435
+ return parseSkillFile(flat, scope).skill;
436
+ }
437
+ return null;
438
+ };
439
+ let skill = null;
274
440
  if (opts.projectWorkDir) {
275
441
  const pdir = projectSkillsDir(opts.projectWorkDir);
276
- if (pdir) {
277
- const candidate = path.join(pdir, name + '.md');
278
- if (existsSync(candidate)) {
279
- const result = parseSkillFile(candidate, 'project');
280
- if (opts.jobs)
281
- result.skill.usedByTriggers = jobsUsing(name, opts.jobs);
282
- return result.skill;
283
- }
284
- }
442
+ if (pdir)
443
+ skill = tryDir(pdir, 'project');
285
444
  }
286
- // Global fallback.
287
- const candidate = path.join(globalSkillsDir(), name + '.md');
288
- if (existsSync(candidate)) {
289
- const result = parseSkillFile(candidate, 'global');
290
- if (opts.jobs)
291
- result.skill.usedByTriggers = jobsUsing(name, opts.jobs);
292
- return result.skill;
445
+ if (!skill)
446
+ skill = tryDir(globalSkillsDir(), 'global');
447
+ if (skill && opts.jobs) {
448
+ for (const j of opts.jobs) {
449
+ if (Array.isArray(j.skills) && j.skills.includes(name))
450
+ skill.usedByTriggers.push(j.name);
451
+ }
293
452
  }
294
- return null;
453
+ return skill;
295
454
  }
296
- /** Internal helper for the used-by join. */
297
- function jobsUsing(skillName, jobs) {
298
- const out = [];
299
- for (const job of jobs) {
300
- if (Array.isArray(job.skills) && job.skills.includes(skillName))
301
- out.push(job.name);
455
+ /** Read one bundled file's contents — used by Phase B's preview pane.
456
+ * Defends against directory traversal: rejects paths that escape the
457
+ * skill folder. */
458
+ export function readBundledFile(skill, relPath) {
459
+ if (skill.layout !== 'folder')
460
+ return null;
461
+ const skillFolder = path.dirname(skill.filePath);
462
+ const absPath = path.resolve(skillFolder, relPath);
463
+ if (!absPath.startsWith(skillFolder + path.sep) && absPath !== skillFolder)
464
+ return null;
465
+ if (!existsSync(absPath))
466
+ return null;
467
+ try {
468
+ return readFileSync(absPath, 'utf-8');
469
+ }
470
+ catch {
471
+ return null;
302
472
  }
303
- return out;
304
473
  }
305
- /** Test-only: where the loader looked. Useful in unit tests + the
306
- * dashboard's diagnostics surface. */
474
+ /** Diagnostics for the dashboard expose where the loader looked. */
307
475
  export function _skillDirsForDiagnostics(workDir) {
308
- return {
309
- global: globalSkillsDir(),
310
- project: projectSkillsDir(workDir) ?? null,
311
- };
476
+ return { global: globalSkillsDir(), project: projectSkillsDir(workDir) ?? null };
312
477
  }
313
478
  //# sourceMappingURL=skill-store.js.map
@@ -26872,6 +26872,29 @@ function onSkillsSearch(value) {
26872
26872
  renderSkillsList();
26873
26873
  }
26874
26874
 
26875
+ // ── Skill list pane ──────────────────────────────────────────────────
26876
+ // Renders the left-rail skill list. Each card shows two compact badges:
26877
+ // - schemaBadge: ANTHROPIC (green) / CLEMENTINE (purple) / LEGACY (yellow)
26878
+ // - layoutBadge: FOLDER (subtle accent) — only shown when folder-form
26879
+ // Plus optional PROJECT scope badge. Below: description preview, used-by
26880
+ // count, and a small ✗ when validation has any errors.
26881
+ function _skillSchemaBadge(version) {
26882
+ if (version === 'anthropic') {
26883
+ return '<span style="font-size:9px;background:var(--green)20;color:var(--green);padding:1px 6px;border-radius:3px;font-weight:600;letter-spacing:0.04em" title="Vanilla Anthropic frontmatter (name + description only)">ANTHROPIC</span>';
26884
+ }
26885
+ if (version === 'clementine') {
26886
+ return '<span style="font-size:9px;background:#8b5cf620;color:#8b5cf6;padding:1px 6px;border-radius:3px;font-weight:600;letter-spacing:0.04em" title="Anthropic-compatible + clementine: extensions">CLEMENTINE</span>';
26887
+ }
26888
+ return '<span style="font-size:9px;background:var(--yellow)20;color:var(--yellow);padding:1px 6px;border-radius:3px;font-weight:600;letter-spacing:0.04em" title="Pre-redesign flat frontmatter — Phase B will help migrate">LEGACY</span>';
26889
+ }
26890
+
26891
+ function _skillLayoutBadge(layout) {
26892
+ if (layout === 'folder') {
26893
+ return '<span style="font-size:9px;background:var(--accent)15;color:var(--accent);padding:1px 6px;border-radius:3px;font-weight:600;letter-spacing:0.04em" title="Folder form (Anthropic spec): SKILL.md plus optional bundled files">FOLDER</span>';
26894
+ }
26895
+ return '';
26896
+ }
26897
+
26875
26898
  function renderSkillsList() {
26876
26899
  var listEl = document.getElementById('skills-list');
26877
26900
  if (!listEl) return;
@@ -26879,7 +26902,7 @@ function renderSkillsList() {
26879
26902
  listEl.innerHTML = '<div style="padding:18px;color:var(--text-muted);font-size:12px;text-align:center;line-height:1.5">'
26880
26903
  + (_skillsState.query
26881
26904
  ? 'No skills match <strong>' + esc(_skillsState.query) + '</strong>.'
26882
- : 'No skills yet. Phase B will add a <strong>+ New skill</strong> button. For now, drop .md files in <code>~/.clementine/vault/00-System/skills/</code>.')
26905
+ : 'No skills yet. Drop a folder with <code>SKILL.md</code> inside, or a flat <code>.md</code> file, into <code>~/.clementine/vault/00-System/skills/</code>. Phase B will add a <strong>+ New skill</strong> button.')
26883
26906
  + '</div>';
26884
26907
  return;
26885
26908
  }
@@ -26889,21 +26912,21 @@ function renderSkillsList() {
26889
26912
  var fm = s.frontmatter || {};
26890
26913
  var isSelected = s.frontmatter.name === _skillsState.selectedName;
26891
26914
  var bg = isSelected ? 'var(--bg-tertiary)' : 'transparent';
26892
- // Schema badge: v1 = green, legacy = yellow (needs migration in Phase B).
26893
- var schemaBadge = s.schemaVersion === 'v1'
26894
- ? '<span style="font-size:9px;background:var(--green)20;color:var(--green);padding:1px 6px;border-radius:3px;font-weight:600;letter-spacing:0.04em">V1</span>'
26895
- : '<span style="font-size:9px;background:var(--yellow)20;color:var(--yellow);padding:1px 6px;border-radius:3px;font-weight:600;letter-spacing:0.04em" title="Legacy frontmatter — Phase B will migrate this">LEGACY</span>';
26915
+ var schemaBadge = _skillSchemaBadge(s.schemaVersion);
26916
+ var layoutBadge = _skillLayoutBadge(s.layout);
26896
26917
  var scopeBadge = s.scope === 'project'
26897
26918
  ? '<span style="font-size:9px;background:var(--blue)20;color:var(--blue);padding:1px 6px;border-radius:3px;font-weight:600;letter-spacing:0.04em" title="Loaded from per-project .clementine/skills/">PROJECT</span>'
26898
26919
  : '';
26920
+ var hasErrors = Array.isArray(s.validation) && s.validation.some(function(v) { return v.severity === 'error'; });
26921
+ var errorMark = hasErrors ? '<span style="color:var(--red);font-size:11px;font-weight:600" title="Has validation errors">⚠</span>' : '';
26899
26922
  var usedCount = (s.usedByTriggers || []).length;
26900
26923
  var displayName = fm.title || fm.name;
26901
26924
  var desc = (fm.description || '').slice(0, 100);
26902
26925
  if (fm.description && fm.description.length > 100) desc += '…';
26903
26926
  html += '<div onclick="showSkillDetail(\\x27' + jsStr(fm.name) + '\\x27)" style="padding:12px 14px;border-bottom:1px solid var(--border);cursor:pointer;background:' + bg + ';transition:background 0.1s" onmouseover="this.style.background=\\x27var(--bg-tertiary)\\x27" onmouseout="this.style.background=\\x27' + bg + '\\x27">'
26904
- + '<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px">'
26905
- + schemaBadge + scopeBadge
26906
- + '<span style="font-weight:500;font-size:13px;color:var(--text-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1" title="' + esc(displayName) + '">' + esc(displayName) + '</span>'
26927
+ + '<div style="display:flex;align-items:center;gap:4px;margin-bottom:4px;flex-wrap:wrap">'
26928
+ + schemaBadge + layoutBadge + scopeBadge + errorMark
26929
+ + '<span style="font-weight:500;font-size:13px;color:var(--text-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0" title="' + esc(displayName) + '">' + esc(displayName) + '</span>'
26907
26930
  + '</div>'
26908
26931
  + (desc ? '<div style="font-size:11px;color:var(--text-muted);line-height:1.4;margin-bottom:4px">' + esc(desc) + '</div>' : '')
26909
26932
  + '<div style="font-size:10px;color:var(--text-muted)">'
@@ -26933,72 +26956,117 @@ async function showSkillDetail(name) {
26933
26956
  }
26934
26957
  }
26935
26958
 
26959
+ // ── Skill detail pane ────────────────────────────────────────────────
26960
+ // Renders a single skill in the right pane. Sections, in order:
26961
+ // 1. Header (name + 3 badges + description + file path)
26962
+ // 2. Validation warnings (red errors / yellow warnings) when present
26963
+ // 3. Used-by triggers
26964
+ // 4. Bundled files (folder-form only) — sibling .md and scripts/
26965
+ // 5. Clementine extensions (inputs / tools / dataSources / stateKeys /
26966
+ // success / limits) — only when frontmatter.clementine is present
26967
+ // 6. Legacy fields (triggers / toolsUsed / useCount / lastUsed)
26968
+ // 7. Procedure body (with line counter "X/500")
26969
+ // 8. Schema-specific footer note (legacy → migration hint)
26936
26970
  function renderSkillDetail(s) {
26937
26971
  var fm = s.frontmatter || {};
26972
+ var ext = fm.clementine || {};
26938
26973
  var displayName = fm.title || fm.name;
26974
+ var bodyLines = (s.body || '').split('\\n').length;
26939
26975
  var html = '<div style="padding:24px 28px">';
26940
- // Header
26976
+
26977
+ // ── 1. Header
26941
26978
  html += '<div style="margin-bottom:18px">';
26942
- html += '<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px;flex-wrap:wrap">';
26979
+ html += '<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;flex-wrap:wrap">';
26943
26980
  html += '<h2 style="margin:0;font-size:20px;font-weight:600;color:var(--text-primary)">' + esc(displayName) + '</h2>';
26944
- html += s.schemaVersion === 'v1'
26945
- ? '<span style="font-size:10px;background:var(--green)20;color:var(--green);padding:2px 8px;border-radius:4px;font-weight:600;letter-spacing:0.04em">V1 SCHEMA</span>'
26946
- : '<span style="font-size:10px;background:var(--yellow)20;color:var(--yellow);padding:2px 8px;border-radius:4px;font-weight:600;letter-spacing:0.04em" title="Phase B will migrate this">LEGACY SCHEMA</span>';
26981
+ html += _skillSchemaBadge(s.schemaVersion).replace('font-size:9px', 'font-size:10px').replace('padding:1px 6px', 'padding:2px 8px');
26982
+ if (s.layout === 'folder') {
26983
+ html += '<span style="font-size:10px;background:var(--accent)15;color:var(--accent);padding:2px 8px;border-radius:4px;font-weight:600;letter-spacing:0.04em" title="Folder layout (Anthropic spec)">FOLDER</span>';
26984
+ } else {
26985
+ html += '<span style="font-size:10px;background:var(--bg-tertiary);color:var(--text-muted);padding:2px 8px;border-radius:4px;font-weight:600;letter-spacing:0.04em" title="Single-file Clementine legacy layout">FLAT</span>';
26986
+ }
26947
26987
  if (s.scope === 'project') {
26948
26988
  html += '<span style="font-size:10px;background:var(--blue)20;color:var(--blue);padding:2px 8px;border-radius:4px;font-weight:600">PROJECT-SCOPED</span>';
26949
26989
  }
26950
- if (typeof fm.version === 'number') {
26951
- html += '<span style="font-size:10px;color:var(--text-muted)">v' + esc(fm.version) + '</span>';
26990
+ if (typeof ext.version === 'number') {
26991
+ html += '<span style="font-size:10px;color:var(--text-muted);font-weight:500">v' + esc(ext.version) + '</span>';
26952
26992
  }
26953
26993
  html += '</div>';
26954
26994
  if (fm.description) {
26955
26995
  html += '<p style="font-size:13px;color:var(--text-secondary);line-height:1.5;margin:0">' + esc(fm.description) + '</p>';
26996
+ } else {
26997
+ html += '<p style="font-size:12px;color:var(--text-muted);font-style:italic;margin:0">No description. Anthropic spec recommends adding one so the skill can be discovered by Claude.</p>';
26956
26998
  }
26957
- html += '<div style="margin-top:8px;font-size:11px;color:var(--text-muted)">'
26958
- + 'File: <code style="font-size:10px">' + esc(s.filePath) + '</code>'
26959
- + '</div>';
26999
+ html += '<div style="margin-top:10px;font-size:11px;color:var(--text-muted);font-family:\\x27JetBrains Mono\\x27,monospace">' + esc(s.filePath) + '</div>';
26960
27000
  html += '</div>';
26961
27001
 
26962
- // Used-by triggers
27002
+ // ── 2. Validation warnings (if any)
27003
+ if (Array.isArray(s.validation) && s.validation.length > 0) {
27004
+ var errors = s.validation.filter(function(v) { return v.severity === 'error'; });
27005
+ var warnings = s.validation.filter(function(v) { return v.severity === 'warning'; });
27006
+ if (errors.length > 0) {
27007
+ html += '<div style="margin-bottom:18px;padding:12px 14px;background:rgba(239,68,68,0.08);border:1px solid var(--red);border-radius:6px">';
27008
+ html += '<div style="font-size:11px;color:var(--red);text-transform:uppercase;letter-spacing:0.04em;font-weight:600;margin-bottom:6px">' + errors.length + ' error' + (errors.length === 1 ? '' : 's') + ' — Anthropic spec violation</div>';
27009
+ html += '<ul style="margin:0;padding-left:18px;font-size:12px;line-height:1.5;color:var(--text-secondary)">';
27010
+ for (var ei = 0; ei < errors.length; ei++) {
27011
+ html += '<li><strong style="color:var(--red)">' + esc(errors[ei].field) + ':</strong> ' + esc(errors[ei].message) + '</li>';
27012
+ }
27013
+ html += '</ul></div>';
27014
+ }
27015
+ if (warnings.length > 0) {
27016
+ html += '<div style="margin-bottom:18px;padding:12px 14px;background:rgba(245,158,11,0.08);border:1px solid var(--yellow);border-radius:6px">';
27017
+ html += '<div style="font-size:11px;color:var(--yellow);text-transform:uppercase;letter-spacing:0.04em;font-weight:600;margin-bottom:6px">' + warnings.length + ' suggestion' + (warnings.length === 1 ? '' : 's') + '</div>';
27018
+ html += '<ul style="margin:0;padding-left:18px;font-size:12px;line-height:1.5;color:var(--text-secondary)">';
27019
+ for (var wi = 0; wi < warnings.length; wi++) {
27020
+ html += '<li><strong>' + esc(warnings[wi].field) + ':</strong> ' + esc(warnings[wi].message) + '</li>';
27021
+ }
27022
+ html += '</ul></div>';
27023
+ }
27024
+ }
27025
+
27026
+ // ── 3. Used-by triggers
26963
27027
  if (Array.isArray(s.usedByTriggers) && s.usedByTriggers.length > 0) {
26964
27028
  html += '<div style="margin-bottom:18px;padding:12px 14px;background:var(--bg-tertiary);border-radius:6px">';
26965
27029
  html += '<div style="font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.04em;margin-bottom:6px;font-weight:500">Used by ' + s.usedByTriggers.length + ' trigger' + (s.usedByTriggers.length === 1 ? '' : 's') + '</div>';
26966
27030
  html += '<div style="display:flex;flex-wrap:wrap;gap:6px">';
26967
- for (var i = 0; i < s.usedByTriggers.length; i++) {
26968
- html += '<span style="font-size:11px;background:var(--bg-secondary);padding:2px 8px;border-radius:4px;border:1px solid var(--border);color:var(--text-secondary)">' + esc(s.usedByTriggers[i]) + '</span>';
27031
+ for (var ui = 0; ui < s.usedByTriggers.length; ui++) {
27032
+ html += '<span style="font-size:11px;background:var(--bg-secondary);padding:2px 8px;border-radius:4px;border:1px solid var(--border);color:var(--text-secondary)">' + esc(s.usedByTriggers[ui]) + '</span>';
26969
27033
  }
26970
27034
  html += '</div></div>';
26971
27035
  }
26972
27036
 
26973
- // V1 fields (only render when present)
26974
- if (fm.inputs && Object.keys(fm.inputs).length > 0) {
26975
- html += renderSkillSection('Inputs', renderSkillInputs(fm.inputs));
27037
+ // ── 4. Bundled files (folder-form only)
27038
+ if (s.layout === 'folder' && Array.isArray(s.bundledFiles) && s.bundledFiles.length > 0) {
27039
+ html += renderSkillSection('Bundled files (' + s.bundledFiles.length + ')', renderSkillBundledFiles(s.bundledFiles));
27040
+ }
27041
+
27042
+ // ── 5. Clementine extensions
27043
+ if (ext.inputs && Object.keys(ext.inputs).length > 0) {
27044
+ html += renderSkillSection('Inputs', renderSkillInputs(ext.inputs));
26976
27045
  }
26977
- if (fm.tools && (fm.tools.allow?.length || fm.tools.deny?.length)) {
26978
- html += renderSkillSection('Tools', renderSkillTools(fm.tools));
27046
+ if (ext.tools && ((ext.tools.allow && ext.tools.allow.length) || (ext.tools.deny && ext.tools.deny.length))) {
27047
+ html += renderSkillSection('Tools', renderSkillTools(ext.tools));
26979
27048
  }
26980
- if (Array.isArray(fm.dataSources) && fm.dataSources.length > 0) {
26981
- html += renderSkillSection('Data sources', renderSkillDataSources(fm.dataSources));
27049
+ if (Array.isArray(ext.dataSources) && ext.dataSources.length > 0) {
27050
+ html += renderSkillSection('Data sources', renderSkillDataSources(ext.dataSources));
26982
27051
  }
26983
- if (Array.isArray(fm.stateKeys) && fm.stateKeys.length > 0) {
26984
- html += renderSkillSection('State keys', '<div style="display:flex;flex-wrap:wrap;gap:4px">' + fm.stateKeys.map(function(k) { return '<code style="font-size:11px;background:var(--bg-tertiary);padding:2px 6px;border-radius:3px">' + esc(k) + '</code>'; }).join('') + '</div>');
27052
+ if (Array.isArray(ext.stateKeys) && ext.stateKeys.length > 0) {
27053
+ html += renderSkillSection('State keys', '<div style="display:flex;flex-wrap:wrap;gap:4px">' + ext.stateKeys.map(function(k) { return '<code style="font-size:11px;background:var(--bg-tertiary);padding:2px 6px;border-radius:3px">' + esc(k) + '</code>'; }).join('') + '</div>');
26985
27054
  }
26986
- if (fm.success && (fm.success.criterion || fm.success.schema)) {
27055
+ if (ext.success && (ext.success.criterion || ext.success.schema)) {
26987
27056
  var sc = '';
26988
- if (fm.success.criterion) sc += '<div style="font-size:12px;line-height:1.5;color:var(--text-secondary);margin-bottom:8px"><em>Criterion:</em> ' + esc(fm.success.criterion) + '</div>';
26989
- if (fm.success.schema) sc += '<details><summary style="cursor:pointer;font-size:11px;color:var(--text-muted)">Schema</summary><pre style="font-size:11px;background:var(--bg-tertiary);padding:10px;border-radius:6px;margin-top:6px;overflow:auto">' + esc(JSON.stringify(fm.success.schema, null, 2)) + '</pre></details>';
27057
+ if (ext.success.criterion) sc += '<div style="font-size:12px;line-height:1.5;color:var(--text-secondary);margin-bottom:8px"><em>Criterion:</em> ' + esc(ext.success.criterion) + '</div>';
27058
+ if (ext.success.schema) sc += '<details><summary style="cursor:pointer;font-size:11px;color:var(--text-muted)">Schema</summary><pre style="font-size:11px;background:var(--bg-tertiary);padding:10px;border-radius:6px;margin-top:6px;overflow:auto">' + esc(JSON.stringify(ext.success.schema, null, 2)) + '</pre></details>';
26990
27059
  html += renderSkillSection('Success criterion', sc);
26991
27060
  }
26992
- if (fm.limits) {
26993
- var l = fm.limits;
26994
- var bits = [];
26995
- if (l.maxTurns) bits.push('max ' + l.maxTurns + ' turns');
26996
- if (l.maxBudgetUsd) bits.push('$' + l.maxBudgetUsd + ' budget');
26997
- if (l.timeoutSeconds) bits.push(l.timeoutSeconds + 's timeout');
26998
- if (bits.length) html += renderSkillSection('Limits', '<div style="font-size:12px;color:var(--text-secondary)">' + bits.map(esc).join(' · ') + '</div>');
27061
+ if (ext.limits) {
27062
+ var lbits = [];
27063
+ if (ext.limits.maxTurns) lbits.push('max ' + ext.limits.maxTurns + ' turns');
27064
+ if (ext.limits.maxBudgetUsd) lbits.push('$' + ext.limits.maxBudgetUsd + ' budget');
27065
+ if (ext.limits.timeoutSeconds) lbits.push(ext.limits.timeoutSeconds + 's timeout');
27066
+ if (lbits.length) html += renderSkillSection('Limits', '<div style="font-size:12px;color:var(--text-secondary)">' + lbits.map(esc).join(' · ') + '</div>');
26999
27067
  }
27000
27068
 
27001
- // Legacy fields (preserved for the migration UI)
27069
+ // ── 6. Legacy fields (preserved for migration UI in Phase B)
27002
27070
  if (Array.isArray(fm.triggers) && fm.triggers.length > 0) {
27003
27071
  html += renderSkillSection('Triggers (legacy NLP phrases)',
27004
27072
  '<div style="display:flex;flex-wrap:wrap;gap:4px">'
@@ -27012,27 +27080,35 @@ function renderSkillDetail(s) {
27012
27080
  + fm.toolsUsed.map(function(t) { return '<code style="font-size:11px;background:var(--bg-tertiary);padding:2px 6px;border-radius:3px">' + esc(t) + '</code>'; }).join('')
27013
27081
  + '</div>');
27014
27082
  }
27015
- if (fm.useCount || fm.lastUsed) {
27016
- var bits = [];
27017
- if (fm.useCount) bits.push('Used ' + fm.useCount + ' times');
27018
- if (fm.lastUsed) bits.push('Last: ' + new Date(fm.lastUsed).toLocaleString());
27019
- html += renderSkillSection('Usage', '<div style="font-size:12px;color:var(--text-secondary)">' + bits.map(esc).join(' · ') + '</div>');
27083
+ if (fm.useCount || ext.lastUsed) {
27084
+ var ubits = [];
27085
+ if (fm.useCount) ubits.push('Used ' + fm.useCount + ' times');
27086
+ if (ext.lastUsed) ubits.push('Last: ' + new Date(ext.lastUsed).toLocaleString());
27087
+ html += renderSkillSection('Usage', '<div style="font-size:12px;color:var(--text-secondary)">' + ubits.map(esc).join(' · ') + '</div>');
27020
27088
  }
27021
27089
 
27022
- // Body — markdown rendered as preformatted text. Phase B will add a
27023
- // proper markdown renderer; Phase A keeps it simple to ship.
27090
+ // ── 7. Procedure body (with line counter)
27024
27091
  if (s.body && s.body.trim()) {
27092
+ var bodyClass = bodyLines > 500 ? 'color:var(--yellow)' : 'color:var(--text-muted)';
27025
27093
  html += '<div style="margin-top:18px">';
27026
- html += '<div style="font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.04em;margin-bottom:8px;font-weight:500">Procedure</div>';
27094
+ html += '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">';
27095
+ html += '<div style="font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.04em;font-weight:500">Procedure</div>';
27096
+ html += '<div style="font-size:10px;' + bodyClass + ';font-family:\\x27JetBrains Mono\\x27,monospace">' + bodyLines + ' / 500 lines</div>';
27097
+ html += '</div>';
27027
27098
  html += '<pre style="font-size:12px;line-height:1.55;background:var(--bg-tertiary);padding:14px 16px;border-radius:6px;white-space:pre-wrap;word-break:break-word;font-family:inherit;border:1px solid var(--border);max-height:500px;overflow:auto">' + esc(s.body) + '</pre>';
27028
27099
  html += '</div>';
27029
27100
  }
27030
27101
 
27102
+ // ── 8. Schema-specific footer
27031
27103
  if (s.schemaVersion === 'legacy') {
27032
- html += '<div style="margin-top:24px;padding:12px 14px;background:var(--yellow)15;border:1px solid var(--yellow);border-radius:6px;font-size:12px;color:var(--text-secondary);line-height:1.5">'
27104
+ html += '<div style="margin-top:24px;padding:12px 14px;background:rgba(245,158,11,0.08);border:1px solid var(--yellow);border-radius:6px;font-size:12px;color:var(--text-secondary);line-height:1.5">'
27033
27105
  + '<strong style="color:var(--yellow)">Legacy schema.</strong> '
27034
- + 'This skill uses the pre-redesign frontmatter shape (title / triggers / toolsUsed / useCount). '
27035
- + 'Phase B will surface a one-click migration that converts it to v1 (inputs / tools.allow / dataSources / stateKeys / success).'
27106
+ + 'This skill uses the pre-redesign frontmatter shape. Phase B will offer a one-click migration to the Anthropic-compatible format (name + description top-level; cron-tailored fields under <code>clementine:</code>).'
27107
+ + '</div>';
27108
+ } else if (s.schemaVersion === 'anthropic' && s.layout === 'flat') {
27109
+ html += '<div style="margin-top:24px;padding:12px 14px;background:rgba(59,130,246,0.06);border:1px solid var(--blue);border-radius:6px;font-size:12px;color:var(--text-secondary);line-height:1.5">'
27110
+ + '<strong style="color:var(--blue)">Anthropic-compatible.</strong> '
27111
+ + 'This skill matches the official Anthropic spec exactly. Consider promoting it to <strong>folder form</strong> (<code>' + esc(fm.name) + '/SKILL.md</code>) so you can bundle reference files and scripts later.'
27036
27112
  + '</div>';
27037
27113
  }
27038
27114
 
@@ -27040,6 +27116,36 @@ function renderSkillDetail(s) {
27040
27116
  return html;
27041
27117
  }
27042
27118
 
27119
+ // PRD § Skills-First Phase A.5 / 1.18.108: bundled file tree.
27120
+ // Renders the folder's sibling files + scripts/ contents grouped by kind.
27121
+ // Each row: icon (📄 markdown / ⚙ script / · other), relPath, size.
27122
+ function renderSkillBundledFiles(files) {
27123
+ var iconFor = function(kind) {
27124
+ if (kind === 'markdown') return '📄';
27125
+ if (kind === 'script') return '⚙';
27126
+ return '·';
27127
+ };
27128
+ var fmt = function(bytes) {
27129
+ if (bytes < 1024) return bytes + ' B';
27130
+ if (bytes < 1024 * 1024) return Math.round(bytes / 1024) + ' KB';
27131
+ return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
27132
+ };
27133
+ var html = '<div style="font-family:\\x27JetBrains Mono\\x27,monospace;font-size:11px;background:var(--bg-tertiary);border-radius:6px;padding:10px 14px">';
27134
+ for (var i = 0; i < files.length; i++) {
27135
+ var f = files[i];
27136
+ var indentStyle = f.relPath.indexOf('/') !== -1 ? 'padding-left:20px' : '';
27137
+ html += '<div style="display:flex;justify-content:space-between;align-items:center;padding:3px 0;' + indentStyle + '">'
27138
+ + '<span style="color:var(--text-secondary)">'
27139
+ + '<span style="display:inline-block;width:18px;color:var(--text-muted)">' + iconFor(f.kind) + '</span>'
27140
+ + esc(f.relPath)
27141
+ + '</span>'
27142
+ + '<span style="font-size:10px;color:var(--text-muted)">' + esc(fmt(f.sizeBytes)) + '</span>'
27143
+ + '</div>';
27144
+ }
27145
+ html += '</div>';
27146
+ return html;
27147
+ }
27148
+
27043
27149
  function renderSkillSection(title, body) {
27044
27150
  return '<div style="margin-bottom:18px">'
27045
27151
  + '<div style="font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.04em;margin-bottom:8px;font-weight:500">' + esc(title) + '</div>'
package/dist/types.d.ts CHANGED
@@ -309,13 +309,18 @@ export interface AgentHeartbeatState {
309
309
  */
310
310
  lastTickKind?: 'acted' | 'quiet' | 'silent' | 'override';
311
311
  }
312
- /** Source of a skill file informational, used by the dashboard. */
312
+ /** Where a skill was loaded from. Per-project skills shadow global. */
313
313
  export type SkillScope = 'global' | 'project';
314
- /** Whether the file's frontmatter matches the v1 spec or is the
315
- * pre-redesign shape. Drives the "needs migration" badge in the UI. */
316
- export type SkillSchemaVersion = 'v1' | 'legacy';
317
- /** A typed skill input backed by JSON Schema. The dashboard form
318
- * generator can derive UI directly from a JSON Schema; ajv validates. */
314
+ /** Three states the dashboard surfaces as badges:
315
+ * 'anthropic' only `name` + `description` (vanilla Anthropic spec)
316
+ * 'clementine' has the `clementine:` namespace with extensions
317
+ * 'legacy' pre-redesign flat frontmatter (title/triggers/toolsUsed) */
318
+ export type SkillSchemaVersion = 'anthropic' | 'clementine' | 'legacy';
319
+ /** Whether the on-disk layout is a folder-with-SKILL.md (Anthropic spec)
320
+ * or a single .md file (Clementine flat legacy). New skills should be
321
+ * created in folder form so they can grow bundled files later. */
322
+ export type SkillLayout = 'folder' | 'flat';
323
+ /** A typed skill input — backed by JSON Schema. Used in `clementine.inputs`. */
319
324
  export interface SkillInputSchema {
320
325
  type?: 'string' | 'integer' | 'number' | 'boolean' | 'array' | 'object';
321
326
  description?: string;
@@ -330,96 +335,125 @@ export interface SkillInputSchema {
330
335
  properties?: Record<string, SkillInputSchema>;
331
336
  required?: string[];
332
337
  }
333
- /** Declarative entry describing where a skill reads data from. Surfaced
334
- * in the dashboard's per-skill detail pane and in the Tools & MCP "used
335
- * by" join. Free-form by design — different skills declare different
336
- * data shapes. */
338
+ /** Declarative entry describing where a skill reads data from. */
337
339
  export interface SkillDataSource {
338
- /** A loose identifier — e.g. 'outlook', 'memory', 'vault', 'cli', 'mcp:ElevenLabs'. */
340
+ /** Loose identifier — 'outlook', 'memory', 'vault', 'cli', 'mcp:ElevenLabs'. */
339
341
  kind: string;
340
- /** One-line human description what the skill reads from this source. */
342
+ /** One-line human description of what gets read. */
341
343
  purpose: string;
342
344
  }
343
- /** Tool allowlist + denylist on a skill. Deny wins on conflict. The
344
- * runtime (Phase C) will refuse to invoke a tool that isn't on allow,
345
- * even if the trigger tries to override. */
345
+ /** Tool allowlist + denylist. Deny wins on conflict. Phase C runtime
346
+ * refuses tools not in `allow`, even when a trigger tries to override. */
346
347
  export interface SkillToolPolicy {
347
348
  allow?: string[];
348
349
  deny?: string[];
349
350
  }
350
- /** Success criterion. Either schema (ajv-validated against the run's
351
- * structured_output) or criterion (free-text Haiku evaluator). Both =
352
- * both must pass. Mirrors the existing CronJobDefinition shape. */
351
+ /** Success criterion JSON Schema (ajv-validated) and/or free-text. */
353
352
  export interface SkillSuccess {
354
353
  schema?: SkillInputSchema;
355
354
  criterion?: string;
356
355
  }
357
- /** Per-skill caps. A trigger can tighten these but never loosen. */
356
+ /** Per-skill caps. A trigger can tighten but never loosen. */
358
357
  export interface SkillLimits {
359
358
  maxTurns?: number;
360
359
  maxBudgetUsd?: number;
361
360
  timeoutSeconds?: number;
362
361
  }
363
- /** Parsed frontmatter + computed metadata. Phase A surfaces this whole
364
- * shape in the Skills page detail pane. */
365
- export interface SkillFrontmatter {
366
- /** Skill identifier — derived from filename if absent in frontmatter. */
367
- name: string;
368
- /** One-line description (matches today's `description:` field). */
369
- description?: string;
362
+ /** Clementine-specific extensions. All optional. Lives under the
363
+ * `clementine:` key in the YAML frontmatter so an Anthropic skill that
364
+ * doesn't have these stays valid. */
365
+ export interface ClementineSkillExtensions {
370
366
  /** Typed parameters the skill accepts at invocation time. */
371
367
  inputs?: Record<string, SkillInputSchema>;
372
368
  /** Tool allowlist + denylist enforced by the runtime. */
373
369
  tools?: SkillToolPolicy;
374
370
  /** Where the skill reads data from — purely declarative. */
375
371
  dataSources?: SkillDataSource[];
376
- /** State.* keys this skill owns (others can't touch them). */
372
+ /** state.* keys this skill owns. Others can't touch them. */
377
373
  stateKeys?: string[];
378
374
  /** Success criterion — schema and/or free-text evaluator. */
379
375
  success?: SkillSuccess;
380
376
  /** Caps the trigger can tighten but never loosen. */
381
377
  limits?: SkillLimits;
382
- /** Bumped by the user on Publish (Phase B). Phase A surfaces but
383
- * doesn't increment. */
378
+ /** Skill version bumped on Publish in Phase B. */
384
379
  version?: number;
385
- /** Timestamps captured by the legacy + v1 schemas alike. */
380
+ /** Timestamps Clementine captures. */
386
381
  createdAt?: string;
387
382
  updatedAt?: string;
388
383
  lastUsed?: string;
389
- /** Last time the user clicked "Test this skill" in the dashboard
390
- * (Phase B). Phase A reads but doesn't write. */
384
+ /** Last successful "Test this skill" run (Phase B). */
391
385
  lastTestPass?: string;
392
- /** Legacy: title (use as fallback for display when description missing). */
386
+ }
387
+ /** Parsed frontmatter. Anthropic-canonical fields are top-level; our
388
+ * extensions live under `clementine`. Legacy fields (title/triggers/
389
+ * toolsUsed/useCount) are also top-level — they're what the existing
390
+ * pre-redesign skills already use and we keep them readable. */
391
+ export interface SkillFrontmatter {
392
+ /** Skill identifier. Filename is canonical; this field is honored when
393
+ * set but not required. Anthropic spec: max 64 chars, lowercase letters
394
+ * + numbers + hyphens, no XML, no reserved words ('anthropic'/'claude'). */
395
+ name: string;
396
+ /** What the skill does AND when to use it (third person). Anthropic
397
+ * spec: non-empty, max 1024 chars, no XML tags. */
398
+ description?: string;
399
+ /** Optional namespace for cron-tailored fields. Absent on vanilla
400
+ * Anthropic skills; present on Clementine-extended skills. */
401
+ clementine?: ClementineSkillExtensions;
402
+ /** Legacy: human-friendly display title. Falls back to `name` when absent. */
393
403
  title?: string;
394
- /** Legacy: NLP-style trigger phrases. Pre-redesign Clementine matched
395
- * these against incoming chat messages. */
404
+ /** Legacy: NLP-style trigger phrases for chat-message matching. */
396
405
  triggers?: string[];
397
406
  /** Legacy: 'manual' / 'auto' / 'imported' — provenance label. */
398
407
  source?: string;
399
- /** Legacy: tools observed during runs. Informational, not constraint. */
408
+ /** Legacy: tools observed during runs. Informational, not enforced. */
400
409
  toolsUsed?: string[];
401
- /** Legacy: incrementing counter of how many runs invoked the skill. */
410
+ /** Legacy: incrementing counter of runs that invoked the skill. */
402
411
  useCount?: number;
403
412
  }
413
+ /** A bundled file inside a folder-form skill. `kind` distinguishes
414
+ * loadable markdown from executable scripts so the dashboard can
415
+ * render them differently. */
416
+ export interface SkillBundledFile {
417
+ /** Path relative to the skill folder (e.g. 'FORMS.md', 'scripts/extract.py'). */
418
+ relPath: string;
419
+ /** Absolute path on disk. */
420
+ absPath: string;
421
+ /** Loose categorization for rendering: markdown reference vs script vs other. */
422
+ kind: 'markdown' | 'script' | 'other';
423
+ /** File size in bytes — surfaced as "X KB" in the dashboard. */
424
+ sizeBytes: number;
425
+ }
426
+ /** A validation finding for a skill — surfaced in the dashboard so
427
+ * authors can see what to fix. Severity 'error' indicates a spec
428
+ * violation (rejected by Anthropic API); 'warning' is a best-practice
429
+ * hint (still loadable). */
430
+ export interface SkillValidationWarning {
431
+ severity: 'error' | 'warning';
432
+ field: 'name' | 'description' | 'body' | 'frontmatter' | 'layout';
433
+ message: string;
434
+ }
404
435
  /** Resolved skill record — frontmatter + body + computed extras the
405
- * dashboard surfaces (file path, scope, schemaVersion, used-by list). */
436
+ * dashboard surfaces. */
406
437
  export interface Skill {
407
- /** Frontmatter, parsed (or synthesized for files without one). */
438
+ /** Parsed frontmatter (or synthesized when none / unparseable). */
408
439
  frontmatter: SkillFrontmatter;
409
- /** Markdown body of the skill the actual procedure. */
440
+ /** Markdown body of the skill (the actual procedure). */
410
441
  body: string;
411
- /** Absolute path to the source .md file. */
442
+ /** Absolute path to the entry-point file. For folder-form skills this
443
+ * points at <folder>/SKILL.md. For flat skills it points at <name>.md. */
412
444
  filePath: string;
413
- /** Whether this skill was loaded from the global pool or a per-project
414
- * override. Per-project wins on name collision. */
445
+ /** Where this skill was loaded from (global vs per-project). */
415
446
  scope: SkillScope;
416
- /** Whether the frontmatter matches the v1 spec or is the pre-redesign
417
- * shape. The Skills page renders a "Schema: legacy" badge accordingly. */
447
+ /** Folder-form (Anthropic-spec) vs flat-file (Clementine legacy). */
448
+ layout: SkillLayout;
449
+ /** Anthropic / clementine / legacy — drives the schema badge. */
418
450
  schemaVersion: SkillSchemaVersion;
419
- /** Used-by join: cron job names that reference this skill. Phase A
420
- * builds this from the legacy `skills:` array on CronJobDefinition;
421
- * Phase C extends it to read the new top-level `skill:` field. */
451
+ /** Sibling .md files + scripts/ contents (only populated for folder-form). */
452
+ bundledFiles: SkillBundledFile[];
453
+ /** Used-by join: cron jobs that reference this skill via skills[]. */
422
454
  usedByTriggers: string[];
455
+ /** Validation findings — populated lazily so listSkills stays cheap. */
456
+ validation: SkillValidationWarning[];
423
457
  }
424
458
  export interface CronJobDefinition {
425
459
  name: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.18.107",
3
+ "version": "1.18.108",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",