clementine-agent 1.18.109 → 1.18.111

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.
@@ -57,6 +57,26 @@ export declare function getSkill(name: string, opts?: ListSkillsOptions): Skill
57
57
  * Defends against directory traversal: rejects paths that escape the
58
58
  * skill folder. */
59
59
  export declare function readBundledFile(skill: Skill, relPath: string): string | null;
60
+ export interface MigrationResult {
61
+ ok: boolean;
62
+ /** Path to the new SKILL.md created. Undefined when ok=false. */
63
+ newSkillPath?: string;
64
+ /** Path the original .md was moved to (.bak). Undefined when ok=false. */
65
+ backupPath?: string;
66
+ /** Set when ok=false — what went wrong. */
67
+ error?: string;
68
+ /** Set when the loader found nothing to migrate (already folder-form). */
69
+ alreadyMigrated?: boolean;
70
+ }
71
+ /** Migrate one legacy flat skill to Anthropic-compatible folder form.
72
+ * Idempotent: a folder-form skill is reported as alreadyMigrated. */
73
+ export declare function migrateLegacySkill(name: string): MigrationResult;
74
+ /** Migrate every legacy skill in the global pool. Returns per-skill
75
+ * results so the dashboard can render a summary banner. */
76
+ export declare function migrateAllLegacySkills(): {
77
+ migrated: MigrationResult[];
78
+ skipped: MigrationResult[];
79
+ };
60
80
  /** Diagnostics for the dashboard — expose where the loader looked. */
61
81
  export declare function _skillDirsForDiagnostics(workDir?: string): {
62
82
  global: string;
@@ -21,7 +21,7 @@
21
21
  * runner. Phase C wires runtime invocation. Phase E migrates legacy
22
22
  * crons → folder-form skills.
23
23
  */
24
- import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
24
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
25
25
  import os from 'node:os';
26
26
  import path from 'node:path';
27
27
  import matter from 'gray-matter';
@@ -161,6 +161,17 @@ function coerceClementineExtensions(raw) {
161
161
  out.lastUsed = r.lastUsed;
162
162
  if (typeof r.lastTestPass === 'string')
163
163
  out.lastTestPass = r.lastTestPass;
164
+ // Legacy Clementine concepts preserved through migration.
165
+ if (Array.isArray(r.triggers))
166
+ out.triggers = r.triggers.map(String);
167
+ if (typeof r.source === 'string')
168
+ out.source = r.source;
169
+ if (typeof r.useCount === 'number')
170
+ out.useCount = r.useCount;
171
+ if (typeof r.migratedFrom === 'string')
172
+ out.migratedFrom = r.migratedFrom;
173
+ if (typeof r.migratedAt === 'string')
174
+ out.migratedAt = r.migratedAt;
164
175
  return Object.keys(out).length > 0 ? out : undefined;
165
176
  }
166
177
  /** Coerce raw YAML object → SkillFrontmatter. Filename always wins for
@@ -471,6 +482,171 @@ export function readBundledFile(skill, relPath) {
471
482
  return null;
472
483
  }
473
484
  }
485
+ // ── Legacy → folder-form migration ───────────────────────────────────
486
+ //
487
+ // Converts a flat-form legacy skill (`<name>.md` with title/triggers/
488
+ // toolsUsed/useCount frontmatter) into Anthropic-compatible folder form
489
+ // (`<name>/SKILL.md` with name+description top-level + clementine: namespace
490
+ // for the migration metadata).
491
+ //
492
+ // Preserves everything:
493
+ // - title (legacy display name) → moves to top-level `title` (still readable)
494
+ // - triggers (NLP phrases) → clementine.triggers (preserved for reference)
495
+ // - toolsUsed → clementine.tools.allow (informational → enforced allowlist)
496
+ // - useCount + lastUsed → clementine.useCount + clementine.lastUsed
497
+ // - source → clementine.source
498
+ // - body → unchanged, written to <folder>/SKILL.md
499
+ //
500
+ // The original `<name>.md` is renamed to `<name>.md.bak` (kept on disk for
501
+ // rollback). The bak is filtered out by isLoadableSkillFile so the dashboard
502
+ // won't show it twice.
503
+ import { renameSync } from 'node:fs';
504
+ /** Migrate one legacy flat skill to Anthropic-compatible folder form.
505
+ * Idempotent: a folder-form skill is reported as alreadyMigrated. */
506
+ export function migrateLegacySkill(name) {
507
+ if (!name)
508
+ return { ok: false, error: 'name required' };
509
+ const dir = globalSkillsDir();
510
+ const flat = path.join(dir, name + '.md');
511
+ const folder = path.join(dir, name);
512
+ // Already folder-form → no-op.
513
+ if (existsSync(folder) && existsSync(path.join(folder, 'SKILL.md'))) {
514
+ return { ok: true, alreadyMigrated: true, newSkillPath: path.join(folder, 'SKILL.md') };
515
+ }
516
+ // Source file must exist.
517
+ if (!existsSync(flat))
518
+ return { ok: false, error: `legacy skill file not found: ${flat}` };
519
+ // Folder must NOT exist (collides with the rename target).
520
+ if (existsSync(folder))
521
+ return { ok: false, error: `target folder already exists: ${folder}` };
522
+ // Read + parse the legacy file.
523
+ let raw;
524
+ try {
525
+ raw = readFileSync(flat, 'utf-8');
526
+ }
527
+ catch (err) {
528
+ return { ok: false, error: 'failed to read source: ' + String(err) };
529
+ }
530
+ let parsed;
531
+ try {
532
+ parsed = matter(raw);
533
+ }
534
+ catch (err) {
535
+ return { ok: false, error: 'failed to parse YAML in source: ' + String(err) };
536
+ }
537
+ const data = parsed.data;
538
+ const body = parsed.content || '';
539
+ // Build the new frontmatter:
540
+ // - name (always the filename)
541
+ // - description (preserved from legacy)
542
+ // - title (preserved as display alias)
543
+ // - clementine.* — bucket all the legacy-only fields here
544
+ const newFm = {
545
+ name,
546
+ };
547
+ if (typeof data.description === 'string' && data.description.trim())
548
+ newFm.description = data.description;
549
+ if (typeof data.title === 'string' && data.title.trim() && data.title !== data.description) {
550
+ newFm.title = data.title;
551
+ }
552
+ const clementine = {};
553
+ if (Array.isArray(data.triggers) && data.triggers.length > 0) {
554
+ clementine.triggers = data.triggers.map(String);
555
+ }
556
+ if (Array.isArray(data.toolsUsed) && data.toolsUsed.length > 0) {
557
+ // Convert informational toolsUsed → enforced tools.allow. Authors can
558
+ // tighten by editing in Phase B.
559
+ clementine.tools = { allow: data.toolsUsed.map(String) };
560
+ }
561
+ if (typeof data.source === 'string' && data.source.trim())
562
+ clementine.source = data.source;
563
+ if (typeof data.useCount === 'number')
564
+ clementine.useCount = data.useCount;
565
+ if (typeof data.lastUsed === 'string')
566
+ clementine.lastUsed = data.lastUsed;
567
+ if (typeof data.createdAt === 'string')
568
+ clementine.createdAt = data.createdAt;
569
+ if (typeof data.updatedAt === 'string')
570
+ clementine.updatedAt = data.updatedAt;
571
+ // Stamp the migration provenance so future tooling can see where this
572
+ // came from.
573
+ clementine.migratedFrom = path.basename(flat);
574
+ clementine.migratedAt = new Date().toISOString();
575
+ clementine.version = 1;
576
+ if (Object.keys(clementine).length > 0)
577
+ newFm.clementine = clementine;
578
+ // Serialize: gray-matter handles YAML output. matter.stringify takes
579
+ // (content, data) → returns the full file with frontmatter.
580
+ const newContent = matter.stringify(body.startsWith('\n') ? body : '\n' + body, newFm);
581
+ // Create the folder + write SKILL.md.
582
+ try {
583
+ mkdirSync(folder, { recursive: true });
584
+ writeFileSync(path.join(folder, 'SKILL.md'), newContent);
585
+ }
586
+ catch (err) {
587
+ return { ok: false, error: 'failed to write new SKILL.md: ' + String(err) };
588
+ }
589
+ // Move the original to .bak so the loader stops surfacing it. We use
590
+ // <name>.md.bak which isLoadableSkillFile already filters out.
591
+ const backupPath = flat + '.bak';
592
+ try {
593
+ renameSync(flat, backupPath);
594
+ }
595
+ catch (err) {
596
+ // Roll back the folder we just created so we don't leave the user in
597
+ // a half-migrated state with both shapes for the same name.
598
+ try {
599
+ const fs = require('node:fs');
600
+ fs.unlinkSync(path.join(folder, 'SKILL.md'));
601
+ fs.rmdirSync(folder);
602
+ }
603
+ catch { /* best-effort */ }
604
+ return { ok: false, error: 'failed to rename original to .bak: ' + String(err) };
605
+ }
606
+ return {
607
+ ok: true,
608
+ newSkillPath: path.join(folder, 'SKILL.md'),
609
+ backupPath,
610
+ };
611
+ }
612
+ /** Migrate every legacy skill in the global pool. Returns per-skill
613
+ * results so the dashboard can render a summary banner. */
614
+ export function migrateAllLegacySkills() {
615
+ // Walk the dir directly so we don't trigger a full parse + validation.
616
+ const dir = globalSkillsDir();
617
+ const migrated = [];
618
+ const skipped = [];
619
+ if (!existsSync(dir))
620
+ return { migrated, skipped };
621
+ let entries;
622
+ try {
623
+ entries = readdirSync(dir);
624
+ }
625
+ catch {
626
+ return { migrated, skipped };
627
+ }
628
+ for (const entry of entries) {
629
+ if (!isLoadableSkillFile(entry))
630
+ continue;
631
+ const name = entry.replace(/\.md$/, '');
632
+ // Quickly check it's actually legacy — avoid migrating an Anthropic-
633
+ // form flat file that the user explicitly authored without a folder.
634
+ const filePath = path.join(dir, entry);
635
+ let isLegacy = false;
636
+ try {
637
+ const raw = readFileSync(filePath, 'utf-8');
638
+ const data = matter(raw).data;
639
+ isLegacy = ['title', 'triggers', 'toolsUsed', 'useCount', 'source'].some((k) => k in data);
640
+ }
641
+ catch { /* leave isLegacy=false */ }
642
+ if (!isLegacy) {
643
+ skipped.push({ ok: true, alreadyMigrated: true, newSkillPath: filePath });
644
+ continue;
645
+ }
646
+ migrated.push(migrateLegacySkill(name));
647
+ }
648
+ return { migrated, skipped };
649
+ }
474
650
  /** Diagnostics for the dashboard — expose where the loader looked. */
475
651
  export function _skillDirsForDiagnostics(workDir) {
476
652
  return { global: globalSkillsDir(), project: projectSkillsDir(workDir) ?? null };
@@ -4647,6 +4647,49 @@ export async function cmdDashboard(opts) {
4647
4647
  res.status(500).json({ ok: false, error: String(err) });
4648
4648
  }
4649
4649
  });
4650
+ // ── Skill migration (legacy .md → folder/SKILL.md) ─────────────────
4651
+ // Two endpoints: per-skill and bulk. Both wrap migrateLegacySkill /
4652
+ // migrateAllLegacySkills from skill-store.ts. The original .md is
4653
+ // renamed to .md.bak so the migration is rollback-able by hand.
4654
+ app.post('/api/skills/:name/migrate', async (req, res) => {
4655
+ try {
4656
+ const name = req.params.name;
4657
+ if (!name) {
4658
+ res.status(400).json({ ok: false, error: 'name required' });
4659
+ return;
4660
+ }
4661
+ const { migrateLegacySkill } = await import('../agent/skill-store.js');
4662
+ const result = migrateLegacySkill(name);
4663
+ if (!result.ok) {
4664
+ res.status(409).json(result);
4665
+ return;
4666
+ }
4667
+ res.json(result);
4668
+ }
4669
+ catch (err) {
4670
+ res.status(500).json({ ok: false, error: String(err) });
4671
+ }
4672
+ });
4673
+ app.post('/api/skills/migrate-all', async (_req, res) => {
4674
+ try {
4675
+ const { migrateAllLegacySkills } = await import('../agent/skill-store.js');
4676
+ const result = migrateAllLegacySkills();
4677
+ const failed = result.migrated.filter((m) => !m.ok);
4678
+ const succeeded = result.migrated.filter((m) => m.ok && !m.alreadyMigrated);
4679
+ res.json({
4680
+ ok: true,
4681
+ totalChecked: result.migrated.length + result.skipped.length,
4682
+ migratedCount: succeeded.length,
4683
+ failedCount: failed.length,
4684
+ skippedCount: result.skipped.length,
4685
+ migrated: result.migrated,
4686
+ skipped: result.skipped,
4687
+ });
4688
+ }
4689
+ catch (err) {
4690
+ res.status(500).json({ ok: false, error: String(err) });
4691
+ }
4692
+ });
4650
4693
  app.get('/api/cron/drafts', async (_req, res) => {
4651
4694
  try {
4652
4695
  const { listDraftNames } = await import('../agent/draft-store.js');
@@ -24416,9 +24459,17 @@ function operationUsageBadge(usage) {
24416
24459
  return '<span class="badge badge-blue" title="' + esc(formatTokens(usage.totalInput || 0)) + ' input, ' + esc(formatTokens(usage.totalOutput || 0)) + ' output">' + esc(formatTokens(usage.totalTokens || 0)) + ' tok 7d</span>';
24417
24460
  }
24418
24461
 
24462
+ // PRD §pretty-cron / 1.18.111: pretty primary, raw cron in a hover
24463
+ // tooltip. Power users still get the literal expression on hover; casual
24464
+ // users never have to read "0 8-18 * * 1-5". Falls back to raw inline
24465
+ // (with a help-cursor hint) when describeCron can't summarize.
24419
24466
  function operationScheduleHtml(schedule) {
24420
- var desc = describeCron(schedule || '');
24421
- return desc ? esc(desc) + ' <code>' + esc(schedule) + '</code>' : '<code style="color:var(--accent)">' + esc(schedule || '') + '</code>';
24467
+ var raw = schedule || '';
24468
+ var desc = describeCron(raw);
24469
+ if (desc) {
24470
+ return '<span title="' + esc(raw) + '" style="cursor:help">' + esc(desc) + '</span>';
24471
+ }
24472
+ return '<code title="' + esc(raw) + '" style="color:var(--accent);cursor:help">' + esc(raw) + '</code>';
24422
24473
  }
24423
24474
 
24424
24475
  function operationSectionHeader(title, subtitle, badgeClass, badgeText, marginTop) {
@@ -26877,6 +26928,44 @@ var _skillsState = {
26877
26928
  query: '', // search input value
26878
26929
  };
26879
26930
 
26931
+ // ── Migration handlers (Phase A.6 / 1.18.110) ────────────────────────
26932
+ // migrateOneSkill: per-skill migration from the detail-pane footer.
26933
+ // migrateAllLegacySkills: bulk migration from the list-pane banner.
26934
+ // Both use POST endpoints in dashboard.ts and refresh the page on success.
26935
+
26936
+ async function migrateOneSkill(name) {
26937
+ if (!confirm('Migrate "' + name + '" to folder form? The original .md file will be renamed to .md.bak (preserved for rollback).')) return;
26938
+ try {
26939
+ var r = await apiFetch('/api/skills/' + encodeURIComponent(name) + '/migrate', { method: 'POST' });
26940
+ var d = await r.json().catch(function() { return {}; });
26941
+ if (!r.ok || d.ok === false) {
26942
+ toast(d.error || ('Migration failed (HTTP ' + r.status + ')'), 'error');
26943
+ return;
26944
+ }
26945
+ toast(d.alreadyMigrated ? 'Already in folder form.' : 'Migrated. Folder created at ' + (d.newSkillPath || ''), 'success');
26946
+ // Reload the skills list so the new layout reflects.
26947
+ if (typeof refreshSkillsPage === 'function') await refreshSkillsPage();
26948
+ if (name && typeof showSkillDetail === 'function') showSkillDetail(name);
26949
+ } catch (err) { toast('Migration failed: ' + err, 'error'); }
26950
+ }
26951
+
26952
+ async function migrateAllLegacySkills() {
26953
+ if (!confirm('Migrate ALL legacy skills to folder form? Each .md file becomes <name>/SKILL.md, originals preserved as .bak.')) return;
26954
+ try {
26955
+ var r = await apiFetch('/api/skills/migrate-all', { method: 'POST' });
26956
+ var d = await r.json().catch(function() { return {}; });
26957
+ if (!r.ok || d.ok === false) {
26958
+ toast(d.error || ('Bulk migration failed (HTTP ' + r.status + ')'), 'error');
26959
+ return;
26960
+ }
26961
+ var msg = 'Migrated ' + d.migratedCount + ' skill' + (d.migratedCount === 1 ? '' : 's')
26962
+ + (d.failedCount > 0 ? ' (' + d.failedCount + ' failed)' : '')
26963
+ + (d.skippedCount > 0 ? ' (' + d.skippedCount + ' already in folder form)' : '');
26964
+ toast(msg, d.failedCount > 0 ? 'error' : 'success');
26965
+ if (typeof refreshSkillsPage === 'function') await refreshSkillsPage();
26966
+ } catch (err) { toast('Bulk migration failed: ' + err, 'error'); }
26967
+ }
26968
+
26880
26969
  async function refreshSkillsPage() {
26881
26970
  var listEl = document.getElementById('skills-list');
26882
26971
  var detailEl = document.getElementById('skills-detail');
@@ -26961,6 +27050,21 @@ function renderSkillsList() {
26961
27050
  return;
26962
27051
  }
26963
27052
  var html = '';
27053
+ // PRD § Skills-First Phase A.6 / 1.18.110: legacy migration banner.
27054
+ // Counts how many flat-form legacy skills exist; shows a one-click
27055
+ // "Migrate all" button when ≥1 found. Banner disappears after they
27056
+ // all become folder form. Per-skill migration also lives in the
27057
+ // detail pane footer for individual control.
27058
+ var legacyCount = _skillsState.data.filter(function(s) {
27059
+ return s.layout === 'flat' && s.schemaVersion === 'legacy';
27060
+ }).length;
27061
+ if (legacyCount > 0 && !_skillsState.query) {
27062
+ html += '<div style="padding:12px 14px;background:rgba(245,158,11,0.10);border-bottom:1px solid var(--yellow);font-size:11px;line-height:1.5;color:var(--text-secondary)">'
27063
+ + '<div style="font-weight:600;color:var(--yellow);margin-bottom:6px">' + legacyCount + ' legacy skill' + (legacyCount === 1 ? '' : 's') + ' to migrate</div>'
27064
+ + '<div style="margin-bottom:8px">Convert flat <code>.md</code> files to folder form (<code>name/SKILL.md</code>) so you can bundle templates + scripts + reference docs.</div>'
27065
+ + '<button class="btn-sm btn-primary" onclick="migrateAllLegacySkills()" style="font-size:11px;padding:4px 10px">Migrate all to folder form</button>'
27066
+ + '</div>';
27067
+ }
26964
27068
  for (var i = 0; i < _skillsState.filtered.length; i++) {
26965
27069
  var s = _skillsState.filtered[i];
26966
27070
  var fm = s.frontmatter || {};
@@ -27157,7 +27261,11 @@ function renderSkillDetail(s) {
27157
27261
  if (s.schemaVersion === 'legacy') {
27158
27262
  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">'
27159
27263
  + '<strong style="color:var(--yellow)">Legacy schema.</strong> '
27160
- + 'This skill uses the pre-redesign frontmatter shape. Phase B will offer a one-click migration to the Anthropic-compatible format (name + description top-level; cron-tailored fields under <code>clementine:</code>).'
27264
+ + 'This skill uses the pre-redesign frontmatter shape. '
27265
+ + 'Click <strong>Migrate to folder form</strong> below to convert it to <code>' + esc(fm.name) + '/SKILL.md</code> with the cron-tailored fields (triggers, toolsUsed, etc.) moved under a <code>clementine:</code> namespace. The original is preserved as a <code>.bak</code> file for rollback.'
27266
+ + '<div style="margin-top:10px">'
27267
+ + '<button class="btn-sm btn-primary" onclick="migrateOneSkill(\\x27' + jsStr(fm.name) + '\\x27)" style="font-size:11px;padding:4px 12px">Migrate to folder form →</button>'
27268
+ + '</div>'
27161
27269
  + '</div>';
27162
27270
  } else if (s.schemaVersion === 'anthropic' && s.layout === 'flat') {
27163
27271
  html += '<div style="margin-top:24px;padding:12px 14px;background:rgba(59,130,246,0.06);border:1px solid var(--blue);border-radius:6px;font-size:12px;color:var(--text-secondary);line-height:1.5">'
@@ -29477,44 +29585,139 @@ function updateScheduleHint() {
29477
29585
 
29478
29586
  const monthNames = ['','Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
29479
29587
 
29588
+ // PRD §pretty-cron / 1.18.111 — describe a cron expression in casual
29589
+ // human-readable form. Output style:
29590
+ // - "Mondays at 8 AM" not "Every Monday at 8:00 AM"
29591
+ // - "5 PM" not "5:00 PM" when minutes=0
29592
+ // - "8 AM–6 PM" (en-dash range) for hour ranges
29593
+ // - "Every minute" / "Every 2 minutes" / "Every 4 hours"
29594
+ // - "Hourly weekdays 8 AM–6 PM" for the business-hours pattern
29595
+ // - Falls back to '' when the expression is too exotic to summarize;
29596
+ // the renderer then shows the raw cron only as a last resort.
29480
29597
  function describeCron(expr) {
29481
- const parts = expr.split(/\\s+/);
29598
+ if (!expr || typeof expr !== 'string') return '';
29599
+ // @aliases first — common shortcuts.
29600
+ var aliases = {
29601
+ '@yearly': 'Once a year (Jan 1, midnight)',
29602
+ '@annually': 'Once a year (Jan 1, midnight)',
29603
+ '@monthly': 'First of every month, midnight',
29604
+ '@weekly': 'Sundays at midnight',
29605
+ '@daily': 'Every day at midnight',
29606
+ '@hourly': 'Every hour',
29607
+ '@reboot': 'On daemon start',
29608
+ };
29609
+ if (aliases[expr]) return aliases[expr];
29610
+
29611
+ var parts = expr.trim().split(/\\s+/);
29482
29612
  if (parts.length !== 5) return '';
29483
- const [min, hour, dom, month, dow] = parts;
29613
+ var min = parts[0], hour = parts[1], dom = parts[2], month = parts[3], dow = parts[4];
29484
29614
 
29485
- // Every N minutes
29486
- if (min.startsWith('*/')) return 'Every ' + min.slice(2) + ' minutes';
29487
- // Every N hours
29488
- if (hour.startsWith('*/')) return 'Every ' + hour.slice(2) + ' hours';
29615
+ // ── Sub-hour cadence ────────────────────────────────────────────────
29616
+ if (min === '*' && hour === '*' && dom === '*' && month === '*' && dow === '*') return 'Every minute';
29617
+ if (min.startsWith('*/')) {
29618
+ var n = parseInt(min.slice(2), 10);
29619
+ if (Number.isFinite(n)) return n === 1 ? 'Every minute' : 'Every ' + n + ' minutes';
29620
+ }
29621
+ // Every N hours (e.g. "0 */2 * * *")
29622
+ if (hour.startsWith('*/') && (min === '0' || min === '*')) {
29623
+ var nh = parseInt(hour.slice(2), 10);
29624
+ if (Number.isFinite(nh)) return nh === 1 ? 'Every hour' : 'Every ' + nh + ' hours';
29625
+ }
29489
29626
 
29490
- const time = formatTime(+hour, +min);
29627
+ // ── Hour ranges (e.g. "0 8-18 * * 1-5" — hourly during business hours) ──
29628
+ var rangeMatch = /^(\d{1,2})-(\d{1,2})$/.exec(hour);
29629
+ if (rangeMatch && (min === '0' || min === '*')) {
29630
+ var startH = parseInt(rangeMatch[1], 10);
29631
+ var endH = parseInt(rangeMatch[2], 10);
29632
+ if (Number.isFinite(startH) && Number.isFinite(endH)) {
29633
+ var span = formatHour(startH) + '–' + formatHour(endH);
29634
+ if (dow === '1-5') return 'Hourly weekdays ' + span;
29635
+ if (dow === '*' && dom === '*' && month === '*') return 'Hourly ' + span;
29636
+ if (/^[0-6]$/.test(dow)) return 'Hourly ' + plural(dayNames[+dow]) + ' ' + span;
29637
+ }
29638
+ }
29639
+
29640
+ // ── Comma-list of hours at fixed minute (e.g. "0 8,12,16 * * *") ────
29641
+ // Check this BEFORE parseInt — parseInt is lenient and would parse
29642
+ // "8,12,16" as 8, missing the multi-hour case entirely.
29643
+ if (hour.indexOf(',') !== -1 && min !== '*' && !min.startsWith('*/')) {
29644
+ var minN = parseInt(min, 10);
29645
+ if (Number.isFinite(minN)) {
29646
+ return 'Daily at ' + hour.split(',').map(function(h) { return formatTimePretty(+h, minN); }).join(', ');
29647
+ }
29648
+ }
29649
+
29650
+ // ── Single-time-of-day patterns ─────────────────────────────────────
29651
+ var hourNum = parseInt(hour, 10);
29652
+ var minNum = parseInt(min, 10);
29653
+ if (!Number.isFinite(hourNum) || !Number.isFinite(minNum)) return '';
29654
+ // Reject parseInt's lenient mode: a hour like "8,12" would parse to 8.
29655
+ if (String(hourNum) !== hour || String(minNum) !== min) return '';
29656
+ var time = formatTimePretty(hourNum, minNum);
29491
29657
 
29492
29658
  // Specific date: day + month set (e.g. "10 16 1 3 *" = Mar 1 at 4:10 PM)
29493
29659
  if (dom !== '*' && month !== '*') {
29494
- const monthStr = monthNames[+month] || 'Month ' + month;
29660
+ var monthStr = monthNames[+month] || ('Month ' + month);
29495
29661
  return monthStr + ' ' + dom + ' at ' + time;
29496
29662
  }
29497
29663
 
29498
29664
  // Day of month only (e.g. "0 9 15 * *" = 15th of every month)
29499
29665
  if (dom !== '*' && month === '*' && dow === '*') {
29500
- const suffix = +dom === 1 ? 'st' : +dom === 2 ? 'nd' : +dom === 3 ? 'rd' : 'th';
29501
- return dom + suffix + ' of every month at ' + time;
29666
+ return ordinal(+dom) + ' of every month at ' + time;
29667
+ }
29668
+
29669
+ // Weekdays (Mon-Fri)
29670
+ if (dow === '1-5') return 'Weekdays at ' + time;
29671
+
29672
+ // Specific weekday → pluralize ("Mondays at 8 AM" not "Every Monday at 8 AM")
29673
+ if (/^[0-6]$/.test(dow)) return plural(dayNames[+dow]) + ' at ' + time;
29674
+
29675
+ // Multiple specific weekdays (e.g. "0 9 * * 1,3,5")
29676
+ if (/^[0-6](,[0-6])+$/.test(dow)) {
29677
+ return dow.split(',').map(function(d) { return shortDay(+d); }).join(', ') + ' at ' + time;
29502
29678
  }
29503
29679
 
29504
- // Weekdays
29505
- if (dow === '1-5' && !hour.includes(',')) return 'Weekdays at ' + time;
29506
29680
  // Every day
29507
- if (dow === '*' && dom === '*' && month === '*' && !hour.includes(',') && !hour.includes('/')) return 'Every day at ' + time;
29508
- // Specific weekday
29509
- if (/^[0-6]$/.test(dow) && !hour.includes(',')) return 'Every ' + dayNames[+dow] + ' at ' + time;
29510
- // Multiple weekdays (e.g. "0 9 * * 1,3,5")
29511
- if (/^[0-6](,[0-6])+$/.test(dow)) return dow.split(',').map(d => dayNames[+d]).join(', ') + ' at ' + time;
29512
- // Multiple hours
29513
- if (hour.includes(',')) return 'Daily at ' + hour.split(',').map(h => formatTime(+h, +min)).join(', ');
29681
+ if (dow === '*' && dom === '*' && month === '*') return 'Every day at ' + time;
29514
29682
 
29515
29683
  return '';
29516
29684
  }
29517
29685
 
29686
+ function plural(day) {
29687
+ // "Monday" → "Mondays". Days end in y already so just append 's'.
29688
+ return day + 's';
29689
+ }
29690
+
29691
+ function shortDay(d) {
29692
+ // Compact form for multi-day lists: "Mon, Wed, Fri".
29693
+ return ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][d] || ('day ' + d);
29694
+ }
29695
+
29696
+ function ordinal(n) {
29697
+ // 1st / 2nd / 3rd / 4th… for the day-of-month phrasing.
29698
+ if (n >= 11 && n <= 13) return n + 'th';
29699
+ var lastDigit = n % 10;
29700
+ if (lastDigit === 1) return n + 'st';
29701
+ if (lastDigit === 2) return n + 'nd';
29702
+ if (lastDigit === 3) return n + 'rd';
29703
+ return n + 'th';
29704
+ }
29705
+
29706
+ function formatHour(h) {
29707
+ // Compact hour-only format used in ranges: "8 AM" / "12 PM" / "6 PM".
29708
+ var ampm = h >= 12 ? 'PM' : 'AM';
29709
+ var hr = h === 0 ? 12 : (h > 12 ? h - 12 : h);
29710
+ return hr + ' ' + ampm;
29711
+ }
29712
+
29713
+ function formatTimePretty(h, m) {
29714
+ // "8 AM" when minutes=0, "8:30 AM" otherwise. Casual, no leading zeros.
29715
+ if (m === 0) return formatHour(h);
29716
+ var ampm = h >= 12 ? 'PM' : 'AM';
29717
+ var hr = h === 0 ? 12 : (h > 12 ? h - 12 : h);
29718
+ return hr + ':' + String(m).padStart(2, '0') + ' ' + ampm;
29719
+ }
29720
+
29518
29721
  function setScheduleFromCron(expr) {
29519
29722
  // Try to reverse-map a cron expression back to the builder
29520
29723
  const parts = expr.split(/\\s+/);
package/dist/types.d.ts CHANGED
@@ -383,6 +383,20 @@ export interface ClementineSkillExtensions {
383
383
  lastUsed?: string;
384
384
  /** Last successful "Test this skill" run (Phase B). */
385
385
  lastTestPass?: string;
386
+ /** Legacy: NLP-style trigger phrases the pre-redesign chat router used
387
+ * to match incoming messages against this skill. Preserved for the
388
+ * migration UI; not enforced. */
389
+ triggers?: string[];
390
+ /** Legacy: 'manual' / 'auto' / 'imported' — provenance label on the
391
+ * pre-redesign skills. */
392
+ source?: string;
393
+ /** Legacy: incrementing counter of runs that invoked the skill. */
394
+ useCount?: number;
395
+ /** Filename the original legacy skill was migrated from. Helps the
396
+ * migration UI show what came from where. */
397
+ migratedFrom?: string;
398
+ /** ISO timestamp of when the migration ran. */
399
+ migratedAt?: string;
386
400
  }
387
401
  /** Parsed frontmatter. Anthropic-canonical fields are top-level; our
388
402
  * extensions live under `clementine`. Legacy fields (title/triggers/
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.18.109",
3
+ "version": "1.18.111",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",