clementine-agent 1.18.128 → 1.18.129

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Schedule registry — 1.18.129.
3
+ *
4
+ * The Anthropic-pure architectural shift: skills stay vanilla SKILL.md
5
+ * folders, scheduling lives in a separate tiny registry. Today's "fat
6
+ * cron" model in CRON.md duplicated ~70% of the skill schema (prompt,
7
+ * tools, MCP allowlists, work_dir, success criteria); this registry
8
+ * replaces all that with a thin {skillName → schedule} map.
9
+ *
10
+ * Storage: a single JSON file at `~/.clementine/schedules.json` with
11
+ * the shape:
12
+ *
13
+ * {
14
+ * "morning-briefing": {
15
+ * "schedule": "0 7 * * 1-5",
16
+ * "enabled": true,
17
+ * "agentSlug": null,
18
+ * "addedAt": "2026-05-08T...",
19
+ * "lastModifiedAt": "2026-05-08T..."
20
+ * }
21
+ * }
22
+ *
23
+ * The cron scheduler reads this alongside CRON.md and emits one
24
+ * CronJobDefinition per scheduled skill. The runtime path is unchanged
25
+ * — the skill body becomes the prompt via the existing buildSkillContext
26
+ * pipeline, no special case.
27
+ *
28
+ * Coexists with CRON.md indefinitely. Both formats run today; new work
29
+ * goes through this registry. Phase 3 ships the migrator that converts
30
+ * legacy crons to scheduled skills.
31
+ */
32
+ export interface ScheduleEntry {
33
+ /** Skill slug — must match a skill in the catalog. The runtime auto-pins
34
+ * this skill, so its body becomes the prompt at fire-time. */
35
+ skillName: string;
36
+ /** Cron expression. Empty / falsy = no auto-fire (still appears on
37
+ * Tasks page so the user can edit it). */
38
+ schedule: string;
39
+ /** When false the scheduler skips it. Lets users pause without losing
40
+ * the schedule definition. */
41
+ enabled: boolean;
42
+ /** When set, the skill runs as the named hired agent (Sasha, Ross,
43
+ * Nora, etc.). null/undefined = Clementine. Per-agent skills load
44
+ * from `agents/<slug>/skills/` first; the runtime resolves precedence. */
45
+ agentSlug?: string | null;
46
+ /** ISO timestamp of when the schedule was first created. */
47
+ addedAt?: string;
48
+ /** ISO timestamp of the last edit. */
49
+ lastModifiedAt?: string;
50
+ }
51
+ /** On-disk shape — keyed by skill name for O(1) lookup + simple merge. */
52
+ export type ScheduleFile = Record<string, Omit<ScheduleEntry, 'skillName'>>;
53
+ /**
54
+ * Read every schedule entry as a flat array. Each entry includes its
55
+ * skill name so callers don't have to reconstruct it.
56
+ */
57
+ export declare function listSchedules(): ScheduleEntry[];
58
+ /** Read one entry by skill name. Returns null when not scheduled. */
59
+ export declare function getSchedule(skillName: string): ScheduleEntry | null;
60
+ export interface SetScheduleInput {
61
+ schedule: string;
62
+ enabled?: boolean;
63
+ agentSlug?: string | null;
64
+ }
65
+ /**
66
+ * Upsert a schedule for a skill. New entries get `addedAt`; existing
67
+ * entries get `lastModifiedAt` updated. Returns the resulting entry so
68
+ * the dashboard can re-render without a re-fetch.
69
+ */
70
+ export declare function setSchedule(skillName: string, input: SetScheduleInput): ScheduleEntry;
71
+ /** Drop the entry for a skill. No-op when nothing is scheduled. */
72
+ export declare function removeSchedule(skillName: string): void;
73
+ /** Toggle the enabled flag. Skill stays in the registry; just won't
74
+ * fire while disabled. Caller-side convenience to avoid re-passing
75
+ * schedule + agentSlug just to flip the boolean. */
76
+ export declare function enableSchedule(skillName: string, enabled: boolean): ScheduleEntry | null;
77
+ //# sourceMappingURL=schedule-registry.d.ts.map
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Schedule registry — 1.18.129.
3
+ *
4
+ * The Anthropic-pure architectural shift: skills stay vanilla SKILL.md
5
+ * folders, scheduling lives in a separate tiny registry. Today's "fat
6
+ * cron" model in CRON.md duplicated ~70% of the skill schema (prompt,
7
+ * tools, MCP allowlists, work_dir, success criteria); this registry
8
+ * replaces all that with a thin {skillName → schedule} map.
9
+ *
10
+ * Storage: a single JSON file at `~/.clementine/schedules.json` with
11
+ * the shape:
12
+ *
13
+ * {
14
+ * "morning-briefing": {
15
+ * "schedule": "0 7 * * 1-5",
16
+ * "enabled": true,
17
+ * "agentSlug": null,
18
+ * "addedAt": "2026-05-08T...",
19
+ * "lastModifiedAt": "2026-05-08T..."
20
+ * }
21
+ * }
22
+ *
23
+ * The cron scheduler reads this alongside CRON.md and emits one
24
+ * CronJobDefinition per scheduled skill. The runtime path is unchanged
25
+ * — the skill body becomes the prompt via the existing buildSkillContext
26
+ * pipeline, no special case.
27
+ *
28
+ * Coexists with CRON.md indefinitely. Both formats run today; new work
29
+ * goes through this registry. Phase 3 ships the migrator that converts
30
+ * legacy crons to scheduled skills.
31
+ */
32
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
33
+ import os from 'node:os';
34
+ import path from 'node:path';
35
+ // Resolve lazily on each call so test environments (which override
36
+ // CLEMENTINE_HOME inside beforeEach) see the fresh value rather than the
37
+ // value snapshot at module-load time. Mirrors skill-suppressions.ts.
38
+ function baseDir() {
39
+ return process.env.CLEMENTINE_HOME || path.join(os.homedir(), '.clementine');
40
+ }
41
+ function schedulesPath() {
42
+ return path.join(baseDir(), 'schedules.json');
43
+ }
44
+ function readFile() {
45
+ const filePath = schedulesPath();
46
+ if (!existsSync(filePath))
47
+ return {};
48
+ try {
49
+ const raw = JSON.parse(readFileSync(filePath, 'utf-8'));
50
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
51
+ return {};
52
+ const out = {};
53
+ for (const [name, entry] of Object.entries(raw)) {
54
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry))
55
+ continue;
56
+ const e = entry;
57
+ // Tolerate partially-populated entries (a hand-edited file might
58
+ // have only `schedule` set). Default everything else.
59
+ out[name] = {
60
+ schedule: typeof e.schedule === 'string' ? e.schedule : '',
61
+ enabled: e.enabled !== false, // default true
62
+ agentSlug: typeof e.agentSlug === 'string' ? e.agentSlug : null,
63
+ addedAt: typeof e.addedAt === 'string' ? e.addedAt : undefined,
64
+ lastModifiedAt: typeof e.lastModifiedAt === 'string' ? e.lastModifiedAt : undefined,
65
+ };
66
+ }
67
+ return out;
68
+ }
69
+ catch {
70
+ // Malformed JSON — never throw out of the registry. Worst case the
71
+ // user re-creates entries via the UI; their CRON.md jobs keep firing.
72
+ return {};
73
+ }
74
+ }
75
+ function writeFile(data) {
76
+ const dir = baseDir();
77
+ if (!existsSync(dir))
78
+ mkdirSync(dir, { recursive: true });
79
+ // Sorted keys → deterministic on disk → friendly to git users who
80
+ // version-control their ~/.clementine/.
81
+ const sorted = {};
82
+ for (const k of Object.keys(data).sort())
83
+ sorted[k] = data[k];
84
+ writeFileSync(schedulesPath(), JSON.stringify(sorted, null, 2));
85
+ }
86
+ /**
87
+ * Read every schedule entry as a flat array. Each entry includes its
88
+ * skill name so callers don't have to reconstruct it.
89
+ */
90
+ export function listSchedules() {
91
+ const file = readFile();
92
+ return Object.entries(file).map(([skillName, entry]) => ({ skillName, ...entry }));
93
+ }
94
+ /** Read one entry by skill name. Returns null when not scheduled. */
95
+ export function getSchedule(skillName) {
96
+ const file = readFile();
97
+ const entry = file[skillName];
98
+ if (!entry)
99
+ return null;
100
+ return { skillName, ...entry };
101
+ }
102
+ /**
103
+ * Upsert a schedule for a skill. New entries get `addedAt`; existing
104
+ * entries get `lastModifiedAt` updated. Returns the resulting entry so
105
+ * the dashboard can re-render without a re-fetch.
106
+ */
107
+ export function setSchedule(skillName, input) {
108
+ if (!skillName || typeof skillName !== 'string') {
109
+ throw new Error('setSchedule: skillName required');
110
+ }
111
+ const file = readFile();
112
+ const existing = file[skillName];
113
+ const now = new Date().toISOString();
114
+ const entry = {
115
+ schedule: input.schedule ?? '',
116
+ enabled: input.enabled !== false,
117
+ agentSlug: input.agentSlug ?? null,
118
+ addedAt: existing?.addedAt ?? now,
119
+ lastModifiedAt: now,
120
+ };
121
+ file[skillName] = entry;
122
+ writeFile(file);
123
+ return { skillName, ...entry };
124
+ }
125
+ /** Drop the entry for a skill. No-op when nothing is scheduled. */
126
+ export function removeSchedule(skillName) {
127
+ const file = readFile();
128
+ if (!(skillName in file))
129
+ return;
130
+ delete file[skillName];
131
+ writeFile(file);
132
+ }
133
+ /** Toggle the enabled flag. Skill stays in the registry; just won't
134
+ * fire while disabled. Caller-side convenience to avoid re-passing
135
+ * schedule + agentSlug just to flip the boolean. */
136
+ export function enableSchedule(skillName, enabled) {
137
+ const file = readFile();
138
+ const entry = file[skillName];
139
+ if (!entry)
140
+ return null;
141
+ entry.enabled = enabled;
142
+ entry.lastModifiedAt = new Date().toISOString();
143
+ file[skillName] = entry;
144
+ writeFile(file);
145
+ return { skillName, ...entry };
146
+ }
147
+ //# sourceMappingURL=schedule-registry.js.map
package/dist/cli/cron.js CHANGED
@@ -62,6 +62,36 @@ export async function cmdCronRun(jobName) {
62
62
  process.env.CLEMENTINE_HOME = BASE_DIR;
63
63
  const jobs = parseCronJobs();
64
64
  let job = jobs.find((j) => j.name === jobName);
65
+ // 1.18.129 — Run-now fallback for skills that aren't on a schedule.
66
+ // The dashboard's "Run now" button on a skill detail pane calls this
67
+ // endpoint with the skill name. If the skill has no registry entry,
68
+ // synthesize a transient CronJobDefinition so the runtime can fire
69
+ // it once. The skill body becomes the prompt via buildSkillContext,
70
+ // same as scheduled-skill jobs.
71
+ if (!job) {
72
+ try {
73
+ const { getSkill } = await import('../agent/skill-store.js');
74
+ const skill = getSkill(jobName);
75
+ if (skill) {
76
+ const ext = (skill.frontmatter.clementine ?? {});
77
+ job = {
78
+ name: skill.frontmatter.name,
79
+ schedule: '0 0 1 1 *', // dummy — never auto-fires; run-now bypasses scheduling
80
+ prompt: '', // buildSkillContext injects the skill body
81
+ tier: 1,
82
+ enabled: true,
83
+ skills: [skill.frontmatter.name],
84
+ agentSlug: ext.agentSlug ?? undefined,
85
+ predictable: true,
86
+ source: 'scheduled-skill',
87
+ };
88
+ console.log(`(running skill "${jobName}" on demand — not yet scheduled)`);
89
+ }
90
+ }
91
+ catch {
92
+ // Fall through to error
93
+ }
94
+ }
65
95
  if (!job) {
66
96
  console.error(`Job not found: ${jobName}`);
67
97
  console.error(`Available jobs: ${jobs.map((j) => j.name).join(', ') || '(none)'}`);
@@ -4618,6 +4618,101 @@ export async function cmdDashboard(opts) {
4618
4618
  res.status(500).json({ ok: false, error: String(err) });
4619
4619
  }
4620
4620
  });
4621
+ // ── Schedule registry (1.18.129) ───────────────────────────────────
4622
+ // Anthropic-pure scheduling: skills stay vanilla, schedules live in
4623
+ // ~/.clementine/schedules.json. The cron scheduler reads this in
4624
+ // parseCronJobs alongside CRON.md, so any entry written here fires
4625
+ // through the same runtime as legacy crons.
4626
+ app.get('/api/schedules', async (_req, res) => {
4627
+ try {
4628
+ const { listSchedules } = await import('../agent/schedule-registry.js');
4629
+ res.json({ ok: true, schedules: listSchedules() });
4630
+ }
4631
+ catch (err) {
4632
+ res.status(500).json({ ok: false, error: String(err) });
4633
+ }
4634
+ });
4635
+ app.get('/api/schedules/:skillName', async (req, res) => {
4636
+ try {
4637
+ const { getSchedule } = await import('../agent/schedule-registry.js');
4638
+ const entry = getSchedule(req.params.skillName);
4639
+ if (!entry)
4640
+ return res.status(404).json({ ok: false, error: 'not scheduled' });
4641
+ res.json({ ok: true, schedule: entry });
4642
+ }
4643
+ catch (err) {
4644
+ res.status(500).json({ ok: false, error: String(err) });
4645
+ }
4646
+ });
4647
+ app.put('/api/schedules/:skillName', async (req, res) => {
4648
+ try {
4649
+ const skillName = req.params.skillName;
4650
+ if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(skillName)) {
4651
+ return res.status(400).json({ ok: false, error: 'invalid skill name slug' });
4652
+ }
4653
+ const body = (req.body ?? {});
4654
+ if (typeof body.schedule !== 'string' || !body.schedule.trim()) {
4655
+ return res.status(400).json({ ok: false, error: 'schedule (cron expression) required' });
4656
+ }
4657
+ // Optional: validate agentSlug shape when provided.
4658
+ if (body.agentSlug && body.agentSlug !== null && !/^[a-z0-9][a-z0-9-]{0,63}$/.test(body.agentSlug)) {
4659
+ return res.status(400).json({ ok: false, error: 'invalid agentSlug' });
4660
+ }
4661
+ // Quick sanity check on cron expression — node-cron validates at
4662
+ // schedule-time anyway, but a 400 here is friendlier than a server
4663
+ // log buried in the daemon output.
4664
+ try {
4665
+ if (!cron.validate(body.schedule)) {
4666
+ return res.status(400).json({ ok: false, error: 'cron expression does not parse' });
4667
+ }
4668
+ }
4669
+ catch {
4670
+ return res.status(400).json({ ok: false, error: 'cron expression does not parse' });
4671
+ }
4672
+ const { setSchedule } = await import('../agent/schedule-registry.js');
4673
+ const entry = setSchedule(skillName, {
4674
+ schedule: body.schedule,
4675
+ enabled: body.enabled !== false,
4676
+ agentSlug: body.agentSlug ?? null,
4677
+ });
4678
+ // Hot-reload: nudge the cron scheduler so the new entry fires on
4679
+ // its first tick instead of waiting for the next daemon restart.
4680
+ try {
4681
+ const gw = await getGateway();
4682
+ const sched = gw.cronScheduler;
4683
+ if (sched && typeof sched.reloadJobs === 'function')
4684
+ sched.reloadJobs();
4685
+ }
4686
+ catch { /* best-effort */ }
4687
+ res.json({ ok: true, schedule: entry });
4688
+ }
4689
+ catch (err) {
4690
+ res.status(500).json({ ok: false, error: String(err) });
4691
+ }
4692
+ });
4693
+ app.delete('/api/schedules/:skillName', async (req, res) => {
4694
+ try {
4695
+ const { removeSchedule } = await import('../agent/schedule-registry.js');
4696
+ removeSchedule(req.params.skillName);
4697
+ try {
4698
+ const gw = await getGateway();
4699
+ const sched = gw.cronScheduler;
4700
+ if (sched && typeof sched.reloadJobs === 'function')
4701
+ sched.reloadJobs();
4702
+ }
4703
+ catch { /* best-effort */ }
4704
+ // Broadcast so any open Tasks tabs drop the row immediately
4705
+ // instead of waiting for the next refresh.
4706
+ try {
4707
+ broadcastEvent({ type: 'cron_deleted', data: { job: req.params.skillName, source: 'scheduled-skill' } });
4708
+ }
4709
+ catch { /* non-fatal */ }
4710
+ res.json({ ok: true });
4711
+ }
4712
+ catch (err) {
4713
+ res.status(500).json({ ok: false, error: String(err) });
4714
+ }
4715
+ });
4621
4716
  // ── Skill migration (legacy .md → folder/SKILL.md) ─────────────────
4622
4717
  // Two endpoints: per-skill and bulk. Both wrap migrateLegacySkill /
4623
4718
  // migrateAllLegacySkills from skill-store.ts. The original .md is
@@ -24844,6 +24939,17 @@ function renderScheduledTaskCard(task) {
24844
24939
  var badges = '';
24845
24940
  if (task.owner) badges += '<span class="badge badge-orange">' + esc(task.owner) + '</span>';
24846
24941
  if (task.category) badges += '<span class="badge badge-gray" title="Category">' + esc(task.category) + '</span>';
24942
+ // 1.18.129 — source format badge. Scheduled skills are the new
24943
+ // canonical format (Anthropic-pure: skill folder + thin schedule
24944
+ // entry). Legacy CRON.md jobs carry their own prompt/tools/MCP and
24945
+ // duplicate ~70% of skill schema; they keep working but new tasks
24946
+ // should be skills.
24947
+ var defObj = task.definition || {};
24948
+ if (defObj.source === 'scheduled-skill') {
24949
+ badges += '<span class="badge" style="background:rgba(124,58,237,0.18);color:var(--purple);font-weight:600" title="Scheduled skill — fires the named SKILL.md folder on this cadence. Click to open the skill.">SKILL</span>';
24950
+ } else {
24951
+ badges += '<span class="badge badge-gray" title="Legacy CRON.md job. Carries its own prompt/tools/MCP. Convert to a scheduled skill when you can.">LEGACY CRON</span>';
24952
+ }
24847
24953
  if (task.predictable === true) badges += '<span class="badge badge-green" title="Contract mode — runs with only the prompt + pinned skills/tools. No MEMORY.md, no auto-matched skills, no team comms injection at fire-time.">🔒 predictable</span>';
24848
24954
  else if (task.predictable === false) badges += '<span class="badge badge-yellow" title="Dynamic mode — fire-time injects MEMORY.md, recent team activity, and auto-matched skills. Can drift from chat-time intent.">🔄 reads memory</span>';
24849
24955
  if (task.mode === 'unleashed') badges += '<span class="badge badge-purple">long-running</span>';
@@ -24897,14 +25003,109 @@ function renderScheduledTaskCard(task) {
24897
25003
  + renderTrickTagChips(task)
24898
25004
  + '<div class="task-card-badges">' + badges + '</div>'
24899
25005
  + '<div class="task-card-actions">'
24900
- + '<button class="btn-sm primary" onclick="openEditCronModal(\\x27' + safeName + '\\x27)" title="Edit task" style="background:var(--bg-tertiary);border:1px solid var(--border);color:var(--text-primary)">Edit</button>'
25006
+ // 1.18.129 — SKILL-source tasks edit on the Skills page (the skill
25007
+ // folder IS the source of truth); legacy CRON.md tasks keep the
25008
+ // existing in-place editor.
25009
+ + (defObj.source === 'scheduled-skill'
25010
+ ? '<button class="btn-sm primary" onclick="navigateTo(\\x27skills\\x27, { skill: \\x27' + safeName + '\\x27 })" title="Edit this skill on the Skills page" style="background:var(--bg-tertiary);border:1px solid var(--border);color:var(--text-primary)">Edit skill</button>'
25011
+ : '<button class="btn-sm primary" onclick="openEditCronModal(\\x27' + safeName + '\\x27)" title="Edit task" style="background:var(--bg-tertiary);border:1px solid var(--border);color:var(--text-primary)">Edit</button>')
24901
25012
  + runOrCancelBtn
24902
25013
  + '<button class="btn-sm secondary" onclick="openCronPreview(\\x27' + safeName + '\\x27)" title="See exactly what will run">Preview</button>'
24903
25014
  + '<button class="btn-sm secondary" data-trace-job="' + esc(task.name) + '" title="View execution trace">Trace</button>'
24904
- + '<button class="btn-sm secondary btn-danger" onclick="confirmDeleteCron(\\x27' + safeName + '\\x27)" title="Delete task">Del</button>'
25015
+ + (defObj.source === 'scheduled-skill'
25016
+ ? '<button class="btn-sm secondary btn-danger" onclick="unscheduleSkillFromCard(\\x27' + safeName + '\\x27)" title="Remove the schedule (skill stays)">Unschedule</button>'
25017
+ : '<button class="btn-sm secondary btn-danger" onclick="confirmDeleteCron(\\x27' + safeName + '\\x27)" title="Delete task">Del</button>')
24905
25018
  + '</div></div>';
24906
25019
  }
24907
25020
 
25021
+ // 1.18.129 — replace the "+ New Task" tile with a small dropdown that
25022
+ // nudges users toward the new "schedule a skill" path. Legacy cron
25023
+ // option stays for backward compat / power users with hand-rolled
25024
+ // CRON.md jobs they want to keep editing.
25025
+ function renderNewTaskMenu() {
25026
+ return ''
25027
+ + '<div class="task-card-add" style="padding:14px;display:flex;flex-direction:column;align-items:stretch;gap:6px;cursor:default">'
25028
+ + '<button class="btn-primary" onclick="openSchedulePickerForNew()" style="font-size:12px;padding:9px 12px;border:none;border-radius:6px;background:var(--accent);color:#fff;font-weight:500;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:6px">'
25029
+ + '+ Schedule a skill'
25030
+ + '</button>'
25031
+ + '<button onclick="openCreateCronModal(getBuildCreateOwner())" style="font-size:11px;padding:5px 10px;border:none;border-radius:4px;background:transparent;color:var(--text-muted);cursor:pointer;text-align:center" title="Legacy CRON.md format — duplicates skill schema. Prefer Schedule a skill above unless you need fat-cron features.">use legacy cron format</button>'
25032
+ + '</div>';
25033
+ }
25034
+
25035
+ // 1.18.129 — entry from the Tasks page: pick a skill, then open the
25036
+ // schedule overlay pre-wired for that skill. Two-step: skill picker
25037
+ // first (small modal), then the existing schedule-overlay flow.
25038
+ async function openSchedulePickerForNew() {
25039
+ // Fetch the skill catalog so users can scan + pick.
25040
+ var skills = [];
25041
+ try {
25042
+ var r = await apiFetch('/api/skills');
25043
+ var d = await r.json();
25044
+ if (r.ok && Array.isArray(d.skills)) skills = d.skills;
25045
+ } catch (_) { /* fall through to empty state */ }
25046
+
25047
+ var modal = document.getElementById('skill-picker-for-schedule-modal');
25048
+ if (!modal) {
25049
+ modal = document.createElement('div');
25050
+ modal.id = 'skill-picker-for-schedule-modal';
25051
+ modal.className = 'modal-overlay';
25052
+ modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.45);display:none;align-items:center;justify-content:center;z-index:1000;padding:20px';
25053
+ document.body.appendChild(modal);
25054
+ }
25055
+ var rows = '';
25056
+ if (skills.length === 0) {
25057
+ rows = '<div class="empty-state" style="padding:18px">No skills yet. Create one on the Skills page first, then come back to schedule it.</div>';
25058
+ } else {
25059
+ rows = skills.map(function(s) {
25060
+ var fm = s.frontmatter || {};
25061
+ var ext = fm.clementine || {};
25062
+ var bodyLines = (s.body || '').split('\\n').length;
25063
+ var useCount = typeof ext.useCount === 'number' ? ext.useCount : 0;
25064
+ return ''
25065
+ + '<div class="cap-picker-row" style="cursor:pointer" onclick="closeSkillPickerForSchedule(); openScheduleOverlayForSkill(\\x27' + jsStr(fm.name) + '\\x27, \\x27' + jsStr(ext.agentSlug || '') + '\\x27)">'
25066
+ + '<div class="cap-picker-row-body">'
25067
+ + '<div class="cap-picker-row-title">' + esc(fm.title || fm.name)
25068
+ + ' <span style="color:var(--text-muted);font-weight:normal;font-size:10px">' + esc(fm.name) + '</span>'
25069
+ + '</div>'
25070
+ + (fm.description ? '<div class="cap-picker-row-desc">' + esc(fm.description) + '</div>' : '')
25071
+ + '<div class="cap-picker-row-meta" style="display:flex;gap:10px;font-size:11px;color:var(--text-muted);margin-top:4px">'
25072
+ + '<span>' + bodyLines + ' lines</span>'
25073
+ + '<span>' + useCount + ' uses</span>'
25074
+ + '</div>'
25075
+ + '</div>'
25076
+ + '</div>';
25077
+ }).join('');
25078
+ }
25079
+ modal.innerHTML =
25080
+ '<div style="background:var(--bg-primary);border:1px solid var(--border);border-radius:10px;width:min(640px,95vw);max-height:80vh;display:flex;flex-direction:column;box-shadow:0 16px 48px rgba(0,0,0,0.35)">'
25081
+ + '<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 20px;border-bottom:1px solid var(--border)">'
25082
+ + '<h3 style="margin:0;font-size:15px;font-weight:600">Pick a skill to schedule</h3>'
25083
+ + '<button onclick="closeSkillPickerForSchedule()" style="background:none;border:none;font-size:18px;color:var(--text-muted);cursor:pointer">✕</button>'
25084
+ + '</div>'
25085
+ + '<div style="flex:1;overflow-y:auto;padding:6px 0">' + rows + '</div>'
25086
+ + '</div>';
25087
+ modal.style.display = 'flex';
25088
+ }
25089
+
25090
+ function closeSkillPickerForSchedule() {
25091
+ var m = document.getElementById('skill-picker-for-schedule-modal');
25092
+ if (m) m.style.display = 'none';
25093
+ }
25094
+
25095
+ // 1.18.129 — convenience: unschedule a skill directly from the Tasks
25096
+ // card without going through the schedule overlay.
25097
+ async function unscheduleSkillFromCard(skillName) {
25098
+ if (!confirm('Unschedule "' + skillName + '"? The skill stays in the catalog — only the schedule is removed.')) return;
25099
+ try {
25100
+ var r = await apiFetch('/api/schedules/' + encodeURIComponent(skillName), { method: 'DELETE' });
25101
+ if (!r.ok) { var d = await r.json(); toast(d.error || 'Failed', 'error'); return; }
25102
+ toast('Unscheduled "' + skillName + '"', 'success');
25103
+ if (typeof refreshCron === 'function') refreshCron();
25104
+ } catch (err) {
25105
+ toast('Failed: ' + err, 'error');
25106
+ }
25107
+ }
25108
+
24908
25109
  function renderScheduledWorkflowCard(wf) {
24909
25110
  var enabled = wf.enabled !== false;
24910
25111
  var wfId = jsStr(wf.id);
@@ -26392,11 +26593,11 @@ async function refreshCron() {
26392
26593
  } else {
26393
26594
  emptyLabel = ownerFilter === BUILD_OWNER_ALL ? 'No tasks across any agent.' : (ownerFilter ? 'No tasks for ' + ownerFilter + '.' : 'No tasks yet.');
26394
26595
  }
26395
- html += '<div class="task-card-add" onclick="openCreateCronModal(getBuildCreateOwner())">+ New Task</div>'
26596
+ html += renderNewTaskMenu()
26396
26597
  + '<div class="empty-state" style="padding:18px;color:var(--text-muted);font-size:13px">' + esc(emptyLabel) + '</div>';
26397
26598
  } else {
26398
26599
  html += filteredTasks.map(renderScheduledTaskCard).join('');
26399
- html += '<div class="task-card-add" onclick="openCreateCronModal(getBuildCreateOwner())">+ New Task</div>';
26600
+ html += renderNewTaskMenu();
26400
26601
  }
26401
26602
  html += '</div>';
26402
26603
 
@@ -27852,6 +28053,7 @@ async function showSkillDetail(name) {
27852
28053
  }
27853
28054
  detailEl.innerHTML = renderSkillDetail(d.skill);
27854
28055
  if (typeof loadSkillSuppressionState === 'function') loadSkillSuppressionState(name);
28056
+ if (typeof loadSkillScheduleState === 'function') loadSkillScheduleState(name);
27855
28057
  } catch (e) {
27856
28058
  detailEl.innerHTML = '<div style="padding:24px;color:var(--red);font-size:12px">Error: ' + esc(String(e)) + '</div>';
27857
28059
  }
@@ -27899,6 +28101,253 @@ async function toggleSkillSuppression(skillName, scope, suppressed) {
27899
28101
  }
27900
28102
  }
27901
28103
 
28104
+ // ── Skill schedule + Run now (1.18.129) ─────────────────────────────
28105
+ // The Anthropic-pure scheduling path: skills stay vanilla, schedules
28106
+ // live in ~/.clementine/schedules.json. This is the user's primary
28107
+ // surface for "this skill should fire on a cadence" — no cron modal.
28108
+ async function loadSkillScheduleState(skillName) {
28109
+ var statusEl = document.getElementById('skill-schedule-status');
28110
+ var btn = document.getElementById('skill-schedule-btn');
28111
+ if (!statusEl) return;
28112
+ try {
28113
+ var r = await apiFetch('/api/schedules/' + encodeURIComponent(skillName));
28114
+ if (r.status === 404) {
28115
+ statusEl.textContent = 'Not scheduled — runs on demand.';
28116
+ if (btn) btn.textContent = '⏰ Schedule';
28117
+ return;
28118
+ }
28119
+ var d = await r.json();
28120
+ if (!r.ok || !d.schedule) {
28121
+ statusEl.textContent = 'Not scheduled — runs on demand.';
28122
+ return;
28123
+ }
28124
+ var pretty = formatCronExpression ? formatCronExpression(d.schedule.schedule) : d.schedule.schedule;
28125
+ statusEl.textContent = (d.schedule.enabled ? '✅ Scheduled — ' : '⏸ Paused — ') + pretty;
28126
+ if (btn) btn.textContent = '⏰ Edit schedule';
28127
+ } catch (err) {
28128
+ statusEl.textContent = 'Schedule status unavailable.';
28129
+ }
28130
+ }
28131
+
28132
+ async function runSkillNow(skillName) {
28133
+ // The /api/cron/run/:job endpoint also runs unscheduled skills — the
28134
+ // CLI's cmdCronRun was extended in 1.18.129 to fall back to the skill
28135
+ // catalog when the name doesn't match a registered cron job. So one
28136
+ // endpoint handles both paths.
28137
+ var btn = document.getElementById('skill-run-now-btn');
28138
+ if (btn) { btn.disabled = true; btn.textContent = '⏳ Running…'; }
28139
+ try {
28140
+ var r = await apiFetch('/api/cron/run/' + encodeURIComponent(skillName), { method: 'POST' });
28141
+ if (r.status === 409) {
28142
+ toast('Already running. Wait for it to finish before firing again.', 'warn');
28143
+ return;
28144
+ }
28145
+ var d = await r.json();
28146
+ if (!r.ok) { toast(d.error || 'Run failed', 'error'); return; }
28147
+ toast('Started "' + skillName + '" — output streams to chat.', 'success');
28148
+ } catch (err) {
28149
+ toast('Failed: ' + err, 'error');
28150
+ } finally {
28151
+ if (btn) { btn.disabled = false; btn.textContent = '▶ Run now'; }
28152
+ }
28153
+ }
28154
+
28155
+ // Open the schedule overlay for a specific skill. The overlay is
28156
+ // pre-filled from the registry when a schedule already exists.
28157
+ async function openScheduleOverlayForSkill(skillName, agentSlug) {
28158
+ var modal = document.getElementById('schedule-skill-modal');
28159
+ if (!modal) {
28160
+ modal = document.createElement('div');
28161
+ modal.id = 'schedule-skill-modal';
28162
+ modal.className = 'modal-overlay';
28163
+ modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.45);display:none;align-items:center;justify-content:center;z-index:1000;padding:20px';
28164
+ modal.innerHTML =
28165
+ '<div style="background:var(--bg-primary);border:1px solid var(--border);border-radius:10px;width:min(540px,95vw);max-height:90vh;display:flex;flex-direction:column;box-shadow:0 16px 48px rgba(0,0,0,0.35)">'
28166
+ + '<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 20px;border-bottom:1px solid var(--border)">'
28167
+ + '<h3 id="ssm-title" style="margin:0;font-size:15px;font-weight:600">Schedule a skill</h3>'
28168
+ + '<button onclick="closeScheduleSkillModal()" style="background:none;border:none;font-size:18px;color:var(--text-muted);cursor:pointer">✕</button>'
28169
+ + '</div>'
28170
+ + '<div style="flex:1;overflow-y:auto;padding:18px 22px">'
28171
+ + '<input type="hidden" id="ssm-skill-name">'
28172
+ + '<input type="hidden" id="ssm-agent-slug">'
28173
+ + '<div style="font-size:12px;color:var(--text-muted);margin-bottom:14px">Skill: <code id="ssm-skill-display" style="background:var(--bg-tertiary);padding:2px 6px;border-radius:3px;font-size:11px"></code></div>'
28174
+ + '<label style="display:block;font-size:12px;font-weight:500;color:var(--text-secondary);margin-bottom:6px">Schedule</label>'
28175
+ + '<div style="display:flex;gap:8px;align-items:center;margin-bottom:8px">'
28176
+ + '<select id="ssm-freq" onchange="ssmUpdateFromBuilder()" style="padding:7px 8px;font-size:12px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary)">'
28177
+ + '<option value="daily">Every day</option>'
28178
+ + '<option value="weekdays">Weekdays (Mon–Fri)</option>'
28179
+ + '<option value="weekly">Weekly</option>'
28180
+ + '<option value="hourly">Every N hours</option>'
28181
+ + '<option value="minutes">Every N minutes</option>'
28182
+ + '</select>'
28183
+ + '<input type="number" id="ssm-interval" value="1" min="1" max="59" style="width:60px;padding:7px 8px;font-size:12px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary);display:none" onchange="ssmUpdateFromBuilder()">'
28184
+ + '<select id="ssm-day" style="padding:7px 8px;font-size:12px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary);display:none" onchange="ssmUpdateFromBuilder()">'
28185
+ + '<option value="1">Monday</option><option value="2">Tuesday</option><option value="3">Wednesday</option><option value="4">Thursday</option><option value="5">Friday</option><option value="6">Saturday</option><option value="0">Sunday</option>'
28186
+ + '</select>'
28187
+ + '<select id="ssm-hour" style="padding:7px 8px;font-size:12px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary)" onchange="ssmUpdateFromBuilder()"></select>'
28188
+ + '<select id="ssm-minute" style="padding:7px 8px;font-size:12px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary)" onchange="ssmUpdateFromBuilder()"></select>'
28189
+ + '</div>'
28190
+ + '<div style="font-size:11px;color:var(--text-muted);margin-bottom:14px">Cron: <code id="ssm-cron-preview" style="background:var(--bg-tertiary);padding:1px 5px;border-radius:3px"></code></div>'
28191
+ + '<label style="display:flex;align-items:center;gap:8px;font-size:12px;color:var(--text-secondary);cursor:pointer;margin-bottom:8px">'
28192
+ + '<input type="checkbox" id="ssm-enabled" checked> Enabled (uncheck to pause without losing the schedule)'
28193
+ + '</label>'
28194
+ + '<div id="ssm-error" style="display:none;color:var(--red);font-size:12px;margin-top:10px;padding:8px 10px;background:rgba(239,68,68,0.08);border:1px solid var(--red);border-radius:6px"></div>'
28195
+ + '</div>'
28196
+ + '<div style="display:flex;justify-content:space-between;gap:8px;padding:14px 20px;border-top:1px solid var(--border);background:var(--bg-secondary)">'
28197
+ + '<button id="ssm-unschedule" onclick="unscheduleSkillFromOverlay()" style="padding:7px 14px;font-size:12px;border:1px solid var(--red);border-radius:6px;background:transparent;color:var(--red);cursor:pointer;display:none">Unschedule</button>'
28198
+ + '<div style="flex:1"></div>'
28199
+ + '<button onclick="closeScheduleSkillModal()" 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>'
28200
+ + '<button id="ssm-save" onclick="saveScheduleFromOverlay()" 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">Save</button>'
28201
+ + '</div>'
28202
+ + '</div>';
28203
+ document.body.appendChild(modal);
28204
+ // Populate hour + minute dropdowns once.
28205
+ var hourSel = document.getElementById('ssm-hour');
28206
+ for (var h = 0; h < 24; h++) {
28207
+ var opt = document.createElement('option');
28208
+ opt.value = String(h);
28209
+ var hh = h === 0 ? 12 : h > 12 ? h - 12 : h;
28210
+ var ampm = h < 12 ? 'AM' : 'PM';
28211
+ opt.textContent = hh + ':00 ' + ampm;
28212
+ if (h === 9) opt.selected = true;
28213
+ hourSel.appendChild(opt);
28214
+ }
28215
+ var minSel = document.getElementById('ssm-minute');
28216
+ [0, 15, 30, 45].forEach(function(m) {
28217
+ var opt = document.createElement('option');
28218
+ opt.value = String(m);
28219
+ opt.textContent = ':' + (m < 10 ? '0' + m : m);
28220
+ minSel.appendChild(opt);
28221
+ });
28222
+ }
28223
+ document.getElementById('ssm-skill-name').value = skillName;
28224
+ document.getElementById('ssm-agent-slug').value = agentSlug || '';
28225
+ document.getElementById('ssm-skill-display').textContent = skillName;
28226
+ // Try to load existing schedule.
28227
+ var existing = null;
28228
+ try {
28229
+ var r = await apiFetch('/api/schedules/' + encodeURIComponent(skillName));
28230
+ if (r.ok) { var d = await r.json(); existing = d.schedule; }
28231
+ } catch (_) { /* not scheduled */ }
28232
+ document.getElementById('ssm-title').textContent = existing ? 'Edit schedule: ' + skillName : 'Schedule: ' + skillName;
28233
+ document.getElementById('ssm-unschedule').style.display = existing ? '' : 'none';
28234
+ document.getElementById('ssm-enabled').checked = existing ? existing.enabled !== false : true;
28235
+ // Default to daily 9am for new entries; round-trip the cron expr if editing.
28236
+ if (existing && existing.schedule) {
28237
+ ssmInferBuilderFromCron(existing.schedule);
28238
+ } else {
28239
+ document.getElementById('ssm-freq').value = 'daily';
28240
+ document.getElementById('ssm-hour').value = '9';
28241
+ document.getElementById('ssm-minute').value = '0';
28242
+ ssmUpdateFromBuilder();
28243
+ }
28244
+ document.getElementById('ssm-error').style.display = 'none';
28245
+ modal.style.display = 'flex';
28246
+ }
28247
+
28248
+ function closeScheduleSkillModal() {
28249
+ var m = document.getElementById('schedule-skill-modal');
28250
+ if (m) m.style.display = 'none';
28251
+ }
28252
+
28253
+ // Build a cron expression from the picker fields. Mirrors the existing
28254
+ // schedule-builder in the cron modal but kept self-contained here so
28255
+ // the overlay doesn't depend on the cron-modal DOM existing.
28256
+ function ssmUpdateFromBuilder() {
28257
+ var freq = document.getElementById('ssm-freq').value;
28258
+ var hour = document.getElementById('ssm-hour').value || '9';
28259
+ var minute = document.getElementById('ssm-minute').value || '0';
28260
+ var day = document.getElementById('ssm-day').value || '1';
28261
+ var interval = document.getElementById('ssm-interval').value || '1';
28262
+ document.getElementById('ssm-day').style.display = freq === 'weekly' ? '' : 'none';
28263
+ document.getElementById('ssm-interval').style.display = (freq === 'hourly' || freq === 'minutes') ? '' : 'none';
28264
+ document.getElementById('ssm-hour').style.display = (freq === 'minutes' || freq === 'hourly') ? 'none' : '';
28265
+ document.getElementById('ssm-minute').style.display = freq === 'minutes' ? 'none' : '';
28266
+ var cron = '';
28267
+ switch (freq) {
28268
+ case 'daily': cron = minute + ' ' + hour + ' * * *'; break;
28269
+ case 'weekdays': cron = minute + ' ' + hour + ' * * 1-5'; break;
28270
+ case 'weekly': cron = minute + ' ' + hour + ' * * ' + day; break;
28271
+ case 'hourly': cron = '0 */' + interval + ' * * *'; break;
28272
+ case 'minutes': cron = '*/' + interval + ' * * * *'; break;
28273
+ }
28274
+ document.getElementById('ssm-cron-preview').textContent = cron;
28275
+ }
28276
+
28277
+ // Best-effort reverse: given a cron expr, set the picker fields to a
28278
+ // sensible default. Falls back to "daily 9am" if we can't infer.
28279
+ function ssmInferBuilderFromCron(cron) {
28280
+ var parts = (cron || '').trim().split(/\\s+/);
28281
+ if (parts.length !== 5) { document.getElementById('ssm-freq').value = 'daily'; ssmUpdateFromBuilder(); return; }
28282
+ var minute = parts[0], hour = parts[1], dow = parts[4];
28283
+ if (minute.indexOf('*/') === 0) {
28284
+ document.getElementById('ssm-freq').value = 'minutes';
28285
+ document.getElementById('ssm-interval').value = minute.slice(2);
28286
+ } else if (hour.indexOf('*/') === 0) {
28287
+ document.getElementById('ssm-freq').value = 'hourly';
28288
+ document.getElementById('ssm-interval').value = hour.slice(2);
28289
+ } else if (dow === '1-5') {
28290
+ document.getElementById('ssm-freq').value = 'weekdays';
28291
+ document.getElementById('ssm-hour').value = hour;
28292
+ document.getElementById('ssm-minute').value = minute;
28293
+ } else if (dow !== '*' && /^[0-6]$/.test(dow)) {
28294
+ document.getElementById('ssm-freq').value = 'weekly';
28295
+ document.getElementById('ssm-day').value = dow;
28296
+ document.getElementById('ssm-hour').value = hour;
28297
+ document.getElementById('ssm-minute').value = minute;
28298
+ } else {
28299
+ document.getElementById('ssm-freq').value = 'daily';
28300
+ document.getElementById('ssm-hour').value = hour;
28301
+ document.getElementById('ssm-minute').value = minute;
28302
+ }
28303
+ ssmUpdateFromBuilder();
28304
+ }
28305
+
28306
+ async function saveScheduleFromOverlay() {
28307
+ var skillName = document.getElementById('ssm-skill-name').value;
28308
+ var agentSlug = document.getElementById('ssm-agent-slug').value;
28309
+ var schedule = document.getElementById('ssm-cron-preview').textContent;
28310
+ var enabled = document.getElementById('ssm-enabled').checked;
28311
+ var errEl = document.getElementById('ssm-error');
28312
+ var saveBtn = document.getElementById('ssm-save');
28313
+ if (saveBtn) { saveBtn.disabled = true; saveBtn.textContent = 'Saving…'; }
28314
+ try {
28315
+ var r = await apiFetch('/api/schedules/' + encodeURIComponent(skillName), {
28316
+ method: 'PUT',
28317
+ headers: { 'Content-Type': 'application/json' },
28318
+ body: JSON.stringify({ schedule: schedule, enabled: enabled, agentSlug: agentSlug || null }),
28319
+ });
28320
+ var d = await r.json();
28321
+ if (!r.ok) {
28322
+ if (errEl) { errEl.textContent = d.error || ('HTTP ' + r.status); errEl.style.display = ''; }
28323
+ return;
28324
+ }
28325
+ closeScheduleSkillModal();
28326
+ toast('Saved schedule for "' + skillName + '"', 'success');
28327
+ if (typeof loadSkillScheduleState === 'function') loadSkillScheduleState(skillName);
28328
+ if (currentPage === 'build' && typeof refreshCron === 'function') refreshCron();
28329
+ } catch (err) {
28330
+ if (errEl) { errEl.textContent = String(err); errEl.style.display = ''; }
28331
+ } finally {
28332
+ if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = 'Save'; }
28333
+ }
28334
+ }
28335
+
28336
+ async function unscheduleSkillFromOverlay() {
28337
+ var skillName = document.getElementById('ssm-skill-name').value;
28338
+ if (!confirm('Unschedule "' + skillName + '"? The skill stays — only the schedule is removed.')) return;
28339
+ try {
28340
+ var r = await apiFetch('/api/schedules/' + encodeURIComponent(skillName), { method: 'DELETE' });
28341
+ if (!r.ok) { var d = await r.json(); toast(d.error || 'Failed', 'error'); return; }
28342
+ closeScheduleSkillModal();
28343
+ toast('Unscheduled "' + skillName + '"', 'success');
28344
+ if (typeof loadSkillScheduleState === 'function') loadSkillScheduleState(skillName);
28345
+ if (currentPage === 'build' && typeof refreshCron === 'function') refreshCron();
28346
+ } catch (err) {
28347
+ toast('Failed: ' + err, 'error');
28348
+ }
28349
+ }
28350
+
27902
28351
  // ── Skill detail pane ────────────────────────────────────────────────
27903
28352
  // Renders a single skill in the right pane. Sections, in order:
27904
28353
  // 1. Header (name + 3 badges + description + file path)
@@ -27951,6 +28400,20 @@ function renderSkillDetail(s) {
27951
28400
  html += '</label>';
27952
28401
  html += '<span id="skill-suppress-status" style="font-size:11px;color:var(--text-muted);margin-left:auto"></span>';
27953
28402
  html += '</div>';
28403
+
28404
+ // 1.18.129 — Schedule + Run-now action row. Architectural shift:
28405
+ // skills can be invoked on demand (Run now) OR fired on a schedule
28406
+ // (the registry maps {skill → cron expr}). Replaces the "create a
28407
+ // cron task and pin this skill" indirection. The exact schedule
28408
+ // state (scheduled? when?) is loaded async after the pane mounts.
28409
+ html += '<div id="skill-schedule-row" data-skill="' + esc(fm.name) + '" style="margin-top:10px;padding:12px 14px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:6px;display:flex;align-items:center;gap:10px;flex-wrap:wrap">';
28410
+ html += '<div style="flex:1;min-width:200px">';
28411
+ html += '<div style="font-size:12px;font-weight:500;color:var(--text-primary);margin-bottom:2px">Run this skill</div>';
28412
+ html += '<div id="skill-schedule-status" style="font-size:11px;color:var(--text-muted)">Loading schedule…</div>';
28413
+ html += '</div>';
28414
+ html += '<button class="btn-sm btn-primary" id="skill-run-now-btn" onclick="runSkillNow(\\x27' + jsStr(fm.name) + '\\x27)" style="font-size:11px;padding:6px 12px;display:inline-flex;align-items:center;gap:6px" title="Fire this skill once, right now">▶ Run now</button>';
28415
+ html += '<button class="btn-sm" id="skill-schedule-btn" onclick="openScheduleOverlayForSkill(\\x27' + jsStr(fm.name) + '\\x27, \\x27' + jsStr(s.frontmatter.clementine?.agentSlug ?? '') + '\\x27)" style="font-size:11px;padding:6px 12px" title="Schedule this skill to run automatically">⏰ Schedule</button>';
28416
+ html += '</div>';
27954
28417
  html += '</div>';
27955
28418
 
27956
28419
  // ── 2. Validation warnings (if any)
@@ -17,7 +17,19 @@ export declare function todayISO(): string;
17
17
  */
18
18
  export declare function logToDailyNote(line: string): void;
19
19
  /**
20
- * Parse cron job definitions from vault/00-System/CRON.md frontmatter.
20
+ * Parse cron job definitions from two sources, merged:
21
+ * 1. The legacy `vault/00-System/CRON.md` frontmatter (fat cron format
22
+ * — its own prompt, allowedTools, allowedMcpServers, etc.).
23
+ * 2. The 1.18.129 schedule registry at `~/.clementine/schedules.json`,
24
+ * which holds thin {skill → schedule} bindings. Each entry is
25
+ * synthesized into a CronJobDefinition where the runtime auto-pins
26
+ * the named skill — the skill body becomes the prompt at fire-time
27
+ * via the existing buildSkillContext pipeline. Anthropic-pure.
28
+ *
29
+ * On a name collision (CRON.md has a job whose name matches a scheduled
30
+ * skill) the **scheduled-skill wins**, because that's the new canonical
31
+ * format and the user has explicitly opted into it for that name.
32
+ *
21
33
  * Used by both the in-process CronScheduler and the standalone CLI runner.
22
34
  */
23
35
  export declare function parseCronJobs(): CronJobDefinition[];
@@ -14,6 +14,7 @@ import matter from 'gray-matter';
14
14
  import pino from 'pino';
15
15
  import { CRON_FILE, WORKFLOWS_DIR, AGENTS_DIR, DAILY_NOTES_DIR, BASE_DIR, DISCORD_OWNER_ID, GOALS_DIR, CRON_REFLECTIONS_DIR, ADVISOR_LOG_PATH, TIMEZONE, } from '../config.js';
16
16
  import { listAllGoals, findGoalPath, readGoalById } from '../tools/shared.js';
17
+ import { listSchedules } from '../agent/schedule-registry.js';
17
18
  import { scanner } from '../security/scanner.js';
18
19
  import { parseAllWorkflows as parseAllWorkflowsSync } from '../agent/workflow-runner.js';
19
20
  import { SelfImproveLoop } from '../agent/self-improve.js';
@@ -161,30 +162,93 @@ function parseJobYaml(job) {
161
162
  };
162
163
  }
163
164
  /**
164
- * Parse cron job definitions from vault/00-System/CRON.md frontmatter.
165
+ * Parse cron job definitions from two sources, merged:
166
+ * 1. The legacy `vault/00-System/CRON.md` frontmatter (fat cron format
167
+ * — its own prompt, allowedTools, allowedMcpServers, etc.).
168
+ * 2. The 1.18.129 schedule registry at `~/.clementine/schedules.json`,
169
+ * which holds thin {skill → schedule} bindings. Each entry is
170
+ * synthesized into a CronJobDefinition where the runtime auto-pins
171
+ * the named skill — the skill body becomes the prompt at fire-time
172
+ * via the existing buildSkillContext pipeline. Anthropic-pure.
173
+ *
174
+ * On a name collision (CRON.md has a job whose name matches a scheduled
175
+ * skill) the **scheduled-skill wins**, because that's the new canonical
176
+ * format and the user has explicitly opted into it for that name.
177
+ *
165
178
  * Used by both the in-process CronScheduler and the standalone CLI runner.
166
179
  */
167
180
  export function parseCronJobs() {
168
- if (!existsSync(CRON_FILE))
169
- return [];
170
- let parsed;
181
+ const fromCronMd = [];
182
+ if (existsSync(CRON_FILE)) {
183
+ try {
184
+ const parsed = matter(readFileSync(CRON_FILE, 'utf-8'));
185
+ const jobDefs = (parsed.data.jobs ?? []);
186
+ for (const job of jobDefs) {
187
+ const def = parseJobYaml(job);
188
+ if (def)
189
+ fromCronMd.push(def);
190
+ else
191
+ logger.warn({ job }, 'Skipping malformed cron job');
192
+ }
193
+ }
194
+ catch (err) {
195
+ logger.error({ err }, 'CRON.md YAML parse error — keeping schedule-registry jobs only.');
196
+ }
197
+ }
198
+ // 1.18.129 — schedule registry → CronJobDefinition[]. The scheduler
199
+ // doesn't care about the source format; each entry surfaces as a
200
+ // self-pinning cron whose only "prompt" is "[skill body]" — the
201
+ // runtime's buildSkillContext loads the actual procedure.
202
+ const fromRegistry = [];
171
203
  try {
172
- parsed = matter(readFileSync(CRON_FILE, 'utf-8'));
204
+ const registry = parseScheduledSkillJobs();
205
+ fromRegistry.push(...registry);
173
206
  }
174
207
  catch (err) {
175
- logger.error({ err }, 'CRON.md YAML parse error — keeping previous jobs. Fix the file manually.');
176
- return [];
177
- }
178
- const jobDefs = (parsed.data.jobs ?? []);
179
- const jobs = [];
180
- for (const job of jobDefs) {
181
- const def = parseJobYaml(job);
182
- if (def)
183
- jobs.push(def);
184
- else
185
- logger.warn({ job }, 'Skipping malformed cron job');
208
+ logger.error({ err }, 'schedules.json parse error — falling back to CRON.md only');
209
+ }
210
+ // Dedup by name with scheduled-skill winning. Build a Map keyed on
211
+ // job name; insert CRON.md first, then registry to overwrite collisions.
212
+ const byName = new Map();
213
+ for (const j of fromCronMd)
214
+ byName.set(j.name, j);
215
+ for (const j of fromRegistry)
216
+ byName.set(j.name, j);
217
+ return [...byName.values()];
218
+ }
219
+ /**
220
+ * Read the schedule registry and project each entry into a
221
+ * CronJobDefinition that runs the named skill on the given schedule.
222
+ * Skill body / tools / MCP servers all flow through buildSkillContext
223
+ * at fire-time — this function only concerns itself with the binding.
224
+ */
225
+ function parseScheduledSkillJobs() {
226
+ const entries = listSchedules();
227
+ const out = [];
228
+ for (const e of entries) {
229
+ if (!e.skillName || !e.schedule)
230
+ continue;
231
+ out.push({
232
+ name: e.skillName,
233
+ schedule: e.schedule,
234
+ // Empty prompt — buildSkillContext injects the skill body. The
235
+ // runtime treats an empty prompt + a single pinned skill as
236
+ // "this skill IS the task." How-to-respond directive still applies.
237
+ prompt: '',
238
+ tier: 1,
239
+ enabled: e.enabled !== false,
240
+ skills: [e.skillName],
241
+ agentSlug: e.agentSlug ?? undefined,
242
+ // Predictable mode is the right default for scheduled skills:
243
+ // skip MEMORY.md auto-injection / team comms / runtime auto-match.
244
+ // The skill is the contract; nothing else fires.
245
+ predictable: true,
246
+ // Source marker so the dashboard can render a SKILL badge vs the
247
+ // legacy CRON.md badge. Not used by the runtime.
248
+ source: 'scheduled-skill',
249
+ });
186
250
  }
187
- return jobs;
251
+ return out;
188
252
  }
189
253
  /**
190
254
  * Parse cron jobs from agent-scoped CRON.md files.
package/dist/types.d.ts CHANGED
@@ -550,6 +550,14 @@ export interface CronJobDefinition {
550
550
  * Existing tricks (no field set) keep current behavior — backward compat.
551
551
  */
552
552
  predictable?: boolean;
553
+ /** 1.18.129 — where this cron job came from. 'cron-md' is the legacy
554
+ * fat-cron format in vault/00-System/CRON.md (or per-agent CRON.md).
555
+ * 'scheduled-skill' is the Anthropic-pure registry — a thin entry in
556
+ * ~/.clementine/schedules.json that auto-pins one named skill. The
557
+ * runtime treats both identically; the field exists so the dashboard
558
+ * can render a SKILL vs LEGACY CRON badge per row. Undefined =
559
+ * legacy CRON.md (older parsers don't stamp this). */
560
+ source?: 'cron-md' | 'scheduled-skill';
553
561
  }
554
562
  export type LongTaskRisk = 'normal' | 'long' | 'huge' | 'unsafe';
555
563
  export type LongTaskRoute = 'standard' | 'checkpointed' | 'opus_1m' | 'sonnet_1m' | 'split_required';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.18.128",
3
+ "version": "1.18.129",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",