clementine-agent 1.18.105 → 1.18.107

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.
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Skill store — Phase A (read-only) of the Skills-First redesign.
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.
7
+ *
8
+ * Discovery order:
9
+ * 1. ~/.clementine/vault/00-System/skills/<name>.md (global)
10
+ * 2. <work_dir>/.clementine/skills/<name>.md (per-project)
11
+ *
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.
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.
20
+ *
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.
24
+ */
25
+ import type { Skill, SkillScope, CronJobDefinition } from '../types.js';
26
+ interface ParseResult {
27
+ skill: Skill;
28
+ /** 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. */
31
+ parseError?: string;
32
+ }
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. */
36
+ export declare function parseSkillFile(filePath: string, scope: SkillScope): ParseResult;
37
+ export interface ListSkillsOptions {
38
+ /** Optional per-project work_dir to also scan. Per-project skills
39
+ * override global skills with the same filename. */
40
+ 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). */
44
+ jobs?: CronJobDefinition[];
45
+ }
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
+ 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. */
52
+ 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. */
55
+ export declare function _skillDirsForDiagnostics(workDir?: string): {
56
+ global: string;
57
+ project: string | null;
58
+ };
59
+ export {};
60
+ //# sourceMappingURL=skill-store.d.ts.map
@@ -0,0 +1,313 @@
1
+ /**
2
+ * Skill store — Phase A (read-only) of the Skills-First redesign.
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.
7
+ *
8
+ * Discovery order:
9
+ * 1. ~/.clementine/vault/00-System/skills/<name>.md (global)
10
+ * 2. <work_dir>/.clementine/skills/<name>.md (per-project)
11
+ *
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.
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.
20
+ *
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.
24
+ */
25
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
26
+ import os from 'node:os';
27
+ import path from 'node:path';
28
+ import matter from 'gray-matter';
29
+ /** Resolve the global skills directory from CLEMENTINE_HOME (or default). */
30
+ function globalSkillsDir() {
31
+ const base = process.env.CLEMENTINE_HOME || path.join(os.homedir(), '.clementine');
32
+ return path.join(base, 'vault', '00-System', 'skills');
33
+ }
34
+ /** Resolve a per-project skills directory. Returns null if work_dir is
35
+ * empty or doesn't have a .clementine/skills/ child. */
36
+ function projectSkillsDir(workDir) {
37
+ if (!workDir)
38
+ return null;
39
+ const dir = path.join(workDir, '.clementine', 'skills');
40
+ return existsSync(dir) ? dir : null;
41
+ }
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');
60
+ }
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';
72
+ }
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;
89
+ }
90
+ // tools.allow / tools.deny
91
+ if (raw.tools && typeof raw.tools === 'object' && !Array.isArray(raw.tools)) {
92
+ const t = raw.tools;
93
+ const policy = {};
94
+ if (Array.isArray(t.allow))
95
+ policy.allow = t.allow.map(String);
96
+ if (Array.isArray(t.deny))
97
+ policy.deny = t.deny.map(String);
98
+ if (policy.allow || policy.deny)
99
+ fm.tools = policy;
100
+ }
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
+ }));
108
+ }
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;
113
+ const success = {};
114
+ if (s.schema && typeof s.schema === 'object')
115
+ success.schema = s.schema;
116
+ if (typeof s.criterion === 'string')
117
+ success.criterion = s.criterion;
118
+ if (success.schema || success.criterion)
119
+ fm.success = success;
120
+ }
121
+ if (raw.limits && typeof raw.limits === 'object' && !Array.isArray(raw.limits)) {
122
+ const l = raw.limits;
123
+ const limits = {};
124
+ if (typeof l.maxTurns === 'number')
125
+ limits.maxTurns = l.maxTurns;
126
+ if (typeof l.maxBudgetUsd === 'number')
127
+ limits.maxBudgetUsd = l.maxBudgetUsd;
128
+ if (typeof l.timeoutSeconds === 'number')
129
+ limits.timeoutSeconds = l.timeoutSeconds;
130
+ if (Object.keys(limits).length > 0)
131
+ fm.limits = limits;
132
+ }
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).
144
+ if (typeof raw.title === 'string')
145
+ fm.title = raw.title;
146
+ if (Array.isArray(raw.triggers))
147
+ fm.triggers = raw.triggers.map(String);
148
+ if (typeof raw.source === 'string')
149
+ fm.source = raw.source;
150
+ if (Array.isArray(raw.toolsUsed))
151
+ fm.toolsUsed = raw.toolsUsed.map(String);
152
+ if (typeof raw.useCount === 'number')
153
+ fm.useCount = raw.useCount;
154
+ return fm;
155
+ }
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. */
159
+ export function parseSkillFile(filePath, scope) {
160
+ const basename = nameFromFile(filePath);
161
+ let raw;
162
+ try {
163
+ raw = readFileSync(filePath, 'utf-8');
164
+ }
165
+ catch (err) {
166
+ return {
167
+ skill: emptySkill(filePath, basename, scope),
168
+ parseError: 'failed to read: ' + String(err),
169
+ };
170
+ }
171
+ let parsed;
172
+ try {
173
+ parsed = matter(raw);
174
+ }
175
+ catch (err) {
176
+ return {
177
+ skill: { ...emptySkill(filePath, basename, scope), body: raw },
178
+ parseError: 'YAML parse error: ' + String(err),
179
+ };
180
+ }
181
+ 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
+ },
193
+ };
194
+ }
195
+ function emptySkill(filePath, basename, scope) {
196
+ return {
197
+ frontmatter: { name: basename },
198
+ body: '',
199
+ filePath,
200
+ scope,
201
+ schemaVersion: 'legacy',
202
+ usedByTriggers: [],
203
+ };
204
+ }
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. */
208
+ function listSkillsInDir(dir, scope) {
209
+ if (!existsSync(dir))
210
+ return [];
211
+ let entries;
212
+ try {
213
+ entries = readdirSync(dir);
214
+ }
215
+ catch {
216
+ return [];
217
+ }
218
+ const out = [];
219
+ for (const entry of entries) {
220
+ if (!isSkillFile(entry))
221
+ continue;
222
+ const fullPath = path.join(dir, entry);
223
+ try {
224
+ const stat = statSync(fullPath);
225
+ if (!stat.isFile())
226
+ continue;
227
+ }
228
+ catch {
229
+ continue;
230
+ }
231
+ out.push(parseSkillFile(fullPath, scope).skill);
232
+ }
233
+ return out;
234
+ }
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. */
238
+ export function listSkills(opts = {}) {
239
+ const globalSkills = listSkillsInDir(globalSkillsDir(), 'global');
240
+ const projectSkills = opts.projectWorkDir
241
+ ? (() => {
242
+ const pdir = projectSkillsDir(opts.projectWorkDir);
243
+ return pdir ? listSkillsInDir(pdir, 'project') : [];
244
+ })()
245
+ : [];
246
+ // Build a map keyed by basename so per-project entries override global.
247
+ const merged = new Map();
248
+ for (const s of globalSkills)
249
+ merged.set(s.frontmatter.name, s);
250
+ for (const s of projectSkills)
251
+ 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
+ if (opts.jobs && opts.jobs.length > 0) {
255
+ for (const job of opts.jobs) {
256
+ if (!Array.isArray(job.skills))
257
+ continue;
258
+ for (const skillName of job.skills) {
259
+ const s = merged.get(skillName);
260
+ if (s)
261
+ s.usedByTriggers.push(job.name);
262
+ }
263
+ }
264
+ }
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
+ return [...merged.values()].sort((a, b) => a.frontmatter.name.localeCompare(b.frontmatter.name));
269
+ }
270
+ /** Get a single skill by name, with the same global/project precedence
271
+ * as listSkills. Returns null if neither pool has the skill. */
272
+ export function getSkill(name, opts = {}) {
273
+ // Per-project first (precedence).
274
+ if (opts.projectWorkDir) {
275
+ 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
+ }
285
+ }
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;
293
+ }
294
+ return null;
295
+ }
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);
302
+ }
303
+ return out;
304
+ }
305
+ /** Test-only: where the loader looked. Useful in unit tests + the
306
+ * dashboard's diagnostics surface. */
307
+ export function _skillDirsForDiagnostics(workDir) {
308
+ return {
309
+ global: globalSkillsDir(),
310
+ project: projectSkillsDir(workDir) ?? null,
311
+ };
312
+ }
313
+ //# sourceMappingURL=skill-store.js.map
@@ -4609,6 +4609,44 @@ export async function cmdDashboard(opts) {
4609
4609
  // POST /api/cron/:job/publish — promote draft to CRON.md
4610
4610
  // DELETE /api/cron/:job/draft — discard the draft
4611
4611
  // GET /api/cron/drafts — list all drafted task names
4612
+ // ── Skills-First redesign Phase A / 1.18.106: skill catalog (read-only) ─
4613
+ // Two endpoints feed the new Skills page:
4614
+ // GET /api/skills — full list with usedByTriggers join
4615
+ // GET /api/skills/:name — single skill detail (frontmatter + body)
4616
+ // Phase A is read-only; Phase B adds POST/PUT for editing.
4617
+ app.get('/api/skills', async (_req, res) => {
4618
+ try {
4619
+ const { listSkills } = await import('../agent/skill-store.js');
4620
+ const { parseCronJobs } = await import('../gateway/cron-scheduler.js');
4621
+ const jobs = parseCronJobs();
4622
+ const skills = listSkills({ jobs });
4623
+ res.json({ ok: true, count: skills.length, skills });
4624
+ }
4625
+ catch (err) {
4626
+ res.status(500).json({ ok: false, error: String(err) });
4627
+ }
4628
+ });
4629
+ app.get('/api/skills/:name', async (req, res) => {
4630
+ try {
4631
+ const name = req.params.name;
4632
+ if (!name) {
4633
+ res.status(400).json({ ok: false, error: 'name required' });
4634
+ return;
4635
+ }
4636
+ const { getSkill } = await import('../agent/skill-store.js');
4637
+ const { parseCronJobs } = await import('../gateway/cron-scheduler.js');
4638
+ const jobs = parseCronJobs();
4639
+ const skill = getSkill(name, { jobs });
4640
+ if (!skill) {
4641
+ res.status(404).json({ ok: false, error: `skill "${name}" not found` });
4642
+ return;
4643
+ }
4644
+ res.json({ ok: true, skill });
4645
+ }
4646
+ catch (err) {
4647
+ res.status(500).json({ ok: false, error: String(err) });
4648
+ }
4649
+ });
4612
4650
  app.get('/api/cron/drafts', async (_req, res) => {
4613
4651
  try {
4614
4652
  const { listDraftNames } = await import('../agent/draft-store.js');
@@ -17116,6 +17154,13 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
17116
17154
  <span class="nav-icon"></span> Tasks
17117
17155
  <span class="nav-badge" id="nav-cron-count" style="display:none">0</span>
17118
17156
  </div>
17157
+ <!-- Skills-First redesign Phase A / 1.18.106: read-only catalog
17158
+ of skill files (procedures + tools + data + state). Phase B
17159
+ adds editing + testing; Phase C wires runtime invocation. -->
17160
+ <div class="nav-item" data-page="skills" data-icon="brain" title="Reusable skill files — procedures + tool allowlists + data sources">
17161
+ <span class="nav-icon"></span> Skills
17162
+ <span class="nav-badge" id="nav-skills-count" style="display:none">0</span>
17163
+ </div>
17119
17164
  <div class="nav-item" data-page="heartbeat" data-icon="bell" title="Heartbeat controls and queued work">
17120
17165
  <span class="nav-icon"></span> Heartbeat
17121
17166
  </div>
@@ -20427,6 +20472,34 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
20427
20472
 
20428
20473
  <!-- page-goals merged into Team → Goals tab. -->
20429
20474
 
20475
+ <!-- ═══ Skills Page — Phase A read-only catalog ═══
20476
+ Skills-First redesign Phase A / 1.18.106: a list of every skill
20477
+ file with detail pane. Editing + testing land in Phase B; runtime
20478
+ invocation lands in Phase C. The page is intentionally minimal —
20479
+ we want users to see what's there, not be overwhelmed by 7 tiles. -->
20480
+ <div class="page" id="page-skills">
20481
+ <div class="page-head">
20482
+ <div class="icon icon-slot" data-icon="brain"></div>
20483
+ <div class="title-block">
20484
+ <h1>Skills</h1>
20485
+ <p class="desc">Reusable procedures Clementine can run. Each skill declares its tools, data sources, and state.</p>
20486
+ </div>
20487
+ </div>
20488
+ <div style="display:grid;grid-template-columns:380px 1fr;gap:18px;height:calc(100vh - 180px);min-height:500px">
20489
+ <div id="skills-list-pane" style="overflow-y:auto;border:1px solid var(--border);border-radius:8px;background:var(--bg-secondary)">
20490
+ <div style="padding:14px 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:8px">
20491
+ <input type="search" id="skills-search" placeholder="Search skills…" oninput="onSkillsSearch(this.value)" style="flex:1;padding:6px 10px;font-size:12px;border:1px solid var(--border);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)">
20492
+ </div>
20493
+ <div id="skills-list" style="padding:6px"></div>
20494
+ </div>
20495
+ <div id="skills-detail-pane" style="overflow-y:auto;border:1px solid var(--border);border-radius:8px;background:var(--bg-secondary);padding:0">
20496
+ <div id="skills-detail" style="padding:24px;color:var(--text-muted);text-align:center;font-size:13px">
20497
+ Select a skill on the left to see its procedure, tools, and data sources.
20498
+ </div>
20499
+ </div>
20500
+ </div>
20501
+ </div>
20502
+
20430
20503
  <!-- ═══ Team Page — The Office ═══ -->
20431
20504
  <div class="page" id="page-team">
20432
20505
  <div class="page-head">
@@ -22106,7 +22179,7 @@ function lucide(name, cls) {
22106
22179
  return '<svg class="icn ' + (cls || '') + '" viewBox="0 0 24 24" aria-hidden="true">' + path + '</svg>';
22107
22180
  }
22108
22181
 
22109
- var DESTINATIONS = ['home', 'build', 'heartbeat', 'team', 'brain', 'settings'];
22182
+ var DESTINATIONS = ['home', 'build', 'skills', 'heartbeat', 'team', 'brain', 'settings'];
22110
22183
 
22111
22184
  var ROUTE_REDIRECTS = {
22112
22185
  // old hash → new {page, tab}
@@ -22183,6 +22256,12 @@ function navigateTo(page, opts) {
22183
22256
  var bp = currentAgentSlug || '';
22184
22257
  refreshBuilderAgents(bp);
22185
22258
  break;
22259
+ case 'skills':
22260
+ // Skills-First redesign Phase A / 1.18.106: load the catalog when
22261
+ // the user navigates to the Skills page. Read-only; no mutation
22262
+ // surfaces here yet.
22263
+ if (typeof refreshSkillsPage === 'function') refreshSkillsPage();
22264
+ break;
22186
22265
  case 'heartbeat':
22187
22266
  refreshHeartbeatControl();
22188
22267
  break;
@@ -26730,6 +26809,294 @@ async function deleteDelegation(id) {
26730
26809
 
26731
26810
  // ── Heartbeat Queue ──────────────────────
26732
26811
 
26812
+ // ── Skills-First redesign Phase A / 1.18.106 ──────────────────────────
26813
+ // Read-only Skills page: list + detail. Phase B adds editor + Test
26814
+ // runner; Phase C wires runtime invocation; Phase D collapses Tasks
26815
+ // page complexity once Triggers can point at skills directly.
26816
+ //
26817
+ // State kept module-local so other surfaces (e.g. the Tools & MCP
26818
+ // catalog's future "used by" join) can read it without re-fetching.
26819
+ var _skillsState = {
26820
+ data: [], // raw skills array from /api/skills
26821
+ filtered: [], // text-filter result
26822
+ selectedName: '', // currently shown in detail pane
26823
+ query: '', // search input value
26824
+ };
26825
+
26826
+ async function refreshSkillsPage() {
26827
+ var listEl = document.getElementById('skills-list');
26828
+ var detailEl = document.getElementById('skills-detail');
26829
+ if (!listEl) return;
26830
+ listEl.innerHTML = '<div style="padding:18px;color:var(--text-muted);font-size:12px">Loading skills…</div>';
26831
+ try {
26832
+ var r = await apiFetch('/api/skills');
26833
+ var d = await r.json();
26834
+ if (!r.ok || d.ok === false) {
26835
+ listEl.innerHTML = '<div style="padding:18px;color:var(--red);font-size:12px">Failed to load: ' + esc(d.error || 'unknown') + '</div>';
26836
+ return;
26837
+ }
26838
+ _skillsState.data = d.skills || [];
26839
+ _skillsState.filtered = applySkillsFilter(_skillsState.data, _skillsState.query);
26840
+ renderSkillsList();
26841
+ var badge = document.getElementById('nav-skills-count');
26842
+ if (badge) {
26843
+ badge.textContent = String(_skillsState.data.length);
26844
+ badge.style.display = _skillsState.data.length > 0 ? '' : 'none';
26845
+ }
26846
+ // If a skill was previously selected, reload its detail. Otherwise
26847
+ // auto-select the first to give the user something to look at.
26848
+ if (_skillsState.selectedName) {
26849
+ var still = _skillsState.data.find(function(s) { return s.frontmatter.name === _skillsState.selectedName; });
26850
+ if (still) showSkillDetail(_skillsState.selectedName);
26851
+ } else if (_skillsState.filtered.length > 0) {
26852
+ showSkillDetail(_skillsState.filtered[0].frontmatter.name);
26853
+ }
26854
+ } catch (e) {
26855
+ listEl.innerHTML = '<div style="padding:18px;color:var(--red);font-size:12px">Error: ' + esc(String(e)) + '</div>';
26856
+ }
26857
+ }
26858
+
26859
+ function applySkillsFilter(skills, query) {
26860
+ var q = (query || '').trim().toLowerCase();
26861
+ if (!q) return skills.slice();
26862
+ return skills.filter(function(s) {
26863
+ var fm = s.frontmatter || {};
26864
+ var hay = [fm.name, fm.title, fm.description, (fm.toolsUsed || []).join(' ')].filter(Boolean).join(' ').toLowerCase();
26865
+ return hay.indexOf(q) !== -1;
26866
+ });
26867
+ }
26868
+
26869
+ function onSkillsSearch(value) {
26870
+ _skillsState.query = value;
26871
+ _skillsState.filtered = applySkillsFilter(_skillsState.data, value);
26872
+ renderSkillsList();
26873
+ }
26874
+
26875
+ function renderSkillsList() {
26876
+ var listEl = document.getElementById('skills-list');
26877
+ if (!listEl) return;
26878
+ if (_skillsState.filtered.length === 0) {
26879
+ listEl.innerHTML = '<div style="padding:18px;color:var(--text-muted);font-size:12px;text-align:center;line-height:1.5">'
26880
+ + (_skillsState.query
26881
+ ? '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>.')
26883
+ + '</div>';
26884
+ return;
26885
+ }
26886
+ var html = '';
26887
+ for (var i = 0; i < _skillsState.filtered.length; i++) {
26888
+ var s = _skillsState.filtered[i];
26889
+ var fm = s.frontmatter || {};
26890
+ var isSelected = s.frontmatter.name === _skillsState.selectedName;
26891
+ 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>';
26896
+ var scopeBadge = s.scope === 'project'
26897
+ ? '<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
+ : '';
26899
+ var usedCount = (s.usedByTriggers || []).length;
26900
+ var displayName = fm.title || fm.name;
26901
+ var desc = (fm.description || '').slice(0, 100);
26902
+ if (fm.description && fm.description.length > 100) desc += '…';
26903
+ 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>'
26907
+ + '</div>'
26908
+ + (desc ? '<div style="font-size:11px;color:var(--text-muted);line-height:1.4;margin-bottom:4px">' + esc(desc) + '</div>' : '')
26909
+ + '<div style="font-size:10px;color:var(--text-muted)">'
26910
+ + (usedCount > 0 ? 'Used by ' + usedCount + ' trigger' + (usedCount === 1 ? '' : 's') : '<span style="color:var(--text-muted);opacity:0.6">Unused</span>')
26911
+ + '</div>'
26912
+ + '</div>';
26913
+ }
26914
+ listEl.innerHTML = html;
26915
+ }
26916
+
26917
+ async function showSkillDetail(name) {
26918
+ _skillsState.selectedName = name;
26919
+ renderSkillsList(); // re-render to update the highlight
26920
+ var detailEl = document.getElementById('skills-detail');
26921
+ if (!detailEl) return;
26922
+ detailEl.innerHTML = '<div style="padding:24px;color:var(--text-muted);font-size:12px">Loading…</div>';
26923
+ try {
26924
+ var r = await apiFetch('/api/skills/' + encodeURIComponent(name));
26925
+ var d = await r.json();
26926
+ if (!r.ok || d.ok === false) {
26927
+ detailEl.innerHTML = '<div style="padding:24px;color:var(--red);font-size:12px">Failed: ' + esc(d.error || 'unknown') + '</div>';
26928
+ return;
26929
+ }
26930
+ detailEl.innerHTML = renderSkillDetail(d.skill);
26931
+ } catch (e) {
26932
+ detailEl.innerHTML = '<div style="padding:24px;color:var(--red);font-size:12px">Error: ' + esc(String(e)) + '</div>';
26933
+ }
26934
+ }
26935
+
26936
+ function renderSkillDetail(s) {
26937
+ var fm = s.frontmatter || {};
26938
+ var displayName = fm.title || fm.name;
26939
+ var html = '<div style="padding:24px 28px">';
26940
+ // Header
26941
+ html += '<div style="margin-bottom:18px">';
26942
+ html += '<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px;flex-wrap:wrap">';
26943
+ 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>';
26947
+ if (s.scope === 'project') {
26948
+ 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
+ }
26950
+ if (typeof fm.version === 'number') {
26951
+ html += '<span style="font-size:10px;color:var(--text-muted)">v' + esc(fm.version) + '</span>';
26952
+ }
26953
+ html += '</div>';
26954
+ if (fm.description) {
26955
+ html += '<p style="font-size:13px;color:var(--text-secondary);line-height:1.5;margin:0">' + esc(fm.description) + '</p>';
26956
+ }
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>';
26960
+ html += '</div>';
26961
+
26962
+ // Used-by triggers
26963
+ if (Array.isArray(s.usedByTriggers) && s.usedByTriggers.length > 0) {
26964
+ html += '<div style="margin-bottom:18px;padding:12px 14px;background:var(--bg-tertiary);border-radius:6px">';
26965
+ 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
+ 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>';
26969
+ }
26970
+ html += '</div></div>';
26971
+ }
26972
+
26973
+ // V1 fields (only render when present)
26974
+ if (fm.inputs && Object.keys(fm.inputs).length > 0) {
26975
+ html += renderSkillSection('Inputs', renderSkillInputs(fm.inputs));
26976
+ }
26977
+ if (fm.tools && (fm.tools.allow?.length || fm.tools.deny?.length)) {
26978
+ html += renderSkillSection('Tools', renderSkillTools(fm.tools));
26979
+ }
26980
+ if (Array.isArray(fm.dataSources) && fm.dataSources.length > 0) {
26981
+ html += renderSkillSection('Data sources', renderSkillDataSources(fm.dataSources));
26982
+ }
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>');
26985
+ }
26986
+ if (fm.success && (fm.success.criterion || fm.success.schema)) {
26987
+ 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>';
26990
+ html += renderSkillSection('Success criterion', sc);
26991
+ }
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>');
26999
+ }
27000
+
27001
+ // Legacy fields (preserved for the migration UI)
27002
+ if (Array.isArray(fm.triggers) && fm.triggers.length > 0) {
27003
+ html += renderSkillSection('Triggers (legacy NLP phrases)',
27004
+ '<div style="display:flex;flex-wrap:wrap;gap:4px">'
27005
+ + fm.triggers.slice(0, 30).map(function(t) { return '<span style="font-size:11px;background:var(--bg-tertiary);padding:2px 6px;border-radius:3px;color:var(--text-secondary)">' + esc(t) + '</span>'; }).join('')
27006
+ + (fm.triggers.length > 30 ? '<span style="font-size:11px;color:var(--text-muted)">+' + (fm.triggers.length - 30) + ' more</span>' : '')
27007
+ + '</div>');
27008
+ }
27009
+ if (Array.isArray(fm.toolsUsed) && fm.toolsUsed.length > 0) {
27010
+ html += renderSkillSection('Tools used (legacy informational)',
27011
+ '<div style="display:flex;flex-wrap:wrap;gap:4px">'
27012
+ + 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
+ + '</div>');
27014
+ }
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>');
27020
+ }
27021
+
27022
+ // Body — markdown rendered as preformatted text. Phase B will add a
27023
+ // proper markdown renderer; Phase A keeps it simple to ship.
27024
+ if (s.body && s.body.trim()) {
27025
+ 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>';
27027
+ 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
+ html += '</div>';
27029
+ }
27030
+
27031
+ 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">'
27033
+ + '<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).'
27036
+ + '</div>';
27037
+ }
27038
+
27039
+ html += '</div>';
27040
+ return html;
27041
+ }
27042
+
27043
+ function renderSkillSection(title, body) {
27044
+ return '<div style="margin-bottom:18px">'
27045
+ + '<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>'
27046
+ + body
27047
+ + '</div>';
27048
+ }
27049
+
27050
+ function renderSkillInputs(inputs) {
27051
+ var html = '<table style="width:100%;font-size:12px;border-collapse:collapse">';
27052
+ html += '<thead><tr style="border-bottom:1px solid var(--border);text-align:left">'
27053
+ + '<th style="padding:6px 8px;font-weight:500;color:var(--text-muted);font-size:10px;text-transform:uppercase">Field</th>'
27054
+ + '<th style="padding:6px 8px;font-weight:500;color:var(--text-muted);font-size:10px;text-transform:uppercase">Type</th>'
27055
+ + '<th style="padding:6px 8px;font-weight:500;color:var(--text-muted);font-size:10px;text-transform:uppercase">Default</th>'
27056
+ + '</tr></thead><tbody>';
27057
+ Object.keys(inputs).forEach(function(key) {
27058
+ var spec = inputs[key] || {};
27059
+ html += '<tr style="border-bottom:1px solid var(--border-light)">';
27060
+ html += '<td style="padding:6px 8px"><code style="font-size:11px">' + esc(key) + '</code></td>';
27061
+ html += '<td style="padding:6px 8px;color:var(--text-secondary)">' + esc(spec.type || 'any') + '</td>';
27062
+ html += '<td style="padding:6px 8px;color:var(--text-muted);font-size:11px">' + (spec.default !== undefined ? '<code>' + esc(JSON.stringify(spec.default)) + '</code>' : '—') + '</td>';
27063
+ html += '</tr>';
27064
+ if (spec.description) {
27065
+ html += '<tr><td colspan="3" style="padding:0 8px 6px 16px;font-size:11px;color:var(--text-muted);font-style:italic">' + esc(spec.description) + '</td></tr>';
27066
+ }
27067
+ });
27068
+ html += '</tbody></table>';
27069
+ return html;
27070
+ }
27071
+
27072
+ function renderSkillTools(tools) {
27073
+ var html = '';
27074
+ if (Array.isArray(tools.allow) && tools.allow.length > 0) {
27075
+ html += '<div style="margin-bottom:6px;font-size:11px;color:var(--text-muted)">Allow:</div>';
27076
+ html += '<div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px">'
27077
+ + tools.allow.map(function(t) { return '<code style="font-size:11px;background:var(--green)15;color:var(--green);padding:2px 6px;border-radius:3px">' + esc(t) + '</code>'; }).join('')
27078
+ + '</div>';
27079
+ }
27080
+ if (Array.isArray(tools.deny) && tools.deny.length > 0) {
27081
+ html += '<div style="margin-bottom:6px;font-size:11px;color:var(--text-muted)">Deny:</div>';
27082
+ html += '<div style="display:flex;flex-wrap:wrap;gap:4px">'
27083
+ + tools.deny.map(function(t) { return '<code style="font-size:11px;background:var(--red)15;color:var(--red);padding:2px 6px;border-radius:3px">' + esc(t) + '</code>'; }).join('')
27084
+ + '</div>';
27085
+ }
27086
+ return html;
27087
+ }
27088
+
27089
+ function renderSkillDataSources(sources) {
27090
+ return '<ul style="list-style:none;padding:0;margin:0;font-size:12px">'
27091
+ + sources.map(function(d) {
27092
+ return '<li style="padding:4px 0;border-bottom:1px solid var(--border-light)">'
27093
+ + '<code style="font-size:11px;background:var(--bg-tertiary);padding:1px 6px;border-radius:3px;margin-right:8px">' + esc(d.kind) + '</code>'
27094
+ + '<span style="color:var(--text-secondary)">' + esc(d.purpose) + '</span>'
27095
+ + '</li>';
27096
+ }).join('')
27097
+ + '</ul>';
27098
+ }
27099
+
26733
27100
  async function refreshHeartbeatControl() {
26734
27101
  var container = document.getElementById('heartbeat-control-content');
26735
27102
  if (!container) return;
package/dist/types.d.ts CHANGED
@@ -309,6 +309,118 @@ 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. */
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. */
319
+ export interface SkillInputSchema {
320
+ type?: 'string' | 'integer' | 'number' | 'boolean' | 'array' | 'object';
321
+ description?: string;
322
+ default?: unknown;
323
+ enum?: unknown[];
324
+ minimum?: number;
325
+ maximum?: number;
326
+ minLength?: number;
327
+ maxLength?: number;
328
+ pattern?: string;
329
+ items?: SkillInputSchema;
330
+ properties?: Record<string, SkillInputSchema>;
331
+ required?: string[];
332
+ }
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. */
337
+ export interface SkillDataSource {
338
+ /** A loose identifier — e.g. 'outlook', 'memory', 'vault', 'cli', 'mcp:ElevenLabs'. */
339
+ kind: string;
340
+ /** One-line human description — what the skill reads from this source. */
341
+ purpose: string;
342
+ }
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. */
346
+ export interface SkillToolPolicy {
347
+ allow?: string[];
348
+ deny?: string[];
349
+ }
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. */
353
+ export interface SkillSuccess {
354
+ schema?: SkillInputSchema;
355
+ criterion?: string;
356
+ }
357
+ /** Per-skill caps. A trigger can tighten these but never loosen. */
358
+ export interface SkillLimits {
359
+ maxTurns?: number;
360
+ maxBudgetUsd?: number;
361
+ timeoutSeconds?: number;
362
+ }
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;
370
+ /** Typed parameters the skill accepts at invocation time. */
371
+ inputs?: Record<string, SkillInputSchema>;
372
+ /** Tool allowlist + denylist enforced by the runtime. */
373
+ tools?: SkillToolPolicy;
374
+ /** Where the skill reads data from — purely declarative. */
375
+ dataSources?: SkillDataSource[];
376
+ /** State.* keys this skill owns (others can't touch them). */
377
+ stateKeys?: string[];
378
+ /** Success criterion — schema and/or free-text evaluator. */
379
+ success?: SkillSuccess;
380
+ /** Caps the trigger can tighten but never loosen. */
381
+ limits?: SkillLimits;
382
+ /** Bumped by the user on Publish (Phase B). Phase A surfaces but
383
+ * doesn't increment. */
384
+ version?: number;
385
+ /** Timestamps captured by the legacy + v1 schemas alike. */
386
+ createdAt?: string;
387
+ updatedAt?: string;
388
+ lastUsed?: string;
389
+ /** Last time the user clicked "Test this skill" in the dashboard
390
+ * (Phase B). Phase A reads but doesn't write. */
391
+ lastTestPass?: string;
392
+ /** Legacy: title (use as fallback for display when description missing). */
393
+ title?: string;
394
+ /** Legacy: NLP-style trigger phrases. Pre-redesign Clementine matched
395
+ * these against incoming chat messages. */
396
+ triggers?: string[];
397
+ /** Legacy: 'manual' / 'auto' / 'imported' — provenance label. */
398
+ source?: string;
399
+ /** Legacy: tools observed during runs. Informational, not constraint. */
400
+ toolsUsed?: string[];
401
+ /** Legacy: incrementing counter of how many runs invoked the skill. */
402
+ useCount?: number;
403
+ }
404
+ /** Resolved skill record — frontmatter + body + computed extras the
405
+ * dashboard surfaces (file path, scope, schemaVersion, used-by list). */
406
+ export interface Skill {
407
+ /** Frontmatter, parsed (or synthesized for files without one). */
408
+ frontmatter: SkillFrontmatter;
409
+ /** Markdown body of the skill — the actual procedure. */
410
+ body: string;
411
+ /** Absolute path to the source .md file. */
412
+ filePath: string;
413
+ /** Whether this skill was loaded from the global pool or a per-project
414
+ * override. Per-project wins on name collision. */
415
+ 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. */
418
+ 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. */
422
+ usedByTriggers: string[];
423
+ }
312
424
  export interface CronJobDefinition {
313
425
  name: string;
314
426
  schedule: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.18.105",
3
+ "version": "1.18.107",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",