opencode-skills-collection 3.1.7 → 3.1.9

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 (43) hide show
  1. package/bundled-skills/.antigravity-install-manifest.json +10 -1
  2. package/bundled-skills/ai-loop/SKILL.md +136 -0
  3. package/bundled-skills/arrowspace/SKILL.md +116 -0
  4. package/bundled-skills/cron-doctor/SKILL.md +244 -0
  5. package/bundled-skills/cron-doctor/scripts/cli.js +75 -0
  6. package/bundled-skills/cron-doctor/scripts/cron-engine.js +638 -0
  7. package/bundled-skills/docs/contributors/quality-bar.md +1 -0
  8. package/bundled-skills/docs/contributors/skill-anatomy.md +19 -3
  9. package/bundled-skills/docs/integrations/jetski-cortex.md +3 -3
  10. package/bundled-skills/docs/integrations/jetski-gemini-loader/README.md +1 -1
  11. package/bundled-skills/docs/maintainers/categorization-implementation.md +14 -24
  12. package/bundled-skills/docs/maintainers/repo-growth-seo.md +3 -3
  13. package/bundled-skills/docs/maintainers/skills-update-guide.md +1 -1
  14. package/bundled-skills/docs/maintainers/smart-auto-categorization.md +24 -25
  15. package/bundled-skills/docs/users/bundles.md +1 -1
  16. package/bundled-skills/docs/users/claude-code-skills.md +1 -1
  17. package/bundled-skills/docs/users/faq.md +5 -5
  18. package/bundled-skills/docs/users/gemini-cli-skills.md +1 -1
  19. package/bundled-skills/docs/users/getting-started.md +1 -1
  20. package/bundled-skills/docs/users/kiro-integration.md +1 -1
  21. package/bundled-skills/docs/users/local-config.md +11 -11
  22. package/bundled-skills/docs/users/specialized-plugin-roadmap.md +3 -3
  23. package/bundled-skills/docs/users/usage.md +4 -4
  24. package/bundled-skills/docs/users/visual-guide.md +4 -4
  25. package/bundled-skills/docs/vietnamese/BUNDLES.vi.md +1 -1
  26. package/bundled-skills/docs/vietnamese/CONTRIBUTING.vi.md +15 -9
  27. package/bundled-skills/docs/vietnamese/FAQ.vi.md +30 -20
  28. package/bundled-skills/docs/vietnamese/GETTING_STARTED.vi.md +22 -14
  29. package/bundled-skills/docs/vietnamese/QUALITY_BAR.vi.md +9 -4
  30. package/bundled-skills/docs/vietnamese/README.vi.md +29 -30
  31. package/bundled-skills/docs/vietnamese/SKILLS_README.vi.md +3 -3
  32. package/bundled-skills/docs/vietnamese/SKILL_ANATOMY.vi.md +33 -5
  33. package/bundled-skills/docs/vietnamese/TRANSLATION_PLAN.vi.md +1 -1
  34. package/bundled-skills/docs/vietnamese/VISUAL_GUIDE.vi.md +17 -18
  35. package/bundled-skills/gh-image/SKILL.md +122 -0
  36. package/bundled-skills/github-actions-debugger/SKILL.md +99 -0
  37. package/bundled-skills/loki-mode/examples/todo-app-generated/frontend/package-lock.json +9 -9
  38. package/bundled-skills/loki-mode/examples/todo-app-generated/frontend/package.json +2 -2
  39. package/bundled-skills/premium-3d-website/SKILL.md +133 -0
  40. package/bundled-skills/sql-sentinel/SKILL.md +131 -0
  41. package/bundled-skills/web-project-brainstorming/SKILL.md +149 -0
  42. package/package.json +1 -1
  43. package/skills_index.json +176 -0
@@ -0,0 +1,638 @@
1
+ 'use strict';
2
+
3
+ // ============================================================================
4
+ // cron.js — Cron expression parser, describer, validator, and next-run engine.
5
+ // Zero dependencies. Extracted from the DevRef Cron Expression Generator
6
+ // (battle-tested in browser) and extended with validate() for Pro insights.
7
+ // ============================================================================
8
+
9
+ const MONTH_NAMES = ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC'];
10
+ const DAY_NAMES = ['SUN','MON','TUE','WED','THU','FRI','SAT'];
11
+
12
+ const FIELDS = [
13
+ { name: 'minute', min: 0, max: 59, key: 'minute' },
14
+ { name: 'hour', min: 0, max: 23, key: 'hour' },
15
+ { name: 'dom', min: 1, max: 31, key: 'dom' },
16
+ { name: 'month', min: 1, max: 12, key: 'month', named: MONTH_NAMES },
17
+ { name: 'dow', min: 0, max: 7, key: 'dow', named: DAY_NAMES },
18
+ ];
19
+
20
+ class CronError extends Error {
21
+ constructor(message, fieldIndex) {
22
+ super(message);
23
+ this.name = 'CronError';
24
+ this.fieldIndex = fieldIndex;
25
+ }
26
+ }
27
+
28
+ // ---- Name resolution ----
29
+ function resolveName(token, names) {
30
+ if (!names) return null;
31
+ const up = token.toUpperCase();
32
+ const idx = names.indexOf(up);
33
+ return idx === -1 ? null : idx;
34
+ }
35
+
36
+ // ---- Field parsing ----
37
+ function parseField(raw, fieldDef, fieldIndex) {
38
+ const trimmed = String(raw).trim();
39
+ if (trimmed === '') throw new CronError(`Field ${fieldIndex + 1} (${fieldDef.name}) is empty`, fieldIndex);
40
+
41
+ const out = { raw: trimmed, values: null, special: null };
42
+
43
+ // Special: day-of-week "#" (nth weekday)
44
+ if (fieldDef.key === 'dow' && trimmed.includes('#')) {
45
+ const m = trimmed.match(/^([0-7A-Za-z]+)#([1-5])$/);
46
+ if (!m) throw new CronError(`Invalid "#" syntax in day-of-week: "${trimmed}"`, fieldIndex);
47
+ let dowNum = parseSingleNum(m[1], fieldDef, fieldIndex);
48
+ if (dowNum === 7) dowNum = 0;
49
+ out.special = { kind: 'hash', dow: dowNum, nth: parseInt(m[2], 10) };
50
+ return out;
51
+ }
52
+
53
+ // Special: day-of-week "L" (last weekday)
54
+ if (fieldDef.key === 'dow' && /L$/i.test(trimmed)) {
55
+ const m = trimmed.match(/^([0-7A-Za-z]+)L$/i);
56
+ if (!m) throw new CronError(`Invalid "L" syntax in day-of-week: "${trimmed}"`, fieldIndex);
57
+ let dowNum = parseSingleNum(m[1], fieldDef, fieldIndex);
58
+ if (dowNum === 7) dowNum = 0;
59
+ out.special = { kind: 'dowLast', dow: dowNum };
60
+ return out;
61
+ }
62
+
63
+ // Special: day-of-month "L" (last day)
64
+ if (fieldDef.key === 'dom' && /^L/i.test(trimmed)) {
65
+ const m = trimmed.match(/^L(?:-(\d+))?$/i);
66
+ if (!m) throw new CronError(`Invalid "L" syntax in day-of-month: "${trimmed}"`, fieldIndex);
67
+ out.special = { kind: 'domLast', offset: m[1] ? parseInt(m[1], 10) : 0 };
68
+ return out;
69
+ }
70
+
71
+ // Special: day-of-month "W" (nearest weekday)
72
+ if (fieldDef.key === 'dom' && /W$/i.test(trimmed)) {
73
+ const m = trimmed.match(/^(\d+)W$/i);
74
+ if (!m) throw new CronError(`Invalid "W" syntax in day-of-month: "${trimmed}"`, fieldIndex);
75
+ const day = parseInt(m[1], 10);
76
+ if (day < fieldDef.min || day > fieldDef.max) {
77
+ throw new CronError(`Day-of-month "${day}W" out of range (${fieldDef.min}-${fieldDef.max})`, fieldIndex);
78
+ }
79
+ out.special = { kind: 'weekday', day: day };
80
+ return out;
81
+ }
82
+
83
+ // Standard parsing
84
+ const values = new Set();
85
+ const items = trimmed.split(',');
86
+ for (const item of items) {
87
+ parseItem(item, fieldDef, fieldIndex, values);
88
+ }
89
+ out.values = values;
90
+ return out;
91
+ }
92
+
93
+ function parseSingleNum(token, fieldDef, fieldIndex) {
94
+ const n = parseInt(token, 10);
95
+ if (!isNaN(n)) return n;
96
+ const named = resolveName(token, fieldDef.named);
97
+ if (named !== null) {
98
+ return fieldDef.key === 'month' ? named + 1 : named;
99
+ }
100
+ throw new CronError(`Invalid value "${token}" in ${fieldDef.name}`, fieldIndex);
101
+ }
102
+
103
+ function parseItem(item, fieldDef, fieldIndex, values) {
104
+ const t = item.trim();
105
+ if (t === '') throw new CronError(`Empty item in ${fieldDef.name}`, fieldIndex);
106
+
107
+ if (t === '*') {
108
+ addRange(values, fieldDef.min, fieldDef.max, fieldDef);
109
+ return;
110
+ }
111
+
112
+ if (t.includes('/')) {
113
+ const [base, stepStr] = t.split('/');
114
+ const step = parseInt(stepStr, 10);
115
+ if (isNaN(step) || step < 1) throw new CronError(`Invalid step "${stepStr}" in ${fieldDef.name}`, fieldIndex);
116
+ let lo, hi;
117
+ if (base === '*' || base === '') {
118
+ lo = fieldDef.min; hi = fieldDef.max;
119
+ } else if (base.includes('-')) {
120
+ const [a, b] = base.split('-');
121
+ lo = parseSingleNum(a.trim(), fieldDef, fieldIndex);
122
+ hi = parseSingleNum(b.trim(), fieldDef, fieldIndex);
123
+ } else {
124
+ lo = parseSingleNum(base.trim(), fieldDef, fieldIndex);
125
+ hi = fieldDef.max;
126
+ }
127
+ if (lo > hi) [lo, hi] = [hi, lo];
128
+ for (let v = lo; v <= hi; v += step) addOne(values, v, fieldDef, fieldIndex);
129
+ return;
130
+ }
131
+
132
+ if (t.includes('-')) {
133
+ const parts = t.split('-');
134
+ if (parts.length !== 2) throw new CronError(`Invalid range "${t}" in ${fieldDef.name}`, fieldIndex);
135
+ const a = parseSingleNum(parts[0].trim(), fieldDef, fieldIndex);
136
+ const b = parseSingleNum(parts[1].trim(), fieldDef, fieldIndex);
137
+ addRange(values, a, b, fieldDef);
138
+ return;
139
+ }
140
+
141
+ const v = parseSingleNum(t, fieldDef, fieldIndex);
142
+ addOne(values, v, fieldDef, fieldIndex);
143
+ }
144
+
145
+ function addOne(values, v, fieldDef, fieldIndex) {
146
+ if (fieldDef.key === 'dow' && v === 7) { values.add(0); return; }
147
+ if (v < fieldDef.min || v > fieldDef.max) {
148
+ throw new CronError(`Value ${v} out of range for ${fieldDef.name} (${fieldDef.min}-${fieldDef.max})`, fieldIndex);
149
+ }
150
+ values.add(v);
151
+ }
152
+
153
+ function addRange(values, lo, hi, fieldDef) {
154
+ if (lo > hi) [lo, hi] = [hi, lo];
155
+ if (lo < fieldDef.min || hi > fieldDef.max) {
156
+ throw new CronError(`Range ${lo}-${hi} out of bounds for ${fieldDef.name} (${fieldDef.min}-${fieldDef.max})`, -1);
157
+ }
158
+ for (let v = lo; v <= hi; v++) {
159
+ if (fieldDef.key === 'dow' && v === 7) { values.add(0); continue; }
160
+ values.add(v);
161
+ }
162
+ }
163
+
164
+ // ---- Full expression parser ----
165
+ function parseCron(expr) {
166
+ const parts = String(expr).trim().split(/\s+/);
167
+ if (parts.length !== 5) {
168
+ throw new CronError(`Expected 5 fields (got ${parts.length}). Format: minute hour day-of-month month day-of-week`, -1);
169
+ }
170
+ const parsed = {};
171
+ for (let i = 0; i < 5; i++) {
172
+ parsed[FIELDS[i].key] = parseField(parts[i], FIELDS[i], i);
173
+ }
174
+ parsed.domRestricted = !/^\s*\*\s*$/.test(parts[2]);
175
+ parsed.dowRestricted = !/^\s*\*\s*$/.test(parts[4]);
176
+ parsed.parts = parts;
177
+ return parsed;
178
+ }
179
+
180
+ // ---- Human-readable description ----
181
+ function describe(expr) {
182
+ let parsed;
183
+ try { parsed = parseCron(expr); } catch (e) { return { text: e.message, error: true }; }
184
+ return { text: describeParsed(parsed), error: false, parsed };
185
+ }
186
+
187
+ function describeParsed(p) {
188
+ const monthDesc = describeFieldMonth(p.month);
189
+ const domDesc = describeFieldDom(p.dom);
190
+ const dowDesc = describeFieldDow(p.dow);
191
+
192
+ const isEveryMin = p.parts[0] === '*';
193
+ const isEveryHour = p.parts[1] === '*';
194
+
195
+ let timePart = '';
196
+ if (isEveryMin && isEveryHour) {
197
+ timePart = 'At every minute';
198
+ } else if (isEveryMin && !isEveryHour) {
199
+ const hours = [...(p.hour.values || [])].sort((a, b) => a - b);
200
+ if (hours.length > 0) {
201
+ timePart = 'Every minute during the ' + hours.map(h => pad2(h)).join(', ') + ' hour' + (hours.length > 1 ? 's' : '');
202
+ } else {
203
+ timePart = 'Every minute';
204
+ }
205
+ } else {
206
+ timePart = 'At ' + describeTimes(p.minute, p.hour);
207
+ }
208
+
209
+ let dayPart = '';
210
+ const domAny = !p.domRestricted;
211
+ const dowAny = !p.dowRestricted;
212
+
213
+ if (domAny && dowAny) {
214
+ if (monthDesc.restricted) {
215
+ dayPart = ', ' + monthDesc.text + ' of every year';
216
+ } else {
217
+ dayPart = ', every day';
218
+ }
219
+ } else if (!domAny && dowAny) {
220
+ dayPart = ', on ' + domDesc.text;
221
+ if (monthDesc.restricted) dayPart += ' in ' + monthDesc.text;
222
+ } else if (domAny && !dowAny) {
223
+ dayPart = ', on ' + dowDesc.text;
224
+ if (monthDesc.restricted) dayPart += ' in ' + monthDesc.text;
225
+ } else {
226
+ dayPart = ', on ' + domDesc.text + ' and on ' + dowDesc.text;
227
+ if (monthDesc.restricted) dayPart += ' in ' + monthDesc.text;
228
+ }
229
+
230
+ return capitalize(timePart + dayPart);
231
+ }
232
+
233
+ function describeTimes(minuteField, hourField) {
234
+ const mins = [...(minuteField.values || [])].sort((a, b) => a - b);
235
+ const hours = [...(hourField.values || [])].sort((a, b) => a - b);
236
+
237
+ if (pIsWildcard(hourField) && !pIsWildcard(minuteField)) {
238
+ if (mins.length === 1) return `minute ${mins[0]} of every hour`;
239
+ return `minutes ${listJoin(mins)} of every hour`;
240
+ }
241
+ if (pIsWildcard(minuteField) && pIsWildcard(hourField)) return 'every minute of every hour';
242
+
243
+ if (pIsWildcard(minuteField)) {
244
+ return `every minute during the ${hours.map(h => pad2(h)).join(', ')} hour${hours.length > 1 ? 's' : ''}`;
245
+ }
246
+
247
+ const combos = [];
248
+ for (const h of hours) {
249
+ for (const m of mins) {
250
+ combos.push(formatHM(h, m));
251
+ }
252
+ }
253
+ return listJoin(combos);
254
+ }
255
+
256
+ function describeFieldMonth(field) {
257
+ if (pIsWildcard(field)) return { restricted: false, text: 'every month' };
258
+ const vals = [...(field.values || [])].sort((a, b) => a - b);
259
+ return { restricted: true, text: 'in ' + listJoin(vals.map(v => capitalize(MONTH_NAMES[v - 1]))) };
260
+ }
261
+
262
+ function describeFieldDom(field) {
263
+ if (pIsWildcard(field)) return { text: 'every day-of-month' };
264
+ if (field.special) {
265
+ if (field.special.kind === 'domLast') {
266
+ return { text: field.special.offset === 0 ? 'the last day of the month' : `the last day of the month minus ${field.special.offset} days` };
267
+ }
268
+ if (field.special.kind === 'weekday') {
269
+ return { text: `the nearest weekday to day ${field.special.day}` };
270
+ }
271
+ }
272
+ const vals = [...(field.values || [])].sort((a, b) => a - b);
273
+ return { text: `day-of-month ${listJoin(vals)}` };
274
+ }
275
+
276
+ function describeFieldDow(field) {
277
+ if (pIsWildcard(field)) return { text: 'every day-of-week' };
278
+ if (field.special) {
279
+ if (field.special.kind === 'hash') {
280
+ return { text: `the ${ordinal(field.special.nth)} ${capitalize(DAY_NAMES[field.special.dow])} of the month` };
281
+ }
282
+ if (field.special.kind === 'dowLast') {
283
+ return { text: `the last ${capitalize(DAY_NAMES[field.special.dow])} of the month` };
284
+ }
285
+ }
286
+ const vals = [...(field.values || [])].sort((a, b) => a - b);
287
+ return { text: listJoin(vals.map(v => capitalize(DAY_NAMES[v]))) };
288
+ }
289
+
290
+ function pIsWildcard(field) { return field.raw === '*'; }
291
+
292
+ // ---- Next run calculator ----
293
+ function nextRuns(expr, fromDate, count) {
294
+ count = count || 10;
295
+ const p = parseCron(expr);
296
+ const runs = [];
297
+ let d = new Date(fromDate.getTime());
298
+ d.setSeconds(0, 0);
299
+ d = new Date(d.getTime() + 60000);
300
+
301
+ let maxScan = 600000; // ~416 days ceiling
302
+ while (runs.length < count && maxScan-- > 0) {
303
+ if (matches(d, p)) {
304
+ runs.push(new Date(d.getTime()));
305
+ }
306
+ d = new Date(d.getTime() + 60000);
307
+ }
308
+ return runs;
309
+ }
310
+
311
+ function matches(d, p) {
312
+ if (!p.minute.values || !p.minute.values.has(d.getMinutes())) return false;
313
+ if (!p.hour.values || !p.hour.values.has(d.getHours())) return false;
314
+ if (!p.month.values || !p.month.values.has(d.getMonth() + 1)) return false;
315
+
316
+ const domAny = !p.domRestricted;
317
+ const dowAny = !p.dowRestricted;
318
+
319
+ let domMatch = false, dowMatch = false;
320
+ if (domAny) {
321
+ domMatch = true;
322
+ } else if (p.dom.special) {
323
+ domMatch = matchDomSpecial(d, p.dom.special);
324
+ } else if (p.dom.values && p.dom.values.has(d.getDate())) {
325
+ domMatch = true;
326
+ }
327
+ if (dowAny) {
328
+ dowMatch = true;
329
+ } else if (p.dow.special) {
330
+ dowMatch = matchDowSpecial(d, p.dow.special);
331
+ } else if (p.dow.values) {
332
+ dowMatch = p.dow.values.has(d.getDay());
333
+ }
334
+
335
+ if (domAny && dowAny) return true;
336
+ if (!domAny && !dowAny) return domMatch || dowMatch; // OR semantics
337
+ return domMatch && dowMatch;
338
+ }
339
+
340
+ function matchDomSpecial(d, special) {
341
+ if (special.kind === 'domLast') {
342
+ const lastDay = lastDayOfMonth(d.getFullYear(), d.getMonth());
343
+ const target = special.offset === 0 ? lastDay : lastDay - special.offset;
344
+ return d.getDate() === target;
345
+ }
346
+ if (special.kind === 'weekday') {
347
+ return d.getDate() === nearestWeekday(d.getFullYear(), d.getMonth(), special.day);
348
+ }
349
+ return false;
350
+ }
351
+
352
+ function matchDowSpecial(d, special) {
353
+ if (special.kind === 'hash') {
354
+ return nthWeekdayMatches(d, special.dow, special.nth);
355
+ }
356
+ if (special.kind === 'dowLast') {
357
+ return lastWeekdayMatches(d, special.dow);
358
+ }
359
+ return false;
360
+ }
361
+
362
+ function nthWeekdayMatches(d, dow, nth) {
363
+ if (d.getDay() !== dow) return false;
364
+ const dayOfMonth = d.getDate();
365
+ const occurrence = Math.ceil(dayOfMonth / 7);
366
+ return occurrence === nth;
367
+ }
368
+
369
+ function lastWeekdayMatches(d, dow) {
370
+ if (d.getDay() !== dow) return false;
371
+ const lastDay = lastDayOfMonth(d.getFullYear(), d.getMonth());
372
+ return d.getDate() + 7 > lastDay;
373
+ }
374
+
375
+ function lastDayOfMonth(year, month) {
376
+ return new Date(year, month + 1, 0).getDate();
377
+ }
378
+
379
+ function nearestWeekday(year, month, day) {
380
+ const lastDay = lastDayOfMonth(year, month);
381
+ const target = Math.min(day, lastDay);
382
+ const dt = new Date(year, month, target);
383
+ const wd = dt.getDay();
384
+ let result = target;
385
+ if (wd === 0) {
386
+ if (target + 1 <= lastDay) result = target + 1;
387
+ else result = target - 2;
388
+ } else if (wd === 6) {
389
+ if (target - 1 >= 1) result = target - 1;
390
+ else result = target + 2;
391
+ }
392
+ return result;
393
+ }
394
+
395
+ // ============================================================================
396
+ // validate() — Pro-tier feature: deeper analysis of a cron expression.
397
+ // Returns warnings, observations, and optimization suggestions.
398
+ // ============================================================================
399
+
400
+ function validate(expr) {
401
+ let parsed;
402
+ try {
403
+ parsed = parseCron(expr);
404
+ } catch (e) {
405
+ return {
406
+ valid: false,
407
+ error: e.message,
408
+ fieldIndex: e.fieldIndex,
409
+ warnings: [],
410
+ observations: [],
411
+ suggestions: [],
412
+ };
413
+ }
414
+
415
+ const warnings = [];
416
+ const observations = [];
417
+ const suggestions = [];
418
+
419
+ const desc = describeParsed(parsed);
420
+
421
+ // Check: day-of-month and day-of-week both restricted (OR semantics surprise)
422
+ if (parsed.domRestricted && parsed.dowRestricted) {
423
+ warnings.push({
424
+ level: 'high',
425
+ message: 'Both day-of-month and day-of-week are restricted. Cron uses OR semantics for these fields — the job will run when EITHER matches, not both. This is a common source of bugs.',
426
+ });
427
+ }
428
+
429
+ // Check: impossible day-of-month values (e.g., 31 in Feb)
430
+ const domValues = [...(parsed.dom.values || [])];
431
+ if (!parsed.domRestricted && parsed.month.values && ![...parsed.month.values].every(m => m === 2)) {
432
+ // skip
433
+ } else if (parsed.domRestricted && !parsed.dom.special && domValues.includes(31)) {
434
+ const monthsWith31 = [1, 3, 5, 7, 8, 10, 12]; // Jan, Mar, May, Jul, Aug, Oct, Dec
435
+ const monthValues = parsed.month.values ? [...parsed.month.values] : [];
436
+ const restrictedMonths = parsed.parts[3] !== '*';
437
+ if (restrictedMonths) {
438
+ const problemMonths = monthValues.filter(m => !monthsWith31.includes(m));
439
+ if (problemMonths.length > 0) {
440
+ warnings.push({
441
+ level: 'medium',
442
+ message: `Day 31 is specified but months ${problemMonths.map(m => capitalize(MONTH_NAMES[m - 1])).join(', ')} have fewer than 31 days. The job will never run in those months.`,
443
+ });
444
+ }
445
+ } else {
446
+ observations.push({
447
+ level: 'info',
448
+ message: 'Day 31 will only match in months with 31 days (7 of 12 months). The job effectively skips Feb, Apr, Jun, Sep, and Nov.',
449
+ });
450
+ }
451
+ }
452
+
453
+ // Check: high-frequency schedules
454
+ if (parsed.parts[0] === '*' && parsed.parts[1] === '*') {
455
+ observations.push({
456
+ level: 'info',
457
+ message: 'This expression runs every minute. For production jobs, consider if this frequency is intentional.',
458
+ });
459
+ }
460
+
461
+ // Check: step values that don't divide evenly
462
+ for (let i = 0; i < 2; i++) {
463
+ const part = parsed.parts[i];
464
+ if (part.startsWith('*/')) {
465
+ const step = parseInt(part.slice(2), 10);
466
+ const range = i === 0 ? 60 : 24;
467
+ if (range % step !== 0) {
468
+ observations.push({
469
+ level: 'info',
470
+ message: `Step value */${step} in ${FIELDS[i].name} doesn't divide evenly into ${range}. The last interval will be shorter than the rest (e.g., */7 in minutes goes 0,7,14,...,56, then 0 again — not 63).`,
471
+ });
472
+ }
473
+ }
474
+ }
475
+
476
+ // Check: February 29th edge case
477
+ if (parsed.domRestricted && !parsed.dom.special) {
478
+ const domVals = [...(parsed.dom.values || [])];
479
+ const monthVals = parsed.month.values ? [...parsed.month.values] : [];
480
+ if (domVals.includes(29) && monthVals.length === 1 && monthVals[0] === 2) {
481
+ warnings.push({
482
+ level: 'medium',
483
+ message: 'February 29th only occurs in leap years. This job will not run at all in non-leap years (3 out of every 4 years).',
484
+ });
485
+ }
486
+ }
487
+
488
+ // Check: midnight rush
489
+ if (parsed.parts[0] === '0' && parsed.parts[1] === '0') {
490
+ suggestions.push({
491
+ level: 'info',
492
+ message: 'Midnight (00:00) is a common schedule and many systems have concurrent job spikes at this time. Consider offsetting to a few minutes past midnight (e.g., 02 0 * * *) to avoid resource contention.',
493
+ });
494
+ }
495
+
496
+ // Check: weekend vs weekday
497
+ if (parsed.parts[4] === '1-5') {
498
+ observations.push({
499
+ level: 'info',
500
+ message: 'Weekdays only (Mon-Fri). This job will not run on weekends.',
501
+ });
502
+ }
503
+
504
+ // Compute frequency estimate
505
+ const freq = estimateFrequency(parsed);
506
+ if (freq) {
507
+ observations.push({
508
+ level: 'info',
509
+ message: `Approximate frequency: ${freq.description} (~${freq.runsPerYear} runs per year).`,
510
+ });
511
+ }
512
+
513
+ return {
514
+ valid: true,
515
+ description: desc,
516
+ warnings,
517
+ observations,
518
+ suggestions,
519
+ parsed,
520
+ };
521
+ }
522
+
523
+ function estimateFrequency(parsed) {
524
+ try {
525
+ // Count runs over a sample year
526
+ const start = new Date(2025, 0, 1, 0, 0, 0, 0);
527
+ const end = new Date(2026, 0, 1, 0, 0, 0, 0);
528
+ let count = 0;
529
+ let d = new Date(start.getTime());
530
+ let maxScan = 540000; // ~375 days
531
+ while (d < end && maxScan-- > 0) {
532
+ if (matches(d, parsed)) count++;
533
+ d = new Date(d.getTime() + 60000);
534
+ }
535
+
536
+ let description = '';
537
+ if (count >= 525600) description = 'every minute';
538
+ else if (count >= 500000) description = 'multiple times per minute';
539
+ else if (count >= 8000) description = 'hourly or more';
540
+ else if (count >= 300) description = 'daily or more';
541
+ else if (count >= 40) description = 'weekly or more';
542
+ else if (count >= 8) description = 'monthly or more';
543
+ else if (count >= 1) description = 'yearly or less';
544
+ else description = 'never (impossible schedule)';
545
+
546
+ return { description, runsPerYear: count };
547
+ } catch (e) {
548
+ return null;
549
+ }
550
+ }
551
+
552
+ // ---- Presets ----
553
+ const PRESETS = [
554
+ { label: 'Every minute', cron: '* * * * *' },
555
+ { label: 'Every 5 min', cron: '*/5 * * * *' },
556
+ { label: 'Every 10 min', cron: '*/10 * * * *' },
557
+ { label: 'Every 15 min', cron: '*/15 * * * *' },
558
+ { label: 'Every 30 min', cron: '*/30 * * * *' },
559
+ { label: 'Hourly', cron: '0 * * * *' },
560
+ { label: 'Every 2 hours', cron: '0 */2 * * *' },
561
+ { label: 'Every 6 hours', cron: '0 */6 * * *' },
562
+ { label: 'Every 12 hours', cron: '0 */12 * * *' },
563
+ { label: 'Daily at midnight', cron: '0 0 * * *' },
564
+ { label: 'Daily 9am', cron: '0 9 * * *' },
565
+ { label: 'Twice daily', cron: '0 9,21 * * *' },
566
+ { label: 'Weekdays 9am', cron: '0 9 * * 1-5' },
567
+ { label: 'Weekends 10am', cron: '0 10 * * 0,6' },
568
+ { label: 'Every Monday', cron: '0 0 * * 1' },
569
+ { label: 'Every Friday', cron: '0 0 * * 5' },
570
+ { label: 'Monthly 1st', cron: '0 0 1 * *' },
571
+ { label: 'Quarterly', cron: '0 0 1 */3 *' },
572
+ { label: 'Yearly Jan 1', cron: '0 0 1 1 *' },
573
+ ];
574
+
575
+ const COMMON = [
576
+ { label: 'At 14:30', cron: '30 14 * * *' },
577
+ { label: '9am weekdays', cron: '0 9 * * 1-5' },
578
+ { label: 'Every Mon 8am', cron: '0 8 * * 1' },
579
+ { label: 'Last day of month', cron: '0 0 L * *' },
580
+ { label: '15th, weekday', cron: '0 0 15W * *' },
581
+ { label: '3rd Thursday', cron: '0 0 * * 4#3' },
582
+ { label: 'Last Friday', cron: '0 0 * * 5L' },
583
+ { label: 'Business hours', cron: '0 9-17 * * 1-5' },
584
+ { label: 'Backup nightly', cron: '0 2 * * *' },
585
+ ];
586
+
587
+ // ---- Helpers ----
588
+ function pad2(n) { return String(n).padStart(2, '0'); }
589
+ function formatHM(h, m) { return `${pad2(h)}:${pad2(m)}`; }
590
+ function capitalize(s) { return s.charAt(0).toUpperCase() + s.slice(1); }
591
+ function ordinal(n) {
592
+ const s = ['th', 'st', 'nd', 'rd'];
593
+ const v = n % 100;
594
+ return n + (s[(v - 20) % 10] || s[v] || s[0]);
595
+ }
596
+ function listJoin(arr) {
597
+ if (arr.length === 0) return '';
598
+ if (arr.length === 1) return String(arr[0]);
599
+ if (arr.length === 2) return `${arr[0]} and ${arr[1]}`;
600
+ return arr.slice(0, -1).join(', ') + ', and ' + arr[arr.length - 1];
601
+ }
602
+
603
+ function formatNextRuns(runs, fromDate) {
604
+ return runs.map(r => {
605
+ const diff = r.getTime() - fromDate.getTime();
606
+ const mins = Math.round(diff / 60000);
607
+ let rel;
608
+ if (mins < 60) rel = `+${mins}m`;
609
+ else if (mins < 2880) rel = `+${Math.round(mins / 60)}h`;
610
+ else rel = `+${Math.round(mins / 1440)}d`;
611
+ return { date: r, relative: rel, formatted: r.toISOString() };
612
+ });
613
+ }
614
+
615
+ module.exports = {
616
+ CronError,
617
+ FIELDS,
618
+ MONTH_NAMES,
619
+ DAY_NAMES,
620
+ PRESETS,
621
+ COMMON,
622
+ parseCron,
623
+ describe,
624
+ describeParsed,
625
+ nextRuns,
626
+ matches,
627
+ validate,
628
+ estimateFrequency,
629
+ formatNextRuns,
630
+ parseField,
631
+ parseItem,
632
+ parseSingleNum,
633
+ resolveName,
634
+ lastDayOfMonth,
635
+ nearestWeekday,
636
+ nthWeekdayMatches,
637
+ lastWeekdayMatches,
638
+ };
@@ -27,6 +27,7 @@ Accepted headings: `## When to Use`, `## Use this skill when`, `## When to Use T
27
27
 
28
28
  Every skill must declare its risk level:
29
29
 
30
+ - ⚪ **unknown**: Legacy or unclassified content. Avoid this for new skills unless maintainer triage is genuinely needed.
30
31
  - 🟢 **none**: Pure text/reasoning (e.g., Brainstorming).
31
32
  - 🔵 **safe**: Reads files, runs safe commands (e.g., Linter).
32
33
  - 🟠 **critical**: Modifies state, deletes files, pushes to prod (e.g., Git Push).