clementine-agent 1.18.118 → 1.18.120
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/cron-migrator.d.ts +123 -0
- package/dist/agent/cron-migrator.js +275 -0
- package/dist/cli/dashboard.js +325 -1
- package/dist/gateway/cron-scheduler.js +14 -2
- package/dist/tools/mcp-server.js +2 -0
- package/dist/tools/skill-tools.d.ts +22 -0
- package/dist/tools/skill-tools.js +243 -0
- package/dist/types.d.ts +5 -0
- package/package.json +1 -1
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cron-clean migrator (1.18.119)
|
|
3
|
+
*
|
|
4
|
+
* Migrates legacy cron jobs to the cleaner Skills-First contract model
|
|
5
|
+
* without changing what the job DOES. Pure function — takes a
|
|
6
|
+
* CronJobDefinition + the available skills inventory, returns the
|
|
7
|
+
* migrated job + a list of human-readable changes + an eligibility flag.
|
|
8
|
+
*
|
|
9
|
+
* What "legacy" means in user-collected data:
|
|
10
|
+
* - `predictable` is undefined or false → at fire-time the runner injects
|
|
11
|
+
* MEMORY.md, recent team activity, the delegation queue, and auto-
|
|
12
|
+
* matches MCP servers based on prompt text.
|
|
13
|
+
* - The prompt body opens with a "TOOL RESTRICTIONS — MANDATORY" preamble
|
|
14
|
+
* listing forbidden + allowed tools in prose. This is leftover from
|
|
15
|
+
* before the runtime allowedTools field existed.
|
|
16
|
+
* - No skill is pinned even though the runtime auto-matches one (or
|
|
17
|
+
* several) every fire. The match is not deterministic and the user
|
|
18
|
+
* can't see what's being chosen.
|
|
19
|
+
* - No description field — the card shows the first 200 chars of the
|
|
20
|
+
* prompt, which is often the tool-restriction boilerplate.
|
|
21
|
+
*
|
|
22
|
+
* What "clean" looks like:
|
|
23
|
+
* - `predictable: true` (contract mode — runs only with what's attached).
|
|
24
|
+
* - `description:` populated (auto-generated from the matching skill's
|
|
25
|
+
* description if there is one, else the first sentence of the
|
|
26
|
+
* non-restriction prompt body).
|
|
27
|
+
* - `skills: [<name>]` pinned when a clear match exists.
|
|
28
|
+
* - `allowed_tools: [...]` pulled out of the "ALLOWED tools (the COMPLETE
|
|
29
|
+
* list)" line of the legacy preamble.
|
|
30
|
+
* - `prompt:` reduced to either "Run the {skill name} skill." (when a
|
|
31
|
+
* skill is pinned) or just the cleaned procedural part (when no skill
|
|
32
|
+
* match — preamble stripped).
|
|
33
|
+
*
|
|
34
|
+
* Eligibility — a job is eligible when it has at least one of:
|
|
35
|
+
* - the legacy preamble in its prompt, OR
|
|
36
|
+
* - predictable: undefined/false AND a candidate skill match.
|
|
37
|
+
* If neither, the migrator returns `eligible: false` and leaves the job
|
|
38
|
+
* unchanged. This keeps "ones that don't need to be fixed" out of the
|
|
39
|
+
* migration UI entirely.
|
|
40
|
+
*/
|
|
41
|
+
import type { CronJobDefinition, Skill } from '../types.js';
|
|
42
|
+
export interface CronMigrationResult {
|
|
43
|
+
/** True when migration would change something. False = "skip this one". */
|
|
44
|
+
eligible: boolean;
|
|
45
|
+
/** The migrated job (or the input job, untouched, when ineligible). */
|
|
46
|
+
migrated: CronJobDefinition;
|
|
47
|
+
/** Human-readable bullets — what the migrator did. Empty when not eligible. */
|
|
48
|
+
changes: string[];
|
|
49
|
+
/** When a skill match was applied, the matched skill's name. Surfaces in
|
|
50
|
+
* the migration UI so the user can verify. */
|
|
51
|
+
matchedSkill?: string;
|
|
52
|
+
/** Brief explanation when eligible:false — drives the dashboard's "this
|
|
53
|
+
* task already looks clean — nothing to do" affordance. */
|
|
54
|
+
notEligibleReason?: string;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* When the prompt opens with the canonical "TOOL RESTRICTIONS — MANDATORY"
|
|
58
|
+
* preamble, this returns:
|
|
59
|
+
* - allowedTools: the parsed list of allowed tools (from the "ALLOWED
|
|
60
|
+
* tools (the COMPLETE list):" line)
|
|
61
|
+
* - cleanedPrompt: the rest of the prompt with the preamble removed
|
|
62
|
+
* and trimmed.
|
|
63
|
+
* When no preamble is found, returns null and the caller treats the prompt
|
|
64
|
+
* as already-clean.
|
|
65
|
+
*/
|
|
66
|
+
export declare function stripToolRestrictionsPreamble(rawPrompt: string): {
|
|
67
|
+
allowedTools: string[];
|
|
68
|
+
cleanedPrompt: string;
|
|
69
|
+
} | null;
|
|
70
|
+
/**
|
|
71
|
+
* Returns a skill from the catalog that matches this job by either:
|
|
72
|
+
* 1. Exact name match (job "morning-briefing" → skill "morning-briefing")
|
|
73
|
+
* 2. Job name appears in skill triggers (job "audit-inbox-check" →
|
|
74
|
+
* skill "checking-audit-inbox" with trigger "audit inbox check")
|
|
75
|
+
* 3. Job-name keyword overlap with skill description (looser, last resort).
|
|
76
|
+
*
|
|
77
|
+
* Returns null when no clear match exists. The migrator then leaves the
|
|
78
|
+
* skill pin off and just strips the preamble — still an improvement.
|
|
79
|
+
*/
|
|
80
|
+
export declare function findMatchingSkill(job: CronJobDefinition, skills: Skill[]): Skill | null;
|
|
81
|
+
/**
|
|
82
|
+
* Picks a 1-paragraph description for the migrated job.
|
|
83
|
+
* Priority:
|
|
84
|
+
* 1. The matched skill's description (best — it's already curated).
|
|
85
|
+
* 2. First sentence of the cleaned prompt body, ≤240 chars.
|
|
86
|
+
* 3. Empty string when prompt is empty or unintelligible — the caller
|
|
87
|
+
* then leaves the description field unset.
|
|
88
|
+
*/
|
|
89
|
+
export declare function generateDescription(cleanedPrompt: string, matchedSkill: Skill | null): string;
|
|
90
|
+
/**
|
|
91
|
+
* Apply the clean-format migration to one cron job. Pure function —
|
|
92
|
+
* does NOT touch disk. Returns the new definition + the list of changes.
|
|
93
|
+
*
|
|
94
|
+
* Idempotent: passing an already-clean job back through returns
|
|
95
|
+
* `eligible: false` with `notEligibleReason`. Safe to call on every
|
|
96
|
+
* job in a vault and filter by eligibility.
|
|
97
|
+
*
|
|
98
|
+
* The caller (an HTTP endpoint or CLI) is responsible for:
|
|
99
|
+
* - serializing the result back to YAML
|
|
100
|
+
* - writing the .bak file
|
|
101
|
+
* - replacing the job in CRON.md
|
|
102
|
+
* - returning the diff to the dashboard
|
|
103
|
+
*/
|
|
104
|
+
export declare function migrateCronJob(job: CronJobDefinition, skills: Skill[]): CronMigrationResult;
|
|
105
|
+
/**
|
|
106
|
+
* Run migrateCronJob across an entire CRON.md inventory. Returns:
|
|
107
|
+
* - eligible: jobs that would be migrated, with their migration result
|
|
108
|
+
* - skipped: jobs that look already-clean (eligibility=false)
|
|
109
|
+
*
|
|
110
|
+
* Used by the bulk-migrate UI to preview the full impact before the user
|
|
111
|
+
* commits to changes.
|
|
112
|
+
*/
|
|
113
|
+
export declare function migrateAllEligibleJobs(jobs: CronJobDefinition[], skills: Skill[]): {
|
|
114
|
+
eligible: Array<{
|
|
115
|
+
job: CronJobDefinition;
|
|
116
|
+
result: CronMigrationResult;
|
|
117
|
+
}>;
|
|
118
|
+
skipped: Array<{
|
|
119
|
+
job: CronJobDefinition;
|
|
120
|
+
reason: string;
|
|
121
|
+
}>;
|
|
122
|
+
};
|
|
123
|
+
//# sourceMappingURL=cron-migrator.d.ts.map
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cron-clean migrator (1.18.119)
|
|
3
|
+
*
|
|
4
|
+
* Migrates legacy cron jobs to the cleaner Skills-First contract model
|
|
5
|
+
* without changing what the job DOES. Pure function — takes a
|
|
6
|
+
* CronJobDefinition + the available skills inventory, returns the
|
|
7
|
+
* migrated job + a list of human-readable changes + an eligibility flag.
|
|
8
|
+
*
|
|
9
|
+
* What "legacy" means in user-collected data:
|
|
10
|
+
* - `predictable` is undefined or false → at fire-time the runner injects
|
|
11
|
+
* MEMORY.md, recent team activity, the delegation queue, and auto-
|
|
12
|
+
* matches MCP servers based on prompt text.
|
|
13
|
+
* - The prompt body opens with a "TOOL RESTRICTIONS — MANDATORY" preamble
|
|
14
|
+
* listing forbidden + allowed tools in prose. This is leftover from
|
|
15
|
+
* before the runtime allowedTools field existed.
|
|
16
|
+
* - No skill is pinned even though the runtime auto-matches one (or
|
|
17
|
+
* several) every fire. The match is not deterministic and the user
|
|
18
|
+
* can't see what's being chosen.
|
|
19
|
+
* - No description field — the card shows the first 200 chars of the
|
|
20
|
+
* prompt, which is often the tool-restriction boilerplate.
|
|
21
|
+
*
|
|
22
|
+
* What "clean" looks like:
|
|
23
|
+
* - `predictable: true` (contract mode — runs only with what's attached).
|
|
24
|
+
* - `description:` populated (auto-generated from the matching skill's
|
|
25
|
+
* description if there is one, else the first sentence of the
|
|
26
|
+
* non-restriction prompt body).
|
|
27
|
+
* - `skills: [<name>]` pinned when a clear match exists.
|
|
28
|
+
* - `allowed_tools: [...]` pulled out of the "ALLOWED tools (the COMPLETE
|
|
29
|
+
* list)" line of the legacy preamble.
|
|
30
|
+
* - `prompt:` reduced to either "Run the {skill name} skill." (when a
|
|
31
|
+
* skill is pinned) or just the cleaned procedural part (when no skill
|
|
32
|
+
* match — preamble stripped).
|
|
33
|
+
*
|
|
34
|
+
* Eligibility — a job is eligible when it has at least one of:
|
|
35
|
+
* - the legacy preamble in its prompt, OR
|
|
36
|
+
* - predictable: undefined/false AND a candidate skill match.
|
|
37
|
+
* If neither, the migrator returns `eligible: false` and leaves the job
|
|
38
|
+
* unchanged. This keeps "ones that don't need to be fixed" out of the
|
|
39
|
+
* migration UI entirely.
|
|
40
|
+
*/
|
|
41
|
+
// ── Preamble detection + parsing ──────────────────────────────────────
|
|
42
|
+
const PREAMBLE_RE = /^TOOL RESTRICTIONS\s*[—-]\s*MANDATORY[\s\S]*?(?:\n\n|\r\n\r\n)/i;
|
|
43
|
+
/**
|
|
44
|
+
* When the prompt opens with the canonical "TOOL RESTRICTIONS — MANDATORY"
|
|
45
|
+
* preamble, this returns:
|
|
46
|
+
* - allowedTools: the parsed list of allowed tools (from the "ALLOWED
|
|
47
|
+
* tools (the COMPLETE list):" line)
|
|
48
|
+
* - cleanedPrompt: the rest of the prompt with the preamble removed
|
|
49
|
+
* and trimmed.
|
|
50
|
+
* When no preamble is found, returns null and the caller treats the prompt
|
|
51
|
+
* as already-clean.
|
|
52
|
+
*/
|
|
53
|
+
export function stripToolRestrictionsPreamble(rawPrompt) {
|
|
54
|
+
if (!rawPrompt)
|
|
55
|
+
return null;
|
|
56
|
+
const m = rawPrompt.match(PREAMBLE_RE);
|
|
57
|
+
if (!m)
|
|
58
|
+
return null;
|
|
59
|
+
const preamble = m[0];
|
|
60
|
+
const cleanedPrompt = rawPrompt.slice(preamble.length).trim();
|
|
61
|
+
// Extract the "ALLOWED tools (the COMPLETE list): a, b, c." line.
|
|
62
|
+
// Tolerant of variations: "ALLOWED — list:", "ALLOWED tools list:", etc.
|
|
63
|
+
const allowedMatch = preamble.match(/ALLOWED[\s\S]*?(?:list|tools)\s*\)?\s*:\s*([^.\n]+)/i);
|
|
64
|
+
const allowedTools = [];
|
|
65
|
+
if (allowedMatch && allowedMatch[1]) {
|
|
66
|
+
const list = allowedMatch[1]
|
|
67
|
+
.split(/[,;]/)
|
|
68
|
+
.map((s) => s.trim().replace(/[.,;:]+$/, ''))
|
|
69
|
+
.filter(Boolean)
|
|
70
|
+
.filter((s) => /^[a-zA-Z][\w-]*$/.test(s) || /^mcp__[\w-]+/.test(s));
|
|
71
|
+
allowedTools.push(...list);
|
|
72
|
+
}
|
|
73
|
+
return { allowedTools, cleanedPrompt };
|
|
74
|
+
}
|
|
75
|
+
// ── Skill matching ────────────────────────────────────────────────────
|
|
76
|
+
/**
|
|
77
|
+
* Returns a skill from the catalog that matches this job by either:
|
|
78
|
+
* 1. Exact name match (job "morning-briefing" → skill "morning-briefing")
|
|
79
|
+
* 2. Job name appears in skill triggers (job "audit-inbox-check" →
|
|
80
|
+
* skill "checking-audit-inbox" with trigger "audit inbox check")
|
|
81
|
+
* 3. Job-name keyword overlap with skill description (looser, last resort).
|
|
82
|
+
*
|
|
83
|
+
* Returns null when no clear match exists. The migrator then leaves the
|
|
84
|
+
* skill pin off and just strips the preamble — still an improvement.
|
|
85
|
+
*/
|
|
86
|
+
export function findMatchingSkill(job, skills) {
|
|
87
|
+
const jobName = job.name.toLowerCase();
|
|
88
|
+
const jobNameWords = new Set(jobName.split(/[-_]/).filter((w) => w.length > 2));
|
|
89
|
+
// 1. Exact name match
|
|
90
|
+
for (const s of skills) {
|
|
91
|
+
if (s.frontmatter.name.toLowerCase() === jobName)
|
|
92
|
+
return s;
|
|
93
|
+
}
|
|
94
|
+
// 2. Trigger phrase contains the job-name word set
|
|
95
|
+
for (const s of skills) {
|
|
96
|
+
const triggers = s.frontmatter.clementine?.triggers ?? [];
|
|
97
|
+
for (const trigger of triggers) {
|
|
98
|
+
const triggerWords = String(trigger).toLowerCase().split(/\s+/);
|
|
99
|
+
const triggerWordSet = new Set(triggerWords);
|
|
100
|
+
// Match if all job-name words appear in trigger words (in any order)
|
|
101
|
+
let allMatch = true;
|
|
102
|
+
for (const w of jobNameWords) {
|
|
103
|
+
if (!triggerWordSet.has(w)) {
|
|
104
|
+
allMatch = false;
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (allMatch && jobNameWords.size > 0)
|
|
109
|
+
return s;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// 3. Job-name word overlap with skill name (substring fallback —
|
|
113
|
+
// catches "audit-inbox-check" → "checking-audit-inbox" via shared
|
|
114
|
+
// "audit" + "inbox" word tokens).
|
|
115
|
+
for (const s of skills) {
|
|
116
|
+
const skillNameWords = new Set(s.frontmatter.name.toLowerCase().split(/[-_]/).filter((w) => w.length > 2));
|
|
117
|
+
let overlap = 0;
|
|
118
|
+
for (const w of jobNameWords)
|
|
119
|
+
if (skillNameWords.has(w))
|
|
120
|
+
overlap++;
|
|
121
|
+
// Need ≥2 overlapping content words for a confident match (avoids
|
|
122
|
+
// matching every "build-*" job to a single "build" skill).
|
|
123
|
+
if (overlap >= 2)
|
|
124
|
+
return s;
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
// ── Description generation ────────────────────────────────────────────
|
|
129
|
+
/**
|
|
130
|
+
* Picks a 1-paragraph description for the migrated job.
|
|
131
|
+
* Priority:
|
|
132
|
+
* 1. The matched skill's description (best — it's already curated).
|
|
133
|
+
* 2. First sentence of the cleaned prompt body, ≤240 chars.
|
|
134
|
+
* 3. Empty string when prompt is empty or unintelligible — the caller
|
|
135
|
+
* then leaves the description field unset.
|
|
136
|
+
*/
|
|
137
|
+
export function generateDescription(cleanedPrompt, matchedSkill) {
|
|
138
|
+
if (matchedSkill?.frontmatter.description) {
|
|
139
|
+
return matchedSkill.frontmatter.description.trim().slice(0, 500);
|
|
140
|
+
}
|
|
141
|
+
if (!cleanedPrompt)
|
|
142
|
+
return '';
|
|
143
|
+
// First sentence — match up to ., !, ?, or first newline-newline.
|
|
144
|
+
// Falls back to first 200 chars when the body is one giant paragraph.
|
|
145
|
+
const sentenceMatch = cleanedPrompt.match(/^[\s\S]*?[.!?](?=\s|$)/);
|
|
146
|
+
const candidate = (sentenceMatch ? sentenceMatch[0] : cleanedPrompt.slice(0, 200))
|
|
147
|
+
.trim()
|
|
148
|
+
.replace(/\s+/g, ' ');
|
|
149
|
+
return candidate.slice(0, 240);
|
|
150
|
+
}
|
|
151
|
+
// ── The migration itself ──────────────────────────────────────────────
|
|
152
|
+
/**
|
|
153
|
+
* Apply the clean-format migration to one cron job. Pure function —
|
|
154
|
+
* does NOT touch disk. Returns the new definition + the list of changes.
|
|
155
|
+
*
|
|
156
|
+
* Idempotent: passing an already-clean job back through returns
|
|
157
|
+
* `eligible: false` with `notEligibleReason`. Safe to call on every
|
|
158
|
+
* job in a vault and filter by eligibility.
|
|
159
|
+
*
|
|
160
|
+
* The caller (an HTTP endpoint or CLI) is responsible for:
|
|
161
|
+
* - serializing the result back to YAML
|
|
162
|
+
* - writing the .bak file
|
|
163
|
+
* - replacing the job in CRON.md
|
|
164
|
+
* - returning the diff to the dashboard
|
|
165
|
+
*/
|
|
166
|
+
export function migrateCronJob(job, skills) {
|
|
167
|
+
const changes = [];
|
|
168
|
+
// 1. Detect the legacy preamble.
|
|
169
|
+
const stripped = stripToolRestrictionsPreamble(job.prompt);
|
|
170
|
+
const hasPreamble = stripped !== null;
|
|
171
|
+
// 2. Find a matching skill (or null).
|
|
172
|
+
const matchedSkill = findMatchingSkill(job, skills);
|
|
173
|
+
// 3. Eligibility — anything that looks legacy is eligible.
|
|
174
|
+
const isLegacyPredictable = job.predictable !== true;
|
|
175
|
+
const hasNoDescription = !job.description || !job.description.trim();
|
|
176
|
+
const hasNoSkillsButCouldPinOne = (!job.skills || job.skills.length === 0) && matchedSkill !== null;
|
|
177
|
+
if (!hasPreamble && !isLegacyPredictable && !hasNoDescription && !hasNoSkillsButCouldPinOne) {
|
|
178
|
+
return {
|
|
179
|
+
eligible: false,
|
|
180
|
+
migrated: job,
|
|
181
|
+
changes: [],
|
|
182
|
+
matchedSkill: matchedSkill?.frontmatter.name,
|
|
183
|
+
notEligibleReason: 'Already clean — has predictable=true, a description, no legacy preamble, and either pinned skills or no skill match.',
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
// 4. Build the migrated definition. Start from a shallow copy so we
|
|
187
|
+
// don't mutate the input.
|
|
188
|
+
const migrated = { ...job };
|
|
189
|
+
// 4a. Set predictable: true (the headline contract-mode flip).
|
|
190
|
+
if (isLegacyPredictable) {
|
|
191
|
+
migrated.predictable = true;
|
|
192
|
+
changes.push('Enabled Strict mode (no auto-injected memory or team comms at fire-time).');
|
|
193
|
+
}
|
|
194
|
+
// 4b. Strip the preamble + populate allowed_tools.
|
|
195
|
+
let cleanedPrompt = job.prompt;
|
|
196
|
+
if (hasPreamble && stripped) {
|
|
197
|
+
cleanedPrompt = stripped.cleanedPrompt;
|
|
198
|
+
changes.push('Stripped TOOL RESTRICTIONS preamble from the prompt.');
|
|
199
|
+
if (stripped.allowedTools.length > 0) {
|
|
200
|
+
// Merge with any existing allowedTools so we don't lose tool-level
|
|
201
|
+
// narrowing the user already had.
|
|
202
|
+
const existing = new Set(job.allowedTools ?? []);
|
|
203
|
+
for (const t of stripped.allowedTools)
|
|
204
|
+
existing.add(t);
|
|
205
|
+
migrated.allowedTools = [...existing];
|
|
206
|
+
changes.push(`Moved ${stripped.allowedTools.length} tool name${stripped.allowedTools.length === 1 ? '' : 's'} into the allowed_tools field (${stripped.allowedTools.slice(0, 3).join(', ')}${stripped.allowedTools.length > 3 ? `, +${stripped.allowedTools.length - 3} more` : ''}).`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
// 4c. Pin the matched skill + replace the prompt with a thin invocation.
|
|
210
|
+
if (matchedSkill && (!job.skills || job.skills.length === 0)) {
|
|
211
|
+
const pinned = [matchedSkill.frontmatter.name];
|
|
212
|
+
migrated.skills = pinned;
|
|
213
|
+
changes.push(`Pinned matching skill: \`${matchedSkill.frontmatter.name}\` (was being auto-matched at fire-time).`);
|
|
214
|
+
// Reduce the prompt to a thin reference. The skill body carries the
|
|
215
|
+
// procedure; the cron prompt just needs to invoke it. Keep ANY
|
|
216
|
+
// post-preamble text the user added that wasn't already in the skill —
|
|
217
|
+
// when in doubt, prefer "Run the X skill." for a true clean migration.
|
|
218
|
+
const remainsBeyondSkill = (cleanedPrompt || '').trim();
|
|
219
|
+
const nameLabel = matchedSkill.frontmatter.title || matchedSkill.frontmatter.name;
|
|
220
|
+
migrated.prompt = `Run the ${nameLabel} skill.`;
|
|
221
|
+
if (remainsBeyondSkill && remainsBeyondSkill.length > 50) {
|
|
222
|
+
// The user had additional instructions after the preamble that aren't
|
|
223
|
+
// in the skill body. Append them so we don't lose intent. Length cap
|
|
224
|
+
// ensures we don't accidentally re-add the boilerplate we just stripped.
|
|
225
|
+
migrated.prompt += `\n\n${remainsBeyondSkill.slice(0, 1000)}`;
|
|
226
|
+
changes.push('Preserved additional prompt instructions after the skill invocation.');
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
else if (hasPreamble) {
|
|
230
|
+
// No skill match but we did strip the preamble — just save the
|
|
231
|
+
// cleaned prompt. Still an improvement: the card preview will now
|
|
232
|
+
// show actual instructions, not boilerplate.
|
|
233
|
+
migrated.prompt = cleanedPrompt.trim();
|
|
234
|
+
}
|
|
235
|
+
// 4d. Generate description if missing.
|
|
236
|
+
if (!job.description || !job.description.trim()) {
|
|
237
|
+
const desc = generateDescription(stripped?.cleanedPrompt || job.prompt, matchedSkill);
|
|
238
|
+
if (desc) {
|
|
239
|
+
migrated.description = desc;
|
|
240
|
+
changes.push(`Added description: "${desc.slice(0, 80)}${desc.length > 80 ? '…' : ''}".`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return {
|
|
244
|
+
eligible: changes.length > 0,
|
|
245
|
+
migrated,
|
|
246
|
+
changes,
|
|
247
|
+
matchedSkill: matchedSkill?.frontmatter.name,
|
|
248
|
+
notEligibleReason: changes.length === 0
|
|
249
|
+
? 'No legacy markers detected — task already follows the clean format.'
|
|
250
|
+
: undefined,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Run migrateCronJob across an entire CRON.md inventory. Returns:
|
|
255
|
+
* - eligible: jobs that would be migrated, with their migration result
|
|
256
|
+
* - skipped: jobs that look already-clean (eligibility=false)
|
|
257
|
+
*
|
|
258
|
+
* Used by the bulk-migrate UI to preview the full impact before the user
|
|
259
|
+
* commits to changes.
|
|
260
|
+
*/
|
|
261
|
+
export function migrateAllEligibleJobs(jobs, skills) {
|
|
262
|
+
const eligible = [];
|
|
263
|
+
const skipped = [];
|
|
264
|
+
for (const job of jobs) {
|
|
265
|
+
const result = migrateCronJob(job, skills);
|
|
266
|
+
if (result.eligible) {
|
|
267
|
+
eligible.push({ job, result });
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
skipped.push({ job, reason: result.notEligibleReason || 'Already clean.' });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return { eligible, skipped };
|
|
274
|
+
}
|
|
275
|
+
//# sourceMappingURL=cron-migrator.js.map
|
package/dist/cli/dashboard.js
CHANGED
|
@@ -4556,6 +4556,206 @@ export async function cmdDashboard(opts) {
|
|
|
4556
4556
|
res.status(500).json({ ok: false, error: String(err) });
|
|
4557
4557
|
}
|
|
4558
4558
|
});
|
|
4559
|
+
// ── Cron-clean migrator (1.18.119) ─────────────────────────────────
|
|
4560
|
+
// Three endpoints (matching the skill-migration pattern):
|
|
4561
|
+
// - GET /api/cron/migrate-preview — dry-run across every job
|
|
4562
|
+
// - POST /api/cron/:job/migrate — apply migration to one job
|
|
4563
|
+
// - POST /api/cron/migrate-all — apply to all eligible jobs
|
|
4564
|
+
// Every apply writes a `<basename>.bak` of the CRON.md before saving.
|
|
4565
|
+
app.get('/api/cron/migrate-preview', async (_req, res) => {
|
|
4566
|
+
try {
|
|
4567
|
+
const { migrateAllEligibleJobs } = await import('../agent/cron-migrator.js');
|
|
4568
|
+
const { listSkills } = await import('../agent/skill-store.js');
|
|
4569
|
+
const { parseCronJobs, parseAgentCronJobs } = await import('../gateway/cron-scheduler.js');
|
|
4570
|
+
const skills = listSkills();
|
|
4571
|
+
const jobs = [...parseCronJobs(), ...parseAgentCronJobs(path.join(VAULT_DIR, '00-System', 'agents'))];
|
|
4572
|
+
const out = migrateAllEligibleJobs(jobs, skills);
|
|
4573
|
+
// Trim the response — the dashboard doesn't need the full migrated job
|
|
4574
|
+
// body for the preview list, just the names + change bullets.
|
|
4575
|
+
res.json({
|
|
4576
|
+
ok: true,
|
|
4577
|
+
eligible: out.eligible.map(({ job, result }) => ({
|
|
4578
|
+
name: job.name,
|
|
4579
|
+
enabled: job.enabled,
|
|
4580
|
+
schedule: job.schedule,
|
|
4581
|
+
changes: result.changes,
|
|
4582
|
+
matchedSkill: result.matchedSkill ?? null,
|
|
4583
|
+
// Send a small before/after snippet so the UI can show a diff.
|
|
4584
|
+
before: { prompt: (job.prompt || '').slice(0, 600), predictable: job.predictable, description: job.description },
|
|
4585
|
+
after: {
|
|
4586
|
+
prompt: (result.migrated.prompt || '').slice(0, 600),
|
|
4587
|
+
predictable: result.migrated.predictable,
|
|
4588
|
+
description: result.migrated.description,
|
|
4589
|
+
skills: result.migrated.skills,
|
|
4590
|
+
allowedTools: result.migrated.allowedTools,
|
|
4591
|
+
},
|
|
4592
|
+
})),
|
|
4593
|
+
skipped: out.skipped.map(({ job, reason }) => ({ name: job.name, reason })),
|
|
4594
|
+
});
|
|
4595
|
+
}
|
|
4596
|
+
catch (err) {
|
|
4597
|
+
res.status(500).json({ ok: false, error: String(err) });
|
|
4598
|
+
}
|
|
4599
|
+
});
|
|
4600
|
+
app.post('/api/cron/:job/migrate', async (req, res) => {
|
|
4601
|
+
try {
|
|
4602
|
+
const jobName = req.params.job;
|
|
4603
|
+
if (!jobName) {
|
|
4604
|
+
res.status(400).json({ ok: false, error: 'job name required' });
|
|
4605
|
+
return;
|
|
4606
|
+
}
|
|
4607
|
+
const { migrateCronJob } = await import('../agent/cron-migrator.js');
|
|
4608
|
+
const { listSkills } = await import('../agent/skill-store.js');
|
|
4609
|
+
const { parseCronJobs, parseAgentCronJobs } = await import('../gateway/cron-scheduler.js');
|
|
4610
|
+
// Locate the source file + the job
|
|
4611
|
+
const { cronFile, bareJobName } = resolveJobCronFile(jobName);
|
|
4612
|
+
const allJobs = [...parseCronJobs(), ...parseAgentCronJobs(path.join(VAULT_DIR, '00-System', 'agents'))];
|
|
4613
|
+
const target = allJobs.find(j => String(j.name).toLowerCase() === jobName.toLowerCase());
|
|
4614
|
+
if (!target) {
|
|
4615
|
+
res.status(404).json({ ok: false, error: 'job "' + jobName + '" not found' });
|
|
4616
|
+
return;
|
|
4617
|
+
}
|
|
4618
|
+
const skills = listSkills();
|
|
4619
|
+
const result = migrateCronJob(target, skills);
|
|
4620
|
+
if (!result.eligible) {
|
|
4621
|
+
res.json({ ok: true, eligible: false, reason: result.notEligibleReason });
|
|
4622
|
+
return;
|
|
4623
|
+
}
|
|
4624
|
+
// Write back. .bak is produced by writeCronFileAt's own backup if any —
|
|
4625
|
+
// here we add one explicitly so the user has a rollback artifact.
|
|
4626
|
+
const bakPath = cronFile + '.bak';
|
|
4627
|
+
try {
|
|
4628
|
+
writeFileSync(bakPath, readFileSync(cronFile, 'utf-8'));
|
|
4629
|
+
}
|
|
4630
|
+
catch { /* best-effort */ }
|
|
4631
|
+
const { parsed, jobs } = readCronFileAt(cronFile);
|
|
4632
|
+
// Find by bare name (job in the YAML uses bareJobName, not the global "agent:job" form)
|
|
4633
|
+
const idx = jobs.findIndex(j => String(j.name).toLowerCase() === bareJobName.toLowerCase());
|
|
4634
|
+
if (idx < 0) {
|
|
4635
|
+
res.status(404).json({ ok: false, error: 'job not in CRON.md (resolved to "' + bareJobName + '")' });
|
|
4636
|
+
return;
|
|
4637
|
+
}
|
|
4638
|
+
// Convert the migrated CronJobDefinition back to YAML-friendly shape.
|
|
4639
|
+
// CRITICAL: agent CRON.md stores BARE job names (no agent: prefix);
|
|
4640
|
+
// parseAgentCronJobs adds the prefix at read time. Writing m.name
|
|
4641
|
+
// back here would double-prefix on the next read. Use bareJobName.
|
|
4642
|
+
const m = result.migrated;
|
|
4643
|
+
const next = { ...jobs[idx] };
|
|
4644
|
+
next.name = bareJobName;
|
|
4645
|
+
next.schedule = m.schedule;
|
|
4646
|
+
next.prompt = m.prompt;
|
|
4647
|
+
next.enabled = m.enabled;
|
|
4648
|
+
next.tier = m.tier;
|
|
4649
|
+
if (m.description)
|
|
4650
|
+
next.description = m.description;
|
|
4651
|
+
else
|
|
4652
|
+
delete next.description;
|
|
4653
|
+
if (m.predictable === true)
|
|
4654
|
+
next.predictable = true;
|
|
4655
|
+
if (m.skills && m.skills.length > 0)
|
|
4656
|
+
next.skills = m.skills;
|
|
4657
|
+
if (m.allowedTools && m.allowedTools.length > 0)
|
|
4658
|
+
next.allowed_tools = m.allowedTools;
|
|
4659
|
+
if (m.allowedMcpServers && m.allowedMcpServers.length > 0)
|
|
4660
|
+
next.allowed_mcp_servers = m.allowedMcpServers;
|
|
4661
|
+
jobs[idx] = next;
|
|
4662
|
+
writeCronFileAt(cronFile, parsed, jobs);
|
|
4663
|
+
res.json({
|
|
4664
|
+
ok: true,
|
|
4665
|
+
eligible: true,
|
|
4666
|
+
name: jobName,
|
|
4667
|
+
cronFile,
|
|
4668
|
+
bakPath,
|
|
4669
|
+
changes: result.changes,
|
|
4670
|
+
matchedSkill: result.matchedSkill ?? null,
|
|
4671
|
+
});
|
|
4672
|
+
}
|
|
4673
|
+
catch (err) {
|
|
4674
|
+
res.status(500).json({ ok: false, error: String(err) });
|
|
4675
|
+
}
|
|
4676
|
+
});
|
|
4677
|
+
app.post('/api/cron/migrate-all', async (_req, res) => {
|
|
4678
|
+
try {
|
|
4679
|
+
const { migrateAllEligibleJobs } = await import('../agent/cron-migrator.js');
|
|
4680
|
+
const { listSkills } = await import('../agent/skill-store.js');
|
|
4681
|
+
const { parseCronJobs, parseAgentCronJobs } = await import('../gateway/cron-scheduler.js');
|
|
4682
|
+
const agentsDir = path.join(VAULT_DIR, '00-System', 'agents');
|
|
4683
|
+
const skills = listSkills();
|
|
4684
|
+
const allJobs = [...parseCronJobs(), ...parseAgentCronJobs(agentsDir)];
|
|
4685
|
+
const out = migrateAllEligibleJobs(allJobs, skills);
|
|
4686
|
+
const applied = [];
|
|
4687
|
+
const failed = [];
|
|
4688
|
+
const touchedFiles = new Set();
|
|
4689
|
+
// Group writes by file so we open + parse + serialize each file once.
|
|
4690
|
+
const byFile = new Map();
|
|
4691
|
+
for (const { job, result } of out.eligible) {
|
|
4692
|
+
const { cronFile, bareJobName } = resolveJobCronFile(job.name);
|
|
4693
|
+
if (!byFile.has(cronFile))
|
|
4694
|
+
byFile.set(cronFile, []);
|
|
4695
|
+
byFile.get(cronFile).push({ bareName: bareJobName, result });
|
|
4696
|
+
}
|
|
4697
|
+
for (const [cronFile, items] of byFile.entries()) {
|
|
4698
|
+
try {
|
|
4699
|
+
// .bak the file before any edits so a single rollback restores it.
|
|
4700
|
+
const bakPath = cronFile + '.bak';
|
|
4701
|
+
try {
|
|
4702
|
+
writeFileSync(bakPath, readFileSync(cronFile, 'utf-8'));
|
|
4703
|
+
}
|
|
4704
|
+
catch { /* best-effort */ }
|
|
4705
|
+
const { parsed, jobs } = readCronFileAt(cronFile);
|
|
4706
|
+
for (const { bareName, result } of items) {
|
|
4707
|
+
const idx = jobs.findIndex(j => String(j.name).toLowerCase() === bareName.toLowerCase());
|
|
4708
|
+
if (idx < 0) {
|
|
4709
|
+
failed.push({ name: bareName, error: 'job vanished mid-migration' });
|
|
4710
|
+
continue;
|
|
4711
|
+
}
|
|
4712
|
+
const m = result.migrated;
|
|
4713
|
+
const next = { ...jobs[idx] };
|
|
4714
|
+
// Same bare-vs-prefixed gotcha as the per-job migrate path —
|
|
4715
|
+
// agent CRON.md stores bare names. Always write bareName here.
|
|
4716
|
+
next.name = bareName;
|
|
4717
|
+
next.schedule = m.schedule;
|
|
4718
|
+
next.prompt = m.prompt;
|
|
4719
|
+
next.enabled = m.enabled;
|
|
4720
|
+
next.tier = m.tier;
|
|
4721
|
+
if (m.description)
|
|
4722
|
+
next.description = m.description;
|
|
4723
|
+
else
|
|
4724
|
+
delete next.description;
|
|
4725
|
+
if (m.predictable === true)
|
|
4726
|
+
next.predictable = true;
|
|
4727
|
+
if (m.skills && m.skills.length > 0)
|
|
4728
|
+
next.skills = m.skills;
|
|
4729
|
+
if (m.allowedTools && m.allowedTools.length > 0)
|
|
4730
|
+
next.allowed_tools = m.allowedTools;
|
|
4731
|
+
if (m.allowedMcpServers && m.allowedMcpServers.length > 0)
|
|
4732
|
+
next.allowed_mcp_servers = m.allowedMcpServers;
|
|
4733
|
+
jobs[idx] = next;
|
|
4734
|
+
applied.push({ name: m.name, cronFile });
|
|
4735
|
+
}
|
|
4736
|
+
writeCronFileAt(cronFile, parsed, jobs);
|
|
4737
|
+
touchedFiles.add(cronFile);
|
|
4738
|
+
}
|
|
4739
|
+
catch (err) {
|
|
4740
|
+
for (const { bareName } of items)
|
|
4741
|
+
failed.push({ name: bareName, error: String(err) });
|
|
4742
|
+
}
|
|
4743
|
+
}
|
|
4744
|
+
res.json({
|
|
4745
|
+
ok: true,
|
|
4746
|
+
appliedCount: applied.length,
|
|
4747
|
+
skippedCount: out.skipped.length,
|
|
4748
|
+
failedCount: failed.length,
|
|
4749
|
+
applied,
|
|
4750
|
+
skipped: out.skipped.map(({ job, reason }) => ({ name: job.name, reason })),
|
|
4751
|
+
failed,
|
|
4752
|
+
touchedFiles: [...touchedFiles],
|
|
4753
|
+
});
|
|
4754
|
+
}
|
|
4755
|
+
catch (err) {
|
|
4756
|
+
res.status(500).json({ ok: false, error: String(err) });
|
|
4757
|
+
}
|
|
4758
|
+
});
|
|
4559
4759
|
app.get('/api/cron/:job/draft', async (req, res) => {
|
|
4560
4760
|
try {
|
|
4561
4761
|
const jobName = req.params.job;
|
|
@@ -25974,7 +26174,12 @@ async function refreshCron() {
|
|
|
25974
26174
|
// top failure). The runs payload from /api/cron/runs (already fetched
|
|
25975
26175
|
// alongside ops) feeds the metrics. Render an empty shell first;
|
|
25976
26176
|
// refreshHealthStrip fills it in.
|
|
25977
|
-
|
|
26177
|
+
// 1.18.119 — cron-clean migration banner. Mounts here as an empty
|
|
26178
|
+
// placeholder; refreshCronCleanBanner() fetches /api/cron/migrate-preview
|
|
26179
|
+
// async and fills this in only when there are eligible legacy jobs.
|
|
26180
|
+
// The banner stays empty (zero visual noise) when the vault is clean.
|
|
26181
|
+
var html = '<div id="cron-migrate-banner-host"></div>';
|
|
26182
|
+
html += '<div id="health-strip" class="health-strip"></div>';
|
|
25978
26183
|
// 1.18.115 — collapse the cost/latency/reliability/activity mini-cards
|
|
25979
26184
|
// into a <details> block. The Health Strip already covers what most
|
|
25980
26185
|
// users want at a glance; the 4 mini-dashboards are for deeper
|
|
@@ -26051,6 +26256,11 @@ async function refreshCron() {
|
|
|
26051
26256
|
if (typeof refreshHealthStrip === 'function') {
|
|
26052
26257
|
refreshHealthStrip().catch(function() { /* non-fatal */ });
|
|
26053
26258
|
}
|
|
26259
|
+
// 1.18.119 — fire-and-forget the cron-migrate banner check. Renders
|
|
26260
|
+
// a soft tip when ≥1 legacy task is eligible; otherwise silent.
|
|
26261
|
+
if (typeof refreshCronMigrateBanner === 'function') {
|
|
26262
|
+
refreshCronMigrateBanner().catch(function() { /* non-fatal */ });
|
|
26263
|
+
}
|
|
26054
26264
|
if (typeof refreshMiniDashboards === 'function') {
|
|
26055
26265
|
refreshMiniDashboards().catch(function() { /* non-fatal */ });
|
|
26056
26266
|
}
|
|
@@ -27172,6 +27382,120 @@ async function confirmDeleteSkill(name) {
|
|
|
27172
27382
|
} catch (err) { toast('Delete failed: ' + err, 'error'); }
|
|
27173
27383
|
}
|
|
27174
27384
|
|
|
27385
|
+
// 1.18.119 — Cron-clean migration banner + flow. Surfaces a soft tip on
|
|
27386
|
+
// the Tasks page when ≥1 legacy task is eligible. Click "Preview" to see
|
|
27387
|
+
// every change in a modal; click "Migrate all" to apply across the vault
|
|
27388
|
+
// in one round-trip. Each migration writes a .bak of the source CRON.md
|
|
27389
|
+
// for trivial rollback. Refreshing the catalog re-runs the eligibility
|
|
27390
|
+
// check, so the banner auto-hides once everything is clean.
|
|
27391
|
+
var _cronMigratePreview = null;
|
|
27392
|
+
|
|
27393
|
+
async function refreshCronMigrateBanner() {
|
|
27394
|
+
var host = document.getElementById('cron-migrate-banner-host');
|
|
27395
|
+
if (!host) return;
|
|
27396
|
+
try {
|
|
27397
|
+
var r = await apiFetch('/api/cron/migrate-preview');
|
|
27398
|
+
var d = await r.json();
|
|
27399
|
+
if (!d.ok || !Array.isArray(d.eligible) || d.eligible.length === 0) {
|
|
27400
|
+
host.innerHTML = '';
|
|
27401
|
+
return;
|
|
27402
|
+
}
|
|
27403
|
+
_cronMigratePreview = d;
|
|
27404
|
+
var n = d.eligible.length;
|
|
27405
|
+
host.innerHTML =
|
|
27406
|
+
'<div style="margin:0 0 14px;padding:12px 16px;border:1px solid var(--accent);background:rgba(255,141,0,0.06);border-radius:8px;display:flex;align-items:center;gap:14px;flex-wrap:wrap">'
|
|
27407
|
+
+ '<span style="font-size:18px">✨</span>'
|
|
27408
|
+
+ '<div style="flex:1;min-width:200px">'
|
|
27409
|
+
+ '<div style="font-size:13px;font-weight:600;color:var(--text-primary);margin-bottom:2px">Clean up ' + n + ' legacy task' + (n === 1 ? '' : 's') + '</div>'
|
|
27410
|
+
+ '<div style="font-size:12px;color:var(--text-secondary);line-height:1.45">Strip TOOL RESTRICTIONS preambles, pin matching skills, enable Strict mode, add descriptions. The runtime behavior stays the same — the editor just gets readable.</div>'
|
|
27411
|
+
+ '</div>'
|
|
27412
|
+
+ '<button onclick="openCronMigratePreview()" style="font-size:12px;padding:7px 14px;border-radius:6px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text-primary);cursor:pointer">Preview changes</button>'
|
|
27413
|
+
+ '<button onclick="applyCronMigrateAll()" class="btn-primary" style="font-size:12px;padding:7px 14px;border-radius:6px;border:none;background:var(--accent);color:#fff;font-weight:500;cursor:pointer">Migrate all</button>'
|
|
27414
|
+
+ '</div>';
|
|
27415
|
+
} catch (e) {
|
|
27416
|
+
host.innerHTML = '';
|
|
27417
|
+
}
|
|
27418
|
+
}
|
|
27419
|
+
|
|
27420
|
+
function openCronMigratePreview() {
|
|
27421
|
+
if (!_cronMigratePreview) return;
|
|
27422
|
+
var modal = document.getElementById('cron-migrate-modal');
|
|
27423
|
+
if (!modal) {
|
|
27424
|
+
modal = document.createElement('div');
|
|
27425
|
+
modal.id = 'cron-migrate-modal';
|
|
27426
|
+
modal.className = 'modal-overlay';
|
|
27427
|
+
modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.45);display:flex;align-items:center;justify-content:center;z-index:1000;padding:20px';
|
|
27428
|
+
document.body.appendChild(modal);
|
|
27429
|
+
}
|
|
27430
|
+
var rows = _cronMigratePreview.eligible.map(function(item) {
|
|
27431
|
+
var changes = (item.changes || []).map(function(c) {
|
|
27432
|
+
return '<li style="margin:2px 0">' + esc(c) + '</li>';
|
|
27433
|
+
}).join('');
|
|
27434
|
+
var skillBadge = item.matchedSkill
|
|
27435
|
+
? ' <span style="font-size:10px;color:var(--accent);background:rgba(255,141,0,0.10);padding:1px 6px;border-radius:3px;margin-left:6px">→ ' + esc(item.matchedSkill) + '</span>'
|
|
27436
|
+
: '';
|
|
27437
|
+
var enabledBadge = item.enabled
|
|
27438
|
+
? ' <span class="badge badge-green" style="font-size:10px">enabled</span>'
|
|
27439
|
+
: ' <span class="badge badge-gray" style="font-size:10px">disabled</span>';
|
|
27440
|
+
return '<div style="border:1px solid var(--border);border-radius:6px;padding:10px 12px;margin-bottom:8px;background:var(--bg-secondary)">'
|
|
27441
|
+
+ '<div style="display:flex;align-items:center;gap:6px;margin-bottom:6px">'
|
|
27442
|
+
+ '<code style="font-size:12px;font-weight:600;color:var(--text-primary)">' + esc(item.name) + '</code>'
|
|
27443
|
+
+ enabledBadge + skillBadge
|
|
27444
|
+
+ '<span style="margin-left:auto;font-size:11px;color:var(--text-muted)">' + esc(item.schedule) + '</span>'
|
|
27445
|
+
+ '</div>'
|
|
27446
|
+
+ '<ul style="margin:0;padding-left:18px;font-size:12px;color:var(--text-secondary);line-height:1.5">' + changes + '</ul>'
|
|
27447
|
+
+ '</div>';
|
|
27448
|
+
}).join('');
|
|
27449
|
+
var skipped = (_cronMigratePreview.skipped || []).length > 0
|
|
27450
|
+
? '<details style="margin-top:14px;font-size:12px;color:var(--text-muted)">'
|
|
27451
|
+
+ '<summary style="cursor:pointer;padding:4px 0">' + _cronMigratePreview.skipped.length + ' task' + (_cronMigratePreview.skipped.length === 1 ? '' : 's') + ' skipped (already clean)</summary>'
|
|
27452
|
+
+ '<ul style="margin:6px 0 0;padding-left:18px;line-height:1.6">'
|
|
27453
|
+
+ _cronMigratePreview.skipped.map(function(s) { return '<li>' + esc(s.name) + '</li>'; }).join('')
|
|
27454
|
+
+ '</ul>'
|
|
27455
|
+
+ '</details>'
|
|
27456
|
+
: '';
|
|
27457
|
+
modal.innerHTML =
|
|
27458
|
+
'<div style="background:var(--bg-primary);border:1px solid var(--border);border-radius:10px;width:min(820px,95vw);max-height:90vh;display:flex;flex-direction:column;box-shadow:0 16px 48px rgba(0,0,0,0.35)">'
|
|
27459
|
+
+ '<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 20px;border-bottom:1px solid var(--border)">'
|
|
27460
|
+
+ '<h3 style="margin:0;font-size:15px;font-weight:600">Migrate ' + _cronMigratePreview.eligible.length + ' legacy task' + (_cronMigratePreview.eligible.length === 1 ? '' : 's') + ' to clean format</h3>'
|
|
27461
|
+
+ '<button onclick="closeCronMigrateModal()" style="background:none;border:none;font-size:18px;color:var(--text-muted);cursor:pointer;padding:0 4px;line-height:1">✕</button>'
|
|
27462
|
+
+ '</div>'
|
|
27463
|
+
+ '<div style="flex:1;overflow-y:auto;padding:18px 22px">'
|
|
27464
|
+
+ '<div style="font-size:12px;color:var(--text-muted);margin-bottom:12px;line-height:1.5">Each task gets a <code>.bak</code> of its CRON.md before changes. Migration is reversible — restore the .bak to undo. The runtime behavior of every task stays identical (same schedule, same prompt outcome, same tools).</div>'
|
|
27465
|
+
+ rows
|
|
27466
|
+
+ skipped
|
|
27467
|
+
+ '</div>'
|
|
27468
|
+
+ '<div style="display:flex;justify-content:flex-end;gap:8px;padding:14px 20px;border-top:1px solid var(--border);background:var(--bg-secondary)">'
|
|
27469
|
+
+ '<button onclick="closeCronMigrateModal()" style="padding:7px 14px;font-size:13px;border:1px solid var(--border);border-radius:6px;background:transparent;color:var(--text-primary);cursor:pointer">Cancel</button>'
|
|
27470
|
+
+ '<button onclick="applyCronMigrateAll()" class="btn-primary" style="padding:7px 16px;font-size:13px;border:none;border-radius:6px;background:var(--accent);color:#fff;font-weight:500;cursor:pointer">Apply migration</button>'
|
|
27471
|
+
+ '</div>'
|
|
27472
|
+
+ '</div>';
|
|
27473
|
+
modal.style.display = 'flex';
|
|
27474
|
+
}
|
|
27475
|
+
|
|
27476
|
+
function closeCronMigrateModal() {
|
|
27477
|
+
var m = document.getElementById('cron-migrate-modal');
|
|
27478
|
+
if (m) m.style.display = 'none';
|
|
27479
|
+
}
|
|
27480
|
+
|
|
27481
|
+
async function applyCronMigrateAll() {
|
|
27482
|
+
if (!confirm('Migrate every eligible legacy task to the clean format? Each touched CRON.md gets a .bak file you can restore from.')) return;
|
|
27483
|
+
try {
|
|
27484
|
+
var r = await apiFetch('/api/cron/migrate-all', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
|
|
27485
|
+
var d = await r.json();
|
|
27486
|
+
if (!d.ok) {
|
|
27487
|
+
toast(d.error || 'Migration failed', 'error');
|
|
27488
|
+
return;
|
|
27489
|
+
}
|
|
27490
|
+
closeCronMigrateModal();
|
|
27491
|
+
var msg = 'Migrated ' + d.appliedCount + ' task' + (d.appliedCount === 1 ? '' : 's')
|
|
27492
|
+
+ (d.skippedCount > 0 ? ' (' + d.skippedCount + ' already clean)' : '')
|
|
27493
|
+
+ (d.failedCount > 0 ? ' — ' + d.failedCount + ' failed' : '');
|
|
27494
|
+
toast(msg, d.failedCount > 0 ? 'error' : 'success');
|
|
27495
|
+
if (typeof refreshCron === 'function') refreshCron();
|
|
27496
|
+
} catch (err) { toast('Migration failed: ' + err, 'error'); }
|
|
27497
|
+
}
|
|
27498
|
+
|
|
27175
27499
|
async function refreshSkillsPage() {
|
|
27176
27500
|
var listEl = document.getElementById('skills-list');
|
|
27177
27501
|
var detailEl = document.getElementById('skills-detail');
|
|
@@ -152,12 +152,18 @@ export function parseCronJobs() {
|
|
|
152
152
|
: undefined;
|
|
153
153
|
// Predictable (contract) mode — undefined means legacy behavior.
|
|
154
154
|
const predictable = typeof job.predictable === 'boolean' ? job.predictable : undefined;
|
|
155
|
+
// 1.18.119 — human-readable description (used by the task card preview
|
|
156
|
+
// and by the cron-clean migrator to surface what each job does without
|
|
157
|
+
// showing raw prompt boilerplate).
|
|
158
|
+
const description = typeof job.description === 'string' && job.description.trim()
|
|
159
|
+
? job.description.trim().slice(0, 500)
|
|
160
|
+
: undefined;
|
|
155
161
|
if (!name || !schedule || !prompt) {
|
|
156
162
|
logger.warn({ job }, 'Skipping malformed cron job');
|
|
157
163
|
continue;
|
|
158
164
|
}
|
|
159
165
|
jobs.push({
|
|
160
|
-
name, schedule, prompt, enabled, tier, maxTurns, model, workDir, mode,
|
|
166
|
+
name, schedule, prompt, enabled, tier, description, maxTurns, model, workDir, mode,
|
|
161
167
|
maxHours, maxRetries, after, successCriteria, successCriteriaText, successSchema, addDirs,
|
|
162
168
|
alwaysDeliver, context, preCheck, agentSlug,
|
|
163
169
|
skills, allowedTools, allowedMcpServers, tags, category, predictable,
|
|
@@ -233,6 +239,12 @@ export function parseAgentCronJobs(agentsDir) {
|
|
|
233
239
|
? categoryRaw.trim().slice(0, 64)
|
|
234
240
|
: undefined;
|
|
235
241
|
const predictable = typeof job.predictable === 'boolean' ? job.predictable : undefined;
|
|
242
|
+
// 1.18.119 — symmetric with the global parseCronJobs description
|
|
243
|
+
// field. Without this, agent jobs always look "missing description"
|
|
244
|
+
// to the cron-clean migrator and stay flagged as eligible forever.
|
|
245
|
+
const description = typeof job.description === 'string' && job.description.trim()
|
|
246
|
+
? job.description.trim().slice(0, 500)
|
|
247
|
+
: undefined;
|
|
236
248
|
if (!name || !schedule || !prompt) {
|
|
237
249
|
logger.warn({ job, agent: slug }, 'Skipping malformed agent cron job');
|
|
238
250
|
continue;
|
|
@@ -240,7 +252,7 @@ export function parseAgentCronJobs(agentsDir) {
|
|
|
240
252
|
// Prefix name with agent slug and tag with agentSlug
|
|
241
253
|
allJobs.push({
|
|
242
254
|
name: `${slug}:${name}`,
|
|
243
|
-
schedule, prompt, enabled, tier, maxTurns, model, workDir,
|
|
255
|
+
schedule, prompt, enabled, tier, description, maxTurns, model, workDir,
|
|
244
256
|
mode, maxHours, maxRetries, after,
|
|
245
257
|
successCriteria, successCriteriaText, successSchema, addDirs,
|
|
246
258
|
context, preCheck,
|
package/dist/tools/mcp-server.js
CHANGED
|
@@ -30,6 +30,7 @@ import { registerAgentHeartbeatTools } from './agent-heartbeat-tools.js';
|
|
|
30
30
|
import { registerBackgroundTaskTools } from './background-task-tools.js';
|
|
31
31
|
import { registerDecisionReflectionTools } from './decision-reflection-tools.js';
|
|
32
32
|
import { registerBuilderTools } from './builder-tools.js';
|
|
33
|
+
import { registerSkillTools } from './skill-tools.js';
|
|
33
34
|
// ── Server ──────────────────────────────────────────────────────────────
|
|
34
35
|
const serverName = (env['ASSISTANT_NAME'] ?? 'Clementine').toLowerCase() + '-tools';
|
|
35
36
|
const server = new McpServer({ name: serverName, version: '1.0.0' });
|
|
@@ -73,6 +74,7 @@ registerAgentHeartbeatTools(scopedServer);
|
|
|
73
74
|
registerBackgroundTaskTools(scopedServer);
|
|
74
75
|
registerDecisionReflectionTools(scopedServer);
|
|
75
76
|
registerBuilderTools(scopedServer);
|
|
77
|
+
registerSkillTools(scopedServer);
|
|
76
78
|
// ── Main ────────────────────────────────────────────────────────────────
|
|
77
79
|
async function main() {
|
|
78
80
|
// Initialize memory store and run full sync on startup
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill MCP tools (1.18.120)
|
|
3
|
+
*
|
|
4
|
+
* Lets the agent (and therefore the user, via chat in Discord / dashboard /
|
|
5
|
+
* Slack / Telegram) author and update skills in natural language.
|
|
6
|
+
*
|
|
7
|
+
* Why this matters:
|
|
8
|
+
* - Before this, creating a skill required either editing files by hand
|
|
9
|
+
* or clicking through the dashboard's "+ New skill" modal.
|
|
10
|
+
* - With these tools registered, a Discord message like "Hey Clem,
|
|
11
|
+
* create a skill called morning-deal-review that checks the deal
|
|
12
|
+
* pipeline at 8am every weekday" can produce a real `<name>/SKILL.md`
|
|
13
|
+
* folder on disk, ready to pin to a cron.
|
|
14
|
+
*
|
|
15
|
+
* The tools are intentionally thin wrappers around the existing skill-store
|
|
16
|
+
* write path. The Anthropic-spec validation (name regex, ≤1024-char
|
|
17
|
+
* description, body presence) is enforced by both the dashboard endpoint
|
|
18
|
+
* and these tools, so you can't smuggle a bad skill through the chat path.
|
|
19
|
+
*/
|
|
20
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
21
|
+
export declare function registerSkillTools(server: McpServer): void;
|
|
22
|
+
//# sourceMappingURL=skill-tools.d.ts.map
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill MCP tools (1.18.120)
|
|
3
|
+
*
|
|
4
|
+
* Lets the agent (and therefore the user, via chat in Discord / dashboard /
|
|
5
|
+
* Slack / Telegram) author and update skills in natural language.
|
|
6
|
+
*
|
|
7
|
+
* Why this matters:
|
|
8
|
+
* - Before this, creating a skill required either editing files by hand
|
|
9
|
+
* or clicking through the dashboard's "+ New skill" modal.
|
|
10
|
+
* - With these tools registered, a Discord message like "Hey Clem,
|
|
11
|
+
* create a skill called morning-deal-review that checks the deal
|
|
12
|
+
* pipeline at 8am every weekday" can produce a real `<name>/SKILL.md`
|
|
13
|
+
* folder on disk, ready to pin to a cron.
|
|
14
|
+
*
|
|
15
|
+
* The tools are intentionally thin wrappers around the existing skill-store
|
|
16
|
+
* write path. The Anthropic-spec validation (name regex, ≤1024-char
|
|
17
|
+
* description, body presence) is enforced by both the dashboard endpoint
|
|
18
|
+
* and these tools, so you can't smuggle a bad skill through the chat path.
|
|
19
|
+
*/
|
|
20
|
+
import { writeFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { z } from 'zod';
|
|
23
|
+
import matter from 'gray-matter';
|
|
24
|
+
import { VAULT_DIR, textResult, logger } from './shared.js';
|
|
25
|
+
// Anthropic spec — keep these in sync with skill-store.validateSkill.
|
|
26
|
+
const NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
27
|
+
const NAME_MAX_LEN = 64;
|
|
28
|
+
const DESCRIPTION_MAX_LEN = 1024;
|
|
29
|
+
const RESERVED_NAMES = new Set(['anthropic', 'claude']);
|
|
30
|
+
function globalSkillsDir() {
|
|
31
|
+
return path.join(VAULT_DIR, '00-System', 'skills');
|
|
32
|
+
}
|
|
33
|
+
export function registerSkillTools(server) {
|
|
34
|
+
// ── create_skill ────────────────────────────────────────────────────
|
|
35
|
+
// Writes a folder-form skill to ~/.clementine/vault/00-System/skills/<name>/SKILL.md
|
|
36
|
+
// with Anthropic-canonical frontmatter (name + description top-level)
|
|
37
|
+
// and Clementine extensions (tools.allow, source: chat) under the
|
|
38
|
+
// `clementine` namespace.
|
|
39
|
+
server.tool('create_skill', 'Author a new reusable skill (a recipe Claude follows when invoked). Writes <name>/SKILL.md in the vault. Returns the skill name + path on success. Anthropic spec: name must match ^[a-z0-9][a-z0-9-]{0,63}$ and description ≤1024 chars.', {
|
|
40
|
+
name: z.string()
|
|
41
|
+
.describe('Skill slug — lowercase letters/digits/dashes, max 64 chars, must start with a letter or digit. Example: "morning-deal-review".'),
|
|
42
|
+
title: z.string().optional()
|
|
43
|
+
.describe('Optional friendlier display name. Example: "Morning Deal Review".'),
|
|
44
|
+
description: z.string()
|
|
45
|
+
.describe('One-paragraph summary of what this skill does and when Claude should run it. Used by the runtime auto-matcher AND surfaced as the cron task card preview when the skill is pinned. Max 1024 chars.'),
|
|
46
|
+
body: z.string()
|
|
47
|
+
.describe('The procedure body in Markdown. Use headers (# / ##), numbered lists, code fences. Max 500 lines is best practice. Example: "# Morning Deal Review\\n\\n1. Pull deals updated in the last 24h.\\n2. Surface high-value ones." '),
|
|
48
|
+
tools: z.array(z.string()).optional()
|
|
49
|
+
.describe('Optional allowlist of tool names this skill should restrict itself to (e.g. ["Read", "Bash", "memory_search"]). Stored under clementine.tools.allow. Empty/omitted means inherit the cron task or chat session defaults.'),
|
|
50
|
+
triggers: z.array(z.string()).optional()
|
|
51
|
+
.describe('Optional natural-language phrases that should auto-match this skill at runtime (e.g. ["morning deal review", "check deals today"]). Stored under clementine.triggers. Pinned skills don\'t need triggers — they fire by name.'),
|
|
52
|
+
}, async ({ name, title, description, body, tools, triggers }) => {
|
|
53
|
+
// Validate per Anthropic spec
|
|
54
|
+
if (!NAME_PATTERN.test(name)) {
|
|
55
|
+
return textResult(`❌ Name "${name}" doesn't match the spec. Use lowercase letters, digits, and dashes only — must start with a letter or digit, max 64 chars.`);
|
|
56
|
+
}
|
|
57
|
+
if (name.length > NAME_MAX_LEN) {
|
|
58
|
+
return textResult(`❌ Name is too long (${name.length} chars). Max is ${NAME_MAX_LEN}.`);
|
|
59
|
+
}
|
|
60
|
+
if (RESERVED_NAMES.has(name) || /\b(anthropic|claude)\b/i.test(name)) {
|
|
61
|
+
return textResult(`❌ Name "${name}" uses a reserved word ("anthropic" or "claude"). Pick another.`);
|
|
62
|
+
}
|
|
63
|
+
if (!description || !description.trim()) {
|
|
64
|
+
return textResult('❌ Description is required — Claude uses it to decide when to apply this skill.');
|
|
65
|
+
}
|
|
66
|
+
if (description.length > DESCRIPTION_MAX_LEN) {
|
|
67
|
+
return textResult(`❌ Description is too long (${description.length} chars). Max is ${DESCRIPTION_MAX_LEN}.`);
|
|
68
|
+
}
|
|
69
|
+
if (!body || !body.trim()) {
|
|
70
|
+
return textResult('❌ Procedure body is required — that\'s what Claude actually runs.');
|
|
71
|
+
}
|
|
72
|
+
const skillsDir = globalSkillsDir();
|
|
73
|
+
const folderPath = path.join(skillsDir, name);
|
|
74
|
+
const entryPath = path.join(folderPath, 'SKILL.md');
|
|
75
|
+
if (existsSync(entryPath)) {
|
|
76
|
+
return textResult(`❌ Skill "${name}" already exists at ${entryPath}. Use update_skill instead, or pick a different name.`);
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
mkdirSync(folderPath, { recursive: true });
|
|
80
|
+
const now = new Date().toISOString();
|
|
81
|
+
const fm = { name, description };
|
|
82
|
+
if (title && title.trim())
|
|
83
|
+
fm.title = title.trim();
|
|
84
|
+
const clementineExt = {
|
|
85
|
+
source: 'chat',
|
|
86
|
+
useCount: 0,
|
|
87
|
+
createdAt: now,
|
|
88
|
+
updatedAt: now,
|
|
89
|
+
version: 1,
|
|
90
|
+
};
|
|
91
|
+
if (Array.isArray(tools) && tools.length > 0) {
|
|
92
|
+
clementineExt.tools = { allow: tools.map(String).map(s => s.trim()).filter(Boolean) };
|
|
93
|
+
}
|
|
94
|
+
if (Array.isArray(triggers) && triggers.length > 0) {
|
|
95
|
+
clementineExt.triggers = triggers.map(String).map(s => s.trim()).filter(Boolean);
|
|
96
|
+
}
|
|
97
|
+
fm.clementine = clementineExt;
|
|
98
|
+
const content = matter.stringify(body.endsWith('\n') ? body : body + '\n', fm);
|
|
99
|
+
writeFileSync(entryPath, content);
|
|
100
|
+
logger.info({ name, entryPath, source: 'chat' }, 'Skill created via chat');
|
|
101
|
+
const toolsLine = (tools && tools.length > 0) ? `\nAllowed tools: ${tools.slice(0, 5).join(', ')}${tools.length > 5 ? `, +${tools.length - 5} more` : ''}` : '';
|
|
102
|
+
const triggersLine = (triggers && triggers.length > 0) ? `\nTriggers: ${triggers.slice(0, 4).join(', ')}${triggers.length > 4 ? `, +${triggers.length - 4} more` : ''}` : '';
|
|
103
|
+
return textResult(`✅ Created skill "${name}" at ${entryPath}\n` +
|
|
104
|
+
`Description: ${description.slice(0, 200)}${description.length > 200 ? '…' : ''}` +
|
|
105
|
+
toolsLine +
|
|
106
|
+
triggersLine +
|
|
107
|
+
`\n\nThe skill is ready to pin to any task — open the cron editor, go to Tools & MCP, click "+ Add skill" and select "${name}". Or invoke it directly in chat: "Run the ${title || name} skill."`);
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
logger.error({ err, name }, 'create_skill failed');
|
|
111
|
+
return textResult(`❌ Failed to write the skill: ${err instanceof Error ? err.message : String(err)}`);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
// ── update_skill ────────────────────────────────────────────────────
|
|
115
|
+
// Edits an existing skill. Preserves frontmatter the caller doesn't
|
|
116
|
+
// touch (useCount, lastUsed, migration provenance, custom fields) so
|
|
117
|
+
// chat edits don't reset the lifecycle metadata.
|
|
118
|
+
server.tool('update_skill', 'Update an existing skill\'s description, body, tools, or triggers. Preserves lifecycle metadata (useCount, createdAt, etc.). Returns the updated path on success.', {
|
|
119
|
+
name: z.string().describe('Slug of the skill to update (e.g. "morning-deal-review").'),
|
|
120
|
+
description: z.string().optional().describe('New description (one paragraph, ≤1024 chars).'),
|
|
121
|
+
body: z.string().optional().describe('New procedure body (Markdown). Replaces the existing body in full.'),
|
|
122
|
+
tools: z.array(z.string()).optional().describe('New allowlist for clementine.tools.allow. Pass [] to clear.'),
|
|
123
|
+
triggers: z.array(z.string()).optional().describe('New trigger phrase list for clementine.triggers. Pass [] to clear.'),
|
|
124
|
+
}, async ({ name, description, body, tools, triggers }) => {
|
|
125
|
+
if (!NAME_PATTERN.test(name)) {
|
|
126
|
+
return textResult(`❌ Name "${name}" is not a valid skill slug.`);
|
|
127
|
+
}
|
|
128
|
+
const skillsDir = globalSkillsDir();
|
|
129
|
+
const folderEntry = path.join(skillsDir, name, 'SKILL.md');
|
|
130
|
+
const flatEntry = path.join(skillsDir, name + '.md');
|
|
131
|
+
const targetPath = existsSync(folderEntry) ? folderEntry : (existsSync(flatEntry) ? flatEntry : null);
|
|
132
|
+
if (!targetPath) {
|
|
133
|
+
return textResult(`❌ Skill "${name}" not found. Use create_skill if you want to author it from scratch.`);
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
const raw = readFileSync(targetPath, 'utf-8');
|
|
137
|
+
const parsed = matter(raw);
|
|
138
|
+
const fm = { ...parsed.data };
|
|
139
|
+
fm.name = name;
|
|
140
|
+
if (description !== undefined) {
|
|
141
|
+
if (description.length > DESCRIPTION_MAX_LEN) {
|
|
142
|
+
return textResult(`❌ Description is too long (${description.length} chars). Max is ${DESCRIPTION_MAX_LEN}.`);
|
|
143
|
+
}
|
|
144
|
+
fm.description = description;
|
|
145
|
+
}
|
|
146
|
+
const ext = (fm.clementine && typeof fm.clementine === 'object') ? fm.clementine : {};
|
|
147
|
+
ext.updatedAt = new Date().toISOString();
|
|
148
|
+
if (tools !== undefined) {
|
|
149
|
+
if (tools.length > 0)
|
|
150
|
+
ext.tools = { ...(ext.tools || {}), allow: tools };
|
|
151
|
+
else if (ext.tools && typeof ext.tools === 'object')
|
|
152
|
+
delete ext.tools.allow;
|
|
153
|
+
}
|
|
154
|
+
if (triggers !== undefined) {
|
|
155
|
+
if (triggers.length > 0)
|
|
156
|
+
ext.triggers = triggers;
|
|
157
|
+
else
|
|
158
|
+
delete ext.triggers;
|
|
159
|
+
}
|
|
160
|
+
fm.clementine = ext;
|
|
161
|
+
const newBody = body !== undefined ? (body.endsWith('\n') ? body : body + '\n') : parsed.content;
|
|
162
|
+
const content = matter.stringify(newBody, fm);
|
|
163
|
+
writeFileSync(targetPath, content);
|
|
164
|
+
logger.info({ name, targetPath, source: 'chat' }, 'Skill updated via chat');
|
|
165
|
+
const changed = [];
|
|
166
|
+
if (description !== undefined)
|
|
167
|
+
changed.push('description');
|
|
168
|
+
if (body !== undefined)
|
|
169
|
+
changed.push('body');
|
|
170
|
+
if (tools !== undefined)
|
|
171
|
+
changed.push('tools');
|
|
172
|
+
if (triggers !== undefined)
|
|
173
|
+
changed.push('triggers');
|
|
174
|
+
return textResult(`✅ Updated skill "${name}" — changed: ${changed.join(', ') || '(no fields specified)'}.\n` +
|
|
175
|
+
`Path: ${targetPath}`);
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
logger.error({ err, name }, 'update_skill failed');
|
|
179
|
+
return textResult(`❌ Failed to update the skill: ${err instanceof Error ? err.message : String(err)}`);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
// ── list_skills ─────────────────────────────────────────────────────
|
|
183
|
+
// Read-only — lets the agent answer "what skills do I have?" in chat
|
|
184
|
+
// without needing to fall back to file-system tools.
|
|
185
|
+
server.tool('list_skills', 'List every skill currently in the global vault. Returns name, title, description, schema version (anthropic / clementine / legacy), and layout (folder / flat). Useful when the user asks "what skills do you have?" or "show me my skills."', {}, async () => {
|
|
186
|
+
try {
|
|
187
|
+
const skillsDir = globalSkillsDir();
|
|
188
|
+
if (!existsSync(skillsDir))
|
|
189
|
+
return textResult('No skills directory yet. Use create_skill to author your first one.');
|
|
190
|
+
const { readdirSync, statSync } = await import('node:fs');
|
|
191
|
+
const items = readdirSync(skillsDir);
|
|
192
|
+
const skills = [];
|
|
193
|
+
for (const item of items) {
|
|
194
|
+
if (item.startsWith('.'))
|
|
195
|
+
continue;
|
|
196
|
+
const full = path.join(skillsDir, item);
|
|
197
|
+
let st;
|
|
198
|
+
try {
|
|
199
|
+
st = statSync(full);
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (st.isDirectory()) {
|
|
205
|
+
const entry = path.join(full, 'SKILL.md');
|
|
206
|
+
if (!existsSync(entry))
|
|
207
|
+
continue;
|
|
208
|
+
try {
|
|
209
|
+
const fm = matter(readFileSync(entry, 'utf-8')).data;
|
|
210
|
+
skills.push({
|
|
211
|
+
name: String(fm.name ?? item),
|
|
212
|
+
title: String(fm.title ?? fm.name ?? item),
|
|
213
|
+
description: String(fm.description ?? ''),
|
|
214
|
+
layout: 'folder',
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
catch { /* skip malformed */ }
|
|
218
|
+
}
|
|
219
|
+
else if (st.isFile() && item.endsWith('.md') && !item.endsWith('.bak.md')) {
|
|
220
|
+
try {
|
|
221
|
+
const fm = matter(readFileSync(full, 'utf-8')).data;
|
|
222
|
+
skills.push({
|
|
223
|
+
name: String(fm.name ?? item.replace(/\.md$/, '')),
|
|
224
|
+
title: String(fm.title ?? fm.name ?? item),
|
|
225
|
+
description: String(fm.description ?? ''),
|
|
226
|
+
layout: 'flat',
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
catch { /* skip malformed */ }
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (skills.length === 0)
|
|
233
|
+
return textResult('No skills found yet. Use create_skill to author your first one.');
|
|
234
|
+
skills.sort((a, b) => a.name.localeCompare(b.name));
|
|
235
|
+
const lines = skills.map(s => `• ${s.title} (\`${s.name}\`) — ${s.description.slice(0, 120)}${s.description.length > 120 ? '…' : ''}`);
|
|
236
|
+
return textResult(`${skills.length} skill${skills.length === 1 ? '' : 's'}:\n\n${lines.join('\n')}`);
|
|
237
|
+
}
|
|
238
|
+
catch (err) {
|
|
239
|
+
return textResult(`❌ Failed to list skills: ${err instanceof Error ? err.message : String(err)}`);
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
//# sourceMappingURL=skill-tools.js.map
|
package/dist/types.d.ts
CHANGED
|
@@ -475,6 +475,11 @@ export interface CronJobDefinition {
|
|
|
475
475
|
prompt: string;
|
|
476
476
|
enabled: boolean;
|
|
477
477
|
tier: number;
|
|
478
|
+
/** Human-readable description of what this task does (one paragraph, ~200
|
|
479
|
+
* chars). Surfaces on the task card as the preview line — replaces the
|
|
480
|
+
* "first sentence of prompt" fallback when present. Auto-populated by
|
|
481
|
+
* the cron-migrator from the matching skill's description. Optional. */
|
|
482
|
+
description?: string;
|
|
478
483
|
maxTurns?: number;
|
|
479
484
|
model?: string;
|
|
480
485
|
workDir?: string;
|