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.
- package/dist/agent/schedule-registry.d.ts +77 -0
- package/dist/agent/schedule-registry.js +147 -0
- package/dist/cli/cron.js +30 -0
- package/dist/cli/dashboard.js +467 -4
- package/dist/gateway/cron-scheduler.d.ts +13 -1
- package/dist/gateway/cron-scheduler.js +81 -17
- package/dist/types.d.ts +8 -0
- package/package.json +1 -1
|
@@ -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)'}`);
|
package/dist/cli/dashboard.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
+
|
|
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 +=
|
|
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 +=
|
|
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
|
|
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
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
204
|
+
const registry = parseScheduledSkillJobs();
|
|
205
|
+
fromRegistry.push(...registry);
|
|
173
206
|
}
|
|
174
207
|
catch (err) {
|
|
175
|
-
logger.error({ err }, '
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
const
|
|
180
|
-
for (const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
|
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';
|