clementine-agent 1.18.104 → 1.18.105

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,57 @@
1
+ /**
2
+ * PRD §11 Phase 5b / 1.18.105 — Draft store for cron tasks.
3
+ *
4
+ * Drafts live alongside CRON.md (the published source of truth) but in a
5
+ * separate per-task JSON sidecar at ~/.clementine/cron-drafts/<safe>.json.
6
+ * Schedule firing always reads CRON.md, so a draft never accidentally
7
+ * goes live until the user clicks Publish.
8
+ *
9
+ * Why a sidecar instead of editing CRON.md and gating with frontmatter:
10
+ * - One CRON.md edit = many tasks affected. Drafts are per-task by design.
11
+ * - Sidecars survive even if CRON.md gets rewritten (e.g. by an agent).
12
+ * - The published-vs-draft diff is a clean two-document compare.
13
+ *
14
+ * Tradeoff: a draft can become "orphaned" if its base task gets renamed.
15
+ * We detect this via basedOnName and surface a banner in the editor when
16
+ * we can't find the published peer. Manual cleanup via DELETE /api/cron/
17
+ * :name/draft.
18
+ */
19
+ import type { CronJobDefinition } from '../types.js';
20
+ /** Stable hash of a job def — used to detect drift between when the draft
21
+ * was created and the current published version. If the published task
22
+ * changed under the draft (someone else edited it), we surface a warning
23
+ * and ask the user if they want to rebase. */
24
+ export declare function hashJobDef(def: CronJobDefinition): string;
25
+ export interface DraftRecord {
26
+ /** Task name this draft belongs to. Matches the published task's name
27
+ * (renames detach the draft — the user must republish to a new name). */
28
+ name: string;
29
+ /** Full job def the user is staging. Same shape as CronJobDefinition. */
30
+ draft: CronJobDefinition;
31
+ /** ISO timestamp of last save. */
32
+ savedAt: string;
33
+ /** Author marker. 'dashboard' for UI saves; future channels may add their
34
+ * own values. */
35
+ changedBy: string;
36
+ /** Hash of the published def at the time the draft was first created.
37
+ * If the live published def hashes to something different now, the
38
+ * draft is "rebased" — the editor surfaces a banner. */
39
+ basedOnPublishedHash: string | null;
40
+ }
41
+ export declare function getDraft(name: string): DraftRecord | null;
42
+ export declare function saveDraft(record: DraftRecord): void;
43
+ export declare function deleteDraft(name: string): boolean;
44
+ export declare function listDraftNames(): string[];
45
+ /** Compute draft state vs current published def. The badge in the editor
46
+ * reads this directly — four states matching the n8n flow:
47
+ * none = no draft sidecar, task is on its published version
48
+ * draft = draft exists, no published peer (new task being created)
49
+ * ready = draft + published peer; draft != published
50
+ * up_to_date = draft + published peer; draft hashes match published
51
+ * rebase_needed = draft + published peer; published has drifted since draft was created
52
+ */
53
+ export type DraftBadgeState = 'none' | 'draft' | 'ready' | 'up_to_date' | 'rebase_needed';
54
+ export declare function computeBadgeState(name: string, publishedDef: CronJobDefinition | null): DraftBadgeState;
55
+ /** Test-only: where we read/write drafts. Tests use a clean tmpdir. */
56
+ export declare function _draftDirForTests(): string;
57
+ //# sourceMappingURL=draft-store.d.ts.map
@@ -0,0 +1,113 @@
1
+ /**
2
+ * PRD §11 Phase 5b / 1.18.105 — Draft store for cron tasks.
3
+ *
4
+ * Drafts live alongside CRON.md (the published source of truth) but in a
5
+ * separate per-task JSON sidecar at ~/.clementine/cron-drafts/<safe>.json.
6
+ * Schedule firing always reads CRON.md, so a draft never accidentally
7
+ * goes live until the user clicks Publish.
8
+ *
9
+ * Why a sidecar instead of editing CRON.md and gating with frontmatter:
10
+ * - One CRON.md edit = many tasks affected. Drafts are per-task by design.
11
+ * - Sidecars survive even if CRON.md gets rewritten (e.g. by an agent).
12
+ * - The published-vs-draft diff is a clean two-document compare.
13
+ *
14
+ * Tradeoff: a draft can become "orphaned" if its base task gets renamed.
15
+ * We detect this via basedOnName and surface a banner in the editor when
16
+ * we can't find the published peer. Manual cleanup via DELETE /api/cron/
17
+ * :name/draft.
18
+ */
19
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, unlinkSync } from 'node:fs';
20
+ import { createHash } from 'node:crypto';
21
+ import os from 'node:os';
22
+ import path from 'node:path';
23
+ /** Read BASE_DIR fresh on every call so tests can swap CLEMENTINE_HOME
24
+ * per-test without the module cache sticking the value at import time. */
25
+ function draftDir() {
26
+ const base = process.env.CLEMENTINE_HOME || path.join(os.homedir(), '.clementine');
27
+ return path.join(base, 'cron-drafts');
28
+ }
29
+ function safeName(name) {
30
+ // Mirror the convention used by CronRunLog.runs/<safe>.jsonl so users
31
+ // can grep for related files easily.
32
+ return String(name).replace(/[^a-zA-Z0-9_:-]/g, '_').slice(0, 128);
33
+ }
34
+ function draftPath(name) {
35
+ return path.join(draftDir(), safeName(name) + '.json');
36
+ }
37
+ /** Stable hash of a job def — used to detect drift between when the draft
38
+ * was created and the current published version. If the published task
39
+ * changed under the draft (someone else edited it), we surface a warning
40
+ * and ask the user if they want to rebase. */
41
+ export function hashJobDef(def) {
42
+ const canonical = JSON.stringify(def, Object.keys(def).sort());
43
+ return createHash('sha256').update(canonical).digest('hex').slice(0, 16);
44
+ }
45
+ export function getDraft(name) {
46
+ const file = draftPath(name);
47
+ if (!existsSync(file))
48
+ return null;
49
+ try {
50
+ const raw = readFileSync(file, 'utf-8');
51
+ return JSON.parse(raw);
52
+ }
53
+ catch {
54
+ return null;
55
+ }
56
+ }
57
+ export function saveDraft(record) {
58
+ if (!record.name)
59
+ throw new Error('draft.name is required');
60
+ if (!record.draft || typeof record.draft !== 'object')
61
+ throw new Error('draft.draft (job def) is required');
62
+ mkdirSync(draftDir(), { recursive: true });
63
+ const file = draftPath(record.name);
64
+ writeFileSync(file, JSON.stringify(record, null, 2) + '\n');
65
+ }
66
+ export function deleteDraft(name) {
67
+ const file = draftPath(name);
68
+ if (!existsSync(file))
69
+ return false;
70
+ try {
71
+ unlinkSync(file);
72
+ return true;
73
+ }
74
+ catch {
75
+ return false;
76
+ }
77
+ }
78
+ export function listDraftNames() {
79
+ const dir = draftDir();
80
+ if (!existsSync(dir))
81
+ return [];
82
+ try {
83
+ return readdirSync(dir)
84
+ .filter((f) => f.endsWith('.json'))
85
+ .map((f) => f.replace(/\.json$/, ''));
86
+ }
87
+ catch {
88
+ return [];
89
+ }
90
+ }
91
+ export function computeBadgeState(name, publishedDef) {
92
+ const d = getDraft(name);
93
+ if (!d)
94
+ return 'none';
95
+ if (!publishedDef)
96
+ return 'draft';
97
+ const publishedHash = hashJobDef(publishedDef);
98
+ const draftHash = hashJobDef(d.draft);
99
+ if (publishedHash === draftHash)
100
+ return 'up_to_date';
101
+ // Drift detection: if the draft was based on a published version we no
102
+ // longer recognise, surface "rebase needed". This covers two scenarios:
103
+ // (a) someone else edited the published def, (b) the user published
104
+ // through a different surface and forgot to discard the draft.
105
+ if (d.basedOnPublishedHash && d.basedOnPublishedHash !== publishedHash)
106
+ return 'rebase_needed';
107
+ return 'ready';
108
+ }
109
+ /** Test-only: where we read/write drafts. Tests use a clean tmpdir. */
110
+ export function _draftDirForTests() {
111
+ return draftDir();
112
+ }
113
+ //# sourceMappingURL=draft-store.js.map
@@ -4600,6 +4600,156 @@ export async function cmdDashboard(opts) {
4600
4600
  // tasks should pay the small per-tool overhead of curl-on-every-event)
4601
4601
  // and the installer refuses to overwrite a settings.local.json that
4602
4602
  // wasn't created by us.
4603
+ // ── PRD §11 Phase 5b / 1.18.105: Draft / Publish endpoints ────────
4604
+ // Tasks support per-task draft sidecars at ~/.clementine/cron-drafts/.
4605
+ // The schedule fires off CRON.md only — drafts never go live until
4606
+ // POST /publish promotes them. Five endpoints cover the n8n-style flow:
4607
+ // GET /api/cron/:job/draft — current draft + badge state
4608
+ // POST /api/cron/:job/draft — save/replace the draft
4609
+ // POST /api/cron/:job/publish — promote draft to CRON.md
4610
+ // DELETE /api/cron/:job/draft — discard the draft
4611
+ // GET /api/cron/drafts — list all drafted task names
4612
+ app.get('/api/cron/drafts', async (_req, res) => {
4613
+ try {
4614
+ const { listDraftNames } = await import('../agent/draft-store.js');
4615
+ res.json({ ok: true, names: listDraftNames() });
4616
+ }
4617
+ catch (err) {
4618
+ res.status(500).json({ ok: false, error: String(err) });
4619
+ }
4620
+ });
4621
+ app.get('/api/cron/:job/draft', async (req, res) => {
4622
+ try {
4623
+ const jobName = req.params.job;
4624
+ if (!jobName) {
4625
+ res.status(400).json({ ok: false, error: 'job required' });
4626
+ return;
4627
+ }
4628
+ const { parseCronJobs } = await import('../gateway/cron-scheduler.js');
4629
+ const { getDraft, computeBadgeState } = await import('../agent/draft-store.js');
4630
+ const draft = getDraft(jobName);
4631
+ const published = parseCronJobs().find((j) => String(j.name).toLowerCase() === jobName.toLowerCase()) ?? null;
4632
+ const badge = computeBadgeState(jobName, published);
4633
+ res.json({ ok: true, draft, published, badge });
4634
+ }
4635
+ catch (err) {
4636
+ res.status(500).json({ ok: false, error: String(err) });
4637
+ }
4638
+ });
4639
+ app.post('/api/cron/:job/draft', async (req, res) => {
4640
+ try {
4641
+ const jobName = req.params.job;
4642
+ if (!jobName) {
4643
+ res.status(400).json({ ok: false, error: 'job required' });
4644
+ return;
4645
+ }
4646
+ const body = (req.body ?? {});
4647
+ const draftDef = body.draft;
4648
+ if (!draftDef || typeof draftDef !== 'object') {
4649
+ res.status(400).json({ ok: false, error: 'draft (job def) required in body' });
4650
+ return;
4651
+ }
4652
+ // Force the draft's name to match the URL param so a stale UI can't
4653
+ // retarget. Mostly defensive — the editor wouldn't call here with a
4654
+ // mismatched name, but be paranoid.
4655
+ draftDef.name = jobName;
4656
+ const { parseCronJobs } = await import('../gateway/cron-scheduler.js');
4657
+ const { saveDraft, hashJobDef } = await import('../agent/draft-store.js');
4658
+ const published = parseCronJobs().find((j) => String(j.name).toLowerCase() === jobName.toLowerCase()) ?? null;
4659
+ saveDraft({
4660
+ name: jobName,
4661
+ draft: draftDef,
4662
+ savedAt: new Date().toISOString(),
4663
+ changedBy: typeof body.changedBy === 'string' ? body.changedBy : 'dashboard',
4664
+ basedOnPublishedHash: published ? hashJobDef(published) : null,
4665
+ });
4666
+ res.json({ ok: true, message: published ? 'Draft saved.' : 'Draft saved (no published version yet — Publish to make it live).' });
4667
+ }
4668
+ catch (err) {
4669
+ res.status(500).json({ ok: false, error: String(err) });
4670
+ }
4671
+ });
4672
+ app.delete('/api/cron/:job/draft', async (req, res) => {
4673
+ try {
4674
+ const jobName = req.params.job;
4675
+ if (!jobName) {
4676
+ res.status(400).json({ ok: false, error: 'job required' });
4677
+ return;
4678
+ }
4679
+ const { deleteDraft } = await import('../agent/draft-store.js');
4680
+ const removed = deleteDraft(jobName);
4681
+ res.json({ ok: true, removed, message: removed ? 'Draft discarded.' : 'No draft to discard.' });
4682
+ }
4683
+ catch (err) {
4684
+ res.status(500).json({ ok: false, error: String(err) });
4685
+ }
4686
+ });
4687
+ app.post('/api/cron/:job/publish', async (req, res) => {
4688
+ try {
4689
+ const jobName = req.params.job;
4690
+ if (!jobName) {
4691
+ res.status(400).json({ ok: false, error: 'job required' });
4692
+ return;
4693
+ }
4694
+ const { getDraft, deleteDraft } = await import('../agent/draft-store.js');
4695
+ const record = getDraft(jobName);
4696
+ if (!record) {
4697
+ res.status(404).json({ ok: false, error: `No draft for "${jobName}". Save changes as a draft first.` });
4698
+ return;
4699
+ }
4700
+ // Promote: write the draft def into CRON.md by re-using the
4701
+ // dashboard's existing readCronFileAt / writeCronFileAt helpers
4702
+ // (defined alongside POST /api/cron). Find the matching job entry
4703
+ // and overwrite in place; if no match, append.
4704
+ try {
4705
+ // Agent-scoped tasks ("<agent>:<jobname>") live in their own
4706
+ // agents/<agent>/CRON.md. Detect via the slug prefix to pick the
4707
+ // right file.
4708
+ const draftDef = record.draft;
4709
+ let targetFile = CRON_FILE;
4710
+ const slugMatch = jobName.match(/^([a-z0-9_-]+):(.+)$/i);
4711
+ if (draftDef.agent || slugMatch) {
4712
+ const slug = String(draftDef.agent ?? slugMatch?.[1] ?? '');
4713
+ if (slug && slug !== 'clementine') {
4714
+ targetFile = path.join(VAULT_DIR, '00-System', 'agents', slug, 'CRON.md');
4715
+ }
4716
+ }
4717
+ const { parsed, jobs } = readCronFileAt(targetFile);
4718
+ const idx = jobs.findIndex((j) => String(j.name ?? '').toLowerCase() === jobName.toLowerCase());
4719
+ // Strip our internal-only fields before writing CRON.md so we
4720
+ // don't pollute the canonical file with hash sidecars etc.
4721
+ const cleanDraft = { ...record.draft };
4722
+ if (idx >= 0)
4723
+ jobs[idx] = cleanDraft;
4724
+ else
4725
+ jobs.push(cleanDraft);
4726
+ writeCronFileAt(targetFile, parsed, jobs);
4727
+ }
4728
+ catch (writeErr) {
4729
+ res.status(500).json({ ok: false, error: 'failed to write CRON.md: ' + String(writeErr) });
4730
+ return;
4731
+ }
4732
+ deleteDraft(jobName);
4733
+ // Hot-reload the scheduler so the new published def takes effect on
4734
+ // the next tick instead of waiting for the next file scan.
4735
+ try {
4736
+ const gw = await getGateway();
4737
+ const sched = gw.cronScheduler;
4738
+ if (sched && typeof sched.reloadJobs === 'function')
4739
+ sched.reloadJobs();
4740
+ }
4741
+ catch { /* non-fatal */ }
4742
+ // Broadcast so other tabs refresh task cards immediately.
4743
+ try {
4744
+ broadcastEvent({ type: 'cron_published', data: { job: jobName } });
4745
+ }
4746
+ catch { /* non-fatal */ }
4747
+ res.json({ ok: true, message: `Published "${jobName}". Schedule fires from this version starting now.` });
4748
+ }
4749
+ catch (err) {
4750
+ res.status(500).json({ ok: false, error: String(err) });
4751
+ }
4752
+ });
4603
4753
  app.get('/api/cron/:job/hooks-status', async (req, res) => {
4604
4754
  try {
4605
4755
  const jobName = req.params.job;
@@ -21179,8 +21329,18 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
21179
21329
  when editing a saved task (set by openEditCronModal). Disabled
21180
21330
  during an in-flight run. -->
21181
21331
  <button class="btn btn-sm btn-success" id="cron-run-once-btn" onclick="runCronOnceFromModal()" style="display:none;font-size:12px;padding:6px 14px">▶ Run task once</button>
21332
+ <!-- PRD §11 Phase 5b / 1.18.105: draft state badge. Updates via
21333
+ refreshCronDraftBadge(jobName) after every save / load /
21334
+ publish / discard. -->
21335
+ <span id="cron-draft-badge" style="display:none;font-size:11px;padding:3px 8px;border-radius:999px;font-weight:500"></span>
21182
21336
  </div>
21183
21337
  <button onclick="closeCronModal()">Cancel</button>
21338
+ <!-- PRD §11 Phase 5b / 1.18.105: Save as draft / Publish split for
21339
+ saved tasks. Visible only when editing an already-published
21340
+ task (cron-modal-save is hidden for those). -->
21341
+ <button class="btn btn-sm" id="cron-discard-draft-btn" onclick="discardCronDraft()" style="display:none;font-size:12px;padding:6px 12px">Discard draft</button>
21342
+ <button class="btn btn-sm" id="cron-save-draft-btn" onclick="saveCronAsDraft()" style="display:none;font-size:12px;padding:6px 14px">Save as draft</button>
21343
+ <button class="btn-primary" id="cron-publish-btn" onclick="publishCronDraft()" style="display:none">Publish</button>
21184
21344
  <button class="btn-primary" id="cron-modal-save" onclick="saveCronJob()">Create Task</button>
21185
21345
  </div>
21186
21346
  </div>
@@ -27841,6 +28001,8 @@ function openEditCronModal(jobName) {
27841
28001
  _cronPreviewLoadedFor = null;
27842
28002
  document.getElementById('cron-modal-title').textContent = 'Edit: ' + jobName;
27843
28003
  document.getElementById('cron-modal-save').textContent = 'Save Changes';
28004
+ // PRD §11 Phase 5b / 1.18.105: load draft state for footer badge + buttons.
28005
+ if (typeof refreshCronDraftBadge === 'function') refreshCronDraftBadge(jobName);
27844
28006
  document.getElementById('cron-name').value = job.name;
27845
28007
  document.getElementById('cron-name').disabled = true;
27846
28008
  setScheduleFromCron(job.schedule || '0 9 * * *');
@@ -28387,6 +28549,168 @@ async function saveCronJob() {
28387
28549
  }
28388
28550
  }
28389
28551
 
28552
+ // ── PRD §11 Phase 5b / 1.18.105: Draft / Publish handlers ───────────
28553
+ // Build the same body saveCronJob builds, but POST it as a draft sidecar
28554
+ // instead of overwriting CRON.md. The schedule keeps firing the published
28555
+ // version until the user clicks Publish.
28556
+ function _buildCronJobBodyForDraft() {
28557
+ // Reuse the same field collection logic saveCronJob uses, minus the
28558
+ // PUT/POST round-trip. Returns null when validation fails so the caller
28559
+ // can bail without further work.
28560
+ var name = document.getElementById('cron-name').value.trim();
28561
+ var schedule = document.getElementById('cron-schedule').value.trim();
28562
+ var prompt = document.getElementById('cron-prompt').value.trim();
28563
+ if (!name) { toast('Task name is required', 'error'); return null; }
28564
+ if (!schedule) { toast('Schedule is required', 'error'); return null; }
28565
+ if (!prompt) { toast('Prompt is required', 'error'); return null; }
28566
+ var tier = parseInt(document.getElementById('cron-tier').value);
28567
+ var work_dir = document.getElementById('cron-workdir').value;
28568
+ var mode = document.getElementById('cron-mode').value;
28569
+ var max_hours = mode === 'unleashed' ? parseInt(document.getElementById('cron-maxhours').value) : undefined;
28570
+ var max_retries_val = document.getElementById('cron-max-retries').value;
28571
+ var max_retries = max_retries_val !== '' ? parseInt(max_retries_val) : undefined;
28572
+ var after = document.getElementById('cron-after').value || undefined;
28573
+ var context = document.getElementById('cron-context').value.trim() || undefined;
28574
+ var category = (document.getElementById('cron-category')?.value || '').trim() || undefined;
28575
+ var allowedTools = parseAllowedToolsRaw();
28576
+ var successCriteriaText = (document.getElementById('cron-success-criteria-text')?.value || '').trim();
28577
+ var successSchemaRaw = (document.getElementById('cron-success-schema')?.value || '').trim();
28578
+ var successSchema;
28579
+ if (successSchemaRaw) {
28580
+ try { successSchema = JSON.parse(successSchemaRaw); }
28581
+ catch (e) { toast('Success schema is not valid JSON', 'error'); return null; }
28582
+ }
28583
+ var addDirs = (document.getElementById('cron-add-dirs')?.value || '')
28584
+ .split(/\\r?\\n/).map(function(s){return s.trim();}).filter(Boolean);
28585
+ return {
28586
+ name: name, schedule: schedule, prompt: prompt, tier: tier,
28587
+ enabled: true,
28588
+ work_dir: work_dir || undefined,
28589
+ mode: mode,
28590
+ max_hours: max_hours,
28591
+ max_retries: max_retries,
28592
+ after: after,
28593
+ context: context,
28594
+ category: category,
28595
+ skills: _cronSelectedSkills,
28596
+ allowedTools: allowedTools,
28597
+ allowedMcpServers: _cronSelectedMcp,
28598
+ tags: _cronTags,
28599
+ predictable: typeof _cronPredictable === 'boolean' ? _cronPredictable : true,
28600
+ successCriteriaText: successCriteriaText || undefined,
28601
+ successSchema: successSchema || undefined,
28602
+ addDirs: addDirs.length ? addDirs : undefined,
28603
+ };
28604
+ }
28605
+
28606
+ async function saveCronAsDraft() {
28607
+ if (!editingCronJob) {
28608
+ toast('Save the task first; drafts apply to existing tasks.', 'info');
28609
+ return;
28610
+ }
28611
+ var body = _buildCronJobBodyForDraft();
28612
+ if (!body) return;
28613
+ try {
28614
+ var r = await apiFetch('/api/cron/' + encodeURIComponent(editingCronJob) + '/draft', {
28615
+ method: 'POST',
28616
+ headers: { 'Content-Type': 'application/json' },
28617
+ body: JSON.stringify({ draft: body, changedBy: 'dashboard' }),
28618
+ });
28619
+ var d = await r.json().catch(function() { return {}; });
28620
+ if (!r.ok || d.ok === false) {
28621
+ toast(d.error || ('Save draft failed (HTTP ' + r.status + ')'), 'error');
28622
+ return;
28623
+ }
28624
+ toast(d.message || 'Draft saved.', 'success');
28625
+ captureCronModalSnapshot();
28626
+ refreshCronDraftBadge(editingCronJob);
28627
+ } catch (err) { toast('Save draft failed: ' + err, 'error'); }
28628
+ }
28629
+
28630
+ async function publishCronDraft() {
28631
+ if (!editingCronJob) return;
28632
+ if (!confirm('Publish the draft to live? The schedule will start firing the new version on the next tick.')) return;
28633
+ try {
28634
+ var r = await apiFetch('/api/cron/' + encodeURIComponent(editingCronJob) + '/publish', { method: 'POST' });
28635
+ var d = await r.json().catch(function() { return {}; });
28636
+ if (!r.ok || d.ok === false) {
28637
+ toast(d.error || ('Publish failed (HTTP ' + r.status + ')'), 'error');
28638
+ return;
28639
+ }
28640
+ toast(d.message || 'Published.', 'success');
28641
+ refreshCron();
28642
+ refreshCronDraftBadge(editingCronJob);
28643
+ } catch (err) { toast('Publish failed: ' + err, 'error'); }
28644
+ }
28645
+
28646
+ async function discardCronDraft() {
28647
+ if (!editingCronJob) return;
28648
+ if (!confirm('Discard the unpublished draft? This restores the editor to the published version.')) return;
28649
+ try {
28650
+ var r = await apiFetch('/api/cron/' + encodeURIComponent(editingCronJob) + '/draft', { method: 'DELETE' });
28651
+ var d = await r.json().catch(function() { return {}; });
28652
+ if (!r.ok || d.ok === false) {
28653
+ toast(d.error || ('Discard failed (HTTP ' + r.status + ')'), 'error');
28654
+ return;
28655
+ }
28656
+ toast(d.message || 'Draft discarded.', 'success');
28657
+ refreshCronDraftBadge(editingCronJob);
28658
+ // Reload the modal contents from the published version so the editor
28659
+ // matches what the schedule will fire.
28660
+ if (editingCronJob && typeof openEditCronModal === 'function') {
28661
+ var name = editingCronJob;
28662
+ // openEditCronModal re-fetches and re-populates fields.
28663
+ openEditCronModal(name);
28664
+ }
28665
+ } catch (err) { toast('Discard failed: ' + err, 'error'); }
28666
+ }
28667
+
28668
+ // Update the draft badge + footer button visibility based on the
28669
+ // computed badge state. Called from openEditCronModal after load and
28670
+ // after every draft action.
28671
+ async function refreshCronDraftBadge(jobName) {
28672
+ var badge = document.getElementById('cron-draft-badge');
28673
+ var saveDraftBtn = document.getElementById('cron-save-draft-btn');
28674
+ var publishBtn = document.getElementById('cron-publish-btn');
28675
+ var discardBtn = document.getElementById('cron-discard-draft-btn');
28676
+ if (!badge || !jobName) return;
28677
+ try {
28678
+ var r = await apiFetch('/api/cron/' + encodeURIComponent(jobName) + '/draft');
28679
+ var d = await r.json();
28680
+ if (!r.ok || !d.ok) {
28681
+ badge.style.display = 'none';
28682
+ if (saveDraftBtn) saveDraftBtn.style.display = '';
28683
+ if (publishBtn) publishBtn.style.display = 'none';
28684
+ if (discardBtn) discardBtn.style.display = 'none';
28685
+ return;
28686
+ }
28687
+ var state = d.badge || 'none';
28688
+ var label, color;
28689
+ if (state === 'up_to_date') { label = '✓ Published, no draft changes'; color = 'var(--green)'; }
28690
+ else if (state === 'ready') { label = '● Ready to publish'; color = 'var(--accent)'; }
28691
+ else if (state === 'rebase_needed') { label = '⚠ Published drifted — review draft'; color = 'var(--yellow)'; }
28692
+ else if (state === 'draft') { label = '✎ Draft (not yet published)'; color = 'var(--accent)'; }
28693
+ else { label = ''; color = ''; }
28694
+ if (label) {
28695
+ badge.style.display = '';
28696
+ badge.textContent = label;
28697
+ badge.style.background = color + '20';
28698
+ badge.style.color = color;
28699
+ } else {
28700
+ badge.style.display = 'none';
28701
+ }
28702
+ // Footer button visibility depends on whether we have a saved task
28703
+ // (editingCronJob) AND whether a draft already exists. Save-as-draft
28704
+ // is always visible when editing; Publish/Discard only when a draft
28705
+ // exists.
28706
+ if (saveDraftBtn) saveDraftBtn.style.display = '';
28707
+ if (publishBtn) publishBtn.style.display = (state === 'ready' || state === 'rebase_needed' || state === 'draft') ? '' : 'none';
28708
+ if (discardBtn) discardBtn.style.display = (state === 'ready' || state === 'rebase_needed' || state === 'draft' || state === 'up_to_date') ? '' : 'none';
28709
+ } catch (err) {
28710
+ if (badge) badge.style.display = 'none';
28711
+ }
28712
+ }
28713
+
28390
28714
  // ── Cron Training Chat ───────────────────
28391
28715
  function showCronTraining() {
28392
28716
  document.getElementById('cron-training-section').style.display = '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.18.104",
3
+ "version": "1.18.105",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",