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.
- package/dist/agent/cron-migrator.d.ts +123 -0
- package/dist/agent/cron-migrator.js +275 -0
- package/dist/cli/dashboard.js +357 -13
- package/dist/gateway/cron-scheduler.js +14 -2
- package/dist/types.d.ts +5 -0
- package/package.json +1 -1
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cron-clean migrator (1.18.119)
|
|
3
|
+
*
|
|
4
|
+
* Migrates legacy cron jobs to the cleaner Skills-First contract model
|
|
5
|
+
* without changing what the job DOES. Pure function — takes a
|
|
6
|
+
* CronJobDefinition + the available skills inventory, returns the
|
|
7
|
+
* migrated job + a list of human-readable changes + an eligibility flag.
|
|
8
|
+
*
|
|
9
|
+
* What "legacy" means in user-collected data:
|
|
10
|
+
* - `predictable` is undefined or false → at fire-time the runner injects
|
|
11
|
+
* MEMORY.md, recent team activity, the delegation queue, and auto-
|
|
12
|
+
* matches MCP servers based on prompt text.
|
|
13
|
+
* - The prompt body opens with a "TOOL RESTRICTIONS — MANDATORY" preamble
|
|
14
|
+
* listing forbidden + allowed tools in prose. This is leftover from
|
|
15
|
+
* before the runtime allowedTools field existed.
|
|
16
|
+
* - No skill is pinned even though the runtime auto-matches one (or
|
|
17
|
+
* several) every fire. The match is not deterministic and the user
|
|
18
|
+
* can't see what's being chosen.
|
|
19
|
+
* - No description field — the card shows the first 200 chars of the
|
|
20
|
+
* prompt, which is often the tool-restriction boilerplate.
|
|
21
|
+
*
|
|
22
|
+
* What "clean" looks like:
|
|
23
|
+
* - `predictable: true` (contract mode — runs only with what's attached).
|
|
24
|
+
* - `description:` populated (auto-generated from the matching skill's
|
|
25
|
+
* description if there is one, else the first sentence of the
|
|
26
|
+
* non-restriction prompt body).
|
|
27
|
+
* - `skills: [<name>]` pinned when a clear match exists.
|
|
28
|
+
* - `allowed_tools: [...]` pulled out of the "ALLOWED tools (the COMPLETE
|
|
29
|
+
* list)" line of the legacy preamble.
|
|
30
|
+
* - `prompt:` reduced to either "Run the {skill name} skill." (when a
|
|
31
|
+
* skill is pinned) or just the cleaned procedural part (when no skill
|
|
32
|
+
* match — preamble stripped).
|
|
33
|
+
*
|
|
34
|
+
* Eligibility — a job is eligible when it has at least one of:
|
|
35
|
+
* - the legacy preamble in its prompt, OR
|
|
36
|
+
* - predictable: undefined/false AND a candidate skill match.
|
|
37
|
+
* If neither, the migrator returns `eligible: false` and leaves the job
|
|
38
|
+
* unchanged. This keeps "ones that don't need to be fixed" out of the
|
|
39
|
+
* migration UI entirely.
|
|
40
|
+
*/
|
|
41
|
+
import type { CronJobDefinition, Skill } from '../types.js';
|
|
42
|
+
export interface CronMigrationResult {
|
|
43
|
+
/** True when migration would change something. False = "skip this one". */
|
|
44
|
+
eligible: boolean;
|
|
45
|
+
/** The migrated job (or the input job, untouched, when ineligible). */
|
|
46
|
+
migrated: CronJobDefinition;
|
|
47
|
+
/** Human-readable bullets — what the migrator did. Empty when not eligible. */
|
|
48
|
+
changes: string[];
|
|
49
|
+
/** When a skill match was applied, the matched skill's name. Surfaces in
|
|
50
|
+
* the migration UI so the user can verify. */
|
|
51
|
+
matchedSkill?: string;
|
|
52
|
+
/** Brief explanation when eligible:false — drives the dashboard's "this
|
|
53
|
+
* task already looks clean — nothing to do" affordance. */
|
|
54
|
+
notEligibleReason?: string;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* When the prompt opens with the canonical "TOOL RESTRICTIONS — MANDATORY"
|
|
58
|
+
* preamble, this returns:
|
|
59
|
+
* - allowedTools: the parsed list of allowed tools (from the "ALLOWED
|
|
60
|
+
* tools (the COMPLETE list):" line)
|
|
61
|
+
* - cleanedPrompt: the rest of the prompt with the preamble removed
|
|
62
|
+
* and trimmed.
|
|
63
|
+
* When no preamble is found, returns null and the caller treats the prompt
|
|
64
|
+
* as already-clean.
|
|
65
|
+
*/
|
|
66
|
+
export declare function stripToolRestrictionsPreamble(rawPrompt: string): {
|
|
67
|
+
allowedTools: string[];
|
|
68
|
+
cleanedPrompt: string;
|
|
69
|
+
} | null;
|
|
70
|
+
/**
|
|
71
|
+
* Returns a skill from the catalog that matches this job by either:
|
|
72
|
+
* 1. Exact name match (job "morning-briefing" → skill "morning-briefing")
|
|
73
|
+
* 2. Job name appears in skill triggers (job "audit-inbox-check" →
|
|
74
|
+
* skill "checking-audit-inbox" with trigger "audit inbox check")
|
|
75
|
+
* 3. Job-name keyword overlap with skill description (looser, last resort).
|
|
76
|
+
*
|
|
77
|
+
* Returns null when no clear match exists. The migrator then leaves the
|
|
78
|
+
* skill pin off and just strips the preamble — still an improvement.
|
|
79
|
+
*/
|
|
80
|
+
export declare function findMatchingSkill(job: CronJobDefinition, skills: Skill[]): Skill | null;
|
|
81
|
+
/**
|
|
82
|
+
* Picks a 1-paragraph description for the migrated job.
|
|
83
|
+
* Priority:
|
|
84
|
+
* 1. The matched skill's description (best — it's already curated).
|
|
85
|
+
* 2. First sentence of the cleaned prompt body, ≤240 chars.
|
|
86
|
+
* 3. Empty string when prompt is empty or unintelligible — the caller
|
|
87
|
+
* then leaves the description field unset.
|
|
88
|
+
*/
|
|
89
|
+
export declare function generateDescription(cleanedPrompt: string, matchedSkill: Skill | null): string;
|
|
90
|
+
/**
|
|
91
|
+
* Apply the clean-format migration to one cron job. Pure function —
|
|
92
|
+
* does NOT touch disk. Returns the new definition + the list of changes.
|
|
93
|
+
*
|
|
94
|
+
* Idempotent: passing an already-clean job back through returns
|
|
95
|
+
* `eligible: false` with `notEligibleReason`. Safe to call on every
|
|
96
|
+
* job in a vault and filter by eligibility.
|
|
97
|
+
*
|
|
98
|
+
* The caller (an HTTP endpoint or CLI) is responsible for:
|
|
99
|
+
* - serializing the result back to YAML
|
|
100
|
+
* - writing the .bak file
|
|
101
|
+
* - replacing the job in CRON.md
|
|
102
|
+
* - returning the diff to the dashboard
|
|
103
|
+
*/
|
|
104
|
+
export declare function migrateCronJob(job: CronJobDefinition, skills: Skill[]): CronMigrationResult;
|
|
105
|
+
/**
|
|
106
|
+
* Run migrateCronJob across an entire CRON.md inventory. Returns:
|
|
107
|
+
* - eligible: jobs that would be migrated, with their migration result
|
|
108
|
+
* - skipped: jobs that look already-clean (eligibility=false)
|
|
109
|
+
*
|
|
110
|
+
* Used by the bulk-migrate UI to preview the full impact before the user
|
|
111
|
+
* commits to changes.
|
|
112
|
+
*/
|
|
113
|
+
export declare function migrateAllEligibleJobs(jobs: CronJobDefinition[], skills: Skill[]): {
|
|
114
|
+
eligible: Array<{
|
|
115
|
+
job: CronJobDefinition;
|
|
116
|
+
result: CronMigrationResult;
|
|
117
|
+
}>;
|
|
118
|
+
skipped: Array<{
|
|
119
|
+
job: CronJobDefinition;
|
|
120
|
+
reason: string;
|
|
121
|
+
}>;
|
|
122
|
+
};
|
|
123
|
+
//# sourceMappingURL=cron-migrator.d.ts.map
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cron-clean migrator (1.18.119)
|
|
3
|
+
*
|
|
4
|
+
* Migrates legacy cron jobs to the cleaner Skills-First contract model
|
|
5
|
+
* without changing what the job DOES. Pure function — takes a
|
|
6
|
+
* CronJobDefinition + the available skills inventory, returns the
|
|
7
|
+
* migrated job + a list of human-readable changes + an eligibility flag.
|
|
8
|
+
*
|
|
9
|
+
* What "legacy" means in user-collected data:
|
|
10
|
+
* - `predictable` is undefined or false → at fire-time the runner injects
|
|
11
|
+
* MEMORY.md, recent team activity, the delegation queue, and auto-
|
|
12
|
+
* matches MCP servers based on prompt text.
|
|
13
|
+
* - The prompt body opens with a "TOOL RESTRICTIONS — MANDATORY" preamble
|
|
14
|
+
* listing forbidden + allowed tools in prose. This is leftover from
|
|
15
|
+
* before the runtime allowedTools field existed.
|
|
16
|
+
* - No skill is pinned even though the runtime auto-matches one (or
|
|
17
|
+
* several) every fire. The match is not deterministic and the user
|
|
18
|
+
* can't see what's being chosen.
|
|
19
|
+
* - No description field — the card shows the first 200 chars of the
|
|
20
|
+
* prompt, which is often the tool-restriction boilerplate.
|
|
21
|
+
*
|
|
22
|
+
* What "clean" looks like:
|
|
23
|
+
* - `predictable: true` (contract mode — runs only with what's attached).
|
|
24
|
+
* - `description:` populated (auto-generated from the matching skill's
|
|
25
|
+
* description if there is one, else the first sentence of the
|
|
26
|
+
* non-restriction prompt body).
|
|
27
|
+
* - `skills: [<name>]` pinned when a clear match exists.
|
|
28
|
+
* - `allowed_tools: [...]` pulled out of the "ALLOWED tools (the COMPLETE
|
|
29
|
+
* list)" line of the legacy preamble.
|
|
30
|
+
* - `prompt:` reduced to either "Run the {skill name} skill." (when a
|
|
31
|
+
* skill is pinned) or just the cleaned procedural part (when no skill
|
|
32
|
+
* match — preamble stripped).
|
|
33
|
+
*
|
|
34
|
+
* Eligibility — a job is eligible when it has at least one of:
|
|
35
|
+
* - the legacy preamble in its prompt, OR
|
|
36
|
+
* - predictable: undefined/false AND a candidate skill match.
|
|
37
|
+
* If neither, the migrator returns `eligible: false` and leaves the job
|
|
38
|
+
* unchanged. This keeps "ones that don't need to be fixed" out of the
|
|
39
|
+
* migration UI entirely.
|
|
40
|
+
*/
|
|
41
|
+
// ── Preamble detection + parsing ──────────────────────────────────────
|
|
42
|
+
const PREAMBLE_RE = /^TOOL RESTRICTIONS\s*[—-]\s*MANDATORY[\s\S]*?(?:\n\n|\r\n\r\n)/i;
|
|
43
|
+
/**
|
|
44
|
+
* When the prompt opens with the canonical "TOOL RESTRICTIONS — MANDATORY"
|
|
45
|
+
* preamble, this returns:
|
|
46
|
+
* - allowedTools: the parsed list of allowed tools (from the "ALLOWED
|
|
47
|
+
* tools (the COMPLETE list):" line)
|
|
48
|
+
* - cleanedPrompt: the rest of the prompt with the preamble removed
|
|
49
|
+
* and trimmed.
|
|
50
|
+
* When no preamble is found, returns null and the caller treats the prompt
|
|
51
|
+
* as already-clean.
|
|
52
|
+
*/
|
|
53
|
+
export function stripToolRestrictionsPreamble(rawPrompt) {
|
|
54
|
+
if (!rawPrompt)
|
|
55
|
+
return null;
|
|
56
|
+
const m = rawPrompt.match(PREAMBLE_RE);
|
|
57
|
+
if (!m)
|
|
58
|
+
return null;
|
|
59
|
+
const preamble = m[0];
|
|
60
|
+
const cleanedPrompt = rawPrompt.slice(preamble.length).trim();
|
|
61
|
+
// Extract the "ALLOWED tools (the COMPLETE list): a, b, c." line.
|
|
62
|
+
// Tolerant of variations: "ALLOWED — list:", "ALLOWED tools list:", etc.
|
|
63
|
+
const allowedMatch = preamble.match(/ALLOWED[\s\S]*?(?:list|tools)\s*\)?\s*:\s*([^.\n]+)/i);
|
|
64
|
+
const allowedTools = [];
|
|
65
|
+
if (allowedMatch && allowedMatch[1]) {
|
|
66
|
+
const list = allowedMatch[1]
|
|
67
|
+
.split(/[,;]/)
|
|
68
|
+
.map((s) => s.trim().replace(/[.,;:]+$/, ''))
|
|
69
|
+
.filter(Boolean)
|
|
70
|
+
.filter((s) => /^[a-zA-Z][\w-]*$/.test(s) || /^mcp__[\w-]+/.test(s));
|
|
71
|
+
allowedTools.push(...list);
|
|
72
|
+
}
|
|
73
|
+
return { allowedTools, cleanedPrompt };
|
|
74
|
+
}
|
|
75
|
+
// ── Skill matching ────────────────────────────────────────────────────
|
|
76
|
+
/**
|
|
77
|
+
* Returns a skill from the catalog that matches this job by either:
|
|
78
|
+
* 1. Exact name match (job "morning-briefing" → skill "morning-briefing")
|
|
79
|
+
* 2. Job name appears in skill triggers (job "audit-inbox-check" →
|
|
80
|
+
* skill "checking-audit-inbox" with trigger "audit inbox check")
|
|
81
|
+
* 3. Job-name keyword overlap with skill description (looser, last resort).
|
|
82
|
+
*
|
|
83
|
+
* Returns null when no clear match exists. The migrator then leaves the
|
|
84
|
+
* skill pin off and just strips the preamble — still an improvement.
|
|
85
|
+
*/
|
|
86
|
+
export function findMatchingSkill(job, skills) {
|
|
87
|
+
const jobName = job.name.toLowerCase();
|
|
88
|
+
const jobNameWords = new Set(jobName.split(/[-_]/).filter((w) => w.length > 2));
|
|
89
|
+
// 1. Exact name match
|
|
90
|
+
for (const s of skills) {
|
|
91
|
+
if (s.frontmatter.name.toLowerCase() === jobName)
|
|
92
|
+
return s;
|
|
93
|
+
}
|
|
94
|
+
// 2. Trigger phrase contains the job-name word set
|
|
95
|
+
for (const s of skills) {
|
|
96
|
+
const triggers = s.frontmatter.clementine?.triggers ?? [];
|
|
97
|
+
for (const trigger of triggers) {
|
|
98
|
+
const triggerWords = String(trigger).toLowerCase().split(/\s+/);
|
|
99
|
+
const triggerWordSet = new Set(triggerWords);
|
|
100
|
+
// Match if all job-name words appear in trigger words (in any order)
|
|
101
|
+
let allMatch = true;
|
|
102
|
+
for (const w of jobNameWords) {
|
|
103
|
+
if (!triggerWordSet.has(w)) {
|
|
104
|
+
allMatch = false;
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (allMatch && jobNameWords.size > 0)
|
|
109
|
+
return s;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// 3. Job-name word overlap with skill name (substring fallback —
|
|
113
|
+
// catches "audit-inbox-check" → "checking-audit-inbox" via shared
|
|
114
|
+
// "audit" + "inbox" word tokens).
|
|
115
|
+
for (const s of skills) {
|
|
116
|
+
const skillNameWords = new Set(s.frontmatter.name.toLowerCase().split(/[-_]/).filter((w) => w.length > 2));
|
|
117
|
+
let overlap = 0;
|
|
118
|
+
for (const w of jobNameWords)
|
|
119
|
+
if (skillNameWords.has(w))
|
|
120
|
+
overlap++;
|
|
121
|
+
// Need ≥2 overlapping content words for a confident match (avoids
|
|
122
|
+
// matching every "build-*" job to a single "build" skill).
|
|
123
|
+
if (overlap >= 2)
|
|
124
|
+
return s;
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
// ── Description generation ────────────────────────────────────────────
|
|
129
|
+
/**
|
|
130
|
+
* Picks a 1-paragraph description for the migrated job.
|
|
131
|
+
* Priority:
|
|
132
|
+
* 1. The matched skill's description (best — it's already curated).
|
|
133
|
+
* 2. First sentence of the cleaned prompt body, ≤240 chars.
|
|
134
|
+
* 3. Empty string when prompt is empty or unintelligible — the caller
|
|
135
|
+
* then leaves the description field unset.
|
|
136
|
+
*/
|
|
137
|
+
export function generateDescription(cleanedPrompt, matchedSkill) {
|
|
138
|
+
if (matchedSkill?.frontmatter.description) {
|
|
139
|
+
return matchedSkill.frontmatter.description.trim().slice(0, 500);
|
|
140
|
+
}
|
|
141
|
+
if (!cleanedPrompt)
|
|
142
|
+
return '';
|
|
143
|
+
// First sentence — match up to ., !, ?, or first newline-newline.
|
|
144
|
+
// Falls back to first 200 chars when the body is one giant paragraph.
|
|
145
|
+
const sentenceMatch = cleanedPrompt.match(/^[\s\S]*?[.!?](?=\s|$)/);
|
|
146
|
+
const candidate = (sentenceMatch ? sentenceMatch[0] : cleanedPrompt.slice(0, 200))
|
|
147
|
+
.trim()
|
|
148
|
+
.replace(/\s+/g, ' ');
|
|
149
|
+
return candidate.slice(0, 240);
|
|
150
|
+
}
|
|
151
|
+
// ── The migration itself ──────────────────────────────────────────────
|
|
152
|
+
/**
|
|
153
|
+
* Apply the clean-format migration to one cron job. Pure function —
|
|
154
|
+
* does NOT touch disk. Returns the new definition + the list of changes.
|
|
155
|
+
*
|
|
156
|
+
* Idempotent: passing an already-clean job back through returns
|
|
157
|
+
* `eligible: false` with `notEligibleReason`. Safe to call on every
|
|
158
|
+
* job in a vault and filter by eligibility.
|
|
159
|
+
*
|
|
160
|
+
* The caller (an HTTP endpoint or CLI) is responsible for:
|
|
161
|
+
* - serializing the result back to YAML
|
|
162
|
+
* - writing the .bak file
|
|
163
|
+
* - replacing the job in CRON.md
|
|
164
|
+
* - returning the diff to the dashboard
|
|
165
|
+
*/
|
|
166
|
+
export function migrateCronJob(job, skills) {
|
|
167
|
+
const changes = [];
|
|
168
|
+
// 1. Detect the legacy preamble.
|
|
169
|
+
const stripped = stripToolRestrictionsPreamble(job.prompt);
|
|
170
|
+
const hasPreamble = stripped !== null;
|
|
171
|
+
// 2. Find a matching skill (or null).
|
|
172
|
+
const matchedSkill = findMatchingSkill(job, skills);
|
|
173
|
+
// 3. Eligibility — anything that looks legacy is eligible.
|
|
174
|
+
const isLegacyPredictable = job.predictable !== true;
|
|
175
|
+
const hasNoDescription = !job.description || !job.description.trim();
|
|
176
|
+
const hasNoSkillsButCouldPinOne = (!job.skills || job.skills.length === 0) && matchedSkill !== null;
|
|
177
|
+
if (!hasPreamble && !isLegacyPredictable && !hasNoDescription && !hasNoSkillsButCouldPinOne) {
|
|
178
|
+
return {
|
|
179
|
+
eligible: false,
|
|
180
|
+
migrated: job,
|
|
181
|
+
changes: [],
|
|
182
|
+
matchedSkill: matchedSkill?.frontmatter.name,
|
|
183
|
+
notEligibleReason: 'Already clean — has predictable=true, a description, no legacy preamble, and either pinned skills or no skill match.',
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
// 4. Build the migrated definition. Start from a shallow copy so we
|
|
187
|
+
// don't mutate the input.
|
|
188
|
+
const migrated = { ...job };
|
|
189
|
+
// 4a. Set predictable: true (the headline contract-mode flip).
|
|
190
|
+
if (isLegacyPredictable) {
|
|
191
|
+
migrated.predictable = true;
|
|
192
|
+
changes.push('Enabled Strict mode (no auto-injected memory or team comms at fire-time).');
|
|
193
|
+
}
|
|
194
|
+
// 4b. Strip the preamble + populate allowed_tools.
|
|
195
|
+
let cleanedPrompt = job.prompt;
|
|
196
|
+
if (hasPreamble && stripped) {
|
|
197
|
+
cleanedPrompt = stripped.cleanedPrompt;
|
|
198
|
+
changes.push('Stripped TOOL RESTRICTIONS preamble from the prompt.');
|
|
199
|
+
if (stripped.allowedTools.length > 0) {
|
|
200
|
+
// Merge with any existing allowedTools so we don't lose tool-level
|
|
201
|
+
// narrowing the user already had.
|
|
202
|
+
const existing = new Set(job.allowedTools ?? []);
|
|
203
|
+
for (const t of stripped.allowedTools)
|
|
204
|
+
existing.add(t);
|
|
205
|
+
migrated.allowedTools = [...existing];
|
|
206
|
+
changes.push(`Moved ${stripped.allowedTools.length} tool name${stripped.allowedTools.length === 1 ? '' : 's'} into the allowed_tools field (${stripped.allowedTools.slice(0, 3).join(', ')}${stripped.allowedTools.length > 3 ? `, +${stripped.allowedTools.length - 3} more` : ''}).`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
// 4c. Pin the matched skill + replace the prompt with a thin invocation.
|
|
210
|
+
if (matchedSkill && (!job.skills || job.skills.length === 0)) {
|
|
211
|
+
const pinned = [matchedSkill.frontmatter.name];
|
|
212
|
+
migrated.skills = pinned;
|
|
213
|
+
changes.push(`Pinned matching skill: \`${matchedSkill.frontmatter.name}\` (was being auto-matched at fire-time).`);
|
|
214
|
+
// Reduce the prompt to a thin reference. The skill body carries the
|
|
215
|
+
// procedure; the cron prompt just needs to invoke it. Keep ANY
|
|
216
|
+
// post-preamble text the user added that wasn't already in the skill —
|
|
217
|
+
// when in doubt, prefer "Run the X skill." for a true clean migration.
|
|
218
|
+
const remainsBeyondSkill = (cleanedPrompt || '').trim();
|
|
219
|
+
const nameLabel = matchedSkill.frontmatter.title || matchedSkill.frontmatter.name;
|
|
220
|
+
migrated.prompt = `Run the ${nameLabel} skill.`;
|
|
221
|
+
if (remainsBeyondSkill && remainsBeyondSkill.length > 50) {
|
|
222
|
+
// The user had additional instructions after the preamble that aren't
|
|
223
|
+
// in the skill body. Append them so we don't lose intent. Length cap
|
|
224
|
+
// ensures we don't accidentally re-add the boilerplate we just stripped.
|
|
225
|
+
migrated.prompt += `\n\n${remainsBeyondSkill.slice(0, 1000)}`;
|
|
226
|
+
changes.push('Preserved additional prompt instructions after the skill invocation.');
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
else if (hasPreamble) {
|
|
230
|
+
// No skill match but we did strip the preamble — just save the
|
|
231
|
+
// cleaned prompt. Still an improvement: the card preview will now
|
|
232
|
+
// show actual instructions, not boilerplate.
|
|
233
|
+
migrated.prompt = cleanedPrompt.trim();
|
|
234
|
+
}
|
|
235
|
+
// 4d. Generate description if missing.
|
|
236
|
+
if (!job.description || !job.description.trim()) {
|
|
237
|
+
const desc = generateDescription(stripped?.cleanedPrompt || job.prompt, matchedSkill);
|
|
238
|
+
if (desc) {
|
|
239
|
+
migrated.description = desc;
|
|
240
|
+
changes.push(`Added description: "${desc.slice(0, 80)}${desc.length > 80 ? '…' : ''}".`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return {
|
|
244
|
+
eligible: changes.length > 0,
|
|
245
|
+
migrated,
|
|
246
|
+
changes,
|
|
247
|
+
matchedSkill: matchedSkill?.frontmatter.name,
|
|
248
|
+
notEligibleReason: changes.length === 0
|
|
249
|
+
? 'No legacy markers detected — task already follows the clean format.'
|
|
250
|
+
: undefined,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Run migrateCronJob across an entire CRON.md inventory. Returns:
|
|
255
|
+
* - eligible: jobs that would be migrated, with their migration result
|
|
256
|
+
* - skipped: jobs that look already-clean (eligibility=false)
|
|
257
|
+
*
|
|
258
|
+
* Used by the bulk-migrate UI to preview the full impact before the user
|
|
259
|
+
* commits to changes.
|
|
260
|
+
*/
|
|
261
|
+
export function migrateAllEligibleJobs(jobs, skills) {
|
|
262
|
+
const eligible = [];
|
|
263
|
+
const skipped = [];
|
|
264
|
+
for (const job of jobs) {
|
|
265
|
+
const result = migrateCronJob(job, skills);
|
|
266
|
+
if (result.eligible) {
|
|
267
|
+
eligible.push({ job, result });
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
skipped.push({ job, reason: result.notEligibleReason || 'Already clean.' });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return { eligible, skipped };
|
|
274
|
+
}
|
|
275
|
+
//# sourceMappingURL=cron-migrator.js.map
|
package/dist/cli/dashboard.js
CHANGED
|
@@ -4556,6 +4556,206 @@ export async function cmdDashboard(opts) {
|
|
|
4556
4556
|
res.status(500).json({ ok: false, error: String(err) });
|
|
4557
4557
|
}
|
|
4558
4558
|
});
|
|
4559
|
+
// ── Cron-clean migrator (1.18.119) ─────────────────────────────────
|
|
4560
|
+
// Three endpoints (matching the skill-migration pattern):
|
|
4561
|
+
// - GET /api/cron/migrate-preview — dry-run across every job
|
|
4562
|
+
// - POST /api/cron/:job/migrate — apply migration to one job
|
|
4563
|
+
// - POST /api/cron/migrate-all — apply to all eligible jobs
|
|
4564
|
+
// Every apply writes a `<basename>.bak` of the CRON.md before saving.
|
|
4565
|
+
app.get('/api/cron/migrate-preview', async (_req, res) => {
|
|
4566
|
+
try {
|
|
4567
|
+
const { migrateAllEligibleJobs } = await import('../agent/cron-migrator.js');
|
|
4568
|
+
const { listSkills } = await import('../agent/skill-store.js');
|
|
4569
|
+
const { parseCronJobs, parseAgentCronJobs } = await import('../gateway/cron-scheduler.js');
|
|
4570
|
+
const skills = listSkills();
|
|
4571
|
+
const jobs = [...parseCronJobs(), ...parseAgentCronJobs(path.join(VAULT_DIR, '00-System', 'agents'))];
|
|
4572
|
+
const out = migrateAllEligibleJobs(jobs, skills);
|
|
4573
|
+
// Trim the response — the dashboard doesn't need the full migrated job
|
|
4574
|
+
// body for the preview list, just the names + change bullets.
|
|
4575
|
+
res.json({
|
|
4576
|
+
ok: true,
|
|
4577
|
+
eligible: out.eligible.map(({ job, result }) => ({
|
|
4578
|
+
name: job.name,
|
|
4579
|
+
enabled: job.enabled,
|
|
4580
|
+
schedule: job.schedule,
|
|
4581
|
+
changes: result.changes,
|
|
4582
|
+
matchedSkill: result.matchedSkill ?? null,
|
|
4583
|
+
// Send a small before/after snippet so the UI can show a diff.
|
|
4584
|
+
before: { prompt: (job.prompt || '').slice(0, 600), predictable: job.predictable, description: job.description },
|
|
4585
|
+
after: {
|
|
4586
|
+
prompt: (result.migrated.prompt || '').slice(0, 600),
|
|
4587
|
+
predictable: result.migrated.predictable,
|
|
4588
|
+
description: result.migrated.description,
|
|
4589
|
+
skills: result.migrated.skills,
|
|
4590
|
+
allowedTools: result.migrated.allowedTools,
|
|
4591
|
+
},
|
|
4592
|
+
})),
|
|
4593
|
+
skipped: out.skipped.map(({ job, reason }) => ({ name: job.name, reason })),
|
|
4594
|
+
});
|
|
4595
|
+
}
|
|
4596
|
+
catch (err) {
|
|
4597
|
+
res.status(500).json({ ok: false, error: String(err) });
|
|
4598
|
+
}
|
|
4599
|
+
});
|
|
4600
|
+
app.post('/api/cron/:job/migrate', async (req, res) => {
|
|
4601
|
+
try {
|
|
4602
|
+
const jobName = req.params.job;
|
|
4603
|
+
if (!jobName) {
|
|
4604
|
+
res.status(400).json({ ok: false, error: 'job name required' });
|
|
4605
|
+
return;
|
|
4606
|
+
}
|
|
4607
|
+
const { migrateCronJob } = await import('../agent/cron-migrator.js');
|
|
4608
|
+
const { listSkills } = await import('../agent/skill-store.js');
|
|
4609
|
+
const { parseCronJobs, parseAgentCronJobs } = await import('../gateway/cron-scheduler.js');
|
|
4610
|
+
// Locate the source file + the job
|
|
4611
|
+
const { cronFile, bareJobName } = resolveJobCronFile(jobName);
|
|
4612
|
+
const allJobs = [...parseCronJobs(), ...parseAgentCronJobs(path.join(VAULT_DIR, '00-System', 'agents'))];
|
|
4613
|
+
const target = allJobs.find(j => String(j.name).toLowerCase() === jobName.toLowerCase());
|
|
4614
|
+
if (!target) {
|
|
4615
|
+
res.status(404).json({ ok: false, error: 'job "' + jobName + '" not found' });
|
|
4616
|
+
return;
|
|
4617
|
+
}
|
|
4618
|
+
const skills = listSkills();
|
|
4619
|
+
const result = migrateCronJob(target, skills);
|
|
4620
|
+
if (!result.eligible) {
|
|
4621
|
+
res.json({ ok: true, eligible: false, reason: result.notEligibleReason });
|
|
4622
|
+
return;
|
|
4623
|
+
}
|
|
4624
|
+
// Write back. .bak is produced by writeCronFileAt's own backup if any —
|
|
4625
|
+
// here we add one explicitly so the user has a rollback artifact.
|
|
4626
|
+
const bakPath = cronFile + '.bak';
|
|
4627
|
+
try {
|
|
4628
|
+
writeFileSync(bakPath, readFileSync(cronFile, 'utf-8'));
|
|
4629
|
+
}
|
|
4630
|
+
catch { /* best-effort */ }
|
|
4631
|
+
const { parsed, jobs } = readCronFileAt(cronFile);
|
|
4632
|
+
// Find by bare name (job in the YAML uses bareJobName, not the global "agent:job" form)
|
|
4633
|
+
const idx = jobs.findIndex(j => String(j.name).toLowerCase() === bareJobName.toLowerCase());
|
|
4634
|
+
if (idx < 0) {
|
|
4635
|
+
res.status(404).json({ ok: false, error: 'job not in CRON.md (resolved to "' + bareJobName + '")' });
|
|
4636
|
+
return;
|
|
4637
|
+
}
|
|
4638
|
+
// Convert the migrated CronJobDefinition back to YAML-friendly shape.
|
|
4639
|
+
// CRITICAL: agent CRON.md stores BARE job names (no agent: prefix);
|
|
4640
|
+
// parseAgentCronJobs adds the prefix at read time. Writing m.name
|
|
4641
|
+
// back here would double-prefix on the next read. Use bareJobName.
|
|
4642
|
+
const m = result.migrated;
|
|
4643
|
+
const next = { ...jobs[idx] };
|
|
4644
|
+
next.name = bareJobName;
|
|
4645
|
+
next.schedule = m.schedule;
|
|
4646
|
+
next.prompt = m.prompt;
|
|
4647
|
+
next.enabled = m.enabled;
|
|
4648
|
+
next.tier = m.tier;
|
|
4649
|
+
if (m.description)
|
|
4650
|
+
next.description = m.description;
|
|
4651
|
+
else
|
|
4652
|
+
delete next.description;
|
|
4653
|
+
if (m.predictable === true)
|
|
4654
|
+
next.predictable = true;
|
|
4655
|
+
if (m.skills && m.skills.length > 0)
|
|
4656
|
+
next.skills = m.skills;
|
|
4657
|
+
if (m.allowedTools && m.allowedTools.length > 0)
|
|
4658
|
+
next.allowed_tools = m.allowedTools;
|
|
4659
|
+
if (m.allowedMcpServers && m.allowedMcpServers.length > 0)
|
|
4660
|
+
next.allowed_mcp_servers = m.allowedMcpServers;
|
|
4661
|
+
jobs[idx] = next;
|
|
4662
|
+
writeCronFileAt(cronFile, parsed, jobs);
|
|
4663
|
+
res.json({
|
|
4664
|
+
ok: true,
|
|
4665
|
+
eligible: true,
|
|
4666
|
+
name: jobName,
|
|
4667
|
+
cronFile,
|
|
4668
|
+
bakPath,
|
|
4669
|
+
changes: result.changes,
|
|
4670
|
+
matchedSkill: result.matchedSkill ?? null,
|
|
4671
|
+
});
|
|
4672
|
+
}
|
|
4673
|
+
catch (err) {
|
|
4674
|
+
res.status(500).json({ ok: false, error: String(err) });
|
|
4675
|
+
}
|
|
4676
|
+
});
|
|
4677
|
+
app.post('/api/cron/migrate-all', async (_req, res) => {
|
|
4678
|
+
try {
|
|
4679
|
+
const { migrateAllEligibleJobs } = await import('../agent/cron-migrator.js');
|
|
4680
|
+
const { listSkills } = await import('../agent/skill-store.js');
|
|
4681
|
+
const { parseCronJobs, parseAgentCronJobs } = await import('../gateway/cron-scheduler.js');
|
|
4682
|
+
const agentsDir = path.join(VAULT_DIR, '00-System', 'agents');
|
|
4683
|
+
const skills = listSkills();
|
|
4684
|
+
const allJobs = [...parseCronJobs(), ...parseAgentCronJobs(agentsDir)];
|
|
4685
|
+
const out = migrateAllEligibleJobs(allJobs, skills);
|
|
4686
|
+
const applied = [];
|
|
4687
|
+
const failed = [];
|
|
4688
|
+
const touchedFiles = new Set();
|
|
4689
|
+
// Group writes by file so we open + parse + serialize each file once.
|
|
4690
|
+
const byFile = new Map();
|
|
4691
|
+
for (const { job, result } of out.eligible) {
|
|
4692
|
+
const { cronFile, bareJobName } = resolveJobCronFile(job.name);
|
|
4693
|
+
if (!byFile.has(cronFile))
|
|
4694
|
+
byFile.set(cronFile, []);
|
|
4695
|
+
byFile.get(cronFile).push({ bareName: bareJobName, result });
|
|
4696
|
+
}
|
|
4697
|
+
for (const [cronFile, items] of byFile.entries()) {
|
|
4698
|
+
try {
|
|
4699
|
+
// .bak the file before any edits so a single rollback restores it.
|
|
4700
|
+
const bakPath = cronFile + '.bak';
|
|
4701
|
+
try {
|
|
4702
|
+
writeFileSync(bakPath, readFileSync(cronFile, 'utf-8'));
|
|
4703
|
+
}
|
|
4704
|
+
catch { /* best-effort */ }
|
|
4705
|
+
const { parsed, jobs } = readCronFileAt(cronFile);
|
|
4706
|
+
for (const { bareName, result } of items) {
|
|
4707
|
+
const idx = jobs.findIndex(j => String(j.name).toLowerCase() === bareName.toLowerCase());
|
|
4708
|
+
if (idx < 0) {
|
|
4709
|
+
failed.push({ name: bareName, error: 'job vanished mid-migration' });
|
|
4710
|
+
continue;
|
|
4711
|
+
}
|
|
4712
|
+
const m = result.migrated;
|
|
4713
|
+
const next = { ...jobs[idx] };
|
|
4714
|
+
// Same bare-vs-prefixed gotcha as the per-job migrate path —
|
|
4715
|
+
// agent CRON.md stores bare names. Always write bareName here.
|
|
4716
|
+
next.name = bareName;
|
|
4717
|
+
next.schedule = m.schedule;
|
|
4718
|
+
next.prompt = m.prompt;
|
|
4719
|
+
next.enabled = m.enabled;
|
|
4720
|
+
next.tier = m.tier;
|
|
4721
|
+
if (m.description)
|
|
4722
|
+
next.description = m.description;
|
|
4723
|
+
else
|
|
4724
|
+
delete next.description;
|
|
4725
|
+
if (m.predictable === true)
|
|
4726
|
+
next.predictable = true;
|
|
4727
|
+
if (m.skills && m.skills.length > 0)
|
|
4728
|
+
next.skills = m.skills;
|
|
4729
|
+
if (m.allowedTools && m.allowedTools.length > 0)
|
|
4730
|
+
next.allowed_tools = m.allowedTools;
|
|
4731
|
+
if (m.allowedMcpServers && m.allowedMcpServers.length > 0)
|
|
4732
|
+
next.allowed_mcp_servers = m.allowedMcpServers;
|
|
4733
|
+
jobs[idx] = next;
|
|
4734
|
+
applied.push({ name: m.name, cronFile });
|
|
4735
|
+
}
|
|
4736
|
+
writeCronFileAt(cronFile, parsed, jobs);
|
|
4737
|
+
touchedFiles.add(cronFile);
|
|
4738
|
+
}
|
|
4739
|
+
catch (err) {
|
|
4740
|
+
for (const { bareName } of items)
|
|
4741
|
+
failed.push({ name: bareName, error: String(err) });
|
|
4742
|
+
}
|
|
4743
|
+
}
|
|
4744
|
+
res.json({
|
|
4745
|
+
ok: true,
|
|
4746
|
+
appliedCount: applied.length,
|
|
4747
|
+
skippedCount: out.skipped.length,
|
|
4748
|
+
failedCount: failed.length,
|
|
4749
|
+
applied,
|
|
4750
|
+
skipped: out.skipped.map(({ job, reason }) => ({ name: job.name, reason })),
|
|
4751
|
+
failed,
|
|
4752
|
+
touchedFiles: [...touchedFiles],
|
|
4753
|
+
});
|
|
4754
|
+
}
|
|
4755
|
+
catch (err) {
|
|
4756
|
+
res.status(500).json({ ok: false, error: String(err) });
|
|
4757
|
+
}
|
|
4758
|
+
});
|
|
4559
4759
|
app.get('/api/cron/:job/draft', async (req, res) => {
|
|
4560
4760
|
try {
|
|
4561
4761
|
const jobName = req.params.job;
|
|
@@ -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:
|
|
20324
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
// ──
|
|
25990
|
-
|
|
25991
|
-
|
|
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,
|
|
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;
|