clementine-agent 1.18.103 → 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;
@@ -5916,6 +6066,90 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
5916
6066
  res.status(500).json({ ok: false, error: String(err) });
5917
6067
  }
5918
6068
  });
6069
+ // ── PRD §12 Phase 6.3 / 1.18.104: real latency split ─────────────
6070
+ // Aggregates per-run tool durations from the event store so the
6071
+ // Latency mini-card can show real numbers (model API time / tool
6072
+ // execution time / framework overhead) instead of the heuristic
6073
+ // placeholder. Only runs with path B hook events contribute to the
6074
+ // tool-time numerator; the dashboard falls back to the heuristic
6075
+ // when coverage is too low.
6076
+ //
6077
+ // Implementation note: walking N event-log files is O(events) but
6078
+ // the data is tiny (hundreds of KB per run, mostly text). For 7d of
6079
+ // runs this is well under 100ms even with hundreds of runs. If it
6080
+ // gets slow we'll add an in-memory cache keyed on the file mtime.
6081
+ app.get('/api/runs/latency-summary', async (req, res) => {
6082
+ try {
6083
+ const windowHours = Math.max(1, Math.min(168, parseInt(String(req.query.windowHours ?? '168'), 10) || 168));
6084
+ const cutoffMs = Date.now() - windowHours * 60 * 60 * 1000;
6085
+ const log = new CronRunLog();
6086
+ const runs = log.readAllRecent(500, 30);
6087
+ const inWindow = runs.filter((r) => {
6088
+ const t = r.startedAt ? new Date(r.startedAt).getTime() : 0;
6089
+ return t >= cutoffMs && r.status === 'ok' && typeof r.durationMs === 'number';
6090
+ });
6091
+ const { EventLog } = await import('../gateway/event-log.js');
6092
+ const eventLog = new EventLog();
6093
+ const summaries = [];
6094
+ let withHooks = 0;
6095
+ let totalDurationMs = 0;
6096
+ let totalToolMs = 0;
6097
+ for (const r of inWindow) {
6098
+ const runId = r.id;
6099
+ if (!runId)
6100
+ continue;
6101
+ const events = eventLog.readByRun(runId);
6102
+ let toolMs = 0;
6103
+ let calls = 0;
6104
+ let hasHook = false;
6105
+ for (const ev of events) {
6106
+ // Path B PostToolUse fires after every tool with duration_ms set
6107
+ // (see hook-event ingest endpoint in 1.18.101). The kind='hook'
6108
+ // + hookEventName='PostToolUse' combo is what we sum.
6109
+ const e = ev;
6110
+ if (e.kind === 'hook' && e.hookEventName === 'PostToolUse' && typeof e.durationMs === 'number') {
6111
+ toolMs += e.durationMs;
6112
+ calls += 1;
6113
+ hasHook = true;
6114
+ }
6115
+ }
6116
+ const durationMs = r.durationMs ?? 0;
6117
+ summaries.push({ runId, durationMs, toolDurationMs: toolMs, toolCalls: calls, hasHookData: hasHook });
6118
+ if (hasHook) {
6119
+ withHooks += 1;
6120
+ totalDurationMs += durationMs;
6121
+ totalToolMs += toolMs;
6122
+ }
6123
+ }
6124
+ // Coverage percentage: how many runs in the window contributed real data.
6125
+ const coverage = inWindow.length > 0 ? withHooks / inWindow.length : 0;
6126
+ // Average splits across runs that DID have hook data. The model
6127
+ // segment is what's left after tools + a small framework overhead
6128
+ // (we use 5% as a conservative estimate for SDK plumbing time —
6129
+ // real measurement of this needs a tighter timing pass that we'll
6130
+ // add when path B's SessionStart/Stop events get duration_ms too).
6131
+ const avgDurationMs = withHooks > 0 ? totalDurationMs / withHooks : 0;
6132
+ const avgToolMs = withHooks > 0 ? totalToolMs / withHooks : 0;
6133
+ const overheadFraction = 0.05;
6134
+ const overheadMs = avgDurationMs * overheadFraction;
6135
+ const modelMs = Math.max(0, avgDurationMs - avgToolMs - overheadMs);
6136
+ res.json({
6137
+ ok: true,
6138
+ windowHours,
6139
+ runsTotal: inWindow.length,
6140
+ runsWithHooks: withHooks,
6141
+ coverage,
6142
+ avgDurationMs,
6143
+ avgToolMs,
6144
+ avgModelMs: modelMs,
6145
+ avgOverheadMs: overheadMs,
6146
+ summaries,
6147
+ });
6148
+ }
6149
+ catch (err) {
6150
+ res.status(500).json({ ok: false, error: String(err) });
6151
+ }
6152
+ });
5919
6153
  // ── Recent runs across ALL cron jobs ───────────────────────────
5920
6154
  // Powers the "Recent History" zone on the Tasks page. Returns the most
5921
6155
  // recent N CronRunEntry rows merged from every per-job .jsonl, sorted
@@ -21095,8 +21329,18 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
21095
21329
  when editing a saved task (set by openEditCronModal). Disabled
21096
21330
  during an in-flight run. -->
21097
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>
21098
21336
  </div>
21099
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>
21100
21344
  <button class="btn-primary" id="cron-modal-save" onclick="saveCronJob()">Create Task</button>
21101
21345
  </div>
21102
21346
  </div>
@@ -24615,20 +24859,38 @@ async function refreshMiniDashboards() {
24615
24859
  var costFigure = totalCost7 < 0.01 ? '$' + totalCost7.toFixed(4) : '$' + totalCost7.toFixed(2);
24616
24860
 
24617
24861
  // ── Latency split card ─────────────────────────────────────────────
24618
- // Sum durationMs across last7 OK runs only we don't yet have a clean
24619
- // signal for tool time per run. Until path B hooks land we approximate:
24620
- // tool ~ 35%, model ~ 55%, overhead ~ 10% — these are placeholders
24621
- // that get replaced with real values once PostToolUse durations are
24622
- // summed from event logs (Phase 4d).
24862
+ // PRD §12 Phase 6.3 / 1.18.104: real latency split when path B hooks
24863
+ // are providing PostToolUse duration_ms data. Falls back to the
24864
+ // heuristic placeholder when coverage is too low.
24623
24865
  var okRuns = last7.filter(function(rn) { return rn.status === 'ok' && typeof rn.durationMs === 'number'; });
24624
24866
  var avgDur = okRuns.length > 0
24625
24867
  ? Math.round(okRuns.reduce(function(a, b) { return a + b.durationMs; }, 0) / okRuns.length)
24626
24868
  : 0;
24869
+ // Default to heuristic split.
24627
24870
  var latToolPct = 35, latModelPct = 55, latOverPct = 10;
24871
+ var latencyMode = 'heuristic'; // becomes 'real' if coverage >= 50%
24872
+ var coverageLabel = '';
24873
+ try {
24874
+ var lr = await apiFetch('/api/runs/latency-summary?windowHours=168');
24875
+ var ld = await lr.json();
24876
+ if (ld && ld.ok && ld.coverage >= 0.5 && ld.avgDurationMs > 0) {
24877
+ var totalMs = ld.avgDurationMs;
24878
+ latToolPct = Math.round((ld.avgToolMs / totalMs) * 100);
24879
+ latModelPct = Math.round((ld.avgModelMs / totalMs) * 100);
24880
+ latOverPct = Math.max(0, 100 - latToolPct - latModelPct);
24881
+ avgDur = Math.round(ld.avgDurationMs);
24882
+ latencyMode = 'real';
24883
+ coverageLabel = ld.runsWithHooks + '/' + ld.runsTotal + ' runs · path B';
24884
+ } else if (ld && ld.ok) {
24885
+ coverageLabel = ld.coverage > 0
24886
+ ? Math.round(ld.coverage * 100) + '% coverage — need 50%+ for real split'
24887
+ : 'no path B data yet';
24888
+ }
24889
+ } catch (e) { /* fall through to heuristic */ }
24628
24890
  var splitHtml = '<div class="mini-split">'
24629
- + '<div class="mini-split-seg" style="background:#3b82f6;width:' + latModelPct + '%" title="Model API time (~' + latModelPct + '%)">' + (latModelPct >= 12 ? 'model' : '') + '</div>'
24630
- + '<div class="mini-split-seg" style="background:#8b5cf6;width:' + latToolPct + '%" title="Tool execution time (~' + latToolPct + '%)">' + (latToolPct >= 12 ? 'tools' : '') + '</div>'
24631
- + '<div class="mini-split-seg" style="background:#6b7280;width:' + latOverPct + '%" title="Framework overhead (~' + latOverPct + '%)">' + (latOverPct >= 12 ? 'overhead' : '') + '</div>'
24891
+ + '<div class="mini-split-seg" style="background:#3b82f6;width:' + latModelPct + '%" title="Model API time">' + (latModelPct >= 12 ? 'model ' + latModelPct + '%' : '') + '</div>'
24892
+ + '<div class="mini-split-seg" style="background:#8b5cf6;width:' + latToolPct + '%" title="Tool execution time">' + (latToolPct >= 12 ? 'tools ' + latToolPct + '%' : '') + '</div>'
24893
+ + '<div class="mini-split-seg" style="background:#6b7280;width:' + latOverPct + '%" title="Framework overhead">' + (latOverPct >= 12 ? 'overhead ' + latOverPct + '%' : '') + '</div>'
24632
24894
  + '</div>'
24633
24895
  + '<div class="mini-split-legend">'
24634
24896
  + '<span><span class="mini-split-legend-dot" style="background:#3b82f6"></span>model</span>'
@@ -24636,7 +24898,14 @@ async function refreshMiniDashboards() {
24636
24898
  + '<span><span class="mini-split-legend-dot" style="background:#6b7280"></span>overhead</span>'
24637
24899
  + '</div>';
24638
24900
  var latFigure = avgDur > 0 ? formatDurationMs(avgDur) : '—';
24639
- var latSub = okRuns.length > 0 ? 'avg of ' + okRuns.length + ' successful runs · 7d' : 'no successful runs in 7d';
24901
+ var latSub;
24902
+ if (okRuns.length === 0) {
24903
+ latSub = 'no successful runs in 7d';
24904
+ } else if (latencyMode === 'real') {
24905
+ latSub = 'avg of ' + okRuns.length + ' successful runs · ' + coverageLabel;
24906
+ } else {
24907
+ latSub = 'avg of ' + okRuns.length + ' successful runs · split is heuristic (' + (coverageLabel || 'install hooks per task to see real numbers') + ')';
24908
+ }
24640
24909
 
24641
24910
  // ── Reliability card ───────────────────────────────────────────────
24642
24911
  // Per-day failure column, stacked by category. Categories use the same
@@ -24750,7 +25019,7 @@ async function refreshMiniDashboards() {
24750
25019
  + '<div class="mini-card">'
24751
25020
  + '<div class="mini-card-head"><span class="mini-card-title">Latency · avg</span><span class="mini-card-figure">' + esc(latFigure) + '</span></div>'
24752
25021
  + splitHtml
24753
- + '<div class="mini-card-sub">' + esc(latSub) + ' · split is heuristic until path B hooks land per-task (enable in cron editor → Last run)</div>'
25022
+ + '<div class="mini-card-sub">' + esc(latSub) + '</div>'
24754
25023
  + '</div>'
24755
25024
  + '<div class="mini-card">'
24756
25025
  + '<div class="mini-card-head"><span class="mini-card-title">Reliability · 7d</span><span class="mini-card-figure">' + totalFails7 + ' fail' + (totalFails7 === 1 ? '' : 's') + '</span></div>'
@@ -27732,6 +28001,8 @@ function openEditCronModal(jobName) {
27732
28001
  _cronPreviewLoadedFor = null;
27733
28002
  document.getElementById('cron-modal-title').textContent = 'Edit: ' + jobName;
27734
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);
27735
28006
  document.getElementById('cron-name').value = job.name;
27736
28007
  document.getElementById('cron-name').disabled = true;
27737
28008
  setScheduleFromCron(job.schedule || '0 9 * * *');
@@ -28278,6 +28549,168 @@ async function saveCronJob() {
28278
28549
  }
28279
28550
  }
28280
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
+
28281
28714
  // ── Cron Training Chat ───────────────────
28282
28715
  function showCronTraining() {
28283
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.103",
3
+ "version": "1.18.105",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",