clementine-agent 1.18.117 → 1.18.119

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,123 @@
1
+ /**
2
+ * Cron-clean migrator (1.18.119)
3
+ *
4
+ * Migrates legacy cron jobs to the cleaner Skills-First contract model
5
+ * without changing what the job DOES. Pure function — takes a
6
+ * CronJobDefinition + the available skills inventory, returns the
7
+ * migrated job + a list of human-readable changes + an eligibility flag.
8
+ *
9
+ * What "legacy" means in user-collected data:
10
+ * - `predictable` is undefined or false → at fire-time the runner injects
11
+ * MEMORY.md, recent team activity, the delegation queue, and auto-
12
+ * matches MCP servers based on prompt text.
13
+ * - The prompt body opens with a "TOOL RESTRICTIONS — MANDATORY" preamble
14
+ * listing forbidden + allowed tools in prose. This is leftover from
15
+ * before the runtime allowedTools field existed.
16
+ * - No skill is pinned even though the runtime auto-matches one (or
17
+ * several) every fire. The match is not deterministic and the user
18
+ * can't see what's being chosen.
19
+ * - No description field — the card shows the first 200 chars of the
20
+ * prompt, which is often the tool-restriction boilerplate.
21
+ *
22
+ * What "clean" looks like:
23
+ * - `predictable: true` (contract mode — runs only with what's attached).
24
+ * - `description:` populated (auto-generated from the matching skill's
25
+ * description if there is one, else the first sentence of the
26
+ * non-restriction prompt body).
27
+ * - `skills: [<name>]` pinned when a clear match exists.
28
+ * - `allowed_tools: [...]` pulled out of the "ALLOWED tools (the COMPLETE
29
+ * list)" line of the legacy preamble.
30
+ * - `prompt:` reduced to either "Run the {skill name} skill." (when a
31
+ * skill is pinned) or just the cleaned procedural part (when no skill
32
+ * match — preamble stripped).
33
+ *
34
+ * Eligibility — a job is eligible when it has at least one of:
35
+ * - the legacy preamble in its prompt, OR
36
+ * - predictable: undefined/false AND a candidate skill match.
37
+ * If neither, the migrator returns `eligible: false` and leaves the job
38
+ * unchanged. This keeps "ones that don't need to be fixed" out of the
39
+ * migration UI entirely.
40
+ */
41
+ import type { CronJobDefinition, Skill } from '../types.js';
42
+ export interface CronMigrationResult {
43
+ /** True when migration would change something. False = "skip this one". */
44
+ eligible: boolean;
45
+ /** The migrated job (or the input job, untouched, when ineligible). */
46
+ migrated: CronJobDefinition;
47
+ /** Human-readable bullets — what the migrator did. Empty when not eligible. */
48
+ changes: string[];
49
+ /** When a skill match was applied, the matched skill's name. Surfaces in
50
+ * the migration UI so the user can verify. */
51
+ matchedSkill?: string;
52
+ /** Brief explanation when eligible:false — drives the dashboard's "this
53
+ * task already looks clean — nothing to do" affordance. */
54
+ notEligibleReason?: string;
55
+ }
56
+ /**
57
+ * When the prompt opens with the canonical "TOOL RESTRICTIONS — MANDATORY"
58
+ * preamble, this returns:
59
+ * - allowedTools: the parsed list of allowed tools (from the "ALLOWED
60
+ * tools (the COMPLETE list):" line)
61
+ * - cleanedPrompt: the rest of the prompt with the preamble removed
62
+ * and trimmed.
63
+ * When no preamble is found, returns null and the caller treats the prompt
64
+ * as already-clean.
65
+ */
66
+ export declare function stripToolRestrictionsPreamble(rawPrompt: string): {
67
+ allowedTools: string[];
68
+ cleanedPrompt: string;
69
+ } | null;
70
+ /**
71
+ * Returns a skill from the catalog that matches this job by either:
72
+ * 1. Exact name match (job "morning-briefing" → skill "morning-briefing")
73
+ * 2. Job name appears in skill triggers (job "audit-inbox-check" →
74
+ * skill "checking-audit-inbox" with trigger "audit inbox check")
75
+ * 3. Job-name keyword overlap with skill description (looser, last resort).
76
+ *
77
+ * Returns null when no clear match exists. The migrator then leaves the
78
+ * skill pin off and just strips the preamble — still an improvement.
79
+ */
80
+ export declare function findMatchingSkill(job: CronJobDefinition, skills: Skill[]): Skill | null;
81
+ /**
82
+ * Picks a 1-paragraph description for the migrated job.
83
+ * Priority:
84
+ * 1. The matched skill's description (best — it's already curated).
85
+ * 2. First sentence of the cleaned prompt body, ≤240 chars.
86
+ * 3. Empty string when prompt is empty or unintelligible — the caller
87
+ * then leaves the description field unset.
88
+ */
89
+ export declare function generateDescription(cleanedPrompt: string, matchedSkill: Skill | null): string;
90
+ /**
91
+ * Apply the clean-format migration to one cron job. Pure function —
92
+ * does NOT touch disk. Returns the new definition + the list of changes.
93
+ *
94
+ * Idempotent: passing an already-clean job back through returns
95
+ * `eligible: false` with `notEligibleReason`. Safe to call on every
96
+ * job in a vault and filter by eligibility.
97
+ *
98
+ * The caller (an HTTP endpoint or CLI) is responsible for:
99
+ * - serializing the result back to YAML
100
+ * - writing the .bak file
101
+ * - replacing the job in CRON.md
102
+ * - returning the diff to the dashboard
103
+ */
104
+ export declare function migrateCronJob(job: CronJobDefinition, skills: Skill[]): CronMigrationResult;
105
+ /**
106
+ * Run migrateCronJob across an entire CRON.md inventory. Returns:
107
+ * - eligible: jobs that would be migrated, with their migration result
108
+ * - skipped: jobs that look already-clean (eligibility=false)
109
+ *
110
+ * Used by the bulk-migrate UI to preview the full impact before the user
111
+ * commits to changes.
112
+ */
113
+ export declare function migrateAllEligibleJobs(jobs: CronJobDefinition[], skills: Skill[]): {
114
+ eligible: Array<{
115
+ job: CronJobDefinition;
116
+ result: CronMigrationResult;
117
+ }>;
118
+ skipped: Array<{
119
+ job: CronJobDefinition;
120
+ reason: string;
121
+ }>;
122
+ };
123
+ //# sourceMappingURL=cron-migrator.d.ts.map
@@ -0,0 +1,275 @@
1
+ /**
2
+ * Cron-clean migrator (1.18.119)
3
+ *
4
+ * Migrates legacy cron jobs to the cleaner Skills-First contract model
5
+ * without changing what the job DOES. Pure function — takes a
6
+ * CronJobDefinition + the available skills inventory, returns the
7
+ * migrated job + a list of human-readable changes + an eligibility flag.
8
+ *
9
+ * What "legacy" means in user-collected data:
10
+ * - `predictable` is undefined or false → at fire-time the runner injects
11
+ * MEMORY.md, recent team activity, the delegation queue, and auto-
12
+ * matches MCP servers based on prompt text.
13
+ * - The prompt body opens with a "TOOL RESTRICTIONS — MANDATORY" preamble
14
+ * listing forbidden + allowed tools in prose. This is leftover from
15
+ * before the runtime allowedTools field existed.
16
+ * - No skill is pinned even though the runtime auto-matches one (or
17
+ * several) every fire. The match is not deterministic and the user
18
+ * can't see what's being chosen.
19
+ * - No description field — the card shows the first 200 chars of the
20
+ * prompt, which is often the tool-restriction boilerplate.
21
+ *
22
+ * What "clean" looks like:
23
+ * - `predictable: true` (contract mode — runs only with what's attached).
24
+ * - `description:` populated (auto-generated from the matching skill's
25
+ * description if there is one, else the first sentence of the
26
+ * non-restriction prompt body).
27
+ * - `skills: [<name>]` pinned when a clear match exists.
28
+ * - `allowed_tools: [...]` pulled out of the "ALLOWED tools (the COMPLETE
29
+ * list)" line of the legacy preamble.
30
+ * - `prompt:` reduced to either "Run the {skill name} skill." (when a
31
+ * skill is pinned) or just the cleaned procedural part (when no skill
32
+ * match — preamble stripped).
33
+ *
34
+ * Eligibility — a job is eligible when it has at least one of:
35
+ * - the legacy preamble in its prompt, OR
36
+ * - predictable: undefined/false AND a candidate skill match.
37
+ * If neither, the migrator returns `eligible: false` and leaves the job
38
+ * unchanged. This keeps "ones that don't need to be fixed" out of the
39
+ * migration UI entirely.
40
+ */
41
+ // ── Preamble detection + parsing ──────────────────────────────────────
42
+ const PREAMBLE_RE = /^TOOL RESTRICTIONS\s*[—-]\s*MANDATORY[\s\S]*?(?:\n\n|\r\n\r\n)/i;
43
+ /**
44
+ * When the prompt opens with the canonical "TOOL RESTRICTIONS — MANDATORY"
45
+ * preamble, this returns:
46
+ * - allowedTools: the parsed list of allowed tools (from the "ALLOWED
47
+ * tools (the COMPLETE list):" line)
48
+ * - cleanedPrompt: the rest of the prompt with the preamble removed
49
+ * and trimmed.
50
+ * When no preamble is found, returns null and the caller treats the prompt
51
+ * as already-clean.
52
+ */
53
+ export function stripToolRestrictionsPreamble(rawPrompt) {
54
+ if (!rawPrompt)
55
+ return null;
56
+ const m = rawPrompt.match(PREAMBLE_RE);
57
+ if (!m)
58
+ return null;
59
+ const preamble = m[0];
60
+ const cleanedPrompt = rawPrompt.slice(preamble.length).trim();
61
+ // Extract the "ALLOWED tools (the COMPLETE list): a, b, c." line.
62
+ // Tolerant of variations: "ALLOWED — list:", "ALLOWED tools list:", etc.
63
+ const allowedMatch = preamble.match(/ALLOWED[\s\S]*?(?:list|tools)\s*\)?\s*:\s*([^.\n]+)/i);
64
+ const allowedTools = [];
65
+ if (allowedMatch && allowedMatch[1]) {
66
+ const list = allowedMatch[1]
67
+ .split(/[,;]/)
68
+ .map((s) => s.trim().replace(/[.,;:]+$/, ''))
69
+ .filter(Boolean)
70
+ .filter((s) => /^[a-zA-Z][\w-]*$/.test(s) || /^mcp__[\w-]+/.test(s));
71
+ allowedTools.push(...list);
72
+ }
73
+ return { allowedTools, cleanedPrompt };
74
+ }
75
+ // ── Skill matching ────────────────────────────────────────────────────
76
+ /**
77
+ * Returns a skill from the catalog that matches this job by either:
78
+ * 1. Exact name match (job "morning-briefing" → skill "morning-briefing")
79
+ * 2. Job name appears in skill triggers (job "audit-inbox-check" →
80
+ * skill "checking-audit-inbox" with trigger "audit inbox check")
81
+ * 3. Job-name keyword overlap with skill description (looser, last resort).
82
+ *
83
+ * Returns null when no clear match exists. The migrator then leaves the
84
+ * skill pin off and just strips the preamble — still an improvement.
85
+ */
86
+ export function findMatchingSkill(job, skills) {
87
+ const jobName = job.name.toLowerCase();
88
+ const jobNameWords = new Set(jobName.split(/[-_]/).filter((w) => w.length > 2));
89
+ // 1. Exact name match
90
+ for (const s of skills) {
91
+ if (s.frontmatter.name.toLowerCase() === jobName)
92
+ return s;
93
+ }
94
+ // 2. Trigger phrase contains the job-name word set
95
+ for (const s of skills) {
96
+ const triggers = s.frontmatter.clementine?.triggers ?? [];
97
+ for (const trigger of triggers) {
98
+ const triggerWords = String(trigger).toLowerCase().split(/\s+/);
99
+ const triggerWordSet = new Set(triggerWords);
100
+ // Match if all job-name words appear in trigger words (in any order)
101
+ let allMatch = true;
102
+ for (const w of jobNameWords) {
103
+ if (!triggerWordSet.has(w)) {
104
+ allMatch = false;
105
+ break;
106
+ }
107
+ }
108
+ if (allMatch && jobNameWords.size > 0)
109
+ return s;
110
+ }
111
+ }
112
+ // 3. Job-name word overlap with skill name (substring fallback —
113
+ // catches "audit-inbox-check" → "checking-audit-inbox" via shared
114
+ // "audit" + "inbox" word tokens).
115
+ for (const s of skills) {
116
+ const skillNameWords = new Set(s.frontmatter.name.toLowerCase().split(/[-_]/).filter((w) => w.length > 2));
117
+ let overlap = 0;
118
+ for (const w of jobNameWords)
119
+ if (skillNameWords.has(w))
120
+ overlap++;
121
+ // Need ≥2 overlapping content words for a confident match (avoids
122
+ // matching every "build-*" job to a single "build" skill).
123
+ if (overlap >= 2)
124
+ return s;
125
+ }
126
+ return null;
127
+ }
128
+ // ── Description generation ────────────────────────────────────────────
129
+ /**
130
+ * Picks a 1-paragraph description for the migrated job.
131
+ * Priority:
132
+ * 1. The matched skill's description (best — it's already curated).
133
+ * 2. First sentence of the cleaned prompt body, ≤240 chars.
134
+ * 3. Empty string when prompt is empty or unintelligible — the caller
135
+ * then leaves the description field unset.
136
+ */
137
+ export function generateDescription(cleanedPrompt, matchedSkill) {
138
+ if (matchedSkill?.frontmatter.description) {
139
+ return matchedSkill.frontmatter.description.trim().slice(0, 500);
140
+ }
141
+ if (!cleanedPrompt)
142
+ return '';
143
+ // First sentence — match up to ., !, ?, or first newline-newline.
144
+ // Falls back to first 200 chars when the body is one giant paragraph.
145
+ const sentenceMatch = cleanedPrompt.match(/^[\s\S]*?[.!?](?=\s|$)/);
146
+ const candidate = (sentenceMatch ? sentenceMatch[0] : cleanedPrompt.slice(0, 200))
147
+ .trim()
148
+ .replace(/\s+/g, ' ');
149
+ return candidate.slice(0, 240);
150
+ }
151
+ // ── The migration itself ──────────────────────────────────────────────
152
+ /**
153
+ * Apply the clean-format migration to one cron job. Pure function —
154
+ * does NOT touch disk. Returns the new definition + the list of changes.
155
+ *
156
+ * Idempotent: passing an already-clean job back through returns
157
+ * `eligible: false` with `notEligibleReason`. Safe to call on every
158
+ * job in a vault and filter by eligibility.
159
+ *
160
+ * The caller (an HTTP endpoint or CLI) is responsible for:
161
+ * - serializing the result back to YAML
162
+ * - writing the .bak file
163
+ * - replacing the job in CRON.md
164
+ * - returning the diff to the dashboard
165
+ */
166
+ export function migrateCronJob(job, skills) {
167
+ const changes = [];
168
+ // 1. Detect the legacy preamble.
169
+ const stripped = stripToolRestrictionsPreamble(job.prompt);
170
+ const hasPreamble = stripped !== null;
171
+ // 2. Find a matching skill (or null).
172
+ const matchedSkill = findMatchingSkill(job, skills);
173
+ // 3. Eligibility — anything that looks legacy is eligible.
174
+ const isLegacyPredictable = job.predictable !== true;
175
+ const hasNoDescription = !job.description || !job.description.trim();
176
+ const hasNoSkillsButCouldPinOne = (!job.skills || job.skills.length === 0) && matchedSkill !== null;
177
+ if (!hasPreamble && !isLegacyPredictable && !hasNoDescription && !hasNoSkillsButCouldPinOne) {
178
+ return {
179
+ eligible: false,
180
+ migrated: job,
181
+ changes: [],
182
+ matchedSkill: matchedSkill?.frontmatter.name,
183
+ notEligibleReason: 'Already clean — has predictable=true, a description, no legacy preamble, and either pinned skills or no skill match.',
184
+ };
185
+ }
186
+ // 4. Build the migrated definition. Start from a shallow copy so we
187
+ // don't mutate the input.
188
+ const migrated = { ...job };
189
+ // 4a. Set predictable: true (the headline contract-mode flip).
190
+ if (isLegacyPredictable) {
191
+ migrated.predictable = true;
192
+ changes.push('Enabled Strict mode (no auto-injected memory or team comms at fire-time).');
193
+ }
194
+ // 4b. Strip the preamble + populate allowed_tools.
195
+ let cleanedPrompt = job.prompt;
196
+ if (hasPreamble && stripped) {
197
+ cleanedPrompt = stripped.cleanedPrompt;
198
+ changes.push('Stripped TOOL RESTRICTIONS preamble from the prompt.');
199
+ if (stripped.allowedTools.length > 0) {
200
+ // Merge with any existing allowedTools so we don't lose tool-level
201
+ // narrowing the user already had.
202
+ const existing = new Set(job.allowedTools ?? []);
203
+ for (const t of stripped.allowedTools)
204
+ existing.add(t);
205
+ migrated.allowedTools = [...existing];
206
+ changes.push(`Moved ${stripped.allowedTools.length} tool name${stripped.allowedTools.length === 1 ? '' : 's'} into the allowed_tools field (${stripped.allowedTools.slice(0, 3).join(', ')}${stripped.allowedTools.length > 3 ? `, +${stripped.allowedTools.length - 3} more` : ''}).`);
207
+ }
208
+ }
209
+ // 4c. Pin the matched skill + replace the prompt with a thin invocation.
210
+ if (matchedSkill && (!job.skills || job.skills.length === 0)) {
211
+ const pinned = [matchedSkill.frontmatter.name];
212
+ migrated.skills = pinned;
213
+ changes.push(`Pinned matching skill: \`${matchedSkill.frontmatter.name}\` (was being auto-matched at fire-time).`);
214
+ // Reduce the prompt to a thin reference. The skill body carries the
215
+ // procedure; the cron prompt just needs to invoke it. Keep ANY
216
+ // post-preamble text the user added that wasn't already in the skill —
217
+ // when in doubt, prefer "Run the X skill." for a true clean migration.
218
+ const remainsBeyondSkill = (cleanedPrompt || '').trim();
219
+ const nameLabel = matchedSkill.frontmatter.title || matchedSkill.frontmatter.name;
220
+ migrated.prompt = `Run the ${nameLabel} skill.`;
221
+ if (remainsBeyondSkill && remainsBeyondSkill.length > 50) {
222
+ // The user had additional instructions after the preamble that aren't
223
+ // in the skill body. Append them so we don't lose intent. Length cap
224
+ // ensures we don't accidentally re-add the boilerplate we just stripped.
225
+ migrated.prompt += `\n\n${remainsBeyondSkill.slice(0, 1000)}`;
226
+ changes.push('Preserved additional prompt instructions after the skill invocation.');
227
+ }
228
+ }
229
+ else if (hasPreamble) {
230
+ // No skill match but we did strip the preamble — just save the
231
+ // cleaned prompt. Still an improvement: the card preview will now
232
+ // show actual instructions, not boilerplate.
233
+ migrated.prompt = cleanedPrompt.trim();
234
+ }
235
+ // 4d. Generate description if missing.
236
+ if (!job.description || !job.description.trim()) {
237
+ const desc = generateDescription(stripped?.cleanedPrompt || job.prompt, matchedSkill);
238
+ if (desc) {
239
+ migrated.description = desc;
240
+ changes.push(`Added description: "${desc.slice(0, 80)}${desc.length > 80 ? '…' : ''}".`);
241
+ }
242
+ }
243
+ return {
244
+ eligible: changes.length > 0,
245
+ migrated,
246
+ changes,
247
+ matchedSkill: matchedSkill?.frontmatter.name,
248
+ notEligibleReason: changes.length === 0
249
+ ? 'No legacy markers detected — task already follows the clean format.'
250
+ : undefined,
251
+ };
252
+ }
253
+ /**
254
+ * Run migrateCronJob across an entire CRON.md inventory. Returns:
255
+ * - eligible: jobs that would be migrated, with their migration result
256
+ * - skipped: jobs that look already-clean (eligibility=false)
257
+ *
258
+ * Used by the bulk-migrate UI to preview the full impact before the user
259
+ * commits to changes.
260
+ */
261
+ export function migrateAllEligibleJobs(jobs, skills) {
262
+ const eligible = [];
263
+ const skipped = [];
264
+ for (const job of jobs) {
265
+ const result = migrateCronJob(job, skills);
266
+ if (result.eligible) {
267
+ eligible.push({ job, result });
268
+ }
269
+ else {
270
+ skipped.push({ job, reason: result.notEligibleReason || 'Already clean.' });
271
+ }
272
+ }
273
+ return { eligible, skipped };
274
+ }
275
+ //# sourceMappingURL=cron-migrator.js.map
@@ -4556,6 +4556,206 @@ export async function cmdDashboard(opts) {
4556
4556
  res.status(500).json({ ok: false, error: String(err) });
4557
4557
  }
4558
4558
  });
4559
+ // ── Cron-clean migrator (1.18.119) ─────────────────────────────────
4560
+ // Three endpoints (matching the skill-migration pattern):
4561
+ // - GET /api/cron/migrate-preview — dry-run across every job
4562
+ // - POST /api/cron/:job/migrate — apply migration to one job
4563
+ // - POST /api/cron/migrate-all — apply to all eligible jobs
4564
+ // Every apply writes a `<basename>.bak` of the CRON.md before saving.
4565
+ app.get('/api/cron/migrate-preview', async (_req, res) => {
4566
+ try {
4567
+ const { migrateAllEligibleJobs } = await import('../agent/cron-migrator.js');
4568
+ const { listSkills } = await import('../agent/skill-store.js');
4569
+ const { parseCronJobs, parseAgentCronJobs } = await import('../gateway/cron-scheduler.js');
4570
+ const skills = listSkills();
4571
+ const jobs = [...parseCronJobs(), ...parseAgentCronJobs(path.join(VAULT_DIR, '00-System', 'agents'))];
4572
+ const out = migrateAllEligibleJobs(jobs, skills);
4573
+ // Trim the response — the dashboard doesn't need the full migrated job
4574
+ // body for the preview list, just the names + change bullets.
4575
+ res.json({
4576
+ ok: true,
4577
+ eligible: out.eligible.map(({ job, result }) => ({
4578
+ name: job.name,
4579
+ enabled: job.enabled,
4580
+ schedule: job.schedule,
4581
+ changes: result.changes,
4582
+ matchedSkill: result.matchedSkill ?? null,
4583
+ // Send a small before/after snippet so the UI can show a diff.
4584
+ before: { prompt: (job.prompt || '').slice(0, 600), predictable: job.predictable, description: job.description },
4585
+ after: {
4586
+ prompt: (result.migrated.prompt || '').slice(0, 600),
4587
+ predictable: result.migrated.predictable,
4588
+ description: result.migrated.description,
4589
+ skills: result.migrated.skills,
4590
+ allowedTools: result.migrated.allowedTools,
4591
+ },
4592
+ })),
4593
+ skipped: out.skipped.map(({ job, reason }) => ({ name: job.name, reason })),
4594
+ });
4595
+ }
4596
+ catch (err) {
4597
+ res.status(500).json({ ok: false, error: String(err) });
4598
+ }
4599
+ });
4600
+ app.post('/api/cron/:job/migrate', async (req, res) => {
4601
+ try {
4602
+ const jobName = req.params.job;
4603
+ if (!jobName) {
4604
+ res.status(400).json({ ok: false, error: 'job name required' });
4605
+ return;
4606
+ }
4607
+ const { migrateCronJob } = await import('../agent/cron-migrator.js');
4608
+ const { listSkills } = await import('../agent/skill-store.js');
4609
+ const { parseCronJobs, parseAgentCronJobs } = await import('../gateway/cron-scheduler.js');
4610
+ // Locate the source file + the job
4611
+ const { cronFile, bareJobName } = resolveJobCronFile(jobName);
4612
+ const allJobs = [...parseCronJobs(), ...parseAgentCronJobs(path.join(VAULT_DIR, '00-System', 'agents'))];
4613
+ const target = allJobs.find(j => String(j.name).toLowerCase() === jobName.toLowerCase());
4614
+ if (!target) {
4615
+ res.status(404).json({ ok: false, error: 'job "' + jobName + '" not found' });
4616
+ return;
4617
+ }
4618
+ const skills = listSkills();
4619
+ const result = migrateCronJob(target, skills);
4620
+ if (!result.eligible) {
4621
+ res.json({ ok: true, eligible: false, reason: result.notEligibleReason });
4622
+ return;
4623
+ }
4624
+ // Write back. .bak is produced by writeCronFileAt's own backup if any —
4625
+ // here we add one explicitly so the user has a rollback artifact.
4626
+ const bakPath = cronFile + '.bak';
4627
+ try {
4628
+ writeFileSync(bakPath, readFileSync(cronFile, 'utf-8'));
4629
+ }
4630
+ catch { /* best-effort */ }
4631
+ const { parsed, jobs } = readCronFileAt(cronFile);
4632
+ // Find by bare name (job in the YAML uses bareJobName, not the global "agent:job" form)
4633
+ const idx = jobs.findIndex(j => String(j.name).toLowerCase() === bareJobName.toLowerCase());
4634
+ if (idx < 0) {
4635
+ res.status(404).json({ ok: false, error: 'job not in CRON.md (resolved to "' + bareJobName + '")' });
4636
+ return;
4637
+ }
4638
+ // Convert the migrated CronJobDefinition back to YAML-friendly shape.
4639
+ // CRITICAL: agent CRON.md stores BARE job names (no agent: prefix);
4640
+ // parseAgentCronJobs adds the prefix at read time. Writing m.name
4641
+ // back here would double-prefix on the next read. Use bareJobName.
4642
+ const m = result.migrated;
4643
+ const next = { ...jobs[idx] };
4644
+ next.name = bareJobName;
4645
+ next.schedule = m.schedule;
4646
+ next.prompt = m.prompt;
4647
+ next.enabled = m.enabled;
4648
+ next.tier = m.tier;
4649
+ if (m.description)
4650
+ next.description = m.description;
4651
+ else
4652
+ delete next.description;
4653
+ if (m.predictable === true)
4654
+ next.predictable = true;
4655
+ if (m.skills && m.skills.length > 0)
4656
+ next.skills = m.skills;
4657
+ if (m.allowedTools && m.allowedTools.length > 0)
4658
+ next.allowed_tools = m.allowedTools;
4659
+ if (m.allowedMcpServers && m.allowedMcpServers.length > 0)
4660
+ next.allowed_mcp_servers = m.allowedMcpServers;
4661
+ jobs[idx] = next;
4662
+ writeCronFileAt(cronFile, parsed, jobs);
4663
+ res.json({
4664
+ ok: true,
4665
+ eligible: true,
4666
+ name: jobName,
4667
+ cronFile,
4668
+ bakPath,
4669
+ changes: result.changes,
4670
+ matchedSkill: result.matchedSkill ?? null,
4671
+ });
4672
+ }
4673
+ catch (err) {
4674
+ res.status(500).json({ ok: false, error: String(err) });
4675
+ }
4676
+ });
4677
+ app.post('/api/cron/migrate-all', async (_req, res) => {
4678
+ try {
4679
+ const { migrateAllEligibleJobs } = await import('../agent/cron-migrator.js');
4680
+ const { listSkills } = await import('../agent/skill-store.js');
4681
+ const { parseCronJobs, parseAgentCronJobs } = await import('../gateway/cron-scheduler.js');
4682
+ const agentsDir = path.join(VAULT_DIR, '00-System', 'agents');
4683
+ const skills = listSkills();
4684
+ const allJobs = [...parseCronJobs(), ...parseAgentCronJobs(agentsDir)];
4685
+ const out = migrateAllEligibleJobs(allJobs, skills);
4686
+ const applied = [];
4687
+ const failed = [];
4688
+ const touchedFiles = new Set();
4689
+ // Group writes by file so we open + parse + serialize each file once.
4690
+ const byFile = new Map();
4691
+ for (const { job, result } of out.eligible) {
4692
+ const { cronFile, bareJobName } = resolveJobCronFile(job.name);
4693
+ if (!byFile.has(cronFile))
4694
+ byFile.set(cronFile, []);
4695
+ byFile.get(cronFile).push({ bareName: bareJobName, result });
4696
+ }
4697
+ for (const [cronFile, items] of byFile.entries()) {
4698
+ try {
4699
+ // .bak the file before any edits so a single rollback restores it.
4700
+ const bakPath = cronFile + '.bak';
4701
+ try {
4702
+ writeFileSync(bakPath, readFileSync(cronFile, 'utf-8'));
4703
+ }
4704
+ catch { /* best-effort */ }
4705
+ const { parsed, jobs } = readCronFileAt(cronFile);
4706
+ for (const { bareName, result } of items) {
4707
+ const idx = jobs.findIndex(j => String(j.name).toLowerCase() === bareName.toLowerCase());
4708
+ if (idx < 0) {
4709
+ failed.push({ name: bareName, error: 'job vanished mid-migration' });
4710
+ continue;
4711
+ }
4712
+ const m = result.migrated;
4713
+ const next = { ...jobs[idx] };
4714
+ // Same bare-vs-prefixed gotcha as the per-job migrate path —
4715
+ // agent CRON.md stores bare names. Always write bareName here.
4716
+ next.name = bareName;
4717
+ next.schedule = m.schedule;
4718
+ next.prompt = m.prompt;
4719
+ next.enabled = m.enabled;
4720
+ next.tier = m.tier;
4721
+ if (m.description)
4722
+ next.description = m.description;
4723
+ else
4724
+ delete next.description;
4725
+ if (m.predictable === true)
4726
+ next.predictable = true;
4727
+ if (m.skills && m.skills.length > 0)
4728
+ next.skills = m.skills;
4729
+ if (m.allowedTools && m.allowedTools.length > 0)
4730
+ next.allowed_tools = m.allowedTools;
4731
+ if (m.allowedMcpServers && m.allowedMcpServers.length > 0)
4732
+ next.allowed_mcp_servers = m.allowedMcpServers;
4733
+ jobs[idx] = next;
4734
+ applied.push({ name: m.name, cronFile });
4735
+ }
4736
+ writeCronFileAt(cronFile, parsed, jobs);
4737
+ touchedFiles.add(cronFile);
4738
+ }
4739
+ catch (err) {
4740
+ for (const { bareName } of items)
4741
+ failed.push({ name: bareName, error: String(err) });
4742
+ }
4743
+ }
4744
+ res.json({
4745
+ ok: true,
4746
+ appliedCount: applied.length,
4747
+ skippedCount: out.skipped.length,
4748
+ failedCount: failed.length,
4749
+ applied,
4750
+ skipped: out.skipped.map(({ job, reason }) => ({ name: job.name, reason })),
4751
+ failed,
4752
+ touchedFiles: [...touchedFiles],
4753
+ });
4754
+ }
4755
+ catch (err) {
4756
+ res.status(500).json({ ok: false, error: String(err) });
4757
+ }
4758
+ });
4559
4759
  app.get('/api/cron/:job/draft', async (req, res) => {
4560
4760
  try {
4561
4761
  const jobName = req.params.job;
@@ -20320,8 +20520,10 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
20320
20520
  <div id="skills-list" style="padding:6px"></div>
20321
20521
  </div>
20322
20522
  <div id="skills-detail-pane" style="overflow-y:auto;border:1px solid var(--border);border-radius:8px;background:var(--bg-secondary);padding:0">
20323
- <div id="skills-detail" style="padding:24px;color:var(--text-muted);text-align:center;font-size:13px">
20324
- Select a skill on the left to see its procedure, tools, and data sources.
20523
+ <div id="skills-detail" style="padding:0;font-size:13px">
20524
+ <div style="padding:60px 24px;color:var(--text-muted);text-align:center;font-size:13px">
20525
+ Select a skill on the left to see its procedure, tools, and data sources.
20526
+ </div>
20325
20527
  </div>
20326
20528
  </div>
20327
20529
  </div>
@@ -24473,7 +24675,14 @@ function renderScheduledTaskCard(task) {
24473
24675
  if (task.maxRetries != null) badges += '<span class="badge badge-gray">' + esc(task.maxRetries) + ' retries</span>';
24474
24676
  badges += operationUsageBadge(task.usage);
24475
24677
  badges += '<span class="badge ' + (enabled ? 'badge-green' : 'badge-gray') + '">' + (enabled ? 'Enabled' : 'Disabled') + '</span>';
24476
- badges += '<span class="badge ' + (task.health === 'broken' || task.health === 'failed' ? 'badge-yellow' : 'badge-gray') + '">' + esc(task.healthLabel || task.health) + '</span>';
24678
+ // 1.18.118 only emit the health badge when it adds new information.
24679
+ // ok/idle are implicit when the green Enabled badge is showing, and
24680
+ // disabled would just duplicate the gray Disabled badge above.
24681
+ var hl = String(task.healthLabel || task.health || '').toLowerCase();
24682
+ if (hl && hl !== 'ok' && hl !== 'idle' && hl !== 'disabled') {
24683
+ var hlClass = (hl === 'broken' || hl === 'failed' || hl === 'timeout') ? 'badge-yellow' : (hl === 'running' ? 'badge-blue' : 'badge-gray');
24684
+ badges += '<span class="badge ' + hlClass + '">' + esc(task.healthLabel || task.health) + '</span>';
24685
+ }
24477
24686
  var safeName = jsStr(task.name);
24478
24687
  // PRD §10 / 1.18.91: when a task is mid-flight, Run Now is meaningless and
24479
24688
  // would race against the concurrency lock; replace it with a Cancel button
@@ -25965,7 +26174,12 @@ async function refreshCron() {
25965
26174
  // top failure). The runs payload from /api/cron/runs (already fetched
25966
26175
  // alongside ops) feeds the metrics. Render an empty shell first;
25967
26176
  // refreshHealthStrip fills it in.
25968
- var html = '<div id="health-strip" class="health-strip"></div>';
26177
+ // 1.18.119 cron-clean migration banner. Mounts here as an empty
26178
+ // placeholder; refreshCronCleanBanner() fetches /api/cron/migrate-preview
26179
+ // async and fills this in only when there are eligible legacy jobs.
26180
+ // The banner stays empty (zero visual noise) when the vault is clean.
26181
+ var html = '<div id="cron-migrate-banner-host"></div>';
26182
+ html += '<div id="health-strip" class="health-strip"></div>';
25969
26183
  // 1.18.115 — collapse the cost/latency/reliability/activity mini-cards
25970
26184
  // into a <details> block. The Health Strip already covers what most
25971
26185
  // users want at a glance; the 4 mini-dashboards are for deeper
@@ -25986,18 +26200,13 @@ async function refreshCron() {
25986
26200
  if (visibleRunning.length > 10) html += '<div class="empty-state" style="padding:18px;color:var(--text-muted);font-size:13px">Showing 10 of ' + visibleRunning.length + ' active runs. Use the Owner filter to narrow this list.</div>';
25987
26201
  }
25988
26202
 
25989
- // ── Needs attention (only when there are issues) ──
25990
- if (visibleAttention.length > 0) {
25991
- html += operationSectionHeader('Needs attention', 'Broken scheduled tasks and failed runtime work that can waste tokens or silently stop.', 'badge-yellow', visibleAttention.length + ' review', visibleRunning.length > 0 ? '28px' : '0')
25992
- + '<div class="task-grid">' + visibleAttention.slice(0, 12).map(renderAttentionCard).join('') + '</div>';
25993
- if (visibleAttention.length > 12) html += '<div class="empty-state" style="padding:18px;color:var(--text-muted);font-size:13px">Showing 12 of ' + visibleAttention.length + ' items. Use the Owner filter to narrow this list.</div>';
25994
- }
25995
-
25996
- // ── Zone 2 — Your tasks (the main card grid) ──
26203
+ // ── Zone 2 — Your tasks (the main card grid; promoted above "Needs
26204
+ // attention" in 1.18.118 so the user sees their working tasks at
26205
+ // fold instead of having to scroll past 1,000+ px of error cards).
25997
26206
  var filteredTasks = applyTrickFilter(visibleTasks, _trickFilter);
25998
26207
  var filterPillsHtml = renderTrickFilterRow(visibleTasks, _trickFilter);
25999
26208
  var taskCountLabel = (_trickFilter.kind ? filteredTasks.length + '/' + visibleTasks.length : visibleTasks.length) + ' task' + (visibleTasks.length === 1 ? '' : 's');
26000
- html += operationSectionHeader('Your tasks', 'Recurring jobs Clementine runs for you. Tap any card to edit; the toggle on each card pauses or resumes it.', 'badge-blue', taskCountLabel, (visibleRunning.length > 0 || visibleAttention.length > 0) ? '28px' : '0')
26209
+ html += operationSectionHeader('Your tasks', 'Recurring jobs Clementine runs for you. Tap any card to edit; the toggle on each card pauses or resumes it.', 'badge-blue', taskCountLabel, visibleRunning.length > 0 ? '28px' : '0')
26001
26210
  + filterPillsHtml
26002
26211
  + '<div class="task-grid">';
26003
26212
  if (filteredTasks.length === 0) {
@@ -26021,6 +26230,22 @@ async function refreshCron() {
26021
26230
  html += '<div class="task-grid">' + visibleWorkflows.map(renderScheduledWorkflowCard).join('') + '</div>';
26022
26231
  }
26023
26232
 
26233
+ // ── Needs attention — collapsed by default in 1.18.118. Was the
26234
+ // second thing on the page; pushed "Your tasks" 1,000+ px below
26235
+ // the fold. Still surfaced visibly via a yellow count chip in the
26236
+ // summary so users know it's there. Click to expand for triage.
26237
+ if (visibleAttention.length > 0) {
26238
+ html += '<details style="margin-top:28px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;padding:0;overflow:hidden">'
26239
+ + '<summary style="padding:12px 16px;cursor:pointer;display:flex;align-items:center;gap:10px;user-select:none">'
26240
+ + '<span style="font-size:14px;font-weight:600;color:var(--text-primary)">Needs attention</span>'
26241
+ + '<span class="badge badge-yellow">' + visibleAttention.length + ' review</span>'
26242
+ + '<span style="font-size:11px;color:var(--text-muted);margin-left:6px">Broken scheduled tasks and failed runtime work that can waste tokens or silently stop.</span>'
26243
+ + '</summary>'
26244
+ + '<div style="padding:0 16px 16px"><div class="task-grid">' + visibleAttention.slice(0, 12).map(renderAttentionCard).join('') + '</div>';
26245
+ if (visibleAttention.length > 12) html += '<div class="empty-state" style="padding:18px;color:var(--text-muted);font-size:13px">Showing 12 of ' + visibleAttention.length + ' items. Use the Owner filter to narrow this list.</div>';
26246
+ html += '</div></details>';
26247
+ }
26248
+
26024
26249
  // ── Zone 3 — Recent history (last 50 runs across all jobs) ──
26025
26250
  html += operationSectionHeader('Recent history', 'The last 50 task runs across every job, newest first. Click any row to open the full trace.', 'badge-gray', historyData.length + ' run' + (historyData.length === 1 ? '' : 's'), '28px');
26026
26251
  html += renderRecentHistoryList(historyData);
@@ -26031,6 +26256,11 @@ async function refreshCron() {
26031
26256
  if (typeof refreshHealthStrip === 'function') {
26032
26257
  refreshHealthStrip().catch(function() { /* non-fatal */ });
26033
26258
  }
26259
+ // 1.18.119 — fire-and-forget the cron-migrate banner check. Renders
26260
+ // a soft tip when ≥1 legacy task is eligible; otherwise silent.
26261
+ if (typeof refreshCronMigrateBanner === 'function') {
26262
+ refreshCronMigrateBanner().catch(function() { /* non-fatal */ });
26263
+ }
26034
26264
  if (typeof refreshMiniDashboards === 'function') {
26035
26265
  refreshMiniDashboards().catch(function() { /* non-fatal */ });
26036
26266
  }
@@ -27152,6 +27382,120 @@ async function confirmDeleteSkill(name) {
27152
27382
  } catch (err) { toast('Delete failed: ' + err, 'error'); }
27153
27383
  }
27154
27384
 
27385
+ // 1.18.119 — Cron-clean migration banner + flow. Surfaces a soft tip on
27386
+ // the Tasks page when ≥1 legacy task is eligible. Click "Preview" to see
27387
+ // every change in a modal; click "Migrate all" to apply across the vault
27388
+ // in one round-trip. Each migration writes a .bak of the source CRON.md
27389
+ // for trivial rollback. Refreshing the catalog re-runs the eligibility
27390
+ // check, so the banner auto-hides once everything is clean.
27391
+ var _cronMigratePreview = null;
27392
+
27393
+ async function refreshCronMigrateBanner() {
27394
+ var host = document.getElementById('cron-migrate-banner-host');
27395
+ if (!host) return;
27396
+ try {
27397
+ var r = await apiFetch('/api/cron/migrate-preview');
27398
+ var d = await r.json();
27399
+ if (!d.ok || !Array.isArray(d.eligible) || d.eligible.length === 0) {
27400
+ host.innerHTML = '';
27401
+ return;
27402
+ }
27403
+ _cronMigratePreview = d;
27404
+ var n = d.eligible.length;
27405
+ host.innerHTML =
27406
+ '<div style="margin:0 0 14px;padding:12px 16px;border:1px solid var(--accent);background:rgba(255,141,0,0.06);border-radius:8px;display:flex;align-items:center;gap:14px;flex-wrap:wrap">'
27407
+ + '<span style="font-size:18px">✨</span>'
27408
+ + '<div style="flex:1;min-width:200px">'
27409
+ + '<div style="font-size:13px;font-weight:600;color:var(--text-primary);margin-bottom:2px">Clean up ' + n + ' legacy task' + (n === 1 ? '' : 's') + '</div>'
27410
+ + '<div style="font-size:12px;color:var(--text-secondary);line-height:1.45">Strip TOOL RESTRICTIONS preambles, pin matching skills, enable Strict mode, add descriptions. The runtime behavior stays the same — the editor just gets readable.</div>'
27411
+ + '</div>'
27412
+ + '<button onclick="openCronMigratePreview()" style="font-size:12px;padding:7px 14px;border-radius:6px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text-primary);cursor:pointer">Preview changes</button>'
27413
+ + '<button onclick="applyCronMigrateAll()" class="btn-primary" style="font-size:12px;padding:7px 14px;border-radius:6px;border:none;background:var(--accent);color:#fff;font-weight:500;cursor:pointer">Migrate all</button>'
27414
+ + '</div>';
27415
+ } catch (e) {
27416
+ host.innerHTML = '';
27417
+ }
27418
+ }
27419
+
27420
+ function openCronMigratePreview() {
27421
+ if (!_cronMigratePreview) return;
27422
+ var modal = document.getElementById('cron-migrate-modal');
27423
+ if (!modal) {
27424
+ modal = document.createElement('div');
27425
+ modal.id = 'cron-migrate-modal';
27426
+ modal.className = 'modal-overlay';
27427
+ modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.45);display:flex;align-items:center;justify-content:center;z-index:1000;padding:20px';
27428
+ document.body.appendChild(modal);
27429
+ }
27430
+ var rows = _cronMigratePreview.eligible.map(function(item) {
27431
+ var changes = (item.changes || []).map(function(c) {
27432
+ return '<li style="margin:2px 0">' + esc(c) + '</li>';
27433
+ }).join('');
27434
+ var skillBadge = item.matchedSkill
27435
+ ? ' <span style="font-size:10px;color:var(--accent);background:rgba(255,141,0,0.10);padding:1px 6px;border-radius:3px;margin-left:6px">→ ' + esc(item.matchedSkill) + '</span>'
27436
+ : '';
27437
+ var enabledBadge = item.enabled
27438
+ ? ' <span class="badge badge-green" style="font-size:10px">enabled</span>'
27439
+ : ' <span class="badge badge-gray" style="font-size:10px">disabled</span>';
27440
+ return '<div style="border:1px solid var(--border);border-radius:6px;padding:10px 12px;margin-bottom:8px;background:var(--bg-secondary)">'
27441
+ + '<div style="display:flex;align-items:center;gap:6px;margin-bottom:6px">'
27442
+ + '<code style="font-size:12px;font-weight:600;color:var(--text-primary)">' + esc(item.name) + '</code>'
27443
+ + enabledBadge + skillBadge
27444
+ + '<span style="margin-left:auto;font-size:11px;color:var(--text-muted)">' + esc(item.schedule) + '</span>'
27445
+ + '</div>'
27446
+ + '<ul style="margin:0;padding-left:18px;font-size:12px;color:var(--text-secondary);line-height:1.5">' + changes + '</ul>'
27447
+ + '</div>';
27448
+ }).join('');
27449
+ var skipped = (_cronMigratePreview.skipped || []).length > 0
27450
+ ? '<details style="margin-top:14px;font-size:12px;color:var(--text-muted)">'
27451
+ + '<summary style="cursor:pointer;padding:4px 0">' + _cronMigratePreview.skipped.length + ' task' + (_cronMigratePreview.skipped.length === 1 ? '' : 's') + ' skipped (already clean)</summary>'
27452
+ + '<ul style="margin:6px 0 0;padding-left:18px;line-height:1.6">'
27453
+ + _cronMigratePreview.skipped.map(function(s) { return '<li>' + esc(s.name) + '</li>'; }).join('')
27454
+ + '</ul>'
27455
+ + '</details>'
27456
+ : '';
27457
+ modal.innerHTML =
27458
+ '<div style="background:var(--bg-primary);border:1px solid var(--border);border-radius:10px;width:min(820px,95vw);max-height:90vh;display:flex;flex-direction:column;box-shadow:0 16px 48px rgba(0,0,0,0.35)">'
27459
+ + '<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 20px;border-bottom:1px solid var(--border)">'
27460
+ + '<h3 style="margin:0;font-size:15px;font-weight:600">Migrate ' + _cronMigratePreview.eligible.length + ' legacy task' + (_cronMigratePreview.eligible.length === 1 ? '' : 's') + ' to clean format</h3>'
27461
+ + '<button onclick="closeCronMigrateModal()" style="background:none;border:none;font-size:18px;color:var(--text-muted);cursor:pointer;padding:0 4px;line-height:1">✕</button>'
27462
+ + '</div>'
27463
+ + '<div style="flex:1;overflow-y:auto;padding:18px 22px">'
27464
+ + '<div style="font-size:12px;color:var(--text-muted);margin-bottom:12px;line-height:1.5">Each task gets a <code>.bak</code> of its CRON.md before changes. Migration is reversible — restore the .bak to undo. The runtime behavior of every task stays identical (same schedule, same prompt outcome, same tools).</div>'
27465
+ + rows
27466
+ + skipped
27467
+ + '</div>'
27468
+ + '<div style="display:flex;justify-content:flex-end;gap:8px;padding:14px 20px;border-top:1px solid var(--border);background:var(--bg-secondary)">'
27469
+ + '<button onclick="closeCronMigrateModal()" style="padding:7px 14px;font-size:13px;border:1px solid var(--border);border-radius:6px;background:transparent;color:var(--text-primary);cursor:pointer">Cancel</button>'
27470
+ + '<button onclick="applyCronMigrateAll()" class="btn-primary" style="padding:7px 16px;font-size:13px;border:none;border-radius:6px;background:var(--accent);color:#fff;font-weight:500;cursor:pointer">Apply migration</button>'
27471
+ + '</div>'
27472
+ + '</div>';
27473
+ modal.style.display = 'flex';
27474
+ }
27475
+
27476
+ function closeCronMigrateModal() {
27477
+ var m = document.getElementById('cron-migrate-modal');
27478
+ if (m) m.style.display = 'none';
27479
+ }
27480
+
27481
+ async function applyCronMigrateAll() {
27482
+ if (!confirm('Migrate every eligible legacy task to the clean format? Each touched CRON.md gets a .bak file you can restore from.')) return;
27483
+ try {
27484
+ var r = await apiFetch('/api/cron/migrate-all', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
27485
+ var d = await r.json();
27486
+ if (!d.ok) {
27487
+ toast(d.error || 'Migration failed', 'error');
27488
+ return;
27489
+ }
27490
+ closeCronMigrateModal();
27491
+ var msg = 'Migrated ' + d.appliedCount + ' task' + (d.appliedCount === 1 ? '' : 's')
27492
+ + (d.skippedCount > 0 ? ' (' + d.skippedCount + ' already clean)' : '')
27493
+ + (d.failedCount > 0 ? ' — ' + d.failedCount + ' failed' : '');
27494
+ toast(msg, d.failedCount > 0 ? 'error' : 'success');
27495
+ if (typeof refreshCron === 'function') refreshCron();
27496
+ } catch (err) { toast('Migration failed: ' + err, 'error'); }
27497
+ }
27498
+
27155
27499
  async function refreshSkillsPage() {
27156
27500
  var listEl = document.getElementById('skills-list');
27157
27501
  var detailEl = document.getElementById('skills-detail');
@@ -152,12 +152,18 @@ export function parseCronJobs() {
152
152
  : undefined;
153
153
  // Predictable (contract) mode — undefined means legacy behavior.
154
154
  const predictable = typeof job.predictable === 'boolean' ? job.predictable : undefined;
155
+ // 1.18.119 — human-readable description (used by the task card preview
156
+ // and by the cron-clean migrator to surface what each job does without
157
+ // showing raw prompt boilerplate).
158
+ const description = typeof job.description === 'string' && job.description.trim()
159
+ ? job.description.trim().slice(0, 500)
160
+ : undefined;
155
161
  if (!name || !schedule || !prompt) {
156
162
  logger.warn({ job }, 'Skipping malformed cron job');
157
163
  continue;
158
164
  }
159
165
  jobs.push({
160
- name, schedule, prompt, enabled, tier, maxTurns, model, workDir, mode,
166
+ name, schedule, prompt, enabled, tier, description, maxTurns, model, workDir, mode,
161
167
  maxHours, maxRetries, after, successCriteria, successCriteriaText, successSchema, addDirs,
162
168
  alwaysDeliver, context, preCheck, agentSlug,
163
169
  skills, allowedTools, allowedMcpServers, tags, category, predictable,
@@ -233,6 +239,12 @@ export function parseAgentCronJobs(agentsDir) {
233
239
  ? categoryRaw.trim().slice(0, 64)
234
240
  : undefined;
235
241
  const predictable = typeof job.predictable === 'boolean' ? job.predictable : undefined;
242
+ // 1.18.119 — symmetric with the global parseCronJobs description
243
+ // field. Without this, agent jobs always look "missing description"
244
+ // to the cron-clean migrator and stay flagged as eligible forever.
245
+ const description = typeof job.description === 'string' && job.description.trim()
246
+ ? job.description.trim().slice(0, 500)
247
+ : undefined;
236
248
  if (!name || !schedule || !prompt) {
237
249
  logger.warn({ job, agent: slug }, 'Skipping malformed agent cron job');
238
250
  continue;
@@ -240,7 +252,7 @@ export function parseAgentCronJobs(agentsDir) {
240
252
  // Prefix name with agent slug and tag with agentSlug
241
253
  allJobs.push({
242
254
  name: `${slug}:${name}`,
243
- schedule, prompt, enabled, tier, maxTurns, model, workDir,
255
+ schedule, prompt, enabled, tier, description, maxTurns, model, workDir,
244
256
  mode, maxHours, maxRetries, after,
245
257
  successCriteria, successCriteriaText, successSchema, addDirs,
246
258
  context, preCheck,
package/dist/types.d.ts CHANGED
@@ -475,6 +475,11 @@ export interface CronJobDefinition {
475
475
  prompt: string;
476
476
  enabled: boolean;
477
477
  tier: number;
478
+ /** Human-readable description of what this task does (one paragraph, ~200
479
+ * chars). Surfaces on the task card as the preview line — replaces the
480
+ * "first sentence of prompt" fallback when present. Auto-populated by
481
+ * the cron-migrator from the matching skill's description. Optional. */
482
+ description?: string;
478
483
  maxTurns?: number;
479
484
  model?: string;
480
485
  workDir?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.18.117",
3
+ "version": "1.18.119",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",