clementine-agent 1.18.122 → 1.18.124
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/skill-extractor.d.ts +2 -2
- package/dist/agent/skill-extractor.js +33 -32
- package/dist/agent/skill-store.d.ts +33 -0
- package/dist/agent/skill-store.js +66 -0
- package/dist/cli/dashboard.js +32 -52
- package/dist/cli/index.js +1 -1
- package/dist/gateway/cron-scheduler.js +95 -132
- package/dist/gateway/router.js +1 -1
- package/dist/tools/skill-tools.js +25 -53
- package/package.json +1 -1
|
@@ -29,10 +29,10 @@ export declare function extractSkill(assistant: PersonalAssistant, context: {
|
|
|
29
29
|
durationMs: number;
|
|
30
30
|
}): Promise<SkillDocument | null>;
|
|
31
31
|
/** Move a pending skill to the active skills directory. */
|
|
32
|
-
export declare function approvePendingSkill(name: string): {
|
|
32
|
+
export declare function approvePendingSkill(name: string): Promise<{
|
|
33
33
|
ok: boolean;
|
|
34
34
|
message: string;
|
|
35
|
-
}
|
|
35
|
+
}>;
|
|
36
36
|
/** Delete a pending skill (reject it). */
|
|
37
37
|
export declare function rejectPendingSkill(name: string): {
|
|
38
38
|
ok: boolean;
|
|
@@ -121,43 +121,44 @@ function savePendingSkill(skill) {
|
|
|
121
121
|
logger.info({ name: skill.name, source: skill.source }, 'Skill queued for approval');
|
|
122
122
|
}
|
|
123
123
|
/** Save an approved skill as a formatted markdown file. Agent-scoped if agentSlug set. */
|
|
124
|
-
|
|
124
|
+
// 1.18.124 — saveActiveSkill is now a thin wrapper around the shared
|
|
125
|
+
// writeSkill helper. Auto-extracted skills used to land as legacy flat-
|
|
126
|
+
// form (`<name>.md` with top-level triggers/toolsUsed/source) which the
|
|
127
|
+
// Skills page tagged with the orange "LEGACY" badge — confusing on a
|
|
128
|
+
// fresh install. Now they write the same Anthropic-canonical folder
|
|
129
|
+
// form the dashboard + chat paths produce.
|
|
130
|
+
//
|
|
131
|
+
// Also drops the per-overwrite `.md.bak` leak — that was paranoia
|
|
132
|
+
// before the skill catalog had proper provenance. The pending file
|
|
133
|
+
// in PENDING_SKILLS_DIR is the rollback artifact.
|
|
134
|
+
async function saveActiveSkill(skill) {
|
|
125
135
|
ensureDirs();
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
//
|
|
130
|
-
const
|
|
136
|
+
const { writeSkill } = await import('./skill-store.js');
|
|
137
|
+
// Map SkillDocument's source enum (which still has 'unleashed'/'cron'
|
|
138
|
+
// for back-compat with old pending-skill JSONs on disk) to the
|
|
139
|
+
// writeSkill source enum.
|
|
140
|
+
const writeSource = skill.source === 'unleashed' || skill.source === 'cron' ? 'auto'
|
|
141
|
+
: skill.source === 'chat' ? 'chat'
|
|
142
|
+
: 'manual';
|
|
143
|
+
const result = writeSkill({
|
|
144
|
+
name: skill.name,
|
|
131
145
|
title: skill.title,
|
|
132
146
|
description: skill.description,
|
|
147
|
+
// Procedure body only — title/description live in frontmatter
|
|
148
|
+
// under the new schema, no need to repeat them inside the body.
|
|
149
|
+
body: skill.steps,
|
|
150
|
+
tools: skill.toolsUsed,
|
|
133
151
|
triggers: skill.triggers,
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
};
|
|
140
|
-
if (skill.sourceJob)
|
|
141
|
-
frontmatter.sourceJob = skill.sourceJob;
|
|
142
|
-
if (skill.agentSlug)
|
|
143
|
-
frontmatter.agentSlug = skill.agentSlug;
|
|
144
|
-
if (skill.lastUsed)
|
|
145
|
-
frontmatter.lastUsed = skill.lastUsed;
|
|
146
|
-
const content = matter.stringify(`\n# ${skill.title}\n\n${skill.description}\n\n## Procedure\n\n${skill.steps}\n`, frontmatter);
|
|
147
|
-
const filePath = path.join(targetDir, `${skill.name}.md`);
|
|
148
|
-
// Backup existing before overwrite
|
|
149
|
-
if (existsSync(filePath)) {
|
|
150
|
-
try {
|
|
151
|
-
copyFileSync(filePath, filePath.replace(/\.md$/, '.md.bak'));
|
|
152
|
-
}
|
|
153
|
-
catch { /* best-effort */ }
|
|
154
|
-
}
|
|
155
|
-
writeFileSync(filePath, content);
|
|
156
|
-
logger.info({ name: skill.name, source: skill.source, agentSlug: skill.agentSlug ?? 'global' }, 'Skill saved');
|
|
152
|
+
agentSlug: skill.agentSlug,
|
|
153
|
+
source: writeSource,
|
|
154
|
+
sourceJob: skill.sourceJob,
|
|
155
|
+
overwrite: true, // approve / merge always replaces in-place
|
|
156
|
+
});
|
|
157
|
+
logger.info({ name: skill.name, source: skill.source, agentSlug: skill.agentSlug ?? 'global', filePath: result.filePath, overwrote: result.overwrote }, 'Skill saved');
|
|
157
158
|
}
|
|
158
159
|
// ── Pending Skill Management ────────────────────────────────────────
|
|
159
160
|
/** Move a pending skill to the active skills directory. */
|
|
160
|
-
export function approvePendingSkill(name) {
|
|
161
|
+
export async function approvePendingSkill(name) {
|
|
161
162
|
ensureDirs();
|
|
162
163
|
const pendingFile = path.join(PENDING_SKILLS_DIR, `${name}.json`);
|
|
163
164
|
if (!existsSync(pendingFile)) {
|
|
@@ -166,7 +167,7 @@ export function approvePendingSkill(name) {
|
|
|
166
167
|
try {
|
|
167
168
|
const skill = JSON.parse(readFileSync(pendingFile, 'utf-8'));
|
|
168
169
|
skill.updatedAt = new Date().toISOString();
|
|
169
|
-
saveActiveSkill(skill);
|
|
170
|
+
await saveActiveSkill(skill);
|
|
170
171
|
unlinkSync(pendingFile);
|
|
171
172
|
logger.info({ name }, 'Pending skill approved and activated');
|
|
172
173
|
return { ok: true, message: `Skill **${skill.title}** is now active${skill.agentSlug ? ` for ${skill.agentSlug}` : ' (global)'}.` };
|
|
@@ -308,7 +309,7 @@ async function mergeSkill(assistant, existing, incoming) {
|
|
|
308
309
|
updatedAt: new Date().toISOString(),
|
|
309
310
|
};
|
|
310
311
|
// Merges go directly to active (existing skill was already approved)
|
|
311
|
-
saveActiveSkill(merged);
|
|
312
|
+
await saveActiveSkill(merged);
|
|
312
313
|
logger.info({ name: merged.name }, 'Skill merged and updated');
|
|
313
314
|
return merged;
|
|
314
315
|
}
|
|
@@ -77,5 +77,38 @@ export declare function migrateAllLegacySkills(): {
|
|
|
77
77
|
migrated: MigrationResult[];
|
|
78
78
|
skipped: MigrationResult[];
|
|
79
79
|
};
|
|
80
|
+
export interface WriteSkillInput {
|
|
81
|
+
/** Slug (lowercase letters/digits/dashes, ≤64 chars, Anthropic regex). */
|
|
82
|
+
name: string;
|
|
83
|
+
/** Human-readable display name. Optional. */
|
|
84
|
+
title?: string;
|
|
85
|
+
/** One-paragraph "what does this do, when should Claude run it" — required by spec. */
|
|
86
|
+
description: string;
|
|
87
|
+
/** Procedure body (Markdown). Required. */
|
|
88
|
+
body: string;
|
|
89
|
+
/** Where the skill came from. Drives lifecycle metadata + dashboard badge. */
|
|
90
|
+
source: 'manual' | 'chat' | 'auto' | 'imported';
|
|
91
|
+
/** Optional tool allowlist — stored under clementine.tools.allow. */
|
|
92
|
+
tools?: string[];
|
|
93
|
+
/** Optional NLP trigger phrases for auto-match — stored under clementine.triggers. */
|
|
94
|
+
triggers?: string[];
|
|
95
|
+
/** Optional agent scope. When set, writes to <agentsDir>/<slug>/skills/
|
|
96
|
+
* instead of the global skills dir. Used by auto-extraction so each
|
|
97
|
+
* hired agent's skills stay isolated by default. */
|
|
98
|
+
agentSlug?: string;
|
|
99
|
+
/** When true, allow overwriting an existing skill (used by update flows). */
|
|
100
|
+
overwrite?: boolean;
|
|
101
|
+
/** Optional source-job tag (auto-extraction provenance). */
|
|
102
|
+
sourceJob?: string;
|
|
103
|
+
}
|
|
104
|
+
export interface WriteSkillResult {
|
|
105
|
+
/** Absolute path to the written SKILL.md. */
|
|
106
|
+
filePath: string;
|
|
107
|
+
/** Slug (matches input name). */
|
|
108
|
+
name: string;
|
|
109
|
+
/** Whether an existing skill was overwritten. */
|
|
110
|
+
overwrote: boolean;
|
|
111
|
+
}
|
|
112
|
+
export declare function writeSkill(input: WriteSkillInput): WriteSkillResult;
|
|
80
113
|
export {};
|
|
81
114
|
//# sourceMappingURL=skill-store.d.ts.map
|
|
@@ -647,4 +647,70 @@ export function migrateAllLegacySkills() {
|
|
|
647
647
|
}
|
|
648
648
|
return { migrated, skipped };
|
|
649
649
|
}
|
|
650
|
+
export function writeSkill(input) {
|
|
651
|
+
// Validate name per Anthropic spec — single guard for every caller.
|
|
652
|
+
if (!input.name || !NAME_PATTERN.test(input.name)) {
|
|
653
|
+
throw new Error('writeSkill: name must match ^[a-z0-9][a-z0-9-]{0,63}$');
|
|
654
|
+
}
|
|
655
|
+
if (input.name.length > NAME_MAX_LEN) {
|
|
656
|
+
throw new Error(`writeSkill: name exceeds ${NAME_MAX_LEN} chars`);
|
|
657
|
+
}
|
|
658
|
+
if (RESERVED_NAMES.has(input.name) || /\b(anthropic|claude)\b/i.test(input.name)) {
|
|
659
|
+
throw new Error(`writeSkill: name uses a reserved word`);
|
|
660
|
+
}
|
|
661
|
+
if (!input.description || !input.description.trim()) {
|
|
662
|
+
throw new Error('writeSkill: description is required');
|
|
663
|
+
}
|
|
664
|
+
if (input.description.length > DESCRIPTION_MAX_LEN) {
|
|
665
|
+
throw new Error(`writeSkill: description exceeds ${DESCRIPTION_MAX_LEN} chars`);
|
|
666
|
+
}
|
|
667
|
+
if (!input.body || !input.body.trim()) {
|
|
668
|
+
throw new Error('writeSkill: body is required');
|
|
669
|
+
}
|
|
670
|
+
// Resolve target directory. Agent-scoped writes land under the agent's
|
|
671
|
+
// skills folder so each hired agent's skill set is independent.
|
|
672
|
+
const base = process.env.CLEMENTINE_HOME || path.join(os.homedir(), '.clementine');
|
|
673
|
+
const targetDir = input.agentSlug
|
|
674
|
+
? path.join(base, 'vault', '00-System', 'agents', input.agentSlug, 'skills')
|
|
675
|
+
: globalSkillsDir();
|
|
676
|
+
if (!existsSync(targetDir))
|
|
677
|
+
mkdirSync(targetDir, { recursive: true });
|
|
678
|
+
const folderPath = path.join(targetDir, input.name);
|
|
679
|
+
const entryPath = path.join(folderPath, 'SKILL.md');
|
|
680
|
+
const existed = existsSync(entryPath);
|
|
681
|
+
if (existed && !input.overwrite) {
|
|
682
|
+
throw new Error(`writeSkill: skill "${input.name}" already exists`);
|
|
683
|
+
}
|
|
684
|
+
mkdirSync(folderPath, { recursive: true });
|
|
685
|
+
// Build the frontmatter. Anthropic-canonical fields (name, description)
|
|
686
|
+
// top-level. Everything else under clementine:. The lifecycle metadata
|
|
687
|
+
// (createdAt / updatedAt / version) keeps the Skills page detail pane
|
|
688
|
+
// accurate without authors having to remember to set it by hand.
|
|
689
|
+
const now = new Date().toISOString();
|
|
690
|
+
const fm = {
|
|
691
|
+
name: input.name,
|
|
692
|
+
description: input.description.trim(),
|
|
693
|
+
};
|
|
694
|
+
if (input.title && input.title.trim())
|
|
695
|
+
fm.title = input.title.trim();
|
|
696
|
+
const ext = {
|
|
697
|
+
source: input.source,
|
|
698
|
+
useCount: 0,
|
|
699
|
+
createdAt: now,
|
|
700
|
+
updatedAt: now,
|
|
701
|
+
version: 1,
|
|
702
|
+
};
|
|
703
|
+
if (input.tools && input.tools.length > 0) {
|
|
704
|
+
ext.tools = { allow: input.tools.map(String).map(s => s.trim()).filter(Boolean) };
|
|
705
|
+
}
|
|
706
|
+
if (input.triggers && input.triggers.length > 0) {
|
|
707
|
+
ext.triggers = input.triggers.map(String).map(s => s.trim()).filter(Boolean);
|
|
708
|
+
}
|
|
709
|
+
if (input.sourceJob)
|
|
710
|
+
ext.sourceJob = input.sourceJob;
|
|
711
|
+
fm.clementine = ext;
|
|
712
|
+
const content = matter.stringify(input.body.endsWith('\n') ? input.body : input.body + '\n', fm);
|
|
713
|
+
writeFileSync(entryPath, content);
|
|
714
|
+
return { filePath: entryPath, name: input.name, overwrote: existed };
|
|
715
|
+
}
|
|
650
716
|
//# sourceMappingURL=skill-store.js.map
|
package/dist/cli/dashboard.js
CHANGED
|
@@ -10293,7 +10293,7 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
|
|
|
10293
10293
|
app.post('/api/skills/pending/:name/approve', async (req, res) => {
|
|
10294
10294
|
try {
|
|
10295
10295
|
const { approvePendingSkill } = await import('../agent/skill-extractor.js');
|
|
10296
|
-
const result = approvePendingSkill(req.params.name);
|
|
10296
|
+
const result = await approvePendingSkill(req.params.name);
|
|
10297
10297
|
if (!result.ok) {
|
|
10298
10298
|
res.status(404).json(result);
|
|
10299
10299
|
return;
|
|
@@ -10318,63 +10318,43 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
|
|
|
10318
10318
|
res.status(500).json({ error: String(err) });
|
|
10319
10319
|
}
|
|
10320
10320
|
});
|
|
10321
|
-
// POST /api/skills — create a new folder-form skill
|
|
10322
|
-
//
|
|
10323
|
-
//
|
|
10324
|
-
//
|
|
10325
|
-
//
|
|
10326
|
-
app.post('/api/skills', (req, res) => {
|
|
10321
|
+
// POST /api/skills — create a new folder-form skill via the shared
|
|
10322
|
+
// writeSkill helper (1.18.124). All three skill-creation paths
|
|
10323
|
+
// (dashboard, MCP create_skill, auto-extraction) flow through the
|
|
10324
|
+
// same write-once-validate-once code; this route is now a thin
|
|
10325
|
+
// adapter over the helper.
|
|
10326
|
+
app.post('/api/skills', async (req, res) => {
|
|
10327
10327
|
try {
|
|
10328
10328
|
const { name, title, description, body, tools } = req.body ?? {};
|
|
10329
|
-
if (
|
|
10330
|
-
res.status(400).json({ error: 'name
|
|
10331
|
-
return;
|
|
10332
|
-
}
|
|
10333
|
-
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(name)) {
|
|
10334
|
-
res.status(400).json({ error: 'name must match ^[a-z0-9][a-z0-9-]{0,63}$ (Anthropic spec)' });
|
|
10335
|
-
return;
|
|
10336
|
-
}
|
|
10337
|
-
if (!description || typeof description !== 'string') {
|
|
10338
|
-
res.status(400).json({ error: 'description is required' });
|
|
10339
|
-
return;
|
|
10340
|
-
}
|
|
10341
|
-
if (description.length > 1024) {
|
|
10342
|
-
res.status(400).json({ error: 'description must be ≤ 1024 chars (Anthropic spec)' });
|
|
10329
|
+
if (typeof name !== 'string' || typeof description !== 'string' || typeof body !== 'string') {
|
|
10330
|
+
res.status(400).json({ error: 'name, description, and body are required strings' });
|
|
10343
10331
|
return;
|
|
10344
10332
|
}
|
|
10345
|
-
|
|
10346
|
-
|
|
10347
|
-
|
|
10333
|
+
const { writeSkill } = await import('../agent/skill-store.js');
|
|
10334
|
+
try {
|
|
10335
|
+
const result = writeSkill({
|
|
10336
|
+
name,
|
|
10337
|
+
title: typeof title === 'string' ? title : undefined,
|
|
10338
|
+
description,
|
|
10339
|
+
body,
|
|
10340
|
+
tools: Array.isArray(tools) ? tools.map(String) : undefined,
|
|
10341
|
+
source: 'manual',
|
|
10342
|
+
});
|
|
10343
|
+
res.json({ ok: true, name: result.name, layout: 'folder', filePath: result.filePath });
|
|
10348
10344
|
}
|
|
10349
|
-
|
|
10350
|
-
|
|
10351
|
-
|
|
10352
|
-
|
|
10353
|
-
|
|
10354
|
-
|
|
10355
|
-
|
|
10356
|
-
|
|
10345
|
+
catch (err) {
|
|
10346
|
+
// writeSkill throws synchronously with a readable message for
|
|
10347
|
+
// every validation failure (name regex, description length,
|
|
10348
|
+
// already-exists, etc.). Surface them as 4xx; only unexpected
|
|
10349
|
+
// I/O errors hit the outer 500 path below.
|
|
10350
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
10351
|
+
if (msg.includes('already exists')) {
|
|
10352
|
+
res.status(409).json({ error: msg });
|
|
10353
|
+
}
|
|
10354
|
+
else {
|
|
10355
|
+
res.status(400).json({ error: msg });
|
|
10356
|
+
}
|
|
10357
10357
|
}
|
|
10358
|
-
mkdirSync(folderPath, { recursive: true });
|
|
10359
|
-
const now = new Date().toISOString();
|
|
10360
|
-
const fm = { name, description };
|
|
10361
|
-
if (title && typeof title === 'string' && title.trim())
|
|
10362
|
-
fm.title = title.trim();
|
|
10363
|
-
const allowed = Array.isArray(tools) ? tools.map(String).map(s => s.trim()).filter(Boolean) : [];
|
|
10364
|
-
const clementineExt = {
|
|
10365
|
-
source: 'manual',
|
|
10366
|
-
useCount: 0,
|
|
10367
|
-
createdAt: now,
|
|
10368
|
-
updatedAt: now,
|
|
10369
|
-
version: 1,
|
|
10370
|
-
};
|
|
10371
|
-
if (allowed.length > 0)
|
|
10372
|
-
clementineExt.tools = { allow: allowed };
|
|
10373
|
-
fm.clementine = clementineExt;
|
|
10374
|
-
const matterMod = require('gray-matter');
|
|
10375
|
-
const content = matterMod.stringify(body.endsWith('\n') ? body : body + '\n', fm);
|
|
10376
|
-
writeFileSync(entryPath, content);
|
|
10377
|
-
res.json({ ok: true, name, layout: 'folder', filePath: entryPath });
|
|
10378
10358
|
}
|
|
10379
10359
|
catch (err) {
|
|
10380
10360
|
res.status(500).json({ error: String(err) });
|
package/dist/cli/index.js
CHANGED
|
@@ -2832,7 +2832,7 @@ skillsCmd
|
|
|
2832
2832
|
try {
|
|
2833
2833
|
process.env.CLEMENTINE_HOME = BASE_DIR;
|
|
2834
2834
|
const { approvePendingSkill } = await import('../agent/skill-extractor.js');
|
|
2835
|
-
const result = approvePendingSkill(name);
|
|
2835
|
+
const result = await approvePendingSkill(name);
|
|
2836
2836
|
if (result.ok) {
|
|
2837
2837
|
console.log(` ${GREEN}✓${RESET} ${result.message}`);
|
|
2838
2838
|
}
|
|
@@ -79,6 +79,87 @@ function normalizeStringArray(v) {
|
|
|
79
79
|
const out = Array.from(new Set(v.map(x => typeof x === 'string' ? x.trim() : '').filter(Boolean)));
|
|
80
80
|
return out.length > 0 ? out : undefined;
|
|
81
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* Single-source-of-truth YAML → CronJobDefinition parser.
|
|
84
|
+
*
|
|
85
|
+
* Used by parseCronJobs (global CRON.md) AND parseAgentCronJobs (per-agent).
|
|
86
|
+
* Caller is responsible for handling the name prefix (agent jobs are
|
|
87
|
+
* prefixed with `<slug>:`) and for tagging agentSlug.
|
|
88
|
+
*
|
|
89
|
+
* Returns null when the row is malformed (missing name/schedule/prompt) —
|
|
90
|
+
* the caller logs + skips. Accepts both snake_case and camelCase YAML keys
|
|
91
|
+
* since users hand-edit CRON.md and we want to be forgiving.
|
|
92
|
+
*
|
|
93
|
+
* Centralizing here closes the drift the previous audit flagged: the agent
|
|
94
|
+
* variant was missing alwaysDeliver/attachments/requiresConfirmation/
|
|
95
|
+
* confirmationTimeoutMin and the description field. After this refactor
|
|
96
|
+
* both surfaces have the same field set.
|
|
97
|
+
*/
|
|
98
|
+
function parseJobYaml(job) {
|
|
99
|
+
const name = String(job.name ?? '');
|
|
100
|
+
const schedule = String(job.schedule ?? '');
|
|
101
|
+
const prompt = String(job.prompt ?? '');
|
|
102
|
+
if (!name || !schedule || !prompt)
|
|
103
|
+
return null;
|
|
104
|
+
const enabled = job.enabled !== false;
|
|
105
|
+
const tier = Number(job.tier ?? 1);
|
|
106
|
+
const maxTurns = job.max_turns != null ? Number(job.max_turns) : undefined;
|
|
107
|
+
const model = job.model != null ? String(job.model) : undefined;
|
|
108
|
+
const workDir = job.work_dir != null ? String(job.work_dir) : undefined;
|
|
109
|
+
const mode = job.mode === 'unleashed' ? 'unleashed' : 'standard';
|
|
110
|
+
const maxHours = job.max_hours != null ? Number(job.max_hours) : undefined;
|
|
111
|
+
const maxRetries = job.max_retries != null ? Number(job.max_retries) : undefined;
|
|
112
|
+
const after = job.after != null ? String(job.after) : undefined;
|
|
113
|
+
const successCriteria = Array.isArray(job.success_criteria)
|
|
114
|
+
? job.success_criteria.map(c => String(c))
|
|
115
|
+
: undefined;
|
|
116
|
+
// Prefer free-form successCriteriaText; fall back to joining the legacy
|
|
117
|
+
// string[] so legacy YAML keeps rendering. Writes go to the new field.
|
|
118
|
+
let successCriteriaText = typeof job.success_criteria_text === 'string'
|
|
119
|
+
? String(job.success_criteria_text)
|
|
120
|
+
: (typeof job.successCriteriaText === 'string' ? String(job.successCriteriaText) : undefined);
|
|
121
|
+
if (!successCriteriaText && Array.isArray(successCriteria) && successCriteria.length > 0) {
|
|
122
|
+
successCriteriaText = successCriteria.join('\n');
|
|
123
|
+
}
|
|
124
|
+
const successSchemaRaw = job.success_schema ?? job.successSchema;
|
|
125
|
+
const successSchema = (successSchemaRaw && typeof successSchemaRaw === 'object' && !Array.isArray(successSchemaRaw))
|
|
126
|
+
? successSchemaRaw
|
|
127
|
+
: undefined;
|
|
128
|
+
const addDirs = normalizeStringArray(job.add_dirs ?? job.addDirs);
|
|
129
|
+
const alwaysDeliver = job.always_deliver === true ? true : undefined;
|
|
130
|
+
const context = job.context != null ? String(job.context) : undefined;
|
|
131
|
+
const preCheck = job.pre_check != null ? String(job.pre_check) : undefined;
|
|
132
|
+
const attachments = normalizeStringArray(job.attachments);
|
|
133
|
+
const requiresConfirmation = job.requires_confirmation === true || job.requiresConfirmation === true ? true : undefined;
|
|
134
|
+
const confirmationTimeoutMin = job.confirmation_timeout_min != null
|
|
135
|
+
? Number(job.confirmation_timeout_min)
|
|
136
|
+
: (job.confirmationTimeoutMin != null ? Number(job.confirmationTimeoutMin) : undefined);
|
|
137
|
+
// Per-job agent scoping (a global cron can be scoped to a specific
|
|
138
|
+
// agent's profile). Accept both casings.
|
|
139
|
+
const agentSlugRaw = job.agentSlug ?? job.agent_slug;
|
|
140
|
+
const agentSlug = typeof agentSlugRaw === 'string' && /^[a-z0-9-]+$/i.test(agentSlugRaw)
|
|
141
|
+
? agentSlugRaw
|
|
142
|
+
: undefined;
|
|
143
|
+
const skills = normalizeStringArray(job.skills);
|
|
144
|
+
const allowedTools = normalizeStringArray(job.allowed_tools ?? job.allowedTools);
|
|
145
|
+
const allowedMcpServers = normalizeStringArray(job.allowed_mcp_servers ?? job.allowedMcpServers);
|
|
146
|
+
const tags = normalizeStringArray(job.tags);
|
|
147
|
+
const categoryRaw = job.category;
|
|
148
|
+
const category = typeof categoryRaw === 'string' && categoryRaw.trim()
|
|
149
|
+
? categoryRaw.trim().slice(0, 64)
|
|
150
|
+
: undefined;
|
|
151
|
+
const predictable = typeof job.predictable === 'boolean' ? job.predictable : undefined;
|
|
152
|
+
const description = typeof job.description === 'string' && job.description.trim()
|
|
153
|
+
? job.description.trim().slice(0, 500)
|
|
154
|
+
: undefined;
|
|
155
|
+
return {
|
|
156
|
+
name, schedule, prompt, enabled, tier, description, maxTurns, model, workDir, mode,
|
|
157
|
+
maxHours, maxRetries, after, successCriteria, successCriteriaText, successSchema, addDirs,
|
|
158
|
+
alwaysDeliver, context, preCheck, attachments, requiresConfirmation, confirmationTimeoutMin,
|
|
159
|
+
agentSlug,
|
|
160
|
+
skills, allowedTools, allowedMcpServers, tags, category, predictable,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
82
163
|
/**
|
|
83
164
|
* Parse cron job definitions from vault/00-System/CRON.md frontmatter.
|
|
84
165
|
* Used by both the in-process CronScheduler and the standalone CLI runner.
|
|
@@ -86,10 +167,9 @@ function normalizeStringArray(v) {
|
|
|
86
167
|
export function parseCronJobs() {
|
|
87
168
|
if (!existsSync(CRON_FILE))
|
|
88
169
|
return [];
|
|
89
|
-
const raw = readFileSync(CRON_FILE, 'utf-8');
|
|
90
170
|
let parsed;
|
|
91
171
|
try {
|
|
92
|
-
parsed = matter(
|
|
172
|
+
parsed = matter(readFileSync(CRON_FILE, 'utf-8'));
|
|
93
173
|
}
|
|
94
174
|
catch (err) {
|
|
95
175
|
logger.error({ err }, 'CRON.md YAML parse error — keeping previous jobs. Fix the file manually.');
|
|
@@ -98,76 +178,11 @@ export function parseCronJobs() {
|
|
|
98
178
|
const jobDefs = (parsed.data.jobs ?? []);
|
|
99
179
|
const jobs = [];
|
|
100
180
|
for (const job of jobDefs) {
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const tier = Number(job.tier ?? 1);
|
|
106
|
-
const maxTurns = job.max_turns != null ? Number(job.max_turns) : undefined;
|
|
107
|
-
const model = job.model != null ? String(job.model) : undefined;
|
|
108
|
-
const workDir = job.work_dir != null ? String(job.work_dir) : undefined;
|
|
109
|
-
const mode = job.mode === 'unleashed' ? 'unleashed' : 'standard';
|
|
110
|
-
const maxHours = job.max_hours != null ? Number(job.max_hours) : undefined;
|
|
111
|
-
const maxRetries = job.max_retries != null ? Number(job.max_retries) : undefined;
|
|
112
|
-
const after = job.after != null ? String(job.after) : undefined;
|
|
113
|
-
const successCriteria = Array.isArray(job.success_criteria)
|
|
114
|
-
? job.success_criteria.map(c => String(c))
|
|
115
|
-
: undefined;
|
|
116
|
-
// PRD Phase 1: prefer success_criteria_text (free-form). On read, fall
|
|
117
|
-
// back to joining the legacy success_criteria string[] so legacy YAML
|
|
118
|
-
// keeps rendering in the new editor surface. Writes go to the new field.
|
|
119
|
-
let successCriteriaText = typeof job.success_criteria_text === 'string'
|
|
120
|
-
? String(job.success_criteria_text)
|
|
121
|
-
: (typeof job.successCriteriaText === 'string' ? String(job.successCriteriaText) : undefined);
|
|
122
|
-
if (!successCriteriaText && Array.isArray(successCriteria) && successCriteria.length > 0) {
|
|
123
|
-
successCriteriaText = successCriteria.join('\n');
|
|
124
|
-
}
|
|
125
|
-
// PRD Phase 1: JSON Schema validated against ResultMessage.structured_output.
|
|
126
|
-
// Accept either snake_case (success_schema) or camelCase from API. Stored
|
|
127
|
-
// as a plain object; ajv is loaded lazily at validation time.
|
|
128
|
-
const successSchemaRaw = job.success_schema ?? job.successSchema;
|
|
129
|
-
const successSchema = (successSchemaRaw && typeof successSchemaRaw === 'object' && !Array.isArray(successSchemaRaw))
|
|
130
|
-
? successSchemaRaw
|
|
131
|
-
: undefined;
|
|
132
|
-
// PRD Phase 1: read scope beyond cwd. Accept either casing.
|
|
133
|
-
const addDirs = normalizeStringArray(job.add_dirs ?? job.addDirs);
|
|
134
|
-
const alwaysDeliver = job.always_deliver === true ? true : undefined;
|
|
135
|
-
const context = job.context != null ? String(job.context) : undefined;
|
|
136
|
-
const preCheck = job.pre_check != null ? String(job.pre_check) : undefined;
|
|
137
|
-
// Optional: scope a global job to a specific agent's profile (loads
|
|
138
|
-
// the agent's allowedTools whitelist, system prompt, etc.). Accept
|
|
139
|
-
// both camelCase and snake_case to be forgiving of user-written YAML.
|
|
140
|
-
const agentSlugRaw = job.agentSlug ?? job.agent_slug;
|
|
141
|
-
const agentSlug = typeof agentSlugRaw === 'string' && /^[a-z0-9-]+$/i.test(agentSlugRaw)
|
|
142
|
-
? agentSlugRaw
|
|
143
|
-
: undefined;
|
|
144
|
-
// ── Trick capabilities — accept both camelCase and snake_case ─────
|
|
145
|
-
const skills = normalizeStringArray(job.skills);
|
|
146
|
-
const allowedTools = normalizeStringArray(job.allowed_tools ?? job.allowedTools);
|
|
147
|
-
const allowedMcpServers = normalizeStringArray(job.allowed_mcp_servers ?? job.allowedMcpServers);
|
|
148
|
-
const tags = normalizeStringArray(job.tags);
|
|
149
|
-
const categoryRaw = job.category;
|
|
150
|
-
const category = typeof categoryRaw === 'string' && categoryRaw.trim()
|
|
151
|
-
? categoryRaw.trim().slice(0, 64)
|
|
152
|
-
: undefined;
|
|
153
|
-
// Predictable (contract) mode — undefined means legacy behavior.
|
|
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;
|
|
161
|
-
if (!name || !schedule || !prompt) {
|
|
181
|
+
const def = parseJobYaml(job);
|
|
182
|
+
if (def)
|
|
183
|
+
jobs.push(def);
|
|
184
|
+
else
|
|
162
185
|
logger.warn({ job }, 'Skipping malformed cron job');
|
|
163
|
-
continue;
|
|
164
|
-
}
|
|
165
|
-
jobs.push({
|
|
166
|
-
name, schedule, prompt, enabled, tier, description, maxTurns, model, workDir, mode,
|
|
167
|
-
maxHours, maxRetries, after, successCriteria, successCriteriaText, successSchema, addDirs,
|
|
168
|
-
alwaysDeliver, context, preCheck, agentSlug,
|
|
169
|
-
skills, allowedTools, allowedMcpServers, tags, category, predictable,
|
|
170
|
-
});
|
|
171
186
|
}
|
|
172
187
|
return jobs;
|
|
173
188
|
}
|
|
@@ -193,72 +208,20 @@ export function parseAgentCronJobs(agentsDir) {
|
|
|
193
208
|
if (!existsSync(cronFile))
|
|
194
209
|
continue;
|
|
195
210
|
try {
|
|
196
|
-
const
|
|
197
|
-
const parsed = matter(raw);
|
|
211
|
+
const parsed = matter(readFileSync(cronFile, 'utf-8'));
|
|
198
212
|
const jobDefs = (parsed.data.jobs ?? []);
|
|
199
213
|
for (const job of jobDefs) {
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
const prompt = String(job.prompt ?? '');
|
|
203
|
-
const enabled = job.enabled !== false;
|
|
204
|
-
const tier = Number(job.tier ?? 1);
|
|
205
|
-
const maxTurns = job.max_turns != null ? Number(job.max_turns) : undefined;
|
|
206
|
-
const model = job.model != null ? String(job.model) : undefined;
|
|
207
|
-
const workDir = job.work_dir != null ? String(job.work_dir) : undefined;
|
|
208
|
-
const mode = job.mode === 'unleashed' ? 'unleashed' : 'standard';
|
|
209
|
-
const maxHours = job.max_hours != null ? Number(job.max_hours) : undefined;
|
|
210
|
-
const maxRetries = job.max_retries != null ? Number(job.max_retries) : undefined;
|
|
211
|
-
const after = job.after != null ? String(job.after) : undefined;
|
|
212
|
-
const successCriteria = Array.isArray(job.success_criteria)
|
|
213
|
-
? job.success_criteria.map(c => String(c))
|
|
214
|
-
: undefined;
|
|
215
|
-
// PRD Phase 1 fields — symmetric with global parser above.
|
|
216
|
-
let successCriteriaText = typeof job.success_criteria_text === 'string'
|
|
217
|
-
? String(job.success_criteria_text)
|
|
218
|
-
: (typeof job.successCriteriaText === 'string' ? String(job.successCriteriaText) : undefined);
|
|
219
|
-
if (!successCriteriaText && Array.isArray(successCriteria) && successCriteria.length > 0) {
|
|
220
|
-
successCriteriaText = successCriteria.join('\n');
|
|
221
|
-
}
|
|
222
|
-
const successSchemaRaw = job.success_schema ?? job.successSchema;
|
|
223
|
-
const successSchema = (successSchemaRaw && typeof successSchemaRaw === 'object' && !Array.isArray(successSchemaRaw))
|
|
224
|
-
? successSchemaRaw
|
|
225
|
-
: undefined;
|
|
226
|
-
const addDirs = normalizeStringArray(job.add_dirs ?? job.addDirs);
|
|
227
|
-
const context = job.context != null ? String(job.context) : undefined;
|
|
228
|
-
const preCheck = job.pre_check != null ? String(job.pre_check) : undefined;
|
|
229
|
-
// ── Trick capabilities — symmetric with global parser ─────────
|
|
230
|
-
// (NB: this parser still lacks alwaysDeliver/attachments/
|
|
231
|
-
// requiresConfirmation/confirmationTimeoutMin from the global
|
|
232
|
-
// parser — pre-existing drift, fix in a separate change.)
|
|
233
|
-
const skills = normalizeStringArray(job.skills);
|
|
234
|
-
const allowedTools = normalizeStringArray(job.allowed_tools ?? job.allowedTools);
|
|
235
|
-
const allowedMcpServers = normalizeStringArray(job.allowed_mcp_servers ?? job.allowedMcpServers);
|
|
236
|
-
const tags = normalizeStringArray(job.tags);
|
|
237
|
-
const categoryRaw = job.category;
|
|
238
|
-
const category = typeof categoryRaw === 'string' && categoryRaw.trim()
|
|
239
|
-
? categoryRaw.trim().slice(0, 64)
|
|
240
|
-
: undefined;
|
|
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;
|
|
248
|
-
if (!name || !schedule || !prompt) {
|
|
214
|
+
const def = parseJobYaml(job);
|
|
215
|
+
if (!def) {
|
|
249
216
|
logger.warn({ job, agent: slug }, 'Skipping malformed agent cron job');
|
|
250
217
|
continue;
|
|
251
218
|
}
|
|
252
|
-
//
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
context, preCheck,
|
|
259
|
-
agentSlug: slug,
|
|
260
|
-
skills, allowedTools, allowedMcpServers, tags, category, predictable,
|
|
261
|
-
});
|
|
219
|
+
// Agent CRON.md stores BARE job names; we prefix with the slug at
|
|
220
|
+
// read time so the runtime can route by `<slug>:<job>` and the
|
|
221
|
+
// dashboard can disambiguate same-named jobs across agents.
|
|
222
|
+
// agentSlug is stamped from the folder location, overriding any
|
|
223
|
+
// value in the YAML — single source of truth.
|
|
224
|
+
allJobs.push({ ...def, name: `${slug}:${def.name}`, agentSlug: slug });
|
|
262
225
|
}
|
|
263
226
|
}
|
|
264
227
|
catch (err) {
|
package/dist/gateway/router.js
CHANGED
|
@@ -1068,7 +1068,7 @@ export class Gateway {
|
|
|
1068
1068
|
case 'approve': {
|
|
1069
1069
|
if (!args?.name)
|
|
1070
1070
|
return 'Missing skill name.';
|
|
1071
|
-
const result = approvePendingSkill(args.name);
|
|
1071
|
+
const result = await approvePendingSkill(args.name);
|
|
1072
1072
|
return result.message;
|
|
1073
1073
|
}
|
|
1074
1074
|
case 'reject': {
|
|
@@ -17,16 +17,16 @@
|
|
|
17
17
|
* description, body presence) is enforced by both the dashboard endpoint
|
|
18
18
|
* and these tools, so you can't smuggle a bad skill through the chat path.
|
|
19
19
|
*/
|
|
20
|
-
import {
|
|
20
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
21
21
|
import path from 'node:path';
|
|
22
22
|
import { z } from 'zod';
|
|
23
23
|
import matter from 'gray-matter';
|
|
24
24
|
import { VAULT_DIR, textResult, logger } from './shared.js';
|
|
25
|
-
//
|
|
25
|
+
// 1.18.124 — name regex is the only validator skill-tools still uses
|
|
26
|
+
// directly (for update_skill's pre-flight slug check). All other
|
|
27
|
+
// validations + the file write live in skill-store.writeSkill.
|
|
26
28
|
const NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
27
|
-
const NAME_MAX_LEN = 64;
|
|
28
29
|
const DESCRIPTION_MAX_LEN = 1024;
|
|
29
|
-
const RESERVED_NAMES = new Set(['anthropic', 'claude']);
|
|
30
30
|
function globalSkillsDir() {
|
|
31
31
|
return path.join(VAULT_DIR, '00-System', 'skills');
|
|
32
32
|
}
|
|
@@ -50,65 +50,37 @@ export function registerSkillTools(server) {
|
|
|
50
50
|
triggers: z.array(z.string()).optional()
|
|
51
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
52
|
}, async ({ name, title, description, body, tools, triggers }) => {
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
}
|
|
53
|
+
// 1.18.124 — delegate to the shared writeSkill helper. Validation
|
|
54
|
+
// (name regex, length caps, reserved words, already-exists) is
|
|
55
|
+
// now centralized; the same checks run for the dashboard endpoint
|
|
56
|
+
// and the auto-extraction path.
|
|
78
57
|
try {
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
58
|
+
const { writeSkill } = await import('../agent/skill-store.js');
|
|
59
|
+
const result = writeSkill({
|
|
60
|
+
name,
|
|
61
|
+
title,
|
|
62
|
+
description,
|
|
63
|
+
body,
|
|
64
|
+
tools,
|
|
65
|
+
triggers,
|
|
85
66
|
source: 'chat',
|
|
86
|
-
|
|
87
|
-
|
|
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');
|
|
67
|
+
});
|
|
68
|
+
logger.info({ name: result.name, entryPath: result.filePath, source: 'chat' }, 'Skill created via chat');
|
|
101
69
|
const toolsLine = (tools && tools.length > 0) ? `\nAllowed tools: ${tools.slice(0, 5).join(', ')}${tools.length > 5 ? `, +${tools.length - 5} more` : ''}` : '';
|
|
102
70
|
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 ${
|
|
71
|
+
return textResult(`✅ Created skill "${result.name}" at ${result.filePath}\n` +
|
|
104
72
|
`Description: ${description.slice(0, 200)}${description.length > 200 ? '…' : ''}` +
|
|
105
73
|
toolsLine +
|
|
106
74
|
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."`);
|
|
75
|
+
`\n\nThe skill is ready to pin to any task — open the cron editor, go to Tools & MCP, click "+ Add skill" and select "${result.name}". Or invoke it directly in chat: "Run the ${title || result.name} skill."`);
|
|
108
76
|
}
|
|
109
77
|
catch (err) {
|
|
78
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
79
|
+
if (msg.includes('already exists')) {
|
|
80
|
+
return textResult(`❌ Skill "${name}" already exists. Use update_skill instead, or pick a different name.`);
|
|
81
|
+
}
|
|
110
82
|
logger.error({ err, name }, 'create_skill failed');
|
|
111
|
-
return textResult(`❌
|
|
83
|
+
return textResult(`❌ ${msg}`);
|
|
112
84
|
}
|
|
113
85
|
});
|
|
114
86
|
// ── update_skill ────────────────────────────────────────────────────
|