Advanced prompt for comprehensive software repository analysis across any language or stack. Combines static analysis, dependency scanning, threat modeling, and dynamic testing to identify and remediate bugs, vulnerabilities, and technical debt. Uses an 8-phase workflow with CVSS/CWE/OWASP metrics, CI/CD, TDD templates, and audit-ready Markdown, JSON, YAML, and CSV deliverables.
1## 🎯 Role and Mission23Act as a **senior multidisciplinary team** composed of:45- **Application Security Engineer (AppSec)**6- **Software Architect**7- **SRE / DevOps Engineer**8- **QA Automation Lead**9- **Compliance Auditor (SOC2 / ISO 27001 / GDPR)**10...+213 more lines
Turns messy meeting notes or transcripts into a strict JSON payload of decisions, action items, owners, due dates, and open questions — ready for tools and project trackers.
1You extract structured follow-ups from meeting notes or transcripts. Output **only valid JSON** matching the schema below — no markdown fences, no commentary outside JSON.23## Task4Given raw notes (bullets, transcripts, or chat dumps), produce:5- meeting metadata (best-effort)6- decisions that were actually agreed7- action items with owners and due dates when stated8- open questions / parking lot9- risks or blockers mentioned10...+63 more lines
Profiles CSV and spreadsheet exports before you trust them: infers column types and measures missing values, duplicates, mixed types, outliers, date format chaos, and key uniqueness with a tested Python script, then writes a prioritized data quality report with safe fixes.
---
name: csv-data-quality-profiler
description: Profiles CSV and spreadsheet exports before they are trusted for analysis, imports, or dashboards - infers column types, measures missing values, duplicates, mixed types, outliers, whitespace and encoding problems, and key candidates - then writes a prioritized data quality report with concrete fixes. Use when a user shares a CSV, asks "is this data clean?", prepares a data import or migration, or sees numbers that look wrong in a report.
---
# CSV Data Quality Profiler
You check a tabular dataset the way a careful analyst would before building anything on top of it. You measure first, then explain what matters for the user's goal, then suggest the smallest safe fixes.
## Files in this skill
- `scripts/profile_csv.py` - column profiler and issue finder (Python 3 standard library only)
- `references/quality-dimensions.md` - the six dimensions you score and what counts as a problem
- `references/fix-playbook.md` - safe fixes per issue type, and what never to do automatically
- `templates/quality-report.md` - report format
- `examples/example-orders-report.md` - a worked report on a small orders export
## Workflow
### 1. Understand the purpose
Ask (or infer) what the data is for: a one-off analysis, a recurring import, a dashboard, or a migration. Ask which column should be unique (the key) and which columns matter most. The same issue can be critical for an import and harmless for a rough analysis.
### 2. Profile
```bash
python3 scripts/profile_csv.py data.csv
python3 scripts/profile_csv.py data.csv --key order_id
python3 scripts/profile_csv.py data.csv --delimiter ";" --json > profile.json
```
The script reports per column: inferred type, missing count and percent, distinct count, top values, min and max, and issues (mixed types, leading or trailing spaces, outliers by the IQR rule, inconsistent date formats, inconsistent casing). It also reports duplicate rows, ragged rows, and whether the key is unique. Exit code is 1 when any HIGH issue is found.
If the user cannot run scripts, read the first 200 rows yourself and apply the same checks by hand, and say the result is a sample.
### 3. Interpret
For each finding, use `references/quality-dimensions.md` to decide:
1. Which dimension it affects (completeness, validity, uniqueness, consistency, accuracy signals, structure).
2. Severity for this purpose: HIGH (wrong results or failed import), MEDIUM (misleading in some views), LOW (cosmetic).
3. Whether it is a real problem or expected (for example, an optional "coupon_code" column is allowed to be mostly empty).
### 4. Recommend fixes
Use `references/fix-playbook.md`. Prefer fixes at the source system over cleaning downstream. Give each fix as a concrete step (a formula, a pandas or SQL snippet, or a source-system change) and say what it changes and how many rows.
### 5. Write the report
Fill `templates/quality-report.md` the way `examples/example-orders-report.md` does: verdict first, then the top issues, then the column table.
## Verdicts
- **READY** - no HIGH issues for the stated purpose.
- **READY WITH CAVEATS** - usable if the listed caveats are accepted.
- **NOT READY** - at least one HIGH issue that would produce wrong numbers or a failed import.
## Rules
- Never silently drop, impute, or deduplicate rows; always state the rule and the affected row count, and keep the original file.
- Do not guess what a code or abbreviation means; ask or mark it as an assumption.
- Treat personal data with care: show at most a few example values, and mask emails, phone numbers, and IDs in the report.
- Outliers are leads, not errors. Ask before removing them.
FILE:references/quality-dimensions.md
# Data Quality Dimensions
Score each dimension as OK, WATCH, or PROBLEM for the user's purpose.
## 1. Completeness
Are required values present?
- Missing markers to treat as empty: "", "NA", "N/A", "null", "NULL", "None", "-", "?" (case-insensitive, after trimming spaces).
- PROBLEM: a required column (key, amount, date) has any missing values for an import, or more than 5 percent for an analysis.
- WATCH: an optional column is more than 50 percent empty (is it still used?).
## 2. Validity
Do values match the expected type and allowed range?
- Mixed types in one column (numbers plus words such as "TBD").
- Numbers stored with thousands separators or currency symbols ("1,200", "$45").
- Dates that do not parse, or impossible values (month 13, negative quantity, age 250).
- PROBLEM when the column feeds a calculation or a typed database column.
## 3. Uniqueness
- Fully duplicated rows: often caused by double exports or re-run jobs.
- Duplicate keys: two rows claim the same ID with different data. Always PROBLEM for imports.
- Near-duplicates (same values after trimming and lowercasing) are WATCH.
## 4. Consistency
- Several date formats in one column (2026-03-01, 03/01/2026, 1 Mar 2026).
- Same category spelled in different ways ("Paid", "paid", "PAID ").
- Units mixed in one column (kg and lb, cents and dollars).
- Leading or trailing spaces that break joins and filters.
## 5. Accuracy signals
The profiler cannot prove accuracy, but it can raise flags:
- Outliers outside 1.5 x IQR from the quartiles.
- Suspicious constants (every row has the same value).
- Default-looking values (1970-01-01, 0, 999999, "test").
- Totals that do not match a known number from the user.
## 6. Structure
- Ragged rows (a different number of fields than the header): usually unquoted delimiters inside text.
- Blank or duplicated header names.
- Encoding problems (mojibake): accented letters shown as two odd characters, for example "cafe" with its accented e turned into an "A" with a tilde plus a symbol (UTF-8 read as Latin-1).
- A byte order mark at the start of the first header.
## Severity by purpose
| Finding | Analysis | Recurring import | Dashboard |
|---|---|---|---|
| Duplicate keys | MEDIUM | HIGH | HIGH |
| Mixed types in a numeric column | HIGH | HIGH | HIGH |
| Several date formats | MEDIUM | HIGH | MEDIUM |
| Trailing spaces in categories | LOW | MEDIUM | MEDIUM |
| Outliers | MEDIUM | LOW | MEDIUM |
| Ragged rows | HIGH | HIGH | HIGH |
FILE:references/fix-playbook.md
# Fix Playbook
Always: keep the original file, write fixes as a repeatable script or documented steps, and report the number of rows each fix touches.
## Missing values
- Required field: fix at the source, or quarantine the rows into a separate file for review.
- Optional field: leave empty; standardize all missing markers to one empty value.
- Never fill amounts or dates with 0 or today's date just to make an import pass.
## Mixed types
- Find the non-matching values first: `df[pd.to_numeric(df.col, errors="coerce").isna() & df.col.notna()]`.
- Decide per value: a real value written differently ("1,200" becomes 1200), a placeholder ("TBD" becomes empty), or a genuine error (send back to the owner).
## Duplicates
- Full duplicate rows: safe to drop after confirming they come from a double export; keep the first.
- Duplicate keys with different data: do not pick one automatically. List both rows and ask which system is the source of truth, or keep the most recent by an updated_at column if the user agrees.
## Inconsistent dates
- Parse with an explicit format per pattern, never with a guessing parser across the whole column.
- Ambiguous day/month values (03/04/2026) need a rule from the user; check whether any value has a day above 12 to infer the format.
- Store the result as ISO 8601 (YYYY-MM-DD).
## Inconsistent categories and spaces
- Trim spaces in every text column used for joins or grouping.
- Map spelling variants with an explicit mapping table that the user approves, not with fuzzy matching.
## Outliers
- Check them with the data owner. Typical real causes: bulk orders, refunds stored as negatives, test transactions.
- If excluded from an analysis, say so in the results and show the numbers with and without them.
## Structure
- Ragged rows: re-export with proper quoting, or parse with the correct delimiter and quote character.
- Encoding: re-read as UTF-8; if mojibake remains, the file was double-encoded at the source.
- BOM: read with encoding "utf-8-sig".
## Never do automatically
- Drop rows with missing values in bulk.
- Impute values in key, amount, or date columns.
- Merge near-duplicate customers or products.
- Remove outliers.
FILE:templates/quality-report.md
# Data Quality Report: {{dataset_name}}
**Purpose:** {{analysis | recurring import | dashboard | migration}}
**File:** {{file_name}} ({{rows}} rows x {{columns}} columns, delimiter "{{delimiter}}")
**Key column:** {{key_column or "none given"}}
**Verdict:** {{READY | READY WITH CAVEATS | NOT READY}}
## Summary
{{Two or three sentences: is the data fit for the purpose, and what must happen first.}}
## Top issues (most severe first)
| # | Severity | Dimension | Column | Finding | Rows affected | Recommended fix |
|---|---|---|---|---|---|---|
| 1 | {{HIGH}} | {{Uniqueness}} | {{col}} | {{finding}} | {{n}} | {{fix}} |
## Dimension scores
| Dimension | Score | Note |
|---|---|---|
| Completeness | {{OK / WATCH / PROBLEM}} | |
| Validity | | |
| Uniqueness | | |
| Consistency | | |
| Accuracy signals | | |
| Structure | | |
## Column profile
| Column | Type | Missing % | Distinct | Range or top values | Issues |
|---|---|---|---|---|---|
## Questions for the data owner
- {{question}}
## Assumptions
- {{assumption}}
## Next steps
1. {{step}}
FILE:examples/example-orders-report.md
# Data Quality Report: Online orders export (September)
**Purpose:** recurring import into the finance database
**File:** orders_sept.csv (8 rows x 6 columns, delimiter ",")
**Key column:** order_id
**Verdict:** NOT READY
## Summary
The export cannot be imported as is: one order ID appears twice with different amounts, and the amount column mixes numbers with the placeholder "TBD". Dates use two formats. After the three fixes below the file should be ready.
## Top issues (most severe first)
| # | Severity | Dimension | Column | Finding | Rows affected | Recommended fix |
|---|---|---|---|---|---|---|
| 1 | HIGH | Uniqueness | order_id | Key A-1003 appears twice (amounts 45.00 and 54.00) | 2 | Ask finance which row is correct; do not auto-pick |
| 2 | HIGH | Validity | amount | Mixed types: 1 non-numeric value ("TBD") | 1 | Replace with the real amount from the shop system, or quarantine the row |
| 3 | MEDIUM | Consistency | order_date | Two date formats (YYYY-MM-DD and DD/MM/YYYY) | 2 | Parse each pattern explicitly, store as ISO 8601 |
| 4 | MEDIUM | Consistency | status | Leading or trailing spaces ("paid ") | 1 | Trim all category columns before import |
| 5 | LOW | Accuracy signals | amount | Outlier 1250.00 (IQR rule) | 1 | Confirm with the shop team; likely a bulk order |
## Dimension scores
| Dimension | Score | Note |
|---|---|---|
| Completeness | WATCH | coupon is 87.5 percent empty, expected for an optional field |
| Validity | PROBLEM | "TBD" in amount |
| Uniqueness | PROBLEM | duplicate key A-1003 |
| Consistency | WATCH | date formats, trailing space in status |
| Accuracy signals | WATCH | one large order |
| Structure | OK | no ragged rows, clean header |
## Column profile
| Column | Type | Missing % | Distinct | Range or top values | Issues |
|---|---|---|---|---|---|
| order_id | string | 0.0 | 7 | A-1003 (2), A-1001 (1), A-1002 (1) | duplicate key |
| order_date | date | 0.0 | 7 | 2026-09-01 to 2026-09-28 | 2 formats |
| customer_email | string | 0.0 | 6 | (masked) | none |
| amount | float | 0.0 | 8 | 18.5 to 1250.0 | mixed types, outlier |
| status | string | 0.0 | 2 | paid (7), refunded (1) | surrounding spaces |
| coupon | string | 87.5 | 1 | FALL10 (1) | mostly empty (expected) |
## Questions for the data owner
- Which A-1003 row is correct, and why was it exported twice?
- What is the real amount for A-1006?
## Assumptions
- DD/MM/YYYY is used for the slash dates (one value has day 28, so it cannot be MM/DD).
## Next steps
1. Resolve A-1003 and A-1006 with finance.
2. Add a trim and date-normalization step to the export job.
3. Re-run `python3 scripts/profile_csv.py orders_sept.csv --key order_id` and import when it exits 0.
FILE:scripts/profile_csv.py
#!/usr/bin/env python3
"""Profile a CSV file for data quality problems (Python 3 standard library only).
Usage:
python3 profile_csv.py FILE.csv [--key COLUMN] [--delimiter ","] [--json]
python3 profile_csv.py - < FILE.csv (read from stdin)
Reports per column: inferred type (int, float, numtext = numbers stored with
separators or currency symbols, date, bool, string), missing values, distinct count, top values,
min/max, and issues (mixed types, surrounding spaces, IQR outliers, several
date formats, inconsistent casing). Also reports ragged rows, duplicate rows,
blank or duplicate headers, and key uniqueness when --key is given.
Exit code: 0 = no HIGH issues, 1 = at least one HIGH issue, 2 = usage error.
"""
import argparse
import csv
import io
import json
import re
import statistics
import sys
from collections import Counter
MISSING = {"", "na", "n/a", "null", "none", "nan", "-", "?"}
DATE_PATTERNS = [
("YYYY-MM-DD", re.compile(r"^\d{4}-\d{2}-\d{2}$")),
("YYYY-MM-DD HH:MM", re.compile(r"^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2})?$")),
("DD/MM/YYYY or MM/DD/YYYY", re.compile(r"^\d{1,2}/\d{1,2}/\d{4}$")),
("DD.MM.YYYY", re.compile(r"^\d{1,2}\.\d{1,2}\.\d{4}$")),
("D Mon YYYY", re.compile(r"^\d{1,2} [A-Za-z]{3,9} \d{4}$")),
]
INT_RE = re.compile(r"^[+-]?\d+$")
FLOAT_RE = re.compile(r"^[+-]?(\d+\.\d*|\.\d+|\d+)([eE][+-]?\d+)?$")
FORMATTED_NUM_RE = re.compile(r"^[+-]?[$\u20ac\u00a3]?\d{1,3}(,\d{3})+(\.\d+)?$|^[$\u20ac\u00a3]\d+(\.\d+)?$")
BOOL_VALUES = {"true", "false", "yes", "no", "y", "n"}
def classify(value):
v = value.strip()
if INT_RE.match(v):
return "int"
if FLOAT_RE.match(v):
return "float"
if v.lower() in BOOL_VALUES:
return "bool"
for name, rx in DATE_PATTERNS:
if rx.match(v):
return "date:" + name
if FORMATTED_NUM_RE.match(v):
return "formatted_number"
return "string"
def quartiles(nums):
q = statistics.quantiles(nums, n=4, method="inclusive")
return q[0], q[2]
def profile(rows, header, key=None):
issues = [] # (severity, column, code, message)
ncols = len(header)
if header and header[0].startswith("\ufeff"):
header[0] = header[0].lstrip("\ufeff")
issues.append(("LOW", header[0], "bom", "File starts with a byte order mark; read with encoding utf-8-sig"))
names = Counter(h.strip() for h in header)
for h in header:
if not h.strip():
issues.append(("MEDIUM", "(header)", "blank-header", "A header cell is blank"))
for h, c in names.items():
if h and c > 1:
issues.append(("HIGH", h, "duplicate-header", f"Header '{h}' appears {c} times"))
ragged = [i + 2 for i, r in enumerate(rows) if len(r) != ncols]
if ragged:
issues.append(("HIGH", "(rows)", "ragged-rows",
f"{len(ragged)} row(s) have a field count different from the header ({ncols}); first at line(s) {ragged[:5]}"))
good = [r for r in rows if len(r) == ncols]
dup_counter = Counter(tuple(c.strip() for c in r) for r in good)
dup_rows = sum(c - 1 for c in dup_counter.values() if c > 1)
if dup_rows:
issues.append(("MEDIUM", "(rows)", "duplicate-rows", f"{dup_rows} fully duplicated row(s)"))
columns = []
for idx, name in enumerate(header):
raw = [r[idx] for r in good]
present = [v for v in raw if v.strip().lower() not in MISSING]
missing = len(raw) - len(present)
kinds = Counter(classify(v) for v in present)
base = Counter()
for k, c in kinds.items():
base["date" if k.startswith("date:") else k] += c
if base:
top_kind, top_n = base.most_common(1)[0]
else:
top_kind, top_n = "empty", 0
if set(base) <= {"int", "float"} and base:
ctype = "float" if "float" in base else "int"
elif set(base) <= {"int", "float", "formatted_number"} and base:
ctype = "numtext"
else:
ctype = top_kind
col = {
"name": name, "type": ctype, "rows": len(raw), "missing": missing,
"missing_pct": round(100.0 * missing / len(raw), 1) if raw else 0.0,
"distinct": len(set(v.strip() for v in present)),
"top_values": Counter(v.strip() for v in present).most_common(3),
"issues": [],
}
def add(sev, code, msg):
col["issues"].append(code)
issues.append((sev, name, code, msg))
numeric_like = base.get("int", 0) + base.get("float", 0)
others = {k: c for k, c in kinds.items() if k not in ("int", "float")}
if numeric_like and others and numeric_like >= sum(others.values()):
bad = [v.strip() for v in present if classify(v) not in ("int", "float")]
add("HIGH", "mixed-types",
f"Mostly numeric but {len(bad)} non-numeric value(s), e.g. {bad[:3]}")
elif base.get("formatted_number") and ctype in ("numtext", "formatted_number"):
add("MEDIUM", "formatted-numbers",
f"{base['formatted_number']} value(s) use thousands separators or currency symbols")
date_formats = {k[5:] for k in kinds if k.startswith("date:")}
if len(date_formats) > 1:
add("MEDIUM", "date-formats", f"Several date formats: {sorted(date_formats)}")
if ctype == "date" and "DD/MM/YYYY or MM/DD/YYYY" in date_formats:
add("LOW", "ambiguous-dates", "Slash dates are ambiguous (day/month order); confirm the format")
spaced = [v for v in present if v != v.strip()]
if spaced:
add("MEDIUM", "surrounding-spaces", f"{len(spaced)} value(s) have leading or trailing spaces")
if ctype == "string" and present:
groups = {}
for v in present:
groups.setdefault(v.strip().lower(), set()).add(v.strip())
variants = [sorted(s) for s in groups.values() if len(s) > 1]
if variants:
add("LOW", "case-variants", f"Same value with different casing: {variants[:3]}")
if col["missing_pct"] > 50:
add("LOW", "mostly-empty", f"{col['missing_pct']}% missing (fine if optional)")
elif missing:
add("LOW", "missing", f"{missing} missing value(s)")
if ctype in ("int", "float"):
nums = [float(v) for v in present if classify(v) in ("int", "float")]
if nums:
fmt = int if ctype == "int" else float
col["min"], col["max"] = fmt(min(nums)), fmt(max(nums))
if len(nums) >= 4:
q1, q3 = quartiles(nums)
iqr = q3 - q1
lo, hi = q1 - 1.5 * iqr, q3 + 1.5 * iqr
outliers = [n for n in nums if n < lo or n > hi]
if outliers:
add("LOW", "outliers", f"{len(outliers)} outlier(s) outside [{lo:g}, {hi:g}]: {outliers[:3]}")
elif ctype == "date" and present:
iso = sorted(v.strip()[:10] for v in present if classify(v).startswith("date:YYYY"))
if iso:
col["min"], col["max"] = iso[0], iso[-1]
if len(present) > 1 and col["distinct"] == 1:
add("LOW", "constant", "Every non-missing value is the same")
columns.append(col)
key_report = None
if key:
names_clean = [h.strip() for h in header]
if key not in names_clean:
issues.append(("HIGH", key, "key-missing", f"Key column '{key}' not found in header"))
else:
k = names_clean.index(key)
vals = [r[k].strip() for r in good]
empty = sum(1 for v in vals if v.lower() in MISSING)
dups = {v: c for v, c in Counter(vals).items() if c > 1 and v.lower() not in MISSING}
key_report = {"column": key, "empty": empty, "duplicate_keys": dups}
if empty:
issues.append(("HIGH", key, "key-empty", f"{empty} row(s) have an empty key"))
if dups:
issues.append(("HIGH", key, "key-duplicate", f"{len(dups)} key value(s) repeat: {dict(list(dups.items())[:5])}"))
return {"rows": len(rows), "columns": len(header), "column_profiles": columns,
"key": key_report, "issues": [dict(zip(("severity", "column", "code", "message"), i)) for i in issues]}
def render(report):
out = [f"Rows: {report['rows']} Columns: {report['columns']}", "",
"Column Type Missing% Distinct Range / top values"]
for c in report["column_profiles"]:
rng = f"{c['min']} .. {c['max']}" if "min" in c else ", ".join(f"{v} ({n})" for v, n in c["top_values"])
out.append(f"{c['name'][:17]:<17} {c['type'][:8]:<8} {c['missing_pct']:>8} {c['distinct']:>8} {rng[:60]}")
out.append("")
order = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
for i in sorted(report["issues"], key=lambda x: order[x["severity"]]):
out.append(f"[{i['severity']}] {i['column']}: {i['code']}: {i['message']}")
counts = Counter(i["severity"] for i in report["issues"])
out.append("")
out.append(f"{counts.get('HIGH', 0)} HIGH, {counts.get('MEDIUM', 0)} MEDIUM, {counts.get('LOW', 0)} LOW")
out.append("Heuristic profile: confirm findings with references/quality-dimensions.md.")
return "\n".join(out)
def main(argv=None):
ap = argparse.ArgumentParser(description="Profile a CSV file for data quality problems.")
ap.add_argument("file", help="CSV path, or - for stdin")
ap.add_argument("--key", help="column that should be unique and non-empty")
ap.add_argument("--delimiter", default=None, help="field delimiter (default: sniffed)")
ap.add_argument("--json", action="store_true", help="print JSON instead of text")
a = ap.parse_args(argv)
try:
text = sys.stdin.read() if a.file == "-" else open(a.file, encoding="utf-8", newline="").read()
except (OSError, UnicodeDecodeError) as e:
print(f"error: cannot read {a.file}: {e}", file=sys.stderr)
return 2
if not text.strip():
print("error: file is empty", file=sys.stderr)
return 2
delim = a.delimiter
if delim is None:
try:
delim = csv.Sniffer().sniff(text[:4096], delimiters=",;\t|").delimiter
except csv.Error:
delim = ","
all_rows = [r for r in csv.reader(io.StringIO(text), delimiter=delim) if any(c.strip() for c in r)]
header, rows = all_rows[0], all_rows[1:]
report = profile(rows, header, a.key)
report["delimiter"] = delim
print(json.dumps(report, indent=2) if a.json else render(report))
return 1 if any(i["severity"] == "HIGH" for i in report["issues"]) else 0
if __name__ == "__main__":
sys.exit(main())Explains any cron expression in plain English, lists the next run times, and flags pitfalls such as the day-of-month OR day-of-week rule, dates that never occur, DST gaps, UTC versus local time, and overlapping jobs. Includes a tested stdlib Python checker for crontab files.
---
name: cron-schedule-explainer
description: Explains, validates, and writes cron schedules - translates a cron expression into plain English, lists the next run times, and flags pitfalls such as the day-of-month OR day-of-week rule, dates that never occur, daylight saving gaps, time zone confusion, and overlapping or too-frequent jobs. Use when a user pastes a crontab line, Kubernetes CronJob, GitHub Actions schedule, or asks "when will this run?" or "write a cron for every second Tuesday".
---
# Cron Schedule Explainer
You make scheduled jobs predictable. For every schedule you give a plain-English meaning, concrete next run times, and the risks that would surprise someone at 2 a.m.
## Files in this skill
- `scripts/cron_explain.py` - parser, explainer, next-run calculator, and pitfall checker (Python 3 standard library only)
- `references/cron-syntax.md` - field ranges, special characters, macros, and platform differences
- `references/scheduling-pitfalls.md` - common mistakes and how to avoid them
- `templates/schedule-review.md` - review format
- `examples/example-backup-review.md` - a worked review of three crontab lines
## Workflow
### 1. Identify the platform
Standard 5-field cron (Vixie cron, cronie, Kubernetes CronJob, GitHub Actions) is the default. Ask or check if the user means Quartz (6 or 7 fields with seconds and `?`), AWS EventBridge (6 fields with year), or systemd timers; see `references/cron-syntax.md`. Note the time zone: GitHub Actions always uses UTC; Kubernetes uses the controller's time zone unless `timeZone` is set.
### 2. Run the checker
```bash
python3 scripts/cron_explain.py "30 2 * * 1-5"
python3 scripts/cron_explain.py "0 9 1 * MON" --count 8 --from "2026-10-08 10:00"
python3 scripts/cron_explain.py --file crontab.txt
```
It prints a plain-English explanation, the next N run times (naive local time of the server), and warnings. Exit code is 1 when an expression is invalid or never runs.
If you cannot run the script, apply the same rules by hand and say so.
### 3. Explain
For each schedule give:
1. One-sentence plain-English meaning.
2. The next 3 to 5 runs with the time zone stated.
3. Warnings from the script and from `references/scheduling-pitfalls.md` that apply (overlap with long jobs, DST, UTC versus local, missed runs while the machine is off).
### 4. Write or fix schedules
When the user describes a schedule in words, write the expression, then run it through the checker to confirm the next runs match their intent. For things cron cannot express directly (every second Tuesday, the last weekday of the month), give a cron expression plus a guard in the command, for example `[ "$(date +\%d)" -le 07 ] && run-job`, and explain why.
### 5. Report
Use `templates/schedule-review.md`, as in `examples/example-backup-review.md`.
## Rules
- Always state the time zone you are assuming.
- Remember that `%` must be escaped as `\%` inside crontab command fields.
- Never edit a live crontab for the user; show the line to add and the `crontab -e` step.
- Recommend a lock (for example `flock -n /tmp/job.lock cmd`) whenever a job could run longer than its interval.
FILE:references/cron-syntax.md
# Cron Syntax Reference (5-field standard)
```
+------------- minute (0-59)
| +----------- hour (0-23)
| | +--------- day of month (1-31)
| | | +------- month (1-12 or JAN-DEC)
| | | | +----- day of week (0-7 or SUN-SAT; 0 and 7 are both Sunday)
| | | | |
* * * * * command
```
## Special characters
| Symbol | Meaning | Example |
|---|---|---|
| `*` | every value | `* * * * *` every minute |
| `,` | list | `0 8,12,18 * * *` at 08:00, 12:00, 18:00 |
| `-` | range | `0 9 * * 1-5` 09:00 Monday to Friday |
| `/` | step | `*/15 * * * *` every 15 minutes; `10-50/20` = 10, 30, 50 |
Names are case-insensitive. Ranges of names (`MON-FRI`) work in most implementations, lists of names work everywhere.
## Macros
| Macro | Equivalent |
|---|---|
| `@yearly` / `@annually` | `0 0 1 1 *` |
| `@monthly` | `0 0 1 * *` |
| `@weekly` | `0 0 * * 0` |
| `@daily` / `@midnight` | `0 0 * * *` |
| `@hourly` | `0 * * * *` |
| `@reboot` | once at startup (not time based) |
## The day rule
If both day of month and day of week are restricted (neither is `*`), the job runs when EITHER matches. `0 9 1 * MON` runs on the 1st of every month AND every Monday.
## Platform differences
| Platform | Fields | Time zone | Notes |
|---|---|---|---|
| Linux cron (cronie, Vixie) | 5 | system local time | `CRON_TZ=` supported by cronie |
| Kubernetes CronJob | 5 | controller time zone, or `spec.timeZone` | use `concurrencyPolicy: Forbid` to prevent overlaps |
| GitHub Actions `schedule` | 5 | always UTC | runs can be delayed under load; minimum interval 5 minutes |
| Quartz (Java) | 6-7 (seconds first, optional year) | configurable | `?` for "no specific value", `L`, `W`, `#` supported |
| AWS EventBridge | 6 (with year) | UTC unless a scheduler time zone is set | either day-of-month or day-of-week must be `?` |
The script in this skill supports the 5-field standard plus the macros above (except `@reboot`, which it reports as not time based).
FILE:references/scheduling-pitfalls.md
# Scheduling Pitfalls
## 1. Day of month OR day of week
`0 0 13 * 5` is NOT "Friday the 13th". It runs on every 13th and every Friday. Use `0 0 13 * *` plus a guard: `[ "$(date +\%u)" = 5 ] && cmd`.
## 2. Dates that never or rarely occur
- `0 0 30 2 *` never runs (February has no 30th).
- `0 0 31 * *` runs only in 7 months of the year.
- `0 0 29 2 *` runs only in leap years.
For "last day of the month" use `0 0 28-31 * *` with a guard: `[ "$(date -d tomorrow +\%d)" = 01 ] && cmd`.
## 3. Daylight saving time
In local time zones with DST, times between about 01:00 and 03:00 can be skipped (spring forward) or run twice (fall back), depending on the cron implementation. Schedule critical jobs outside that window, or run cron in UTC.
## 4. UTC versus local time
GitHub Actions and many cloud schedulers use UTC. "Every day at 09:00" for a team in Istanbul (UTC+3) is `0 6 * * *` in UTC. Always write the time zone next to the expression in docs and code comments.
## 5. Too frequent or overlapping runs
- `* * * * *` runs 1440 times a day. Make sure that is intended.
- A minute field of `*` with a fixed hour (`* 3 * * *`) runs 60 times between 03:00 and 03:59; usually `0 3 * * *` was meant.
- If a job can take longer than its interval, use a lock (`flock -n`) or `concurrencyPolicy: Forbid`.
## 6. Step values do not wrap evenly
`*/7` in the minute field runs at 0, 7, ..., 56, then again at 0 (a 4-minute gap). `*/25` runs at 0, 25, 50. Steps restart every hour, day, or month.
## 7. Thundering herd
Many teams pick `0 0 * * *` or `0 * * * *`. Shift jobs to an odd minute (for example `17 2 * * *`) to avoid load spikes on shared systems and rate-limited APIs.
## 8. Environment and output
Cron runs with a minimal PATH and no login shell. Use absolute paths, set needed variables in the crontab, and redirect output (`>> /var/log/job.log 2>&1`) so failures are visible. Escape `%` as `\%`.
## 9. Missed runs
Plain cron does not catch up on runs missed while the machine was off. Use anacron, systemd timers with `Persistent=true`, or Kubernetes `startingDeadlineSeconds` when a missed run matters.
FILE:templates/schedule-review.md
# Schedule Review: {{system_or_repo}}
**Platform:** {{Linux cron | Kubernetes CronJob | GitHub Actions | other}}
**Time zone assumed:** {{time_zone}}
**Reviewed on:** {{date}}
## Summary
{{One or two sentences: are the schedules doing what the team expects, and what must change.}}
## Schedules
### {{n}}. `{{expression}}` - {{job name}}
- **Meaning:** {{plain-English explanation}}
- **Next runs:** {{run 1}}, {{run 2}}, {{run 3}}
- **Verdict:** {{OK | FIX | CLARIFY}}
- **Warnings:**
- {{warning}}
- **Suggested line:**
```
{{corrected crontab line}}
```
## Questions
- {{question for the team}}
FILE:examples/example-backup-review.md
# Schedule Review: ops server crontab
**Platform:** Linux cron (cronie)
**Time zone assumed:** Europe/Berlin (server local time, has DST)
**Reviewed on:** 2026-10-08
## Summary
Two of the three lines do not do what the comments say. The backup runs inside the DST window, and the "Friday the 13th" report actually runs every Friday and every 13th.
## Schedules
### 1. `30 2 * * *` - nightly database backup
- **Meaning:** At 02:30 every day.
- **Next runs:** 2026-10-09 02:30, 2026-10-10 02:30, 2026-10-11 02:30
- **Verdict:** FIX
- **Warnings:**
- 02:30 is inside the DST change window; on the spring-forward night it may be skipped and in autumn it may run twice.
- The backup can take over an hour on month-end; no lock.
- **Suggested line:**
```
17 4 * * * flock -n /tmp/db-backup.lock /opt/scripts/db-backup.sh >> /var/log/db-backup.log 2>&1
```
### 2. `0 9 13 * FRI` - "Friday the 13th" fun report
- **Meaning:** At 09:00 on day 13 of the month OR on every Friday (cron's day rule).
- **Next runs:** 2026-10-09 09:00 (Fri), 2026-10-13 09:00 (Tue, the 13th), 2026-10-16 09:00 (Fri)
- **Verdict:** FIX
- **Warnings:**
- Both day fields are restricted, so cron uses OR, not AND.
- **Suggested line:**
```
0 9 13 * * [ "$(date +\%u)" = 5 ] && /opt/scripts/fun-report.sh
```
### 3. `*/20 8-18 * * 1-5` - sync tickets from the help desk
- **Meaning:** Every 20 minutes (at :00, :20, :40) from 08:00 to 18:59, Monday to Friday.
- **Next runs:** 2026-10-08 10:20, 2026-10-08 10:40, 2026-10-08 11:00
- **Verdict:** CLARIFY
- **Warnings:**
- Last run of the day is 18:40, not 18:00. Use `8-17` plus a separate `0 18 * * 1-5` if the sync should stop at 18:00.
## Questions
- Should the server run cron in UTC to avoid DST issues entirely?
FILE:scripts/cron_explain.py
#!/usr/bin/env python3
"""Explain, validate, and preview standard 5-field cron expressions (stdlib only).
Usage:
python3 cron_explain.py "EXPR" [--count N] [--from "YYYY-MM-DD HH:MM"]
python3 cron_explain.py --file crontab.txt [--count N] [--from ...]
For each expression: a plain-English explanation, the next N run times
(naive server-local time), and pitfall warnings. In --file mode, crontab
lines are read; comments, blank lines and VAR=value lines are skipped and the
first five fields (or a leading @macro) are taken as the schedule.
Exit code: 0 = all valid, 1 = an expression is invalid or never runs, 2 = usage.
"""
import argparse
import calendar
import datetime as dt
import sys
MONTHS = {m.lower(): i for i, m in enumerate(calendar.month_abbr) if m}
DAYS = {"sun": 0, "mon": 1, "tue": 2, "wed": 3, "thu": 4, "fri": 5, "sat": 6}
MACROS = {
"@yearly": "0 0 1 1 *", "@annually": "0 0 1 1 *", "@monthly": "0 0 1 * *",
"@weekly": "0 0 * * 0", "@daily": "0 0 * * *", "@midnight": "0 0 * * *",
"@hourly": "0 * * * *",
}
FIELDS = [("minute", 0, 59, {}), ("hour", 0, 23, {}), ("day of month", 1, 31, {}),
("month", 1, 12, MONTHS), ("day of week", 0, 7, DAYS)]
DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
def compress(values, fmt=str):
"""[1,2,3,5] -> '1-3, 5' using fmt for each number."""
vals, out, i = sorted(values), [], 0
while i < len(vals):
j = i
while j + 1 < len(vals) and vals[j + 1] == vals[j] + 1:
j += 1
out.append(fmt(vals[i]) if j - i < 2 else f"{fmt(vals[i])}-{fmt(vals[j])}")
if 0 < j - i < 2:
out.append(fmt(vals[j]))
i = j + 1
return ", ".join(out)
class CronError(ValueError):
pass
def _num(token, lo, hi, names, field):
t = token.lower()
if t in names:
return names[t]
if not t.isdigit():
raise CronError(f"{field}: '{token}' is not a number or known name")
v = int(t)
if not lo <= v <= hi:
raise CronError(f"{field}: {v} is outside {lo}-{hi}")
return v
def parse_field(text, lo, hi, names, field):
values = set()
for part in text.split(","):
if not part:
raise CronError(f"{field}: empty list item in '{text}'")
step = 1
if "/" in part:
part, step_s = part.split("/", 1)
if not step_s.isdigit() or int(step_s) == 0:
raise CronError(f"{field}: bad step '/{step_s}'")
step = int(step_s)
if part == "*":
start, end = lo, hi
elif "-" in part:
a, b = part.split("-", 1)
start, end = _num(a, lo, hi, names, field), _num(b, lo, hi, names, field)
if start > end:
raise CronError(f"{field}: range {a}-{b} is reversed")
else:
start = _num(part, lo, hi, names, field)
end = hi if step > 1 else start
values.update(range(start, end + 1, step))
return values
def parse(expr):
expr = expr.strip()
if expr.lower() == "@reboot":
raise CronError("@reboot runs once at startup and is not time based")
expr = MACROS.get(expr.lower(), expr)
parts = expr.split()
if len(parts) != 5:
hint = " (6-7 fields look like Quartz or EventBridge; see references/cron-syntax.md)" if len(parts) in (6, 7) else ""
raise CronError(f"expected 5 fields, got {len(parts)}{hint}")
for p in parts:
bare = p.lower()
for n in list(MONTHS) + list(DAYS):
bare = bare.replace(n, "")
if any(c in bare for c in "?lw#"):
raise CronError(f"'{p}': '?', 'L', 'W' and '#' are Quartz extensions, not standard cron")
sets = [parse_field(p, lo, hi, names, name) for p, (name, lo, hi, names) in zip(parts, FIELDS)]
if 7 in sets[4]:
sets[4].discard(7)
sets[4].add(0)
return parts, sets
def describe_set(values, lo, hi, field, raw):
vals = sorted(values)
if raw == "*":
return None
if field == "day of week":
names = [DAY_NAMES[v] for v in vals]
if vals == [1, 2, 3, 4, 5]:
return "Monday to Friday"
if vals == [0, 6]:
return "on weekends"
return ", ".join(names)
if field == "month":
return ", ".join(calendar.month_name[v] for v in vals)
return compress(vals)
def explain(parts, sets):
minute, hour, dom, month, dow = sets
rm, rh, rdom, rmon, rdow = parts
if rm == "*" and rh == "*":
time_txt = "every minute"
elif rm.startswith("*/") and rh == "*":
time_txt = f"every {rm[2:]} minutes"
elif rm.startswith("*/"):
time_txt = (f"every {rm[2:]} minutes (at minute " + ", ".join(str(m) for m in sorted(minute)) +
") during hour(s) " + compress(hour, lambda h: f"{h:02d}"))
elif rh == "*":
time_txt = "at minute " + ", ".join(str(m) for m in sorted(minute)) + " of every hour"
elif rm == "*":
time_txt = "every minute during hour(s) " + compress(hour, lambda h: f"{h:02d}")
elif len(minute) * len(hour) <= 6:
time_txt = "at " + ", ".join(f"{h:02d}:{m:02d}" for h in sorted(hour) for m in sorted(minute))
else:
time_txt = ("at minute(s) " + ", ".join(str(m) for m in sorted(minute)) +
" past hour(s) " + compress(hour, lambda h: f"{h:02d}"))
day_txt = []
d_dom = describe_set(dom, 1, 31, "day of month", rdom)
d_dow = describe_set(dow, 0, 6, "day of week", rdow)
if d_dom and d_dow:
day_txt.append(f"on day(s) {d_dom} of the month OR on {d_dow}")
elif d_dom:
day_txt.append(f"on day(s) {d_dom} of the month")
elif d_dow:
day_txt.append(d_dow if d_dow.startswith("on ") else f"on {d_dow}")
else:
day_txt.append("every day")
d_mon = describe_set(month, 1, 12, "month", rmon)
if d_mon:
day_txt.append(f"in {d_mon}")
text = f"{time_txt}, {' '.join(day_txt)}"
return text[0].upper() + text[1:] + "."
def day_matches(d, parts, sets):
_, _, dom, month, dow = sets
if d.month not in month:
return False
cron_dow = (d.weekday() + 1) % 7
dom_r, dow_r = parts[2] != "*", parts[4] != "*"
if dom_r and dow_r:
return d.day in dom or cron_dow in dow
if dom_r:
return d.day in dom
if dow_r:
return cron_dow in dow
return True
def next_runs(parts, sets, start, count, max_days=366 * 8):
minute, hour = sorted(sets[0]), sorted(sets[1])
runs = []
day = start.date()
for _ in range(max_days):
if day_matches(day, parts, sets):
for h in hour:
for m in minute:
t = dt.datetime(day.year, day.month, day.day, h, m)
if t > start:
runs.append(t)
if len(runs) >= count:
return runs
day += dt.timedelta(days=1)
return runs
def warnings(parts, sets, runs):
minute, hour, dom, month, dow = sets
out = []
if parts[2] != "*" and parts[4] != "*":
out.append("Day of month AND day of week are both set: cron runs when EITHER matches (OR, not AND).")
if parts[2] != "*" and parts[4] == "*":
max_days = {m: (29 if m == 2 else calendar.monthrange(2026, m)[1]) for m in month}
if not any(d <= max_days[m] for m in month for d in dom):
out.append("Never runs: the chosen day(s) of month do not exist in the chosen month(s).")
elif any(d > 28 for d in dom):
out.append("Some chosen days (29-31) do not exist in every month, so some months are skipped.")
if parts[0] == "*" and parts[1] != "*":
out.append("Minute is '*': runs every minute of the chosen hour(s); did you mean minute 0?")
runs_per_day = len(minute) * len(hour)
if runs_per_day >= 288:
out.append(f"Runs {runs_per_day} times a day; make sure that is intended and add a lock against overlap.")
if any(1 <= h <= 2 for h in hour) and parts[1] != "*":
out.append("Runs between 01:00 and 02:59: in local time zones with DST this can be skipped or run twice.")
for i, raw in ((0, parts[0]), (1, parts[1])):
if "/" in raw:
step = int(raw.split("/")[1])
span = 60 if i == 0 else 24
if span % step:
out.append(f"Step /{step} does not divide {span}: the gap is uneven where the {'hour' if i == 0 else 'day'} wraps.")
if parts[0] == "0" and parts[1] in ("*", "0"):
out.append("Minute 0 at the top of the hour is a popular slot; consider an odd minute to avoid load spikes.")
return out
def check(expr, start, count):
print(f"Expression: {expr}")
try:
parts, sets = parse(expr)
except CronError as e:
print(f" INVALID: {e}\n")
return False
print(f" Meaning: {explain(parts, sets)}")
runs = next_runs(parts, sets, start, count)
ok = True
if runs:
print(f" Next {len(runs)} run(s) after {start:%Y-%m-%d %H:%M} (server local time):")
for r in runs:
print(f" {r:%Y-%m-%d %H:%M} {r:%a}")
else:
print(" Next runs: none found in the next 8 years")
ok = False
for w in warnings(parts, sets, runs):
print(f" WARNING: {w}")
print()
return ok
def crontab_schedules(path):
with open(path, encoding="utf-8") as f:
for line in f:
s = line.strip()
if not s or s.startswith("#"):
continue
first = s.split()[0]
if "=" in first and not first.startswith("@"):
continue
yield first if first.startswith("@") else " ".join(s.split()[:5])
def main(argv=None):
ap = argparse.ArgumentParser(description="Explain and validate cron expressions.")
ap.add_argument("expr", nargs="?", help='cron expression in quotes, e.g. "*/15 9-17 * * 1-5"')
ap.add_argument("--file", help="read schedules from a crontab file")
ap.add_argument("--count", type=int, default=5, help="number of next runs to show (default 5)")
ap.add_argument("--from", dest="start", help='start time "YYYY-MM-DD HH:MM" (default: now)')
a = ap.parse_args(argv)
if bool(a.expr) == bool(a.file):
ap.print_usage(sys.stderr)
print("error: give exactly one of EXPR or --file", file=sys.stderr)
return 2
try:
start = dt.datetime.strptime(a.start, "%Y-%m-%d %H:%M") if a.start else dt.datetime.now().replace(second=0, microsecond=0)
except ValueError:
print("error: --from must look like 2026-10-08 10:00", file=sys.stderr)
return 2
exprs = list(crontab_schedules(a.file)) if a.file else [a.expr]
results = [check(e, start, max(1, a.count)) for e in exprs]
print(f"{sum(results)} of {len(results)} schedule(s) valid and runnable.")
return 0 if all(results) else 1
if __name__ == "__main__":
sys.exit(main())