clementine-agent 1.18.116 → 1.18.118

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.
Files changed (2) hide show
  1. package/dist/cli/dashboard.js +505 -60
  2. package/package.json +1 -1
@@ -10118,43 +10118,156 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
10118
10118
  res.status(500).json({ error: String(err) });
10119
10119
  }
10120
10120
  });
10121
+ // POST /api/skills — create a new folder-form skill (1.18.115).
10122
+ // Anthropic-compatible: top-level `name` + `description` required; the
10123
+ // optional `tools` array goes under `clementine.tools.allow` so the
10124
+ // frontmatter parses cleanly in vanilla Claude Agent SDK while still
10125
+ // letting the cron runtime enforce the allowlist.
10121
10126
  app.post('/api/skills', (req, res) => {
10122
10127
  try {
10123
- const { title, description, triggers, steps } = req.body;
10124
- if (!title || !steps) {
10125
- res.status(400).json({ error: 'title and steps are required' });
10128
+ const { name, title, description, body, tools } = req.body ?? {};
10129
+ if (!name || typeof name !== 'string') {
10130
+ res.status(400).json({ error: 'name is required' });
10131
+ return;
10132
+ }
10133
+ if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(name)) {
10134
+ res.status(400).json({ error: 'name must match ^[a-z0-9][a-z0-9-]{0,63}$ (Anthropic spec)' });
10135
+ return;
10136
+ }
10137
+ if (!description || typeof description !== 'string') {
10138
+ res.status(400).json({ error: 'description is required' });
10139
+ return;
10140
+ }
10141
+ if (description.length > 1024) {
10142
+ res.status(400).json({ error: 'description must be ≤ 1024 chars (Anthropic spec)' });
10143
+ return;
10144
+ }
10145
+ if (!body || typeof body !== 'string' || !body.trim()) {
10146
+ res.status(400).json({ error: 'body is required' });
10126
10147
  return;
10127
10148
  }
10128
10149
  const skillsDir = path.join(VAULT_DIR, '00-System', 'skills');
10129
10150
  if (!existsSync(skillsDir))
10130
10151
  mkdirSync(skillsDir, { recursive: true });
10131
- const name = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60);
10152
+ const folderPath = path.join(skillsDir, name);
10153
+ const entryPath = path.join(folderPath, 'SKILL.md');
10154
+ if (existsSync(entryPath)) {
10155
+ res.status(409).json({ error: 'Skill "' + name + '" already exists. Use PUT to update.' });
10156
+ return;
10157
+ }
10158
+ mkdirSync(folderPath, { recursive: true });
10132
10159
  const now = new Date().toISOString();
10133
- const triggerList = (triggers || '').split(',').map((t) => t.trim()).filter(Boolean);
10160
+ const fm = { name, description };
10161
+ if (title && typeof title === 'string' && title.trim())
10162
+ fm.title = title.trim();
10163
+ const allowed = Array.isArray(tools) ? tools.map(String).map(s => s.trim()).filter(Boolean) : [];
10164
+ const clementineExt = {
10165
+ source: 'manual',
10166
+ useCount: 0,
10167
+ createdAt: now,
10168
+ updatedAt: now,
10169
+ version: 1,
10170
+ };
10171
+ if (allowed.length > 0)
10172
+ clementineExt.tools = { allow: allowed };
10173
+ fm.clementine = clementineExt;
10134
10174
  const matterMod = require('gray-matter');
10135
- const content = matterMod.stringify(`\n# ${title}\n\n${description || ''}\n\n## Procedure\n\n${steps}\n`, { title, description: description || '', triggers: triggerList, source: 'manual', toolsUsed: [], useCount: 0, createdAt: now, updatedAt: now });
10136
- writeFileSync(path.join(skillsDir, `${name}.md`), content);
10137
- res.json({ ok: true, name });
10175
+ const content = matterMod.stringify(body.endsWith('\n') ? body : body + '\n', fm);
10176
+ writeFileSync(entryPath, content);
10177
+ res.json({ ok: true, name, layout: 'folder', filePath: entryPath });
10138
10178
  }
10139
10179
  catch (err) {
10140
10180
  res.status(500).json({ error: String(err) });
10141
10181
  }
10142
10182
  });
10183
+ // PUT /api/skills/:name — update an existing skill (1.18.115).
10184
+ // Folder-aware: writes back to <name>/SKILL.md when the skill is in
10185
+ // folder layout, otherwise updates the flat <name>.md (preserves the
10186
+ // existing layout — we don't auto-migrate on edit; users go through the
10187
+ // Migrate flow when they're ready).
10188
+ app.put('/api/skills/:name', (req, res) => {
10189
+ try {
10190
+ const skillName = req.params.name;
10191
+ const { title, description, body, tools } = req.body ?? {};
10192
+ if (!description || typeof description !== 'string') {
10193
+ res.status(400).json({ error: 'description is required' });
10194
+ return;
10195
+ }
10196
+ if (description.length > 1024) {
10197
+ res.status(400).json({ error: 'description must be ≤ 1024 chars (Anthropic spec)' });
10198
+ return;
10199
+ }
10200
+ if (!body || typeof body !== 'string' || !body.trim()) {
10201
+ res.status(400).json({ error: 'body is required' });
10202
+ return;
10203
+ }
10204
+ const skillsDir = path.join(VAULT_DIR, '00-System', 'skills');
10205
+ const folderEntry = path.join(skillsDir, skillName, 'SKILL.md');
10206
+ const flatEntry = path.join(skillsDir, skillName + '.md');
10207
+ const targetPath = existsSync(folderEntry) ? folderEntry : (existsSync(flatEntry) ? flatEntry : null);
10208
+ if (!targetPath) {
10209
+ res.status(404).json({ error: 'Skill "' + skillName + '" not found' });
10210
+ return;
10211
+ }
10212
+ // Preserve existing frontmatter (esp. clementine namespace fields like
10213
+ // useCount, createdAt, migration provenance) and only merge in updates.
10214
+ const matterMod = require('gray-matter');
10215
+ const existingRaw = readFileSync(targetPath, 'utf-8');
10216
+ const parsed = matterMod(existingRaw);
10217
+ const fm = { ...parsed.data };
10218
+ fm.name = skillName;
10219
+ fm.description = description;
10220
+ if (title && typeof title === 'string' && title.trim())
10221
+ fm.title = title.trim();
10222
+ else
10223
+ delete fm.title;
10224
+ const ext = (fm.clementine && typeof fm.clementine === 'object') ? fm.clementine : {};
10225
+ ext.updatedAt = new Date().toISOString();
10226
+ const allowed = Array.isArray(tools) ? tools.map(String).map(s => s.trim()).filter(Boolean) : [];
10227
+ if (allowed.length > 0)
10228
+ ext.tools = { ...(ext.tools || {}), allow: allowed };
10229
+ else if (ext.tools && typeof ext.tools === 'object')
10230
+ delete ext.tools.allow;
10231
+ fm.clementine = ext;
10232
+ const content = matterMod.stringify(body.endsWith('\n') ? body : body + '\n', fm);
10233
+ writeFileSync(targetPath, content);
10234
+ res.json({ ok: true, name: skillName, layout: targetPath === folderEntry ? 'folder' : 'flat' });
10235
+ }
10236
+ catch (err) {
10237
+ res.status(500).json({ error: String(err) });
10238
+ }
10239
+ });
10240
+ // DELETE /api/skills/:name — remove a skill (folder OR flat). 1.18.115:
10241
+ // updated to handle folder form. The .md.bak (if present) stays as a
10242
+ // rollback artefact unless the caller passes `?bak=clean`.
10143
10243
  app.delete('/api/skills/:name', (req, res) => {
10144
10244
  try {
10245
+ const skillName = req.params.name;
10145
10246
  const skillsDir = path.join(VAULT_DIR, '00-System', 'skills');
10146
- const filePath = path.join(skillsDir, `${req.params.name}.md`);
10147
- if (!existsSync(filePath)) {
10148
- res.status(404).json({ error: 'Skill not found' });
10247
+ const folderPath = path.join(skillsDir, skillName);
10248
+ const folderEntry = path.join(folderPath, 'SKILL.md');
10249
+ const flatEntry = path.join(skillsDir, skillName + '.md');
10250
+ const cleanBak = req.query.bak === 'clean';
10251
+ let removed = false;
10252
+ if (existsSync(folderEntry)) {
10253
+ // Folder form — remove the whole skill folder.
10254
+ rmSync(folderPath, { recursive: true, force: true });
10255
+ removed = true;
10256
+ }
10257
+ else if (existsSync(flatEntry)) {
10258
+ // Legacy flat form — remove the .md plus optional .files dir.
10259
+ unlinkSync(flatEntry);
10260
+ const filesDir = path.join(skillsDir, skillName + '.files');
10261
+ if (existsSync(filesDir))
10262
+ rmSync(filesDir, { recursive: true, force: true });
10263
+ removed = true;
10264
+ }
10265
+ if (!removed) {
10266
+ res.status(404).json({ error: 'Skill "' + skillName + '" not found' });
10149
10267
  return;
10150
10268
  }
10151
- unlinkSync(filePath);
10152
- // Clean up attachments directory and backup
10153
- const filesDir = path.join(skillsDir, `${req.params.name}.files`);
10154
- if (existsSync(filesDir))
10155
- rmSync(filesDir, { recursive: true, force: true });
10156
- const bakPath = filePath.replace(/\.md$/, '.md.bak');
10157
- if (existsSync(bakPath))
10269
+ const bakPath = path.join(skillsDir, skillName + '.md.bak');
10270
+ if (cleanBak && existsSync(bakPath))
10158
10271
  unlinkSync(bakPath);
10159
10272
  res.json({ ok: true });
10160
10273
  }
@@ -16107,6 +16220,13 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
16107
16220
  color: var(--green); font-size: 12px; font-weight: 700;
16108
16221
  margin: 0 0 4px 0; text-transform: uppercase; letter-spacing: 0.4px;
16109
16222
  }
16223
+ /* Soft info variant — used by the Strict-mode-available tip on legacy
16224
+ tasks. Quieter than .warn so it reads as a suggestion, not a defect. */
16225
+ .cron-banner.info {
16226
+ background: var(--bg-secondary);
16227
+ border: 1px solid var(--border);
16228
+ color: var(--text-primary);
16229
+ }
16110
16230
  .cron-banner .banner-actions { margin-top: 10px; display: flex; gap: 8px; }
16111
16231
  .cron-banner .banner-actions button {
16112
16232
  font-size: 12px; padding: 6px 12px; border-radius: 6px; cursor: pointer;
@@ -17063,7 +17183,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
17063
17183
  Tasks (default) + Tools & MCP catalog. Workflows still reachable
17064
17184
  via deep-link ?tab=workflows for power users with existing
17065
17185
  multi-step workflows. -->
17066
- <div id="build-tabs" style="display:flex;gap:4px;padding:8px 18px 0;background:var(--bg-secondary);border-bottom:1px solid var(--border);flex-shrink:0">
17186
+ <div id="build-tabs" style="display:flex;gap:4px;padding:8px 18px 0;background:var(--bg-secondary);border-bottom:1px solid var(--border);flex-shrink:0;align-items:flex-end">
17067
17187
  <button class="build-tab-btn active" data-build-tab="crons" onclick="switchBuildTab('crons')" style="padding:8px 14px;border-radius:6px 6px 0 0;border:none;background:transparent;color:var(--text-primary);font-size:13px;font-weight:500;cursor:pointer;border-bottom:2px solid transparent">
17068
17188
  <span style="margin-right:6px">📅</span>Tasks <span id="build-tab-cron-count" style="display:none;margin-left:4px;font-size:10px;background:var(--bg-tertiary);padding:1px 6px;border-radius:999px;color:var(--text-muted)">0</span>
17069
17189
  </button>
@@ -17076,6 +17196,11 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
17076
17196
  <button class="build-tab-btn" data-build-tab="workflows" onclick="switchBuildTab('workflows')" style="display:none;padding:8px 14px;border-radius:6px 6px 0 0;border:none;background:transparent;color:var(--text-secondary);font-size:13px;font-weight:500;cursor:pointer;border-bottom:2px solid transparent">
17077
17197
  <span style="margin-right:6px">🔧</span>Workflows <span id="build-tab-workflows-count" style="display:none;margin-left:4px;font-size:10px;background:var(--bg-tertiary);padding:1px 6px;border-radius:999px;color:var(--text-muted)">0</span>
17078
17198
  </button>
17199
+ <!-- Spacer + primary "create" CTA. The Tasks/Runs/Tools tabs are above; this sits flush with them on the right so creation is one click from anywhere on the Tasks domain. -->
17200
+ <div style="flex:1"></div>
17201
+ <button class="btn-primary" onclick="openCreateCronModal()" style="margin-bottom:6px;font-size:13px;padding:7px 14px;border-radius:6px;border:none;background:var(--accent);color:#fff;font-weight:500;cursor:pointer;display:inline-flex;align-items:center;gap:6px">
17202
+ <span style="font-size:14px;line-height:1">+</span> New task
17203
+ </button>
17079
17204
  </div>
17080
17205
  <style>
17081
17206
  .build-tab-btn.active { color:var(--accent) !important; border-bottom-color:var(--accent) !important; background:var(--bg-primary) !important; }
@@ -20173,11 +20298,18 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
20173
20298
  invocation lands in Phase C. The page is intentionally minimal —
20174
20299
  we want users to see what's there, not be overwhelmed by 7 tiles. -->
20175
20300
  <div class="page" id="page-skills">
20176
- <div class="page-head">
20177
- <div class="icon icon-slot" data-icon="brain"></div>
20178
- <div class="title-block">
20179
- <h1>Skills</h1>
20180
- <p class="desc">Reusable procedures Clementine can run. Each skill declares its tools, data sources, and state.</p>
20301
+ <div class="page-head" style="display:flex;align-items:flex-start;justify-content:space-between;gap:18px">
20302
+ <div style="display:flex;align-items:flex-start;gap:14px;flex:1;min-width:0">
20303
+ <div class="icon icon-slot" data-icon="brain"></div>
20304
+ <div class="title-block">
20305
+ <h1>Skills</h1>
20306
+ <p class="desc">Reusable procedures. A <strong>skill</strong> is a recipe (Markdown body + tool allowlist + bundled docs). Tasks pin skills; chats can run them. One skill, many tasks.</p>
20307
+ </div>
20308
+ </div>
20309
+ <div style="display:flex;align-items:center;gap:8px;flex-shrink:0">
20310
+ <button class="btn-primary" onclick="openCreateSkillModal()" style="font-size:13px;padding:8px 14px;border-radius:6px;border:none;background:var(--accent);color:#fff;font-weight:500;cursor:pointer;display:inline-flex;align-items:center;gap:6px">
20311
+ <span style="font-size:14px;line-height:1">+</span> New skill
20312
+ </button>
20181
20313
  </div>
20182
20314
  </div>
20183
20315
  <div style="display:grid;grid-template-columns:380px 1fr;gap:18px;height:calc(100vh - 180px);min-height:500px">
@@ -20188,8 +20320,10 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
20188
20320
  <div id="skills-list" style="padding:6px"></div>
20189
20321
  </div>
20190
20322
  <div id="skills-detail-pane" style="overflow-y:auto;border:1px solid var(--border);border-radius:8px;background:var(--bg-secondary);padding:0">
20191
- <div id="skills-detail" style="padding:24px;color:var(--text-muted);text-align:center;font-size:13px">
20192
- Select a skill on the left to see its procedure, tools, and data sources.
20323
+ <div id="skills-detail" style="padding:0;font-size:13px">
20324
+ <div style="padding:60px 24px;color:var(--text-muted);text-align:center;font-size:13px">
20325
+ Select a skill on the left to see its procedure, tools, and data sources.
20326
+ </div>
20193
20327
  </div>
20194
20328
  </div>
20195
20329
  </div>
@@ -24341,7 +24475,14 @@ function renderScheduledTaskCard(task) {
24341
24475
  if (task.maxRetries != null) badges += '<span class="badge badge-gray">' + esc(task.maxRetries) + ' retries</span>';
24342
24476
  badges += operationUsageBadge(task.usage);
24343
24477
  badges += '<span class="badge ' + (enabled ? 'badge-green' : 'badge-gray') + '">' + (enabled ? 'Enabled' : 'Disabled') + '</span>';
24344
- badges += '<span class="badge ' + (task.health === 'broken' || task.health === 'failed' ? 'badge-yellow' : 'badge-gray') + '">' + esc(task.healthLabel || task.health) + '</span>';
24478
+ // 1.18.118 only emit the health badge when it adds new information.
24479
+ // ok/idle are implicit when the green Enabled badge is showing, and
24480
+ // disabled would just duplicate the gray Disabled badge above.
24481
+ var hl = String(task.healthLabel || task.health || '').toLowerCase();
24482
+ if (hl && hl !== 'ok' && hl !== 'idle' && hl !== 'disabled') {
24483
+ var hlClass = (hl === 'broken' || hl === 'failed' || hl === 'timeout') ? 'badge-yellow' : (hl === 'running' ? 'badge-blue' : 'badge-gray');
24484
+ badges += '<span class="badge ' + hlClass + '">' + esc(task.healthLabel || task.health) + '</span>';
24485
+ }
24345
24486
  var safeName = jsStr(task.name);
24346
24487
  // PRD §10 / 1.18.91: when a task is mid-flight, Run Now is meaningless and
24347
24488
  // would race against the concurrency lock; replace it with a Cancel button
@@ -24351,11 +24492,30 @@ function renderScheduledTaskCard(task) {
24351
24492
  var runOrCancelBtn = isRunning
24352
24493
  ? '<button class="btn-sm secondary btn-danger" onclick="cancelCronRun(\\x27' + safeName + '\\x27)" title="Stop this in-flight run (SIGTERM)">Cancel</button>'
24353
24494
  : '<button class="btn-sm secondary btn-success" onclick="apiPost(\\x27/api/cron/run/' + encodeURIComponent(task.name) + '\\x27)" title="Run this task once now">Run Now</button>';
24495
+ // 1.18.115 — preview line. Prefer task.description (a future-proof
24496
+ // dedicated field), then strip leading TOOL RESTRICTIONS / FORBIDDEN
24497
+ // boilerplate (purely a runtime allowlist, not what the task does), then
24498
+ // grab the first real sentence so the card reads as "what this task is
24499
+ // for" rather than "wall of LLM scaffolding."
24500
+ function _taskPreview(t) {
24501
+ if (t.description && String(t.description).trim()) return String(t.description).trim();
24502
+ var p = String(t.prompt || '').trim();
24503
+ // Strip the canonical tool-restriction preamble (matches the user's
24504
+ // TOOL RESTRICTIONS — MANDATORY... block up to the first paragraph
24505
+ // that doesn't start with a numbered restriction line).
24506
+ var stripped = p.replace(/^TOOL RESTRICTIONS[\\s\\S]*?(?=\\n\\n[A-Z][^.]*\\.|\\n\\n\\w)/i, '').trim();
24507
+ if (!stripped) stripped = p;
24508
+ // First sentence or first line, whichever is shorter. Falls back to
24509
+ // the first 200 chars if nothing punctuates.
24510
+ var firstLine = stripped.split('\\n').map(function(s){ return s.trim(); }).find(function(s){ return s.length > 0; }) || '';
24511
+ var firstSentence = (firstLine.match(/^[^.!?]+[.!?]/) || [firstLine])[0];
24512
+ return (firstSentence || stripped).slice(0, 200);
24513
+ }
24354
24514
  return '<div class="' + cardCls + '" style="' + style + '">'
24355
24515
  + '<div class="task-card-header"><strong>' + esc(task.displayName || task.name) + '</strong>'
24356
24516
  + '<label class="toggle-switch"><input type="checkbox"' + (enabled ? ' checked' : '') + ' onchange="toggleCronJob(\\x27' + safeName + '\\x27)"><span class="toggle-slider"></span></label></div>'
24357
24517
  + '<div class="task-card-schedule">' + operationScheduleHtml(task.schedule) + '</div>'
24358
- + '<div class="task-card-prompt">' + esc(task.prompt || '') + '</div>'
24518
+ + '<div class="task-card-prompt">' + esc(_taskPreview(task)) + '</div>'
24359
24519
  + renderTrickCapabilityStrip(task)
24360
24520
  + '<div class="task-card-status">' + lastRunHtml + '</div>'
24361
24521
  + renderTrickTagChips(task)
@@ -25809,15 +25969,24 @@ async function refreshCron() {
25809
25969
  var visibleRunning = ownerScoped ? (ops.runningNow || []).filter(function(i) { return buildOpsOwnerMatches(i.owner || ''); }) : (ops.runningNow || []);
25810
25970
  var ownerFilter = getBuildOwnerFilter();
25811
25971
 
25812
- // PRD §12 / 1.18.88: Health Strip placeholder. The runs payload from
25813
- // /api/cron/runs (already fetched alongside ops) feeds the metrics.
25814
- // Render an empty shell first; refreshHealthStrip fills it in.
25972
+ // PRD §12 / 1.18.88: Health Strip kept always-visible at the top.
25973
+ // 7 KPI tiles (24h runs, success rate, cost, p50/p95 latency, running,
25974
+ // top failure). The runs payload from /api/cron/runs (already fetched
25975
+ // alongside ops) feeds the metrics. Render an empty shell first;
25976
+ // refreshHealthStrip fills it in.
25815
25977
  var html = '<div id="health-strip" class="health-strip"></div>';
25816
- // PRD §12 / 1.18.93: three mini-dashboards below the Health Strip —
25817
- // Cost (7d sparkline), Latency split (model / tool / overhead),
25818
- // Reliability (failures stacked by category). Filled in by
25819
- // refreshMiniDashboards from the same /api/cron/runs payload.
25820
- html += '<div id="mini-dashboards" class="mini-dashboards"></div>';
25978
+ // 1.18.115 collapse the cost/latency/reliability/activity mini-cards
25979
+ // into a <details> block. The Health Strip already covers what most
25980
+ // users want at a glance; the 4 mini-dashboards are for deeper
25981
+ // observability and don't need to be the second thing on the page.
25982
+ // Closed by default; users who want them flip it open once and the
25983
+ // browser remembers via the [open] attribute persistence pattern.
25984
+ html += '<details class="mini-dashboards-toggle" style="margin:14px 0 4px">'
25985
+ + '<summary style="font-size:12px;color:var(--text-muted);cursor:pointer;padding:6px 0;user-select:none;display:inline-flex;align-items:center;gap:6px">'
25986
+ + '<span style="font-size:11px">▸</span> Show cost / latency / reliability mini-dashboards'
25987
+ + '</summary>'
25988
+ + '<div id="mini-dashboards" class="mini-dashboards" style="margin-top:10px"></div>'
25989
+ + '</details>';
25821
25990
 
25822
25991
  // ── Zone 1 — Running now (promoted to top, primary "what's live" view) ──
25823
25992
  if (visibleRunning.length > 0) {
@@ -25826,18 +25995,13 @@ async function refreshCron() {
25826
25995
  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>';
25827
25996
  }
25828
25997
 
25829
- // ── Needs attention (only when there are issues) ──
25830
- if (visibleAttention.length > 0) {
25831
- html += operationSectionHeader('Needs attention', 'Broken scheduled tasks and failed runtime work that can waste tokens or silently stop.', 'badge-yellow', visibleAttention.length + ' review', visibleRunning.length > 0 ? '28px' : '0')
25832
- + '<div class="task-grid">' + visibleAttention.slice(0, 12).map(renderAttentionCard).join('') + '</div>';
25833
- 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>';
25834
- }
25835
-
25836
- // ── Zone 2 — Your tasks (the main card grid) ──
25998
+ // ── Zone 2 — Your tasks (the main card grid; promoted above "Needs
25999
+ // attention" in 1.18.118 so the user sees their working tasks at
26000
+ // fold instead of having to scroll past 1,000+ px of error cards).
25837
26001
  var filteredTasks = applyTrickFilter(visibleTasks, _trickFilter);
25838
26002
  var filterPillsHtml = renderTrickFilterRow(visibleTasks, _trickFilter);
25839
26003
  var taskCountLabel = (_trickFilter.kind ? filteredTasks.length + '/' + visibleTasks.length : visibleTasks.length) + ' task' + (visibleTasks.length === 1 ? '' : 's');
25840
- html += operationSectionHeader('Your tasks', 'Recurring jobs Clementine runs for you. Tap any card to edit; the toggle on each card pauses or resumes it.', 'badge-blue', taskCountLabel, (visibleRunning.length > 0 || visibleAttention.length > 0) ? '28px' : '0')
26004
+ 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')
25841
26005
  + filterPillsHtml
25842
26006
  + '<div class="task-grid">';
25843
26007
  if (filteredTasks.length === 0) {
@@ -25861,6 +26025,22 @@ async function refreshCron() {
25861
26025
  html += '<div class="task-grid">' + visibleWorkflows.map(renderScheduledWorkflowCard).join('') + '</div>';
25862
26026
  }
25863
26027
 
26028
+ // ── Needs attention — collapsed by default in 1.18.118. Was the
26029
+ // second thing on the page; pushed "Your tasks" 1,000+ px below
26030
+ // the fold. Still surfaced visibly via a yellow count chip in the
26031
+ // summary so users know it's there. Click to expand for triage.
26032
+ if (visibleAttention.length > 0) {
26033
+ html += '<details style="margin-top:28px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;padding:0;overflow:hidden">'
26034
+ + '<summary style="padding:12px 16px;cursor:pointer;display:flex;align-items:center;gap:10px;user-select:none">'
26035
+ + '<span style="font-size:14px;font-weight:600;color:var(--text-primary)">Needs attention</span>'
26036
+ + '<span class="badge badge-yellow">' + visibleAttention.length + ' review</span>'
26037
+ + '<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>'
26038
+ + '</summary>'
26039
+ + '<div style="padding:0 16px 16px"><div class="task-grid">' + visibleAttention.slice(0, 12).map(renderAttentionCard).join('') + '</div>';
26040
+ 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>';
26041
+ html += '</div></details>';
26042
+ }
26043
+
25864
26044
  // ── Zone 3 — Recent history (last 50 runs across all jobs) ──
25865
26045
  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');
25866
26046
  html += renderRecentHistoryList(historyData);
@@ -26748,6 +26928,250 @@ async function migrateAllLegacySkills() {
26748
26928
  } catch (err) { toast('Bulk migration failed: ' + err, 'error'); }
26749
26929
  }
26750
26930
 
26931
+ // 1.18.115 — tiny inline Markdown renderer for the Skills detail pane.
26932
+ // Handles the subset SKILL.md bodies use in practice: headers, bold,
26933
+ // italic, inline-code, fenced code blocks, ordered + unordered lists,
26934
+ // paragraphs, line breaks. Pulling in marked or markdown-it would balloon
26935
+ // the served bundle for ~80 lines of regex. Output is always escaped
26936
+ // first, then re-styled.
26937
+ //
26938
+ // NOTE: regex patterns containing backtick characters are constructed via
26939
+ // new RegExp(string) instead of regex literals — raw backticks would
26940
+ // otherwise close the surrounding TypeScript template literal that
26941
+ // builds the served HTML.
26942
+ function renderMarkdown(src) {
26943
+ if (!src) return '';
26944
+ // Escape every char first; we never inject raw HTML from the body.
26945
+ var s = String(src)
26946
+ .replace(/&/g, '&amp;')
26947
+ .replace(/</g, '&lt;')
26948
+ .replace(/>/g, '&gt;')
26949
+ .replace(/"/g, '&quot;');
26950
+ // Fenced code blocks (preserve interior verbatim — no inline markup
26951
+ // applied inside). Use a placeholder map so subsequent regex passes
26952
+ // don't munge the contents. Fence delimiter is three backticks.
26953
+ var BACKTICK = String.fromCharCode(96);
26954
+ var fences = [];
26955
+ var fenceRe = new RegExp(BACKTICK + BACKTICK + BACKTICK + '([a-z0-9_-]*)\\n?([\\s\\S]*?)' + BACKTICK + BACKTICK + BACKTICK, 'gi');
26956
+ s = s.replace(fenceRe, function(_, lang, code) {
26957
+ fences.push('<pre style="background:var(--bg-tertiary);padding:12px 14px;border-radius:6px;overflow:auto;font-size:12px;line-height:1.5;margin:8px 0;border:1px solid var(--border)"><code>' + code.replace(/\\n+$/, '') + '</code></pre>');
26958
+ return '\\u0000FENCE_' + (fences.length - 1) + '\\u0000';
26959
+ });
26960
+ // Inline code — single backticks
26961
+ var inlineRe = new RegExp(BACKTICK + '([^' + BACKTICK + '\\n]+)' + BACKTICK, 'g');
26962
+ s = s.replace(inlineRe, '<code style="background:var(--bg-tertiary);padding:1px 6px;border-radius:3px;font-size:0.92em">$1</code>');
26963
+ // Bold first (greedier double-star) so it doesn't get eaten by single-star italic.
26964
+ s = s.replace(/\\*\\*([^*\\n][^*]*?)\\*\\*/g, '<strong>$1</strong>');
26965
+ s = s.replace(/(^|[^*])\\*([^*\\n][^*]*?)\\*/g, '$1<em>$2</em>');
26966
+ // Headers — process line-by-line so we don't accidentally match across
26967
+ // paragraphs. Also collect lists into <ul>/<ol> blocks.
26968
+ var lines = s.split('\\n');
26969
+ var out = [];
26970
+ var listKind = null; // 'ul' | 'ol' | null
26971
+ var listItems = [];
26972
+ function flushList() {
26973
+ if (!listKind) return;
26974
+ out.push('<' + listKind + ' style="margin:6px 0 10px;padding-left:22px">' + listItems.map(function(li) { return '<li style="margin:2px 0">' + li + '</li>'; }).join('') + '</' + listKind + '>');
26975
+ listKind = null; listItems = [];
26976
+ }
26977
+ function paraStart() { out.push('<p style="margin:8px 0">'); }
26978
+ function paraEnd() {
26979
+ // Close the last <p> if there is one open.
26980
+ var last = out.length - 1;
26981
+ if (last >= 0 && out[last].endsWith('</p>')) return;
26982
+ if (last >= 0 && out[last] === '<p style="margin:8px 0">') { out.pop(); return; }
26983
+ if (last >= 0 && !/^<(h\\d|ul|ol|pre|hr|details)/.test(out[last])) {
26984
+ // We're mid-paragraph — emit the close tag.
26985
+ out.push('</p>');
26986
+ }
26987
+ }
26988
+ var paraOpen = false;
26989
+ function closePara() { if (paraOpen) { out.push('</p>'); paraOpen = false; } }
26990
+ function openPara() { if (!paraOpen) { out.push('<p style="margin:8px 0">'); paraOpen = true; } }
26991
+ for (var i = 0; i < lines.length; i++) {
26992
+ var line = lines[i];
26993
+ // Restore fence placeholders as their own block.
26994
+ var fenceMatch = line.match(/\\u0000FENCE_(\\d+)\\u0000/);
26995
+ if (fenceMatch) { closePara(); flushList(); out.push(fences[Number(fenceMatch[1])]); continue; }
26996
+ // Headers
26997
+ var hMatch = line.match(/^(#{1,6})\\s+(.+)$/);
26998
+ if (hMatch) {
26999
+ closePara(); flushList();
27000
+ var level = hMatch[1].length;
27001
+ var sizes = { 1: '1.4em', 2: '1.2em', 3: '1.05em', 4: '1em', 5: '0.95em', 6: '0.9em' };
27002
+ out.push('<h' + level + ' style="margin:14px 0 6px;font-size:' + sizes[level] + ';font-weight:600;color:var(--text-primary)">' + hMatch[2] + '</h' + level + '>');
27003
+ continue;
27004
+ }
27005
+ // Unordered list — dash, star, or plus prefix
27006
+ var ulMatch = line.match(/^\\s*[-*+]\\s+(.+)$/);
27007
+ if (ulMatch) {
27008
+ closePara();
27009
+ if (listKind && listKind !== 'ul') flushList();
27010
+ listKind = 'ul';
27011
+ listItems.push(ulMatch[1]);
27012
+ continue;
27013
+ }
27014
+ // Ordered list — 1., 2., etc.
27015
+ var olMatch = line.match(/^\\s*\\d+\\.\\s+(.+)$/);
27016
+ if (olMatch) {
27017
+ closePara();
27018
+ if (listKind && listKind !== 'ol') flushList();
27019
+ listKind = 'ol';
27020
+ listItems.push(olMatch[1]);
27021
+ continue;
27022
+ }
27023
+ // Blank line — paragraph break
27024
+ if (!line.trim()) { closePara(); flushList(); continue; }
27025
+ // Hr
27026
+ if (/^---+$/.test(line.trim())) { closePara(); flushList(); out.push('<hr style="border:none;border-top:1px solid var(--border);margin:12px 0">'); continue; }
27027
+ // Default — wrap as paragraph text
27028
+ flushList();
27029
+ openPara();
27030
+ // Append to current paragraph with a soft break between consecutive
27031
+ // text lines (markdown convention: lines in a paragraph join with space).
27032
+ var top = out.length - 1;
27033
+ if (out[top] === '<p style="margin:8px 0">') {
27034
+ out[top] = '<p style="margin:8px 0">' + line;
27035
+ } else {
27036
+ out[top] = out[top] + ' ' + line;
27037
+ }
27038
+ }
27039
+ closePara(); flushList();
27040
+ return out.join('\\n');
27041
+ }
27042
+
27043
+ // 1.18.115 — Skill creation modal. Until now there was no UI to make a
27044
+ // new skill; users had to mkdir + write SKILL.md by hand. Modal collects
27045
+ // name (Anthropic regex enforced client-side), description, body, and an
27046
+ // optional comma-separated tools.allow allowlist; POSTs to a new endpoint
27047
+ // that calls skill-store.parseSkillFolder/write under the hood.
27048
+ function openCreateSkillModal() { _openSkillModal({ mode: 'create' }); }
27049
+ function openEditSkillModal(name) { _openSkillModal({ mode: 'edit', name: name }); }
27050
+
27051
+ async function _openSkillModal(opts) {
27052
+ opts = opts || {};
27053
+ var existing = null;
27054
+ if (opts.mode === 'edit' && opts.name) {
27055
+ try {
27056
+ var r = await apiFetch('/api/skills/' + encodeURIComponent(opts.name));
27057
+ if (r.ok) existing = await r.json();
27058
+ } catch (e) { toast('Failed to load skill: ' + e, 'error'); return; }
27059
+ }
27060
+ var fm = (existing && existing.frontmatter) || {};
27061
+ var ext = fm.clementine || {};
27062
+ var nameVal = fm.name || '';
27063
+ var titleVal = fm.title || '';
27064
+ var descVal = fm.description || '';
27065
+ var bodyVal = (existing && existing.body) || '';
27066
+ var toolsVal = (ext.tools && Array.isArray(ext.tools.allow)) ? ext.tools.allow.join(', ') : '';
27067
+ var modal = document.getElementById('skill-edit-modal');
27068
+ if (!modal) {
27069
+ modal = document.createElement('div');
27070
+ modal.id = 'skill-edit-modal';
27071
+ modal.className = 'modal-overlay';
27072
+ 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';
27073
+ modal.innerHTML =
27074
+ '<div style="background:var(--bg-primary);border:1px solid var(--border);border-radius:10px;width:min(720px,95vw);max-height:90vh;display:flex;flex-direction:column;box-shadow:0 16px 48px rgba(0,0,0,0.35)">'
27075
+ + '<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 20px;border-bottom:1px solid var(--border)">'
27076
+ + '<h3 id="skill-modal-title" style="margin:0;font-size:15px;font-weight:600">New skill</h3>'
27077
+ + '<button onclick="closeSkillModal()" style="background:none;border:none;font-size:18px;color:var(--text-muted);cursor:pointer;padding:0 4px;line-height:1">✕</button>'
27078
+ + '</div>'
27079
+ + '<div style="flex:1;overflow-y:auto;padding:18px 22px">'
27080
+ + '<input type="hidden" id="skill-modal-original-name">'
27081
+ + '<label style="display:block;font-size:12px;color:var(--text-secondary);margin-bottom:4px;font-weight:500">Name <span style="color:var(--text-muted)">(lowercase, dashes, max 64 chars)</span></label>'
27082
+ + '<input id="skill-modal-name" type="text" placeholder="e.g. morning-briefing" style="width:100%;padding:8px 10px;font-size:13px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary);margin-bottom:12px;font-family:\\x27JetBrains Mono\\x27,monospace">'
27083
+ + '<label style="display:block;font-size:12px;color:var(--text-secondary);margin-bottom:4px;font-weight:500">Display title <span style="color:var(--text-muted)">(optional, friendlier name)</span></label>'
27084
+ + '<input id="skill-modal-title" type="text" placeholder="e.g. Morning Briefing" style="width:100%;padding:8px 10px;font-size:13px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary);margin-bottom:12px">'
27085
+ + '<label style="display:block;font-size:12px;color:var(--text-secondary);margin-bottom:4px;font-weight:500">Description <span style="color:var(--text-muted)">(what this skill does — used by Claude to know when to apply it)</span></label>'
27086
+ + '<textarea id="skill-modal-desc" rows="2" placeholder="One paragraph: what does this skill do, when should Claude run it?" style="width:100%;padding:8px 10px;font-size:13px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary);margin-bottom:12px;font-family:inherit;resize:vertical"></textarea>'
27087
+ + '<label style="display:block;font-size:12px;color:var(--text-secondary);margin-bottom:4px;font-weight:500">Allowed tools <span style="color:var(--text-muted)">(comma-separated, leave blank for default)</span></label>'
27088
+ + '<input id="skill-modal-tools" type="text" placeholder="e.g. Read, Bash, mcp__supabase__list_tables" style="width:100%;padding:8px 10px;font-size:13px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary);margin-bottom:12px">'
27089
+ + '<label style="display:block;font-size:12px;color:var(--text-secondary);margin-bottom:4px;font-weight:500">Procedure <span style="color:var(--text-muted)">(Markdown — the actual steps Claude follows)</span></label>'
27090
+ + '<textarea id="skill-modal-body" rows="14" placeholder="# Morning Briefing\\n\\nSteps Claude follows when this skill is invoked.\\n\\n1. Check the inbox.\\n2. Summarize.\\n3. Send to Discord." style="width:100%;padding:10px 12px;font-size:12px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary);font-family:\\x27JetBrains Mono\\x27,monospace;line-height:1.55;resize:vertical"></textarea>'
27091
+ + '<div id="skill-modal-error" style="display:none;color:var(--red);font-size:12px;margin-top:10px;padding:8px 10px;background:rgba(239,68,68,0.08);border:1px solid var(--red);border-radius:6px"></div>'
27092
+ + '</div>'
27093
+ + '<div style="display:flex;justify-content:flex-end;gap:8px;padding:14px 20px;border-top:1px solid var(--border);background:var(--bg-secondary)">'
27094
+ + '<button onclick="closeSkillModal()" 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>'
27095
+ + '<button id="skill-modal-save" onclick="saveSkillFromModal()" class="btn-primary" style="padding:7px 16px;font-size:13px;border:none;border-radius:6px;background:var(--accent);color:#fff;font-weight:500;cursor:pointer">Save skill</button>'
27096
+ + '</div>'
27097
+ + '</div>';
27098
+ document.body.appendChild(modal);
27099
+ }
27100
+ document.getElementById('skill-modal-title').textContent = opts.mode === 'edit' ? 'Edit skill: ' + nameVal : 'New skill';
27101
+ document.getElementById('skill-modal-original-name').value = opts.mode === 'edit' ? nameVal : '';
27102
+ document.getElementById('skill-modal-name').value = nameVal;
27103
+ document.getElementById('skill-modal-name').disabled = opts.mode === 'edit';
27104
+ document.getElementById('skill-modal-title').nextElementSibling; // no-op
27105
+ document.getElementById('skill-modal-title').value = titleVal;
27106
+ document.getElementById('skill-modal-desc').value = descVal;
27107
+ document.getElementById('skill-modal-tools').value = toolsVal;
27108
+ document.getElementById('skill-modal-body').value = bodyVal;
27109
+ var errEl = document.getElementById('skill-modal-error');
27110
+ if (errEl) { errEl.style.display = 'none'; errEl.textContent = ''; }
27111
+ modal.style.display = 'flex';
27112
+ document.getElementById('skill-modal-name').focus();
27113
+ }
27114
+
27115
+ function closeSkillModal() {
27116
+ var m = document.getElementById('skill-edit-modal');
27117
+ if (m) m.style.display = 'none';
27118
+ }
27119
+
27120
+ async function saveSkillFromModal() {
27121
+ var name = (document.getElementById('skill-modal-name')?.value || '').trim();
27122
+ var title = (document.getElementById('skill-modal-title')?.value || '').trim();
27123
+ var desc = (document.getElementById('skill-modal-desc')?.value || '').trim();
27124
+ var toolsRaw = (document.getElementById('skill-modal-tools')?.value || '').trim();
27125
+ var body = (document.getElementById('skill-modal-body')?.value || '');
27126
+ var originalName = (document.getElementById('skill-modal-original-name')?.value || '').trim();
27127
+ var errEl = document.getElementById('skill-modal-error');
27128
+ function fail(msg) {
27129
+ if (errEl) { errEl.textContent = msg; errEl.style.display = ''; }
27130
+ else toast(msg, 'error');
27131
+ }
27132
+ if (!name) return fail('Name is required.');
27133
+ if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(name)) return fail('Name must be lowercase letters/digits/dashes, start with a letter or digit, max 64 chars.');
27134
+ if (!desc) return fail('Description is required (used by Claude to decide when to apply this skill).');
27135
+ if (desc.length > 1024) return fail('Description must be ≤ 1024 chars (Anthropic spec).');
27136
+ if (!body.trim()) return fail('Procedure body is required.');
27137
+ var tools = toolsRaw ? toolsRaw.split(',').map(function(s){ return s.trim(); }).filter(Boolean) : [];
27138
+ var saveBtn = document.getElementById('skill-modal-save');
27139
+ if (saveBtn) { saveBtn.disabled = true; saveBtn.textContent = 'Saving…'; }
27140
+ try {
27141
+ var endpoint = originalName ? '/api/skills/' + encodeURIComponent(originalName) : '/api/skills';
27142
+ var method = originalName ? 'PUT' : 'POST';
27143
+ var r = await apiFetch(endpoint, {
27144
+ method: method,
27145
+ headers: { 'Content-Type': 'application/json' },
27146
+ body: JSON.stringify({ name: name, title: title || undefined, description: desc, tools: tools, body: body }),
27147
+ });
27148
+ if (!r.ok) {
27149
+ var d = await r.json().catch(function(){ return {}; });
27150
+ return fail(d.error || ('Save failed: HTTP ' + r.status));
27151
+ }
27152
+ closeSkillModal();
27153
+ toast(originalName ? 'Skill updated' : 'Skill created', 'success');
27154
+ if (typeof refreshSkillsPage === 'function') await refreshSkillsPage();
27155
+ // Auto-open the freshly-saved skill so the user sees their work.
27156
+ if (typeof showSkillDetail === 'function') showSkillDetail(name);
27157
+ } catch (err) { fail('Save failed: ' + err); }
27158
+ finally { if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = 'Save skill'; } }
27159
+ }
27160
+
27161
+ async function confirmDeleteSkill(name) {
27162
+ if (!confirm('Delete skill "' + name + '"? The folder will be removed; the .md.bak (if present) is preserved.')) return;
27163
+ try {
27164
+ var r = await apiFetch('/api/skills/' + encodeURIComponent(name), { method: 'DELETE' });
27165
+ if (!r.ok) {
27166
+ var d = await r.json().catch(function(){ return {}; });
27167
+ toast(d.error || 'Delete failed', 'error');
27168
+ return;
27169
+ }
27170
+ toast('Skill "' + name + '" deleted', 'success');
27171
+ if (typeof refreshSkillsPage === 'function') await refreshSkillsPage();
27172
+ } catch (err) { toast('Delete failed: ' + err, 'error'); }
27173
+ }
27174
+
26751
27175
  async function refreshSkillsPage() {
26752
27176
  var listEl = document.getElementById('skills-list');
26753
27177
  var detailEl = document.getElementById('skills-detail');
@@ -27027,7 +27451,10 @@ function renderSkillDetail(s) {
27027
27451
  html += renderSkillSection('Usage', '<div style="font-size:12px;color:var(--text-secondary)">' + ubits.map(esc).join(' · ') + '</div>');
27028
27452
  }
27029
27453
 
27030
- // ── 7. Procedure body (with line counter)
27454
+ // ── 7. Procedure body — rendered as markdown (1.18.115). Prior shipped
27455
+ // raw <pre> source which read like config, not procedure. Headers/lists/
27456
+ // bold/code now render visually. Line counter still appears so authors
27457
+ // know if they're approaching Anthropic's ≤500-line guidance.
27031
27458
  if (s.body && s.body.trim()) {
27032
27459
  var bodyClass = bodyLines > 500 ? 'color:var(--yellow)' : 'color:var(--text-muted)';
27033
27460
  html += '<div style="margin-top:18px">';
@@ -27035,10 +27462,17 @@ function renderSkillDetail(s) {
27035
27462
  html += '<div style="font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.04em;font-weight:500">Procedure</div>';
27036
27463
  html += '<div style="font-size:10px;' + bodyClass + ';font-family:\\x27JetBrains Mono\\x27,monospace">' + bodyLines + ' / 500 lines</div>';
27037
27464
  html += '</div>';
27038
- html += '<pre style="font-size:12px;line-height:1.55;background:var(--bg-tertiary);padding:14px 16px;border-radius:6px;white-space:pre-wrap;word-break:break-word;font-family:inherit;border:1px solid var(--border);max-height:500px;overflow:auto">' + esc(s.body) + '</pre>';
27465
+ html += '<div class="skill-md" style="font-size:13px;line-height:1.6;color:var(--text-primary);background:var(--bg-secondary);padding:18px 22px;border-radius:8px;border:1px solid var(--border);max-height:560px;overflow:auto">' + renderMarkdown(s.body) + '</div>';
27039
27466
  html += '</div>';
27040
27467
  }
27041
27468
 
27469
+ // ── 8. Action footer (1.18.115) — Edit + Delete + Open file. The pane
27470
+ // was read-only; users had to leave the dashboard to edit anything.
27471
+ html += '<div style="margin-top:24px;display:flex;gap:8px;flex-wrap:wrap">';
27472
+ html += '<button class="btn-primary" onclick="openEditSkillModal(\\x27' + jsStr(fm.name) + '\\x27)" style="font-size:12px;padding:6px 14px;border-radius:6px;border:none;background:var(--accent);color:#fff;font-weight:500;cursor:pointer">Edit skill</button>';
27473
+ html += '<button class="btn-sm" onclick="confirmDeleteSkill(\\x27' + jsStr(fm.name) + '\\x27)" style="font-size:12px;padding:6px 14px;border-radius:6px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--red);cursor:pointer">Delete</button>';
27474
+ html += '</div>';
27475
+
27042
27476
  // ── 8. Schema-specific footer
27043
27477
  if (s.schemaVersion === 'legacy') {
27044
27478
  html += '<div style="margin-top:24px;padding:12px 14px;background:rgba(245,158,11,0.08);border:1px solid var(--yellow);border-radius:6px;font-size:12px;color:var(--text-secondary);line-height:1.5">'
@@ -27616,11 +28050,20 @@ function renderSkillsPickerList() {
27616
28050
  return hay.indexOf(q) !== -1;
27617
28051
  });
27618
28052
  }
28053
+ // 1.18.115 — "+ Create new skill" affordance pinned to the top of the
28054
+ // picker. Lets users author a skill in-flight without losing their cron
28055
+ // edit; the new skill appears in the list right after the modal closes.
28056
+ var createRow = '<div class="cap-picker-row" style="border:1px dashed var(--accent);background:transparent" onclick="openCreateSkillModal()">'
28057
+ + '<div class="cap-picker-row-body">'
28058
+ + '<div class="cap-picker-row-title" style="color:var(--accent);font-weight:600">+ Create new skill</div>'
28059
+ + '<div class="cap-picker-row-desc">Author a fresh skill — opens the editor without leaving this task.</div>'
28060
+ + '</div>'
28061
+ + '</div>';
27619
28062
  if (skills.length === 0) {
27620
- listEl.innerHTML = '<div class="cap-picker-empty-state">' + (q ? 'No matches.' : 'No skills available.') + '</div>';
28063
+ listEl.innerHTML = createRow + '<div class="cap-picker-empty-state">' + (q ? 'No matches.' : 'No skills available.') + '</div>';
27621
28064
  return;
27622
28065
  }
27623
- listEl.innerHTML = skills.slice(0, 50).map(function(s) {
28066
+ listEl.innerHTML = createRow + skills.slice(0, 50).map(function(s) {
27624
28067
  var sel = _cronSelectedSkills.indexOf(s.name) !== -1;
27625
28068
  var triggers = (s.triggers || []).slice(0, 4).join(', ');
27626
28069
  return '<div class="cap-picker-row' + (sel ? ' selected' : '') + '" onclick="addSkillToTrick(\\x27' + jsStr(s.name) + '\\x27)">'
@@ -28300,19 +28743,21 @@ function onPredictableChange() {
28300
28743
  function renderCronLegacyBanner(job) {
28301
28744
  var host = document.getElementById('cron-legacy-banner-host');
28302
28745
  if (!host) return;
28303
- // Banner only when predictable is undefined or explicitly false. Jobs
28304
- // saved with predictable: true are migrated and skip the banner.
28746
+ // Predictable jobs skip the tip entirely; legacy jobs get a non-alarming
28747
+ // suggestion. The previous wording ("OUTPUT MAY NOT MATCH WHAT YOU SEE
28748
+ // HERE") read as "the editor is lying to you" and made every legacy task
28749
+ // feel broken. It isn't — runs work fine. The toggle below explains the
28750
+ // trade-off; this is just a one-click shortcut for the common upgrade.
28305
28751
  if (job && job.predictable === true) { host.innerHTML = ''; return; }
28306
- var msg = (job && job.predictable === false)
28307
- ? "This task is set to legacy mode. At fire-time the runner injects MEMORY.md, recent team activity, the delegation queue, and auto-matches MCP servers based on prompt text — even if your prompt forbids them. The <strong>What will run</strong> tab shows what actually gets attached."
28308
- : "This task was created before Predictable Mode existed. At fire-time the runner still injects MEMORY.md, recent team activity, and auto-matches MCP servers based on prompt text. Open the <strong>What will run</strong> tab to see what actually gets attached.";
28309
28752
  host.innerHTML =
28310
- '<div class="cron-banner warn">'
28311
- + '<h5>⚠ Legacy mode — output may not match what you see here</h5>'
28312
- + '<div>' + msg + '</div>'
28313
- + '<div class="banner-actions">'
28314
- + '<button class="btn-primary btn-sm" onclick="enablePredictableFromBanner()">Migrate now</button>'
28315
- + '<button class="btn-sm" onclick="switchCronTab(\\x27preview\\x27)" style="background:transparent;border:1px solid var(--border);color:var(--text-primary)">See what will run</button>'
28753
+ '<div class="cron-banner info">'
28754
+ + '<div style="display:flex;align-items:flex-start;gap:10px">'
28755
+ + '<span style="font-size:14px;line-height:1.2;flex-shrink:0">💡</span>'
28756
+ + '<div style="flex:1;min-width:0">'
28757
+ + '<div style="font-size:13px;font-weight:600;color:var(--text-primary);margin-bottom:2px">Strict mode available</div>'
28758
+ + '<div style="font-size:12px;color:var(--text-secondary);line-height:1.45">Runs use only the prompt + pinned skills/MCP below — no auto-injected memory or team comms. More reproducible. <a href="javascript:void(0)" onclick="switchCronTab(\\x27preview\\x27)" style="color:var(--accent);text-decoration:none">See what runs today →</a></div>'
28759
+ + '</div>'
28760
+ + '<button class="btn-primary btn-sm" onclick="enablePredictableFromBanner()" style="flex-shrink:0;font-size:11px;padding:5px 12px">Switch on</button>'
28316
28761
  + '</div>'
28317
28762
  + '</div>';
28318
28763
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.18.116",
3
+ "version": "1.18.118",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",