@f
Turn a cancelled, delayed, or missed-connection flight into a strict JSON recovery plan: urgency, ordered immediate actions, rebooking options vs your deadline, baggage steps, care to request, receipts to keep, ready-to-send messages, fallbacks, and post-trip follow-ups. No invented policies.
1{2 "role": "Calm travel disruption assistant",3 "task": "When a traveler's flight is cancelled, heavily delayed, or a connection is missed, turn their situation into a clear, prioritized recovery plan in strict JSON.",4 "instructions": [5 "First ask for any missing inputs listed in required_inputs, in one short message. If the traveler is in a hurry, proceed with what you have and list assumptions.",6 "Prioritize getting the traveler to their destination safely and on time over saving money, unless they say otherwise.",7 "Never invent airline policies, compensation amounts, or legal rights. Where rules depend on the airline, route, or region, say what to check and where (airline app, conditions of carriage, official passenger rights pages).",8 "Keep every action short and concrete. Order actions so the fastest high-impact steps come first (for example: rebook in the app while standing in the queue).",9 "Output only valid JSON matching output_schema. No markdown, no commentary outside the JSON."10 ],...+86 more lines
Design a safe, repeatable load test as YAML: scope and mocked dependencies, environment parity gaps, weighted user journeys with think time, smoke-to-soak scenarios with ramps, numeric p95/p99 and error-rate gates, dashboards, abort conditions with a stop owner, schedule, and reporting.
1role: Performance engineer who designs safe, realistic load tests2task: >3 Turn a short description of a web service, API, or background worker into a complete4 load test plan in YAML that a team can review, run, and repeat. The plan must protect5 production, model real traffic, and define clear pass/fail criteria before any test runs.67ask_first:8 - What is being tested (service name, endpoints or jobs, dependencies such as databases, caches, queues, third-party APIs)9 - Current traffic shape (average and peak requests per second, daily pattern, read/write mix, payload sizes)10 - Business goal for the test (launch readiness, capacity for an event, regression check, cost tuning)...+78 more lines

Traditional Chinese ink wash landscape: misty karst peaks fading into layered fog, a cliffside pine, and an empty canopied sampan moored in reeds on a still river. Monochrome ink with a touch of ochre, rice paper texture, generous empty space.
Misty limestone karst peaks rising above a calm river with a single empty wooden sampan moored among reeds, painted as a traditional Chinese ink wash landscape on rice paper. Tall rounded peaks fade into layered mist from dark foreground ink to pale grey silhouettes in the distance. A twisted pine clings to the nearest cliff, its needles drawn in quick dry-brush strokes. The small sampan with a curved woven canopy rests near the lower left shore, reflected faintly in the still water. Generous empty paper space for sky and river, soft ink bleeds where wet washes meet, a few birds as tiny brush flicks far away. Monochrome black and grey ink with one faint touch of warm ochre on the boat canopy. Visible rice paper fiber texture, wide horizontal handscroll composition filling the entire frame edge to edge, quiet contemplative mood. No people, no text, no calligraphy, no seals, no signature.

Antique natural history plate of forest mushrooms (fly agaric, chanterelles, porcini, amethyst deceivers, a gill cross-section) with mosses, birch bark, and oak leaves on aged cream paper. Engraved outlines, stippled shading, muted lithograph colors.
Forest mushrooms and mosses arranged as a vintage natural history chromolithograph plate: a red fly agaric with white spots, a cluster of golden chanterelles, a brown porcini with a thick pale stem, delicate purple amethyst deceivers, and a cross-section of one cap showing gills. The specimens are laid out in a balanced grid on aged cream paper, each drawn with fine engraved outlines and soft stippled shading. Tufts of green cushion moss, a curl of birch bark, and a few fallen oak leaves sit between them. Muted 19th-century print colors: brick red, ochre, olive green, umber, and dusty violet, with slight color registration offset typical of stone lithography. Foxing spots and gentle paper yellowing at the edges, thin double-ruled border around the plate. Scientific, calm, collectible antique print mood. No text, no labels, no numbers, no captions, no people.

Retro synthwave scene: an empty cherry-red vintage convertible on a straight desert highway, giant striped sunset sun, glowing magenta grid, saguaro silhouettes, and neon-rimmed purple mountains. 1980s retro-futuristic palette with bloom and film grain.
An empty cherry-red vintage convertible parked on the shoulder of a straight desert highway at sunset, rendered in retro synthwave style. The highway runs to a vanishing point where a huge striped sun, banded in hot pink, orange, and yellow, sits half below the horizon. Glowing magenta grid lines stretch across the desert floor, and tall saguaro cacti stand in dark silhouette along the road. Low purple mountains with neon cyan rim light frame the horizon. The sky fades from deep violet at the top to coral near the sun, with a few soft stars and thin horizontal clouds. Chrome bumpers and the car's curved fins catch pink and teal reflections; the dashed center line glows faintly. Clean 1980s retro-futuristic look, subtle film grain and gentle bloom, cinematic wide composition. No people, no text, no logos, no license plate characters.
Build an image-ready brief for a small hillside vineyard tasting pavilion: slope orientation and sun path, compact form with downhill terrace, a four-to-five material palette, vine-row landscape, fixed visual details, and two exterior prompt seeds (harvest morning and sunset terrace).
Act as an architectural concept designer who writes image-ready design briefs. Help me shape a small hillside vineyard tasting pavilion: a single-story building where visitors taste wine and look out over the vine rows. Your brief must be specific enough that two separate image generations (an establishing exterior and a second exterior angle) show the same building. First ask me, in one short message, for: - Region and climate (for example Mediterranean, temperate river valley, high desert) and the season I want to show - Slope direction and the main view (valley, lake, distant mountains, village) - Capacity (how many guests at once) and whether there is a small barrel room or kitchen - Material preferences and anything I want to avoid (glass boxes, rustic kitsch, very modern minimalism) - Mood words (calm, sunny, earthy, elegant, family-run) Then produce the brief with these sections: 1. **Site and Orientation**: where the pavilion sits on the slope, which side faces the view, sun path for morning and sunset, and how a gravel path climbs from the parking area through the vines. 2. **Building Form**: footprint size, roof shape and pitch, overhang depth, the long covered terrace on the downhill side, and the number and position of large openings. Keep it compact and believable. 3. **Materials Palette**: exactly four or five materials with colors (for example local limestone walls, oiled oak beams, clay roof tiles, bronze window frames, gravel terrace) and where each one appears. 4. **Landscape**: vine row direction, an old olive or oak tree near the entrance, low stone retaining walls, lavender or herb borders, and outdoor tables. 5. **Fixed Visual Details**: a numbered list of 8 to 10 details that must stay identical in every image (roof type and color, wall material, number of terrace arches or posts, door color, chimney or none, the signature tree, lighting fixtures). These details make the images consistent. 6. **Prompt Seed A - Establishing Exterior**: a ready-to-paste image prompt under 150 words for a morning or harvest-season exterior from the uphill path, with the building and its key objects in the first sentence. 7. **Prompt Seed B - Second Exterior Angle**: a ready-to-paste image prompt under 150 words for a sunset view from the downhill vine rows looking up at the terrace, repeating every fixed visual detail in plain words. 8. **Practical Notes**: three short notes on shade, accessibility (step-free entry), and drainage on a slope. Rules: no people as the main subject, no text or logos in the image prompts, no interiors in the seeds (image models often ignore furniture). Use short paragraphs and lists.

Photoreal harvest-morning exterior of a single-story limestone tasting pavilion with a terracotta tile roof, oak-post terrace, bronze glass doors, green front door, an old olive tree, and vine rows heavy with grapes running down to a misty valley. Step 2 of the Vineyard Tasting Pavilion workflow.
A single-story limestone tasting pavilion with a terracotta clay tile roof sits on a gentle vineyard hillside on a clear harvest morning, seen from the uphill gravel path. Pale honey-colored limestone walls, a low-pitched hipped roof with deep eaves, and a long covered terrace with five oiled oak posts on the downhill side. Tall bronze-framed glass doors and a deep green wooden front door. A large old olive tree with a twisted trunk stands beside the entrance, and a short limestone chimney rises from the left end of the roof. Neat vine rows heavy with dark grapes run downhill toward a misty river valley. Low dry-stone walls, lavender borders, and wooden crates of harvested grapes stacked near the path. Soft golden side light, long morning shadows, light haze in the valley. Photoreal architectural photography, landscape 16:9. No people, no text, no signs, no cars.

Photoreal sunset view up the vine rows to the same limestone tasting pavilion: five-post oak terrace with string lights and set tables, terracotta hipped roof, bronze glass doors, left-end chimney, olive tree, lavender and stone walls. Step 3 of the Vineyard Tasting Pavilion workflow.
The long covered terrace of a single-story limestone tasting pavilion at sunset, seen from the downhill vine rows looking up the slope. The terrace runs along the long side of the building with five dark oiled oak posts, wooden tables with empty wine glasses, and warm string lights glowing under the deep eaves. Same building: pale honey-colored limestone walls, low-pitched gabled terracotta clay tile roof, tall bronze-framed glass doors along the terrace, a short square limestone chimney rising from the left gable end, a deep green double door in that left gable wall, and the large old olive tree with a twisted trunk standing at the left corner beside that door. Grape-heavy vine rows lead the eye uphill to the pavilion, with low dry-stone walls and lavender borders below the terrace. Sky in apricot and rose, sun setting behind the viewer so the walls glow warm amber, soft shadows between the vines. Photoreal architectural photography, landscape 16:9. No people, no text, no signs, no cars.
Finds flaky tests from repeated CI runs: parses JUnit XML or a CSV run history, flags tests that both pass and fail (confirmed when on the same commit), ranks them by flip and failure rate, and suggests likely causes and fixes. Use when CI is "randomly red", before adding retries, or triaging a quarantine list. Includes a stdlib Python detector with --self-test.
---
name: flaky-test-detector
description: Finds flaky tests from repeated CI runs: parses JUnit XML or a CSV run history, flags tests that both pass and fail (confirmed when on the same commit), ranks them by flip and failure rate, and suggests likely causes and fixes. Use when CI is "randomly red", before adding retries, or triaging a quarantine list. Includes a stdlib Python detector with --self-test.
---
# Flaky Test Detector
You help engineering teams separate genuinely broken tests from flaky ones. You read results from several CI runs, find tests whose outcome changes without a code change, rank them by how much pain they cause, and propose a concrete fix or quarantine plan for each. You never "fix" flakiness by just adding retries, and you never call a test flaky without evidence from at least two runs that disagree.
## Files in this skill
- `scripts/flaky_tests.py` - parses JUnit XML files (one per run) or a CSV history and prints a ranked flakiness table or JSON (Python 3 standard library only)
- `references/flakiness-causes.md` - common root causes with symptoms and fixes
- `references/junit-and-history-formats.md` - how to export JUnit XML and CSV history from popular runners and CI systems
- `templates/flaky-report.md` - report for a PR comment, issue, or weekly CI health review
- `examples/test-history.csv` - sample run history with a same-commit flake
## Workflow
### 1. Collect evidence
Ask for, or help produce:
- JUnit XML from at least 3 runs of the same suite (CI artifacts, or a local loop such as running the suite 10 times)
- Or a CSV with `run_id,test,status` and, ideally, `commit` and `duration_s`
- Context: runner (pytest, Jest, JUnit, Go test, RSpec), parallelism, retries already enabled, shared services (DB, browser, network)
If only one run is available, explain that flakiness cannot be proven and suggest a rerun loop on an unchanged commit.
### 2. Run the detector
```
python3 scripts/flaky_tests.py run1.xml run2.xml run3.xml
python3 scripts/flaky_tests.py --csv examples/test-history.csv --only-flaky
python3 scripts/flaky_tests.py --csv history.csv --json
python3 scripts/flaky_tests.py --self-test
```
Exit code 1 means at least one flaky test was found (handy as a CI health gate); 2 means bad input.
### 3. Classify
- **flaky_confirmed**: passed and failed on the same commit. Strongest evidence; fix first.
- **flaky**: mixed results across runs, commit unknown or different. Check whether the code changed between runs.
- **always_failing**: a real failure, not flakiness. Route to the owner as a bug.
- **stable** / **insufficient_data**: no action, or collect more runs.
Use `flip_rate` (how often the result changes between consecutive runs) and `fail_rate` together. A high `duration_spread` hints at timeouts, network calls, or resource contention.
### 4. Diagnose
For each flaky test, read the test code and failure messages, then match symptoms against `references/flakiness-causes.md`: timing and sleeps, test order dependence, shared state, time zones and clocks, randomness without seeds, network, concurrency, resource limits.
### 5. Recommend
For each test give: likely cause, evidence, a specific fix (for example "replace sleep(2) with waiting for the order status event"), an owner, and whether to quarantine temporarily. Fill `templates/flaky-report.md`.
## Rules
- Retries are a temporary bandage with an expiry date, never the fix.
- Quarantined tests need an owner and a ticket; list them in the report.
- Do not delete a flaky test without the owner's agreement.
- Keep statistics honest: say how many runs the conclusion is based on.
- Never paste secrets or full logs with tokens into reports; trim to the relevant assertion.
FILE:references/flakiness-causes.md
# Common causes of flaky tests
| Cause | Typical symptoms | Fix |
|---|---|---|
| Fixed sleeps and timing | Fails more on busy CI agents; duration spread is high | Wait for a condition or event with a timeout instead of `sleep` |
| Test order dependence | Passes alone, fails in the full suite or with random order | Reset shared state in setup/teardown; run with random order to reproduce |
| Shared mutable state | Globals, singletons, caches, env vars leak between tests | Isolate fixtures; restore env; avoid module-level state |
| Shared external resources | Same DB rows, ports, temp files, or queues used by parallel workers | Unique names per worker; transactions rolled back per test; random free ports |
| Clock and time zone | Fails near midnight, month end, DST changes, or on agents in other zones | Freeze or inject the clock; use UTC; avoid "now" in assertions |
| Unseeded randomness | Rare failures with odd data | Seed generators and print the seed on failure |
| Network and third-party calls | Timeouts, 429s, DNS errors | Mock or record responses; keep real calls in a separate, non-blocking suite |
| Concurrency and async | Race conditions, unawaited promises, threads not joined | Await everything; use proper synchronization; add deterministic hooks |
| Resource limits | OOM, file handle exhaustion, slow containers | Lower parallelism; clean up resources; raise limits deliberately |
| UI and browser tests | Element not found, animation timing, viewport differences | Use stable selectors and auto-waiting locators; disable animations in test mode |
| Floating point and ordering | Assertion on unordered collections or exact floats | Compare sorted values; use tolerance |
## Triage questions
1. Does it fail on the same commit when rerun? (flaky_confirmed)
2. Does it fail alone, or only with other tests?
3. Does failure correlate with a specific agent, time of day, or parallel worker?
4. Is the failure message always the same?
5. Did retries hide it before? Check retry logs.
FILE:references/junit-and-history-formats.md
# Getting JUnit XML and run history
## JUnit XML per run
- pytest: `pytest --junitxml=reports/run-$N.xml`
- Jest: `jest-junit` reporter (`JEST_JUNIT_OUTPUT_FILE=reports/run-$N.xml`)
- Go: `go test -v ./... 2>&1 | go-junit-report > reports/run-$N.xml`
- JUnit / Maven Surefire: `target/surefire-reports/*.xml` (merge per run)
- RSpec: `rspec_junit_formatter`
- Playwright: `--reporter=junit`
Rerun loop on an unchanged commit:
```
for N in $(seq 1 10); do pytest --junitxml=reports/run-$N.xml || true; done
python3 scripts/flaky_tests.py reports/run-*.xml
```
Pass files in run order (glob order is fine if names sort correctly, e.g. run-01..run-10).
## How the script reads JUnit
- Test id = `classname::name`
- `<failure>` or `<error>` child = fail; `<skipped>` = skip; otherwise pass
- `time` attribute = duration in seconds
## CSV history
Columns: `run_id,test,status` plus optional `commit,duration_s`. Accepted statuses: pass/passed/ok/success, fail/failed/failure/error/broken, skip/skipped/ignored/pending. Rows must be in run order.
Export ideas: CI test analytics APIs, a small job that appends one row per test per run to a CSV artifact, or a query against your test results database.
FILE:templates/flaky-report.md
# Flaky Test Report
**Suite:** <name> **Runs analyzed:** <n> (<date range or commits>) **Runner:** <pytest / Jest / ...>
## Summary
- flaky_confirmed: <n> flaky: <n> always_failing: <n> stable: <n> insufficient_data: <n>
- Retries currently enabled: <yes/no, count>
## Fix first
| Test | Class | Fail rate | Flip rate | Likely cause | Evidence | Proposed fix | Owner | Quarantine? |
|---|---|---|---|---|---|---|---|---|
| | | | | | | | | |
## Real failures (not flaky)
| Test | Failing since | Message (trimmed) | Owner |
|---|---|---|---|
## Quarantine list
| Test | Ticket | Owner | Expiry date |
|---|---|---|---|
## Next steps
1.
2.
FILE:examples/test-history.csv
run_id,test,status,commit,duration_s
1,checkout::test_apply_coupon,pass,a1b2c3,0.42
1,checkout::test_payment_timeout,pass,a1b2c3,1.10
1,search::test_autocomplete,pass,a1b2c3,0.31
1,profile::test_avatar_upload,failed,a1b2c3,2.05
2,checkout::test_apply_coupon,pass,a1b2c3,0.40
2,checkout::test_payment_timeout,failed,a1b2c3,5.02
2,search::test_autocomplete,pass,a1b2c3,0.29
2,profile::test_avatar_upload,failed,a1b2c3,2.10
3,checkout::test_apply_coupon,pass,d4e5f6,0.44
3,checkout::test_payment_timeout,pass,d4e5f6,1.07
3,search::test_autocomplete,skipped,d4e5f6,
3,profile::test_avatar_upload,failed,d4e5f6,2.01
4,checkout::test_apply_coupon,pass,d4e5f6,0.41
4,checkout::test_payment_timeout,failed,d4e5f6,5.01
4,search::test_autocomplete,pass,d4e5f6,0.30
4,profile::test_avatar_upload,failed,d4e5f6,2.07
FILE:scripts/flaky_tests.py
#!/usr/bin/env python3
"""Detect flaky tests from repeated CI runs (Python 3 standard library only).
Inputs (any mix):
* JUnit XML files, one per run, in run order: flaky_tests.py run1.xml run2.xml run3.xml
* A CSV history file with columns run_id,test,status[,commit][,duration_s]: flaky_tests.py --csv history.csv
Statuses: pass, fail (failure or error), skip. Skips are ignored for flakiness math.
Classifications per test:
flaky_confirmed - passed AND failed on the same commit
flaky - passed AND failed across runs (commit unknown or different)
always_failing - failed in every non-skipped run (>= --min-runs)
stable - passed in every non-skipped run (>= --min-runs)
insufficient_data - fewer than --min-runs non-skipped results
Exit code: 1 if any flaky/flaky_confirmed test is found, else 0 (2 on input errors).
"""
import argparse
import csv
import json
import sys
import xml.etree.ElementTree as ET
from collections import OrderedDict
STATUS_ALIASES = {
"pass": "pass", "passed": "pass", "ok": "pass", "success": "pass",
"fail": "fail", "failed": "fail", "failure": "fail", "error": "fail", "broken": "fail",
"skip": "skip", "skipped": "skip", "ignored": "skip", "pending": "skip",
}
def norm_status(raw):
s = STATUS_ALIASES.get(str(raw).strip().lower())
if s is None:
raise ValueError(f"unknown status: {raw!r}")
return s
def parse_junit_text(text, run_id):
"""Return list of (run_id, test_id, status, commit, duration) from JUnit XML text."""
root = ET.fromstring(text)
rows = []
for case in root.iter("testcase"):
cls = case.get("classname") or ""
name = case.get("name") or "?"
test_id = f"{cls}::{name}" if cls else name
if case.find("failure") is not None or case.find("error") is not None:
status = "fail"
elif case.find("skipped") is not None:
status = "skip"
else:
status = "pass"
try:
dur = float(case.get("time", "") or 0.0)
except ValueError:
dur = 0.0
rows.append((run_id, test_id, status, None, dur))
return rows
def parse_csv_text(text):
rows = []
reader = csv.DictReader(text.splitlines())
need = {"run_id", "test", "status"}
if not reader.fieldnames or not need.issubset({f.strip() for f in reader.fieldnames}):
raise ValueError("CSV needs columns run_id,test,status (optional commit,duration_s)")
for r in reader:
r = {k.strip(): (v or "").strip() for k, v in r.items() if k}
dur = float(r["duration_s"]) if r.get("duration_s") else 0.0
rows.append((r["run_id"], r["test"], norm_status(r["status"]), r.get("commit") or None, dur))
return rows
def analyze(rows, min_runs=3):
"""rows in run order. Returns OrderedDict test_id -> stats dict."""
tests = OrderedDict()
for run_id, test_id, status, commit, dur in rows:
t = tests.setdefault(test_id, {"results": [], "durations": []})
t["results"].append((run_id, status, commit))
if status != "skip" and dur:
t["durations"].append(dur)
out = OrderedDict()
for test_id, t in tests.items():
seq = [s for _, s, _ in t["results"] if s != "skip"]
passes = seq.count("pass")
fails = seq.count("fail")
skips = sum(1 for _, s, _ in t["results"] if s == "skip")
flips = sum(1 for a, b in zip(seq, seq[1:]) if a != b)
by_commit = {}
for _, s, c in t["results"]:
if c and s != "skip":
by_commit.setdefault(c, set()).add(s)
mixed_commits = sorted(c for c, ss in by_commit.items() if ss == {"pass", "fail"})
n = len(seq)
if passes and fails:
cls = "flaky_confirmed" if mixed_commits else "flaky"
elif n < min_runs:
cls = "insufficient_data"
elif fails == n:
cls = "always_failing"
else:
cls = "stable"
durs = t["durations"]
out[test_id] = {
"runs": n,
"passes": passes,
"fails": fails,
"skips": skips,
"fail_rate": round(fails / n, 3) if n else 0.0,
"flips": flips,
"flip_rate": round(flips / (n - 1), 3) if n > 1 else 0.0,
"mixed_commits": mixed_commits,
"max_duration_s": round(max(durs), 3) if durs else None,
"duration_spread": round(max(durs) / min(durs), 2) if durs and min(durs) > 0 else None,
"classification": cls,
}
return out
def priority(stats):
"""Higher = fix first. Confirmed flakiness and high flip rates rank highest."""
base = {"flaky_confirmed": 3.0, "flaky": 2.0, "always_failing": 1.0}.get(stats["classification"], 0.0)
return round(base + stats["flip_rate"] + stats["fail_rate"], 3)
def render_table(results):
lines = ["| Test | Class | Runs | Fails | Fail rate | Flip rate | Same-commit mix | Priority |",
"|---|---|---|---|---|---|---|---|"]
ranked = sorted(results.items(), key=lambda kv: (-priority(kv[1]), kv[0]))
for tid, s in ranked:
lines.append(f"| {tid} | {s['classification']} | {s['runs']} | {s['fails']} | "
f"{s['fail_rate']:.0%} | {s['flip_rate']:.0%} | {', '.join(s['mixed_commits']) or '-'} | {priority(s)} |")
return "\n".join(lines)
def summary(results):
counts = {}
for s in results.values():
counts[s["classification"]] = counts.get(s["classification"], 0) + 1
return counts
JUNIT_RUN = """<testsuites><testsuite name="api">
<testcase classname="api.test_orders" name="test_create" time="0.12"/>
<testcase classname="api.test_orders" name="test_refund" time="{refund_time}">{refund}</testcase>
<testcase classname="api.test_auth" name="test_expired_token" time="0.05">{auth}</testcase>
<testcase classname="api.test_search" name="test_reindex" time="1.0"><skipped/></testcase>
</testsuite></testsuites>"""
def _run(refund_fail, auth_fail, refund_time="0.30"):
return JUNIT_RUN.format(
refund='<failure message="timeout"/>' if refund_fail else "",
auth='<error message="boom"/>' if auth_fail else "",
refund_time=refund_time)
def self_test():
rows = []
pattern = [(False, True), (True, True), (False, True), (True, True), (False, True)]
for i, (rf, af) in enumerate(pattern, 1):
rows += parse_junit_text(_run(rf, af, "0.30" if not rf else "3.10"), f"run{i}")
res = analyze(rows, min_runs=3)
csv_text = ("run_id,test,status,commit,duration_s\n"
"1,ui::login,pass,abc,1.0\n2,ui::login,failed,abc,1.2\n3,ui::login,pass,def,0.9\n"
"1,ui::cart,pass,abc,\n2,ui::cart,pass,abc,\n"
"1,ui::banner,skipped,abc,\n2,ui::banner,ok,abc,\n3,ui::banner,ok,def,\n4,ui::banner,ok,def,\n")
res_csv = analyze(parse_csv_text(csv_text), min_runs=3)
bad_status = False
try:
norm_status("weird")
except ValueError:
bad_status = True
bad_csv = False
try:
parse_csv_text("a,b\n1,2\n")
except ValueError:
bad_csv = True
cases = [
("junit_case_count", len(res), 4),
("create_stable", res["api.test_orders::test_create"]["classification"], "stable"),
("refund_flaky", res["api.test_orders::test_refund"]["classification"], "flaky"),
("refund_flips", res["api.test_orders::test_refund"]["flips"], 4),
("refund_flip_rate", res["api.test_orders::test_refund"]["flip_rate"], 1.0),
("refund_fail_rate", res["api.test_orders::test_refund"]["fail_rate"], 0.4),
("refund_duration_spread", res["api.test_orders::test_refund"]["duration_spread"], 10.33),
("error_counts_as_fail", res["api.test_auth::test_expired_token"]["classification"], "always_failing"),
("skip_only_insufficient", res["api.test_search::test_reindex"]["classification"], "insufficient_data"),
("skip_counted", res["api.test_search::test_reindex"]["skips"], 5),
("csv_same_commit_confirmed", res_csv["ui::login"]["classification"], "flaky_confirmed"),
("csv_mixed_commit_listed", res_csv["ui::login"]["mixed_commits"], ["abc"]),
("csv_two_runs_insufficient", res_csv["ui::cart"]["classification"], "insufficient_data"),
("csv_skip_ignored_stable", res_csv["ui::banner"]["classification"], "stable"),
("priority_order", priority(res_csv["ui::login"]) > priority(res["api.test_orders::test_refund"]), True),
("unknown_status_rejected", bad_status, True),
("bad_csv_rejected", bad_csv, True),
("summary_counts", summary(res), {"stable": 1, "flaky": 1, "always_failing": 1, "insufficient_data": 1}),
]
failed = 0
for name, got, expected in cases:
ok = abs(got - expected) < 0.01 if isinstance(expected, float) else got == expected
failed += not ok
print(f"{'PASS' if ok else 'FAIL'} {name}: got={got!r} expected={expected!r}")
print(f"\n{len(cases) - failed}/{len(cases)} passed")
return 1 if failed else 0
def main(argv=None):
p = argparse.ArgumentParser(description="Detect flaky tests from repeated JUnit XML runs or a CSV history.")
p.add_argument("junit", nargs="*", help="JUnit XML files in run order")
p.add_argument("--csv", help="CSV history: run_id,test,status[,commit][,duration_s]")
p.add_argument("--min-runs", type=int, default=3)
p.add_argument("--json", action="store_true", help="print JSON instead of a markdown table")
p.add_argument("--only-flaky", action="store_true")
p.add_argument("--self-test", action="store_true")
a = p.parse_args(argv)
if a.self_test:
return self_test()
rows = []
try:
for i, path in enumerate(a.junit, 1):
with open(path, encoding="utf-8") as fh:
rows += parse_junit_text(fh.read(), f"run{i}:{path}")
if a.csv:
with open(a.csv, encoding="utf-8") as fh:
rows += parse_csv_text(fh.read())
except (OSError, ValueError, ET.ParseError) as e:
print(f"error: {e}", file=sys.stderr)
return 2
if not rows:
p.error("give JUnit XML files, --csv, or --self-test")
res = analyze(rows, a.min_runs)
if a.only_flaky:
res = OrderedDict((k, v) for k, v in res.items() if v["classification"].startswith("flaky"))
if a.json:
print(json.dumps({"summary": summary(res), "tests": res}, indent=2))
else:
print(render_table(res))
print("\nSummary:", ", ".join(f"{k}={v}" for k, v in sorted(summary(res).items())))
return 1 if any(v["classification"].startswith("flaky") for v in res.values()) else 0
if __name__ == "__main__":
sys.exit(main())Scans a codebase for TODO, FIXME, HACK, and XXX comments, extracts owner, ticket, and due date, and flags unowned, untracked, overdue, or vague items with a priority score. Use when cleaning up tech debt, before a release, or asking "which TODOs actually matter?". Includes a stdlib Python scanner with --self-test and a CI-friendly exit code.
---
name: todo-comment-debt-tracker
description: Scans a codebase for TODO, FIXME, HACK, and XXX comments, extracts owner, ticket, and due date, and flags unowned, untracked, overdue, or vague items with a priority score. Use when cleaning up tech debt, before a release, or asking "which TODOs actually matter?". Includes a stdlib Python scanner with --self-test and a CI-friendly exit code.
---
# TODO Comment Debt Tracker
You help teams turn scattered TODO, FIXME, HACK, and XXX comments into a short, owned, prioritized debt list. You scan the code, parse who owns each item, which ticket tracks it, and when it is due, then highlight the ones that are risky or forgotten. You never delete or rewrite comments on your own; you propose changes and let the team decide.
## Files in this skill
- `scripts/todo_debt.py` - scans files or directories, parses owner/ticket/due date, scores each item, and prints a markdown table or JSON (Python 3 standard library only)
- `references/comment-conventions.md` - a recommended TODO format and how to adopt it without churn
- `references/triage-rules.md` - how to decide fix now, ticket, or delete
- `templates/debt-report.md` - report for a sprint review, release checklist, or PR comment
- `examples/sample_service.py` - a small file with typical good and bad TODOs for demos
## Workflow
### 1. Scope the scan
Ask which directories matter and what to skip (generated code, vendored libraries, fixtures). The script already skips `.git`, `node_modules`, `vendor`, `dist`, `build`, virtualenvs, and `target`.
### 2. Run the scanner
```
python3 scripts/todo_debt.py src/ --today 2026-10-18
python3 scripts/todo_debt.py src/ tests/ --ext .py,.ts --json > todo-debt.json
python3 scripts/todo_debt.py examples/sample_service.py --strict
python3 scripts/todo_debt.py --self-test
```
Exit code 1 when an item is overdue or a FIXME/HACK/XXX has no ticket (`--strict`: any problem). Use it as a soft CI gate or a weekly report job.
### 3. Read the problems
- **overdue**: due date passed. Highest priority.
- **no_ticket**: nothing tracks the work; it will be forgotten.
- **stale_hack**: a HACK or XXX with no ticket, often a workaround that outlived its reason.
- **no_owner**: nobody is responsible.
- **vague**: fewer than three words, such as "TODO: fix".
Scores: FIXME/HACK/XXX start at 3, TODO at 1; +2 if overdue, +1 if no ticket.
### 4. Triage with the team
Apply `references/triage-rules.md`: fix now if it is under 30 minutes, create a ticket and add its id if it is real work, delete it if it is obsolete. Use `git blame` on the line to find a likely owner when none is written.
### 5. Report
Fill `templates/debt-report.md` with the top items, totals by marker and owner, and agreed actions. Suggest the convention in `references/comment-conventions.md` for new comments.
## Rules
- Do not count markers inside string literals or plain prose; only comments.
- Never invent ticket numbers or owners; mark them unknown and suggest how to find them.
- Do not remove comments in bulk without review; propose a patch instead.
- Respect generated and vendored code: report it separately or skip it.
FILE:references/comment-conventions.md
# Recommended TODO convention
```
# TODO(owner, TICKET-123, 2026-12-01): what needs to happen and why
```
- **owner**: a username or team handle (`alice`, `@payments-team`). One name, the person who will make the call.
- **ticket**: `#123`, `PROJ-123`, or a full issue URL. Required for FIXME, HACK, and XXX.
- **due date**: ISO `YYYY-MM-DD`, optional for TODO, recommended for HACK workarounds.
- **text**: an action plus the reason, at least a short sentence. "TODO: fix" is not useful.
## Which marker to use
| Marker | Meaning | Ticket required? |
|---|---|---|
| TODO | Planned improvement, not wrong today | Recommended |
| FIXME | Known bug or incorrect behavior | Yes |
| HACK | Deliberate workaround that should be removed | Yes, plus a due date |
| XXX | Danger, surprising code, review needed | Yes |
## Adopting without churn
1. Run the scanner and publish the report; do not mass-edit.
2. Apply the convention to new comments via code review or a lint rule.
3. Fix old comments when you touch that code anyway.
4. Revisit the totals monthly; aim for "no overdue, no untracked FIXME".
FILE:references/triage-rules.md
# Triage rules
For each item, ask in order:
1. **Is it still true?** Read the surrounding code and `git log -L` for the lines. If the issue is gone, delete the comment.
2. **Is it under 30 minutes?** Fix it now in a small PR that removes the comment.
3. **Is it a bug or a risk?** (FIXME/XXX, security, data loss, money) Create a ticket with priority, add the ticket id to the comment, and assign an owner.
4. **Is it a workaround?** (HACK) Write down the condition that allows removal ("remove after vendor SDK 5.x") and a due date.
5. **Is it a wish?** If nobody would schedule it in the next two quarters, delete it or move it to a backlog ticket.
## Priority hints
- Overdue items first, then FIXME/HACK/XXX without tickets, then unowned items in hot files (high churn).
- Items in auth, payments, data migrations, or concurrency code deserve a closer look regardless of score.
## Finding an owner
- `git blame -L <line>,<line> <file>` shows who added it; that person or their team is the default owner.
- CODEOWNERS files map paths to teams.
- If the author has left, assign to the current maintainer of the module.
FILE:templates/debt-report.md
# TODO Debt Report
**Repository:** <name> **Scanned:** <paths> **Date:** <YYYY-MM-DD>
## Totals
| Marker | Count | Overdue | No ticket | No owner |
|---|---|---|---|---|
| TODO | | | | |
| FIXME | | | | |
| HACK | | | | |
| XXX | | | | |
## Top 10 by score
| Score | Location | Marker | Owner | Ticket | Due | Problems | Proposed action |
|---|---|---|---|---|---|---|---|
| | | | | | | | |
## By owner
| Owner | Items | Overdue |
|---|---|---|
## Agreed actions
- [ ] Fix now:
- [ ] Ticket created:
- [ ] Delete (obsolete):
## Convention reminder
`# TODO(owner, TICKET-123, YYYY-MM-DD): action and reason`
FILE:examples/sample_service.py
"""Sample order service with typical TODO comments for demos of todo_debt.py."""
import json
TAX_RATE = 0.2 # XXX(PAY-311): confirm tax rate per region with finance
def load_orders(path):
# TODO(maria, 2026-03-01): switch to streaming parser once files exceed 50 MB (#88)
with open(path, encoding="utf-8") as fh:
return json.load(fh)
def total(order):
# FIXME: returns wrong total when a discount exceeds the subtotal
subtotal = sum(item["price"] * item["qty"] for item in order["items"])
discount = order.get("discount", 0)
return round((subtotal - discount) * (1 + TAX_RATE), 2)
def notify(order):
# HACK [2026-11-30] skip emails for test accounts until the staging mail stub is fixed
if order.get("email", "").endswith("@test.local"):
return False
# TODO: retry
return True
def label(order):
note = "TODO: this string is not a comment and should be ignored"
# TODO(@ken, OPS-19): move label printing to the warehouse service
return f"{order['id']} {note[:0]}"
FILE:scripts/todo_debt.py
#!/usr/bin/env python3
"""Scan source files for TODO/FIXME/HACK/XXX comments and grade them (Python 3 standard library only).
Recognized forms (marker must follow a comment token: # // /* * -- ; <!--):
# TODO: plain note
// TODO(alice): owner
# FIXME(#123): issue number # TODO(PROJ-42) ticket key # TODO(https://...) link
# TODO(bob, 2026-12-01): owner + due date # HACK [2026-11-15] due date in brackets
Problems flagged per item:
no_owner, no_ticket, overdue (due date before --today), stale_hack (HACK/XXX without ticket),
vague (fewer than 3 words of text)
Exit code: 1 if any item is overdue or a FIXME/HACK/XXX has no ticket (with --strict: any problem).
"""
import argparse
import datetime as dt
import json
import os
import re
import sys
MARKERS = ("TODO", "FIXME", "HACK", "XXX")
SEVERITY = {"FIXME": 3, "HACK": 3, "XXX": 3, "TODO": 1}
COMMENT_RE = re.compile(
r"(?:#|//|/\*|(?:^|\s)\*|--|;|<!--)\s*(?P<marker>TODO|FIXME|HACK|XXX)\b"
r"(?:\s*\((?P<paren>[^)]*)\))?(?:\s*\[(?P<bracket>[^\]]*)\])?\s*:?\s*(?P<text>.*)$")
DATE_RE = re.compile(r"\b(\d{4}-\d{2}-\d{2})\b")
TICKET_RE = re.compile(r"(#\d+\b|\b[A-Z][A-Z0-9]+-\d+\b|https?://\S+)")
OWNER_RE = re.compile(r"^@?([A-Za-z][\w.-]*)$")
DEFAULT_EXT = {".py", ".js", ".ts", ".tsx", ".jsx", ".go", ".rs", ".java", ".kt", ".c", ".h", ".cpp",
".cs", ".rb", ".php", ".swift", ".sh", ".sql", ".yaml", ".yml", ".toml", ".css", ".scss",
".html", ".vue", ".lua"}
SKIP_DIRS = {".git", "node_modules", "vendor", "dist", "build", ".venv", "venv", "__pycache__", "target"}
def parse_line(line, path="<text>", lineno=0, today=None):
m = COMMENT_RE.search(line)
if not m:
return None
marker = m.group("marker")
meta = ",".join(x for x in (m.group("paren"), m.group("bracket")) if x)
text = (m.group("text") or "").strip().rstrip("*/").rstrip("-->").strip()
owner = None
ticket = None
due = None
for part in [p.strip() for p in meta.split(",") if p.strip()]:
if DATE_RE.fullmatch(part.replace("by ", "").replace("until ", "").strip()):
due = DATE_RE.search(part).group(1)
elif TICKET_RE.fullmatch(part):
ticket = part
elif OWNER_RE.fullmatch(part) and owner is None:
owner = OWNER_RE.fullmatch(part).group(1)
if ticket is None:
t = TICKET_RE.search(text)
if t:
ticket = t.group(1)
if due is None:
d = re.search(r"\b(?:by|until|before|due)\s+(\d{4}-\d{2}-\d{2})\b", text)
if d:
due = d.group(1)
problems = []
if not owner:
problems.append("no_owner")
if not ticket:
problems.append("no_ticket")
if marker in ("HACK", "XXX"):
problems.append("stale_hack")
if due and today:
try:
if dt.date.fromisoformat(due) < today:
problems.append("overdue")
except ValueError:
problems.append("bad_date")
if len(re.findall(r"[A-Za-z]{2,}", text)) < 3:
problems.append("vague")
return {"file": path, "line": lineno, "marker": marker, "owner": owner, "ticket": ticket,
"due": due, "text": text, "problems": problems,
"score": SEVERITY[marker] + 2 * ("overdue" in problems) + ("no_ticket" in problems)}
def scan_text(text, path="<text>", today=None):
items = []
for i, line in enumerate(text.splitlines(), 1):
it = parse_line(line, path, i, today)
if it:
items.append(it)
return items
def iter_files(root, exts):
if os.path.isfile(root):
yield root
return
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIRS)
for fn in sorted(filenames):
if os.path.splitext(fn)[1].lower() in exts:
yield os.path.join(dirpath, fn)
def is_blocking(item, strict=False):
if strict:
return bool(item["problems"])
return "overdue" in item["problems"] or (item["marker"] != "TODO" and "no_ticket" in item["problems"])
def summarize(items):
s = {"total": len(items), "by_marker": {}, "by_owner": {}, "problems": {}}
for it in items:
s["by_marker"][it["marker"]] = s["by_marker"].get(it["marker"], 0) + 1
o = it["owner"] or "(unowned)"
s["by_owner"][o] = s["by_owner"].get(o, 0) + 1
for p in it["problems"]:
s["problems"][p] = s["problems"].get(p, 0) + 1
return s
def render(items):
out = ["| Score | Marker | Location | Owner | Ticket | Due | Problems | Text |", "|---|---|---|---|---|---|---|---|"]
for it in sorted(items, key=lambda x: (-x["score"], x["file"], x["line"])):
out.append(f"| {it['score']} | {it['marker']} | {it['file']}:{it['line']} | {it['owner'] or '-'} | "
f"{it['ticket'] or '-'} | {it['due'] or '-'} | {', '.join(it['problems']) or 'ok'} | {it['text'][:60]} |")
return "\n".join(out)
SAMPLE = '''import os
# TODO(alice, 2026-01-15): remove legacy CSV export after #412 ships
def f():
pass # FIXME: crashes when list is empty
// HACK [2026-12-31] skip auth in local dev
/* XXX(PROJ-77): double-check rounding with finance */
-- TODO(@bob): add index
x = "TODO: this is a string, not a comment"
<!-- TODO(carol): https://example.com/issues/9 replace hero image -->
* TODO update docs by 2025-05-01
# todo lowercase is ignored
'''
def self_test():
today = dt.date(2026, 10, 18)
items = scan_text(SAMPLE, "sample", today)
by_line = {it["line"]: it for it in items}
cases = [
("count", len(items), 7),
("owner_parsed", by_line[2]["owner"], "alice"),
("due_parsed", by_line[2]["due"], "2026-01-15"),
("overdue_flag", "overdue" in by_line[2]["problems"], True),
("ticket_from_text", by_line[2]["ticket"], "#412"),
("fixme_no_ticket", by_line[4]["problems"][:2], ["no_owner", "no_ticket"]),
("fixme_blocking", is_blocking(by_line[4]), True),
("hack_bracket_due_future", "overdue" in by_line[5]["problems"], False),
("hack_stale", "stale_hack" in by_line[5]["problems"], True),
("xxx_ticket_key", by_line[6]["ticket"], "PROJ-77"),
("at_owner", by_line[7]["owner"], "bob"),
("vague_short", "vague" in by_line[7]["problems"], True),
("string_not_comment", 8 in by_line, False),
("html_comment_link", by_line[9]["ticket"], "https://example.com/issues/9"),
("star_comment_due_text", by_line[10]["due"], "2025-05-01"),
("lowercase_ignored", 11 in by_line, False),
("todo_not_blocking_default", is_blocking(by_line[7]), False),
("todo_blocking_strict", is_blocking(by_line[7], strict=True), True),
("summary_markers", summarize(items)["by_marker"], {"TODO": 4, "FIXME": 1, "HACK": 1, "XXX": 1}),
("score_overdue_todo", by_line[2]["score"], 3),
]
failed = 0
for name, got, expected in cases:
ok = got == expected
failed += not ok
print(f"{'PASS' if ok else 'FAIL'} {name}: got={got!r} expected={expected!r}")
print(f"\n{len(cases) - failed}/{len(cases)} passed")
return 1 if failed else 0
def main(argv=None):
p = argparse.ArgumentParser(description="Find and grade TODO/FIXME/HACK/XXX comments.")
p.add_argument("paths", nargs="*", default=[], help="files or directories to scan")
p.add_argument("--today", help="YYYY-MM-DD for overdue checks (default: today)")
p.add_argument("--ext", help="comma-separated extensions to include, e.g. .py,.ts")
p.add_argument("--json", action="store_true")
p.add_argument("--strict", action="store_true", help="exit 1 on any problem")
p.add_argument("--self-test", action="store_true")
a = p.parse_args(argv)
if a.self_test:
return self_test()
if not a.paths:
p.error("give files/directories to scan, or --self-test")
today = dt.date.fromisoformat(a.today) if a.today else dt.date.today()
exts = {e if e.startswith(".") else "." + e for e in a.ext.split(",")} if a.ext else DEFAULT_EXT
items = []
for root in a.paths:
for path in iter_files(root, exts):
try:
with open(path, encoding="utf-8", errors="replace") as fh:
items += scan_text(fh.read(), path, today)
except OSError as e:
print(f"warn: {path}: {e}", file=sys.stderr)
if a.json:
print(json.dumps({"summary": summarize(items), "items": items}, indent=2))
else:
print(render(items))
s = summarize(items)
print(f"\nTotal {s['total']}; markers {s['by_marker']}; problems {s['problems']}")
return 1 if any(is_blocking(it, a.strict) for it in items) else 0
if __name__ == "__main__":
sys.exit(main())Plans a fair, rotating weekly chore schedule for a household: weighs each chore by minutes and frequency, respects capacity shares and "cannot do" limits, rotates jobs week to week, and reports each person's load against a fair target. Use when roommates or families argue about chores or ask "who does what this month?". Includes a stdlib Python planner with --self-test.
---
name: household-chore-rotation-planner
description: Plans a fair, rotating weekly chore schedule for a household: weighs each chore by minutes and frequency, respects capacity shares and "cannot do" limits, rotates jobs week to week, and reports each person's load against a fair target. Use when roommates or families argue about chores or ask "who does what this month?". Includes a stdlib Python planner with --self-test.
---
# Household Chore Rotation Planner
You help households (roommates, couples, families with kids) split chores fairly and rotate them so nobody is stuck with the worst jobs forever. You measure fairness in minutes, not in number of tasks, adjust for each person's capacity, respect real limits (age, allergies, health, skills), and produce a schedule people can stick on the fridge. You stay neutral and kind; you never take sides in a household argument.
## Files in this skill
- `scripts/chore_rotation.py` - builds a multi-week rotation from a JSON list of people and chores and reports minutes vs fair target per person (Python 3 standard library only)
- `references/chore-time-estimates.md` - typical minutes for common chores to use as defaults
- `references/fairness-and-agreements.md` - how to set shares, handle swaps, and run a short house meeting
- `templates/rotation-chart.md` - printable weekly chart plus a house agreement section
- `examples/household.json` - four-person sample household with constraints
## Workflow
### 1. Gather the household
Ask for:
- People and their capacity: full share (1.0), reduced share (for example 0.5 for a young child or someone working night shifts), or larger share by agreement
- Hard limits: chores someone cannot do (`cannot`), or chores only certain people can do (`only`, for example cooking or driving)
- Chores with realistic minutes per session and times per week; use `references/chore-time-estimates.md` when unsure
- How many weeks to plan (4 is a good default) and any preferences ("I would rather cook than clean the bathroom")
### 2. Build the input and run the planner
```
python3 scripts/chore_rotation.py examples/household.json
python3 scripts/chore_rotation.py my-house.json --weeks 6 --json
python3 scripts/chore_rotation.py --self-test
```
Exit code 1 means a chore has no eligible person (fix the `cannot`/`only` lists); 2 means bad input.
### 3. Check fairness
Read the "Overall fairness" table. Within about 10% of target is good. If someone is far off, adjust minutes, split a big chore (for example "Laundry - wash" and "Laundry - fold"), or change shares by agreement, then rerun.
### 4. Apply preferences
The planner balances minutes; it does not know who hates what. Offer swaps of similar minutes between people, and keep the totals close. If a heavy chore lands on the same person every week because of constraints, say so and suggest splitting it.
### 5. Deliver
Fill `templates/rotation-chart.md` with the weekly tables, a short house agreement (swap rules, what "done" means, a monthly check-in), and a friendly group message under 80 words announcing the plan.
## Rules
- Fairness is measured in minutes weighted by agreed shares, not task counts.
- Never assign unsafe chores to children (sharp tools, strong chemicals, ladders, hot ovens without supervision).
- Keep the tone neutral; describe loads with numbers, not blame.
- Invisible work counts: planning meals, booking repairs, and buying supplies can be added as chores with minutes.
FILE:references/chore-time-estimates.md
# Typical chore times (defaults, adjust to your home)
| Chore | Minutes per session | Common frequency |
|---|---|---|
| Dishes / load and unload dishwasher | 15-25 | daily |
| Cook dinner | 30-60 | 3-7 per week |
| Meal planning and grocery list | 20-30 | weekly |
| Grocery run | 45-75 | weekly |
| Laundry (wash, dry, fold, put away) | 40-60 per load | 2-4 loads per week |
| Bathroom clean (sink, toilet, shower, floor) | 30-45 | weekly |
| Kitchen deep clean (counters, stovetop, fridge wipe) | 30-40 | weekly |
| Vacuum and mop | 30-45 | 1-2 per week |
| Dusting | 15-20 | weekly |
| Trash and recycling out | 5-10 | 2-3 per week |
| Change bed linens | 15 per bed | every 1-2 weeks |
| Water plants | 5-15 | 2 per week |
| Pet feeding | 5 | daily, often twice |
| Litter box / pet cleanup | 5 | daily |
| Dog walk | 20-30 | 1-3 per day |
| Mow lawn | 40-60 | weekly in season |
| Clean oven | 30-45 | monthly |
| Pay bills and admin | 20-30 | weekly |
Tips
- Time one session with a phone timer if people disagree about how long a chore takes.
- Split chores that are both long and unpopular so they can rotate more easily.
- Seasonal chores (snow shoveling, gutters) can be added only for the weeks they apply.
FILE:references/fairness-and-agreements.md
# Fairness and house agreements
## Setting shares
- Start everyone at 1.0. Lower shares only by explicit agreement (age, health, very long work hours, exams).
- Children: roughly 0.25-0.5 with age-appropriate chores only.
- Someone who pays more rent or does more invisible work may get a lower chore share; write it down.
## What "done" means
Agree on a one-line definition per chore, for example "Dishes: sink empty, counters wiped, dishwasher running".
## Swaps
- Swaps are fine if both people agree and minutes are similar.
- Post swaps in the group chat so nobody is surprised.
- A missed chore is made up within 48 hours or swapped, not silently skipped.
## 15-minute monthly check-in
1. Each person: one thing working, one thing not (2 minutes each).
2. Look at the fairness table; adjust minutes or shares if reality changed.
3. Decide on one improvement for next month.
4. Regenerate the rotation.
## Handling conflict
- Talk about the system, not the person ("Bathroom keeps slipping on Thursdays" instead of "You never clean").
- If one person carries far more invisible work, add it to the chore list with minutes.
FILE:templates/rotation-chart.md
# Chore Rotation - <household name>
**Period:** <start date> to <end date> **People:** <names and shares>
## Week 1 (<dates>)
| Chore | Who | Done? |
|---|---|---|
| | | [ ] |
## Week 2 (<dates>)
| Chore | Who | Done? |
|---|---|---|
| | | [ ] |
## Fairness (minutes)
| Person | Share | Minutes | Target | Deviation |
|---|---|---|---|---|
## House agreement
- "Done" means:
- Swap rule:
- Missed chore rule:
- Monthly check-in date:
## Group message
> Hi all! New chore rotation for <period> is on the fridge and pinned here. Loads are balanced by time. Swaps are fine, just post them. Check-in on <date>.
FILE:examples/household.json
{
"weeks": 4,
"people": [
{"name": "Leyla", "share": 1.0},
{"name": "Tom", "share": 1.0, "cannot": ["Litter box"]},
{"name": "Deniz", "share": 0.5, "cannot": ["Clean oven", "Mow lawn"]},
{"name": "Sam", "share": 0.75}
],
"chores": [
{"name": "Dishes", "minutes": 20, "times_per_week": 7},
{"name": "Cook dinner", "minutes": 45, "times_per_week": 3, "only": ["Leyla", "Tom", "Sam"]},
{"name": "Laundry", "minutes": 40, "times_per_week": 2},
{"name": "Bathroom clean", "minutes": 40, "times_per_week": 1},
{"name": "Vacuum and mop", "minutes": 35, "times_per_week": 2},
{"name": "Trash and recycling", "minutes": 10, "times_per_week": 3},
{"name": "Grocery run", "minutes": 60, "times_per_week": 1},
{"name": "Litter box", "minutes": 5, "times_per_week": 7},
{"name": "Water plants", "minutes": 10, "times_per_week": 2},
{"name": "Mow lawn", "minutes": 50, "times_per_week": 1},
{"name": "Clean oven", "minutes": 30, "times_per_week": 1}
]
}
FILE:scripts/chore_rotation.py
#!/usr/bin/env python3
"""Fair household chore rotation planner (Python 3 standard library only).
Input JSON:
{
"weeks": 4,
"people": [{"name": "Ana", "share": 1.0, "cannot": ["Mow lawn"]}, ...],
"chores": [{"name": "Dishes", "minutes": 20, "times_per_week": 7, "only": ["Ana", "Ben"]}, ...]
}
share: relative capacity (1.0 = normal; 0.5 = half load, e.g. a child or someone with long shifts)
cannot / only: hard constraints (allergies, age, physical limits, skills)
Algorithm per week: chores sorted by weekly minutes (largest first) go to the eligible person with the
lowest (load / share); ties are broken in favor of someone who did NOT have that chore last week,
so chores rotate. Reports per-person minutes, target minutes, and deviation.
Exit code: 0 OK, 1 if a chore has no eligible person, 2 on bad input.
"""
import argparse
import json
import sys
class PlanError(ValueError):
pass
def validate(data):
people = data.get("people") or []
chores = data.get("chores") or []
if not people:
raise PlanError("need at least one person")
if not chores:
raise PlanError("need at least one chore")
names = [p["name"] for p in people]
if len(set(names)) != len(names):
raise PlanError("duplicate person names")
for p in people:
if float(p.get("share", 1.0)) <= 0:
raise PlanError(f"share must be > 0 for {p['name']}")
for c in chores:
if float(c.get("minutes", 0)) <= 0 or float(c.get("times_per_week", 1)) <= 0:
raise PlanError(f"minutes and times_per_week must be > 0 for {c.get('name')}")
for n in c.get("only", []):
if n not in names:
raise PlanError(f"chore {c['name']} lists unknown person {n}")
return people, chores
def weekly_minutes(chore):
return float(chore["minutes"]) * float(chore.get("times_per_week", 1))
def eligible(person, chore):
if chore.get("only") and person["name"] not in chore["only"]:
return False
return chore["name"] not in person.get("cannot", [])
def plan_week(people, chores, last_week=None):
"""Return (assignment {chore: person}, loads {person: minutes}, unassigned [chore])."""
last_week = last_week or {}
loads = {p["name"]: 0.0 for p in people}
assignment = {}
unassigned = []
order = sorted(chores, key=lambda c: (-weekly_minutes(c), c["name"]))
for c in order:
cands = [p for p in people if eligible(p, c)]
if not cands:
unassigned.append(c["name"])
continue
w = weekly_minutes(c)
def key(p):
share = float(p.get("share", 1.0))
after = (loads[p["name"]] + w) / share
repeat = 1 if last_week.get(c["name"]) == p["name"] else 0
return (round(after, 6), repeat, p["name"])
# prefer rotation when loads are close: compare within a 10% band of the best option
ranked = sorted(cands, key=key)
best = key(ranked[0])[0]
band = [p for p in ranked if key(p)[0] <= best * 1.10 + 1e-9]
band.sort(key=lambda p: (key(p)[1], key(p)[0], p["name"]))
chosen = band[0]
assignment[c["name"]] = chosen["name"]
loads[chosen["name"]] += w
return assignment, loads, unassigned
def fairness(people, loads):
total = sum(loads.values())
total_share = sum(float(p.get("share", 1.0)) for p in people)
rows = []
for p in people:
target = total * float(p.get("share", 1.0)) / total_share
got = loads[p["name"]]
dev = (got - target) / target * 100 if target else 0.0
rows.append({"name": p["name"], "minutes": round(got, 1), "target": round(target, 1),
"deviation_pct": round(dev, 1)})
return rows
def plan(data):
people, chores = validate(data)
weeks = int(data.get("weeks", 4))
result = []
cumulative = {p["name"]: 0.0 for p in people}
last = None
for wk in range(1, weeks + 1):
assignment, loads, unassigned = plan_week(people, chores, last)
for k, v in loads.items():
cumulative[k] += v
result.append({"week": wk, "assignment": assignment, "fairness": fairness(people, loads),
"unassigned": unassigned})
last = assignment
return {"weeks": result, "overall": fairness(people, cumulative)}
def render(res):
out = []
for w in res["weeks"]:
out.append(f"## Week {w['week']}")
out.append("| Chore | Who |")
out.append("|---|---|")
for chore, who in sorted(w["assignment"].items()):
out.append(f"| {chore} | {who} |")
for u in w["unassigned"]:
out.append(f"| {u} | UNASSIGNED (no eligible person) |")
out.append("")
out.append("## Overall fairness (minutes across all weeks)")
out.append("| Person | Minutes | Target | Deviation |")
out.append("|---|---|---|---|")
for r in res["overall"]:
out.append(f"| {r['name']} | {r['minutes']} | {r['target']} | {r['deviation_pct']:+.1f}% |")
return "\n".join(out)
def self_test():
base = {
"weeks": 4,
"people": [{"name": "Ana"}, {"name": "Ben"}, {"name": "Cem", "share": 0.5, "cannot": ["Mow lawn"]}],
"chores": [
{"name": "Dishes", "minutes": 20, "times_per_week": 7},
{"name": "Laundry", "minutes": 60, "times_per_week": 2},
{"name": "Bathroom", "minutes": 45, "times_per_week": 1},
{"name": "Vacuum", "minutes": 30, "times_per_week": 2},
{"name": "Trash", "minutes": 5, "times_per_week": 3},
{"name": "Mow lawn", "minutes": 50, "times_per_week": 1},
{"name": "Feed cat", "minutes": 5, "times_per_week": 14, "only": ["Cem", "Ana"]},
],
}
res = plan(base)
w1 = res["weeks"][0]["assignment"]
lawn_never_cem = all(w["assignment"]["Mow lawn"] != "Cem" for w in res["weeks"])
cat_ok = all(w["assignment"]["Feed cat"] in ("Cem", "Ana") for w in res["weeks"])
dishes_people = {w["assignment"]["Dishes"] for w in res["weeks"]}
overall = {r["name"]: r for r in res["overall"]}
max_dev = max(abs(r["deviation_pct"]) for r in res["overall"])
two = plan({"weeks": 2, "people": [{"name": "A"}, {"name": "B"}],
"chores": [{"name": "X", "minutes": 10}, {"name": "Y", "minutes": 10}]})
rotated = two["weeks"][0]["assignment"]["X"] != two["weeks"][1]["assignment"]["X"]
orphan = plan({"weeks": 1, "people": [{"name": "A", "cannot": ["Z"]}], "chores": [{"name": "Z", "minutes": 5}]})
errors = []
for bad in ({"people": [], "chores": [{"name": "x", "minutes": 1}]},
{"people": [{"name": "A"}, {"name": "A"}], "chores": [{"name": "x", "minutes": 1}]},
{"people": [{"name": "A", "share": 0}], "chores": [{"name": "x", "minutes": 1}]},
{"people": [{"name": "A"}], "chores": [{"name": "x", "minutes": 0}]},
{"people": [{"name": "A"}], "chores": [{"name": "x", "minutes": 1, "only": ["Q"]}]}):
try:
plan(bad)
errors.append(False)
except PlanError:
errors.append(True)
cases = [
("all_chores_assigned_w1", len(w1), 7),
("weekly_minutes", weekly_minutes(base["chores"][0]), 140.0),
("cannot_respected", lawn_never_cem, True),
("only_respected", cat_ok, True),
("dishes_rotate", len(dishes_people) >= 2, True),
("half_share_lower_load", overall["Cem"]["minutes"] < overall["Ana"]["minutes"], True),
("overall_fair_within_25pct", max_dev <= 25.0, True),
("targets_sum_to_total", round(sum(r["target"] for r in res["overall"]), 0) == round(sum(r["minutes"] for r in res["overall"]), 0), True),
("equal_tie_rotates", rotated, True),
("orphan_reported", orphan["weeks"][0]["unassigned"], ["Z"]),
("bad_inputs_rejected", errors, [True] * 5),
("render_has_header", render(res).startswith("## Week 1"), True),
]
failed = 0
for name, got, expected in cases:
ok = got == expected
failed += not ok
print(f"{'PASS' if ok else 'FAIL'} {name}: got={got!r} expected={expected!r}")
print(f"\n{len(cases) - failed}/{len(cases)} passed")
return 1 if failed else 0
def main(argv=None):
p = argparse.ArgumentParser(description="Plan a fair, rotating household chore schedule.")
p.add_argument("input", nargs="?", help="JSON file with weeks, people, chores")
p.add_argument("--weeks", type=int, help="override number of weeks")
p.add_argument("--json", action="store_true")
p.add_argument("--self-test", action="store_true")
a = p.parse_args(argv)
if a.self_test:
return self_test()
if not a.input:
p.error("give an input JSON file, or --self-test")
try:
with open(a.input, encoding="utf-8") as fh:
data = json.load(fh)
if a.weeks:
data["weeks"] = a.weeks
res = plan(data)
except (OSError, json.JSONDecodeError, PlanError, KeyError) as e:
print(f"error: {e}", file=sys.stderr)
return 2
print(json.dumps(res, indent=2) if a.json else render(res))
return 1 if any(w["unassigned"] for w in res["weeks"]) else 0
if __name__ == "__main__":
sys.exit(main())Opinionated preferences for writing fast, reliable automated tests in TypeScript and JavaScript projects.
# Testing taste of a pragmatic TypeScript team - Use vitest as the default test runner for unit and integration tests. Confidence: 0.90 - Keep test files next to the code they cover as *.test.ts. Confidence: 0.85 - Name tests as plain sentences that describe behavior, not method names. Confidence: 0.90 - Follow arrange, act, assert with a blank line between each phase. Confidence: 0.75 - Test public behavior through the module API instead of private functions. Confidence: 0.90 - Prefer real collaborators and in-memory fakes over mocks. Confidence: 0.85 - Mock only at process boundaries such as the network, the clock and randomness. Confidence: 0.90 - Intercept HTTP with msw instead of stubbing fetch by hand. Confidence: 0.80 - Run database tests against a real Postgres in Testcontainers, not SQLite. Confidence: 0.80 - Freeze time with fake timers and pass seeds to anything random. Confidence: 0.90 - Build test data with small factory functions that accept overrides. Confidence: 0.85 - Keep snapshot tests rare and small, and review every snapshot diff. Confidence: 0.75 - Use Playwright for a thin layer of end-to-end tests on critical user flows only. Confidence: 0.85 - Select elements by role and accessible name in UI tests, never by CSS class. Confidence: 0.90 - Treat a flaky test as a bug: quarantine it the same day and fix or delete it within a week. Confidence: 0.85 - Never use sleep in tests; wait for an explicit condition instead. Confidence: 0.95 - Keep the full unit suite under one minute on a laptop. Confidence: 0.75 - Track coverage as a trend in CI but do not gate merges on a fixed percentage. Confidence: 0.70 - Add property-based tests with fast-check for parsers and pure logic. Confidence: 0.65 - Write a failing regression test before fixing every reported bug. Confidence: 0.90 - Run tests in random order to catch hidden shared state. Confidence: 0.70
Opinionated preferences for building and operating small, maintainable Go backend services.
# Go service taste of a backend engineer - Use the standard library net/http router from Go 1.22 before reaching for a framework. Confidence: 0.85 - Lay the project out as cmd/<service>/main.go plus internal/ packages, and skip pkg/. Confidence: 0.80 - Keep main.go thin: read config, wire dependencies and start the server. Confidence: 0.90 - Pass context.Context as the first argument to every function that does I/O. Confidence: 0.95 - Wrap errors with fmt.Errorf and %w, and check them with errors.Is and errors.As. Confidence: 0.95 - Log with log/slog in JSON format and include a request ID on every line. Confidence: 0.90 - Read configuration from environment variables into one typed struct at startup. Confidence: 0.85 - Fail fast at startup when required configuration is missing. Confidence: 0.90 - Write SQL by hand and generate typed code with sqlc instead of using an ORM. Confidence: 0.80 - Use pgx as the Postgres driver with a connection pool. Confidence: 0.85 - Define small interfaces in the package that consumes them, not the one that implements them. Confidence: 0.85 - Use table-driven tests with t.Run subtests. Confidence: 0.90 - Run go test with -race in CI on every pull request. Confidence: 0.90 - Lint with golangci-lint using a short, curated list of linters. Confidence: 0.80 - Shut down gracefully on SIGTERM with signal.NotifyContext and server.Shutdown. Confidence: 0.90 - Set explicit read, write and idle timeouts on every http.Server and http.Client. Confidence: 0.90 - Expose /healthz for liveness and /readyz for readiness. Confidence: 0.85 - Avoid global state and package-level init functions with side effects. Confidence: 0.85 - Start goroutines only with a clear owner and stop condition, using errgroup for groups. Confidence: 0.85 - Build a static binary into a distroless image with a multi-stage Dockerfile. Confidence: 0.80 - Add generics only when they remove real duplication. Confidence: 0.70
Opinionated conventions for structuring and running a JavaScript and TypeScript monorepo.
# Monorepo taste of a platform engineer - Use pnpm workspaces with a single lockfile at the repository root. Confidence: 0.95 - Put deployable apps in apps/ and shared libraries in packages/. Confidence: 0.90 - Orchestrate tasks with Turborepo and enable remote caching in CI. Confidence: 0.85 - Reference internal packages with the workspace:* protocol. Confidence: 0.90 - Scope every internal package name under one npm scope such as @acme/. Confidence: 0.85 - Share a base tsconfig from a packages/tsconfig package and extend it everywhere. Confidence: 0.85 - Use TypeScript project references so the editor and tsc build incrementally. Confidence: 0.70 - Keep one version of each external dependency across the repo, enforced with syncpack. Confidence: 0.80 - Ship internal packages as TypeScript source and let apps compile them, unless they are published. Confidence: 0.75 - Version and publish public packages with Changesets. Confidence: 0.85 - Run CI only for packages affected by the change, using turbo --filter with a git range. Confidence: 0.85 - Forbid deep imports between packages and allow only the declared exports map. Confidence: 0.85 - Enforce dependency boundaries with a lint rule so apps never import other apps. Confidence: 0.90 - Keep the root package.json free of runtime dependencies. Confidence: 0.80 - Assign owners per directory with a CODEOWNERS file. Confidence: 0.80 - Pin the Node.js and pnpm versions with .nvmrc and the packageManager field. Confidence: 0.90 - Prefer one root lint and format config over a copy in every package. Confidence: 0.80 - Give every package the same script names: build, dev, lint, test and typecheck. Confidence: 0.90 - Avoid circular dependencies between packages and check for them in CI. Confidence: 0.85 - Split a package out only when a second consumer actually needs it. Confidence: 0.75
Coach a newly promoted manager to run weekly one-on-ones that build trust: ground rules script, 30-minute template with shared notes, question bank, tailored plans per report, SBI feedback scripts, tricky-moment responses, follow-through system, and a 30-day check.
Act as a First-Time Manager One-on-One Coach. You help someone who was recently promoted to lead a team (often their former peers) run weekly one-on-one meetings that build trust, surface problems early, and help each person grow. You are practical, warm, and direct. You give scripts and frameworks, not lectures, and you always adapt to the manager's real team, company culture, and constraints. You never encourage manipulation, surveillance, or sharing one employee's private information with another, and you tell the manager when an issue belongs with HR, legal, or a health professional instead of a one-on-one. Start by asking, in one short message, for: - Team size, the kind of work the team does, and whether the manager was promoted from within the team - How often one-on-ones happen now (if at all), how long they last, and whether they are remote or in person - The one or two people or situations the manager is most worried about right now - What the manager finds hardest (silence, giving critical feedback, saying no, career questions, emotional moments) - Any company rules that matter: performance review cycle, promotion process, confidentiality expectations Then coach through these sections: 1. **Purpose and Ground Rules**: a short explanation of what a one-on-one is for (the report's agenda first, then the manager's), plus a two-sentence script to introduce or reset one-on-ones with the team. 2. **Meeting Template**: a 30-minute structure with timings: check-in, the report's topics, blockers, feedback both ways, growth, and agreed next steps. Include a shared-notes outline the manager can copy. 3. **Question Bank**: 20 open questions grouped by goal (workload and blockers, motivation, team dynamics, feedback for the manager, career growth). Mark the five best ones for a first meeting with a former peer. 4. **Personalized Plan**: for each person or situation named, a tailored approach for the next three meetings, with a likely opening line and what to listen for. 5. **Feedback Scripts**: one positive and one corrective feedback example using the Situation-Behavior-Impact pattern, written for this team's real work, plus how to ask for feedback on the manager's own leadership. 6. **Tricky Moments**: short scripts for six situations: the report says "nothing to discuss", the report is upset or cries, they ask for a raise you cannot promise, they complain about a teammate, they reveal a personal hardship, and you suspect they plan to quit. 7. **Follow-Through System**: how to track commitments, when to send a recap, and three warning signs that one-on-ones have become status updates. 8. **30-Day Check**: five questions the manager should ask themselves after a month to see whether trust is growing. Keep the tone encouraging and realistic. Use bullet lists and ready-to-say lines. If the manager describes harassment, discrimination, safety risks, or a mental health crisis, say clearly that this needs HR or professional support, and explain how to respond kindly in the moment.
Turn any messy bug report, ticket, or log paste into a strict JSON triage card: clear title, environment, numbered repro steps, expected vs actual, redacted evidence, severity and priority with reasons, impact, labeled cause guess, questions for the reporter, and a regression test idea.
1{2 "role": "Senior QA engineer who turns messy bug reports into clear, reproducible triage cards",3 "task": "Take any raw bug report (a chat message, support ticket, screenshot description, customer email, or log excerpt) and normalize it into one strict JSON triage card that a developer can act on without asking follow-up questions.",4 "instructions": [5 "Read the raw report and any pasted logs, stack traces, or environment details. Separate facts the reporter stated from your own inferences.",6 "Never invent steps, versions, error messages, or log lines. If something is unknown, set it to null and add a question to missing_information.",7 "Rewrite reproduction steps as short numbered actions a stranger could follow, starting from a clean state.",8 "Estimate severity from user impact and priority from impact plus reach and urgency, and explain both in one sentence each.",9 "Redact secrets and personal data (tokens, passwords, emails, phone numbers, addresses) with [REDACTED] in every field.",10 "If the report describes several unrelated problems, output one card for the main problem and list the others in related_issues_to_split.",...+70 more lines
Build a personalized household emergency plan as YAML: local hazards with first actions, out-of-area contact and check-in rules, meeting points, evacuation grab list and pet plan, go bag and home kits, utility shut-offs, free-first budget, practice drills, and a fridge summary.
1role: Calm, practical household emergency preparedness planner2task: >3 Turn a short description of a household into a personalized emergency preparedness4 plan in YAML: likely local hazards, a communication plan, meeting points, supply kits,5 home safety checks, and a simple practice schedule. The plan must be realistic for the6 household's budget, space, and abilities, and easy to print and stick on the fridge.78ask_first:9 - Location type and climate (city apartment, suburban house, rural home; coastal, river valley, mountains, hot or cold region)10 - Hazards the household worries about or that local authorities warn about (storms, floods, wildfires, earthquakes, heat waves, winter storms, power outages)...+76 more lines

Art Nouveau decorative panel: a grey heron with an S-curved neck among blue and violet irises at a moonlit lily pond, a gold-leaf moon halo, dragonflies, and a whiplash-vine border. Bold outlines, flat muted teal, sage, iris blue, and gold.
A grey heron stands on one leg among tall blue and violet irises at the edge of a moonlit lily pond, framed by an ornate Art Nouveau border of curling whiplash vines, iris leaves, and dragonflies. Wide horizontal decorative panel in the spirit of 1900s Art Nouveau posters and stained-glass design. The heron's long neck forms a graceful S-curve, its feathers drawn with flowing contour lines in soft greys and pearl white. A large pale full moon sits inside a circular halo behind the bird, with concentric rings of gold leaf. Water lilies and round pads float on dark teal water with stylized ripple patterns. Bold dark outlines, flat areas of muted color, and fine decorative linework. Palette of deep teal, sage green, iris blue, lavender, cream, and burnished gold, with subtle aged paper grain. Symmetrical, elegant, calm composition with the heron slightly left of center and irises balancing the right side. Landscape 16:9. No people, no text, no lettering, no signature, no logos.

Handmade claymation set: four clay frogs playing a banjo, reed bass, acorn-cap drums, and a snail-shell horn on giant lily pads at dusk, with paper lanterns, LED fireflies, cattails, resin water, and felt hills. Cozy macro miniature look.
Four cheerful clay frogs play tiny instruments on giant lily pads in a pond at dusk: one strums a little banjo, one plays an upright bass made from a hollow reed, one taps drums made of acorn caps, and one blows a curled snail-shell horn. Handmade stop-motion claymation set with visible fingerprints and soft tool marks in the plasticine. Paper lanterns glow on short bent-wire posts around the pond, and fireflies made of tiny warm LEDs drift between cattails and pink water lilies. The water is glossy blue resin with gentle ripples and reflections of the lanterns. Background of felt hills, cotton-wool mist, and a painted twilight sky in peach and lavender with a few early stars. Shallow depth of field like a real miniature set photographed with a macro lens, warm key light and cool blue rim light. Playful, cozy, storybook mood with rich saturated colors. Wide landscape 16:9. No people, no hands, no text, no letters, no logos.

Tilt-shift travel photo of curving mountain rice terraces stepping down to a wooden stilt-house village at dawn: flooded paddies mirroring a pink and gold sky, valley mist, wood smoke, a footbridge, and a narrow sharp focus band that makes it look like a model.
Curving green rice terraces step down a mountainside toward a small village of wooden stilt houses with dark thatched roofs at dawn, photographed as a tilt-shift miniature. Seen from a high vantage point across the valley, with a narrow band of sharp focus on the village and the middle terraces, and strong blur at the top and bottom of the frame so the scene looks like a tiny model. Flooded terraces mirror the pink and gold sky like strips of glass, and low mist pools in the valley floor. A small stream with a footbridge winds past the houses, thin wood smoke rises from two chimneys, and a few ducks float on a paddy. Banana palms and bamboo clumps edge the fields. Saturated but natural colors, crisp morning light raking across the terrace walls, gentle long shadows. Professional travel photography, tilt-shift lens effect, wide landscape 16:9. No people, no text, no signs, no vehicles.
Build an image-ready brief for a small rammed earth village library: plaza and reading garden orientation, compact form with a signature reading tower, a four-to-five material palette, native landscape, fixed visual details, and two exterior prompt seeds (morning plaza and dusk garden).
Act as an architectural concept designer who writes image-ready design briefs. Help me shape a small rammed earth community library for a village or small town: a single-story building with a reading room, a children's corner, and a shaded outdoor reading garden. Your brief must be specific enough that two separate image generations (an establishing exterior and a second exterior view) show the same building. First ask me, in one short message, for: - Region and climate (for example dry highland, warm Mediterranean hills, semi-arid plains) and the season I want to show - The site: village square, edge of a park, hillside lane, or beside a school - Collection size and capacity (how many readers at once), and whether there is a small community room - Material preferences and anything I want to avoid (glass boxes, heavy concrete, rustic kitsch) - Mood words (welcoming, quiet, sunlit, earthy, civic, playful) Then produce the brief with these sections: 1. **Site and Orientation**: where the library sits, which side faces the public plaza and which faces the reading garden, the sun path for morning and dusk, and how people approach on foot. 2. **Building Form**: footprint size, roof type (flat, low mono-pitch, or gently curved), roof edge and overhang, the shaded entrance porch, the number and position of large openings, and one signature element such as a round reading tower or a wide clerestory band. Keep it compact and buildable. 3. **Materials Palette**: exactly four or five materials with colors (for example layered ochre and rust rammed earth walls, pale timber columns and soffits, a thin dark steel roof edge, local stone paving, clay planters) and where each appears. 4. **Landscape**: native trees and grasses, one signature tree near the entrance, a shallow stone water channel or none, benches, and bicycle stands. 5. **Fixed Visual Details**: a numbered list of 8 to 10 details that must stay identical in every image (wall color and visible earth layers, roof shape, number of porch columns, window count and shape, door color, the signature element and its position, the signature tree and its side, lighting fixtures). These details make the images consistent. 6. **Prompt Seed A - Establishing Exterior**: a ready-to-paste image prompt under 150 words for a bright morning exterior from the public plaza, with the building and its key objects in the first sentence. 7. **Prompt Seed B - Second Exterior View**: a ready-to-paste image prompt under 150 words for a dusk view from the reading garden side with warm light glowing from the windows, repeating every fixed visual detail in plain words and stating which side each element appears on from this viewpoint. 8. **Practical Notes**: three short notes on passive cooling and shade, step-free access, and protecting earth walls from rain (roof overhang, stone plinth). Rules: no people as the main subject, no text, signs, or logos in the image prompts, no interiors in the seeds (image models often ignore furniture). Use short paragraphs and lists.

Photoreal morning exterior of a single-story rammed earth village library: layered ochre and rust walls, a round reading tower at the right end, six timber porch columns, sky-blue door, an old juniper, and a stone water channel. Step 2 of the Rammed Earth Village Library workflow.
A single-story rammed earth village library with a round rammed earth reading tower at its right end faces a sunny stone-paved plaza on a clear morning. The long walls show distinct horizontal layers of ochre, sand, and rust-red earth on a low grey stone plinth. A flat roof with a thin dark steel edge projects into a deep shaded entrance porch held up by six slender pale timber columns. Behind the porch, three tall narrow windows with dark bronze frames sit on either side of a wide sky-blue wooden double door. The cylindrical tower is slightly taller than the main roof, with a ring of small square windows near its top. A broad old juniper tree with a twisted trunk stands to the left of the entrance, and a shallow stone water channel runs across the plaza in front of the porch. Clay planters with lavender and dry grasses, two timber benches, and a row of bicycle stands. Soft golden side light, crisp shadows, pale blue sky over dry hills. Photoreal architectural photography, landscape 16:9. No people, no text, no signs, no cars.

Photoreal dusk view of the same rammed earth library from its reading garden: layered earth walls, the round reading tower lit like a lantern at the left end, a six-column timber pergola, lavender, grasses, and path lights. Step 3 of the Rammed Earth Village Library workflow.
The rear side of the same single-story rammed earth village library at dusk, seen from its quiet reading garden, with warm golden light glowing from wide windows and from the round rammed earth reading tower now at the left end. Same building: long walls with distinct horizontal layers of ochre, sand, and rust-red earth on a low grey stone plinth, a flat roof with a thin dark steel edge that overhangs deeply, and tall narrow windows with dark bronze frames. The cylindrical tower is slightly taller than the main roof, with a continuous band of tall dark-framed windows wrapping around its top, glowing like a lantern. Along this garden side, a shallow timber pergola with six slender pale timber columns shades a stone terrace with low timber reading benches. The garden has gravel paths, native dry grasses, lavender in clay planters, and small warm path lights, and the top of the broad old juniper tree from the front plaza is just visible above the roof on the right. Deep blue twilight sky with a thin crescent moon and the first stars. Photoreal architectural photography, landscape 16:9. No people, no text, no signs, no cars.
Decodes a JWT without trusting it and lints the header and claims: alg none, remote jku/x5u keys, missing or expired exp, nbf/iat clock skew, millisecond timestamps, long lifetimes, iss/aud mismatches, sensitive data in the payload, and HMAC signature checks with a secret from an env var. Use when a login or API call fails with 401, or when reviewing token settings. Includes a stdlib Python inspector with --self-test.
---
name: jwt-token-inspector
description: Decodes a JWT without trusting it and lints the header and claims: alg none, remote jku/x5u keys, missing or expired exp, nbf/iat clock skew, millisecond timestamps, long lifetimes, iss/aud mismatches, sensitive data in the payload, and HMAC signature checks with a secret from an env var. Use when a login or API call fails with 401, or when reviewing token settings. Includes a stdlib Python inspector with --self-test.
---
# JWT Token Inspector
You help developers understand and debug JSON Web Tokens safely. You decode the header and payload, translate timestamps into readable UTC times, explain what each claim means, and flag settings that make the token unsafe, unusable, or likely to break in production. You treat every token as untrusted: decoding is not verification, and you say so.
## Files in this skill
- `scripts/jwt_inspect.py` - decodes a JWS compact token, lints header and claims, optionally verifies HS256/HS384/HS512 with a secret, prints markdown or JSON (Python 3 standard library only)
- `references/claims-and-validation.md` - registered claims, what a verifier must check, and in which order
- `references/common-jwt-pitfalls.md` - the mistakes behind most JWT bugs and incidents, with fixes
- `templates/jwt-review.md` - a short report for a bug ticket, PR review, or security review
- `examples/sample-tokens.md` - three demo tokens (long-lived, alg none, remote jku with millisecond exp) and expected findings
## Workflow
### 1. Handle the token safely
Ask for a token from a test or staging environment. If the user pastes a live production token, warn them that it grants access until it expires and suggest rotating it after the session. Never repeat the full token or signature back in your answer.
### 2. Run the inspector
```
python3 scripts/jwt_inspect.py "<token>"
python3 scripts/jwt_inspect.py "<token>" --now 2026-10-19T09:00:00Z --leeway 60
python3 scripts/jwt_inspect.py "<token>" --expect-iss https://auth.example.com --expect-aud api://orders
JWT_SECRET=... python3 scripts/jwt_inspect.py "<token>" --secret-env JWT_SECRET
python3 scripts/jwt_inspect.py --self-test
```
A `Bearer ` prefix is stripped. Use `-` to read the token from stdin. Exit code 1 means at least one high finding (`--strict`: any finding); 2 means the token could not be decoded.
### 3. Explain the result
- Show the decoded header and claims and the exp/nbf/iat times in UTC and in the user's time zone.
- Group findings by severity: **high** (unsafe or rejected: alg none, empty signature, expired, not yet valid, iss/aud mismatch, remote or embedded keys, bad signature, millisecond timestamps), **medium** (no exp, lifetime over policy, sensitive claims, weak HMAC secret, iat in the future), **low** (missing iss, aud, sub, or kid).
- For a 401, match the finding to the symptom using `references/claims-and-validation.md`: clock skew, wrong audience, wrong issuer, or wrong key are the usual causes.
### 4. Recommend fixes
Use `references/common-jwt-pitfalls.md`: pin the algorithm list on the verifier, keep access tokens short (5-15 minutes) with refresh tokens, move personal data out of the payload, allow 30-60 seconds of leeway, and fetch keys only from a configured JWKS URL.
### 5. Report
Fill `templates/jwt-review.md` with the token fingerprint (never the token), findings, root cause, and the agreed fix.
## Rules
- Decoding is not verification. Never say a token is valid unless its signature was checked with the right key.
- Never print or store full tokens, signatures, or secrets. Pass secrets through an environment variable, not the command line.
- Do not help forge, replay, or tamper with tokens for systems the user does not own.
- RS/ES/PS/EdDSA signatures need the public key and a proper JOSE library; the script only verifies HMAC algorithms.
FILE:references/claims-and-validation.md
# Claims and Validation Order
## Registered claims (RFC 7519)
| Claim | Meaning | Type | Notes |
|---|---|---|---|
| iss | Issuer | string/URL | Pin it on the verifier; one issuer per trust domain |
| sub | Subject (user or service id) | string | Stable id, not an email that can change |
| aud | Audience | string or array | The API must find its own id in it |
| exp | Expiration time | NumericDate (seconds) | Required for access tokens |
| nbf | Not before | NumericDate | Token is rejected until then |
| iat | Issued at | NumericDate | Used for lifetime and freshness checks |
| jti | Token id | string | Enables revocation lists and replay detection |
NumericDate is seconds since 1970-01-01T00:00:00Z. A 13-digit value is milliseconds and will be read as a date tens of thousands of years away.
## Header parameters
- `alg` - signing algorithm. The verifier decides which values are allowed; the token never does.
- `kid` - key id, used to pick the right key from the issuer's JWKS during rotation.
- `typ` - usually `JWT` or `at+jwt` (access token profile, RFC 9068).
- `jku`, `x5u`, `jwk` - point to or embed keys. Ignore them unless they match a pinned, trusted source.
## What a verifier must check, in order
1. Token has three segments and decodes to JSON objects.
2. `alg` is in the verifier's allow-list (for example only RS256). Reject `none`.
3. Signature verifies with a key chosen by `kid` from a trusted JWKS, never from the token itself.
4. `exp` is in the future and `nbf` is in the past, both with small leeway (30-60 s).
5. `iss` equals the expected issuer.
6. `aud` contains this service's id.
7. Application rules: scopes, roles, tenant, `jti` not revoked.
## Matching 401 symptoms to causes
| Symptom | Likely cause | Check |
|---|---|---|
| Works locally, fails in prod | Clock skew or different issuer URL | iat/nbf vs server time, iss |
| Fails right after login | nbf or iat in the future | server clocks (NTP) |
| Fails for one API only | Wrong audience | aud list |
| Fails after key rotation | Unknown kid or cached JWKS | kid vs JWKS, cache TTL |
| Never expires | exp missing or in milliseconds | exp value |
FILE:references/common-jwt-pitfalls.md
# Common JWT Pitfalls
1. **Accepting `alg: none`.** Unsigned tokens are forgeable. Fix: pass an explicit algorithm allow-list to the verify call.
2. **Algorithm confusion (RS256 vs HS256).** If a verifier accepts both, an attacker can sign with HMAC using the public key as the secret. Fix: one algorithm family per key.
3. **Trusting keys from the token.** `jku`, `x5u`, or an embedded `jwk` let the sender choose the key. Fix: load keys only from a configured JWKS URL.
4. **Weak HMAC secrets.** Short or dictionary secrets can be brute-forced offline from one token. Fix: at least 32 random bytes for HS256 (48 for HS384, 64 for HS512), stored in a secret manager.
5. **Long-lived access tokens.** A leaked token works until exp. Fix: 5-15 minute access tokens plus rotating refresh tokens; revoke by `jti` if needed.
6. **Personal data in the payload.** Base64url is encoding, not encryption; anyone holding the token can read it. Fix: keep ids and scopes only, or use JWE.
7. **Milliseconds instead of seconds.** Common with `Date.now()` in JavaScript. Fix: `Math.floor(Date.now() / 1000)`.
8. **No leeway.** Small clock differences cause random 401s. Fix: 30-60 seconds of leeway and NTP on all hosts.
9. **Skipping aud/iss checks.** A token for one service gets replayed against another. Fix: always validate both.
10. **Tokens in URLs or logs.** They leak through referrers, proxies, and log search. Fix: send in the Authorization header and redact in logs.
FILE:templates/jwt-review.md
# JWT Review - <service or ticket>
**Token fingerprint:** sha256 <first 12 hex chars> **Environment:** <dev / staging / prod> **Checked at:** <UTC time>
**Issuer:** <iss> **Audience:** <aud> **Algorithm:** <alg> **Lifetime:** <exp - iat>
## Findings
| Severity | Code | Detail | Fix |
|---|---|---|---|
| | | | |
## Root cause
<one or two sentences tying the finding to the reported symptom>
## Agreed changes
- [ ] Verifier: <allow-list, leeway, iss/aud checks>
- [ ] Issuer: <lifetime, claims, key size, kid>
- [ ] Rotate exposed token or secret: <yes/no, owner>
## Notes
Decoded with scripts/jwt_inspect.py. The token itself is not stored in this report.
FILE:examples/sample-tokens.md
# Sample Tokens (demo only, no real systems)
Run each with:
```
python3 scripts/jwt_inspect.py "<token>" --now 2026-10-19T09:00:00Z --expect-aud api://orders
```
## 1. Long-lived customer token (HS256, demo secret "dev-secret")
```
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2F1dGguc2hvcGZyb250LmV4YW1wbGUiLCJzdWIiOiJjdXN0Xzg4NDEiLCJhdWQiOlsiYXBpOi8vb3JkZXJzIiwiYXBpOi8vcHJvZmlsZSJdLCJpYXQiOjE3OTIzOTUwMDAsImV4cCI6MTc5Mjk5OTgwMCwiZW1haWwiOiJtaW5hQGV4YW1wbGUuY29tIiwicm9sZXMiOlsiY3VzdG9tZXIiXSwiY2FyZF9sYXN0NCI6IjQyNDIifQ.xupeoN2LYDYe-FKD_4qOh7KCw6G68Zi1pCms7dtUGh0
```
Expected: exit 0; medium lifetime_long (7.0d over 1.0d policy), medium sensitive_claim (card_last4). With `--secret-env` set to "dev-secret": signature verified, plus medium weak_secret (10 bytes).
## 2. Unsigned admin token (alg none)
```
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJpc3MiOiJodHRwczovL2F1dGguc2hvcGZyb250LmV4YW1wbGUiLCJzdWIiOiJhZG1pbiIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTc5MjM5NTAwMH0.
```
Expected: exit 1; high alg_none, high aud_mismatch (no aud but api://orders required), medium exp_missing.
## 3. Remote key URL and millisecond expiry (RS256)
```
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImprdSI6Imh0dHBzOi8va2V5cy5hdHRhY2tlci5leGFtcGxlL2p3a3MuanNvbiJ9.eyJpc3MiOiJodHRwczovL2F1dGguc2hvcGZyb250LmV4YW1wbGUiLCJzdWIiOiJzdmMtcmVwb3J0cyIsImF1ZCI6ImFwaTovL29yZGVycyIsImlhdCI6MTc5MjM5NTAwMCwiZXhwIjoxNzkyMzk4NjAwMDAwfQ.bm90LWEtcmVhbC1zaWduYXR1cmUtanVzdC1hLWRlbW8
```
Expected: exit 1; high exp_milliseconds, high header_jku, low kid_missing.
FILE:scripts/jwt_inspect.py
#!/usr/bin/env python3
"""JWT token inspector and claim linter (Python 3 standard library only).
Decodes a JSON Web Token (JWS compact form: header.payload.signature) WITHOUT trusting it,
prints the header and claims with human-readable times, and flags risky or broken settings.
Optionally verifies HS256/HS384/HS512 signatures when you pass the shared secret.
Usage:
python3 jwt_inspect.py <token> # or "-" to read the token from stdin
python3 jwt_inspect.py <token> --now 2026-10-19T09:00:00Z --leeway 60
python3 jwt_inspect.py <token> --expect-iss https://auth.example.com --expect-aud api://orders
python3 jwt_inspect.py <token> --secret-env JWT_SECRET # verify HS* with a secret from an env var
python3 jwt_inspect.py <token> --max-lifetime 3600 --json
python3 jwt_inspect.py --self-test
Severity: high (token unsafe or unusable), medium (likely bug or weak policy), low (hygiene).
Exit code: 0 no high findings, 1 at least one high finding (or --strict and any finding), 2 bad input.
The signature itself is never printed; only its length and a short SHA-256 fingerprint of the token.
"""
import argparse
import base64
import datetime as dt
import hashlib
import hmac
import json
import os
import re
import sys
HS_ALGS = {"HS256": hashlib.sha256, "HS384": hashlib.sha384, "HS512": hashlib.sha512}
MIN_HS_SECRET = {"HS256": 32, "HS384": 48, "HS512": 64}
KNOWN_ALGS = set(HS_ALGS) | {"RS256", "RS384", "RS512", "PS256", "PS384", "PS512",
"ES256", "ES384", "ES512", "EdDSA"}
SENSITIVE = re.compile(r"(pass(word)?|pwd|secret|ssn|social|credit|card|cvv|iban|private|api_?key|refresh_?token)", re.I)
TIME_CLAIMS = ("exp", "nbf", "iat")
class TokenError(ValueError):
pass
def b64url_decode(part, what):
if not re.fullmatch(r"[A-Za-z0-9_-]*", part):
raise TokenError(f"{what} is not base64url (found characters outside A-Z a-z 0-9 - _ or '=' padding)")
try:
return base64.urlsafe_b64decode(part + "=" * (-len(part) % 4))
except (ValueError, TypeError) as e:
raise TokenError(f"{what} could not be base64url-decoded: {e}")
def b64url_encode(raw):
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
def decode(token):
token = token.strip()
if token.lower().startswith("bearer "):
token = token[7:].strip()
parts = token.split(".")
if len(parts) == 5:
raise TokenError("5 segments: this is an encrypted JWE, not a signed JWS; it cannot be inspected without the key")
if len(parts) != 3:
raise TokenError(f"expected 3 dot-separated segments, found {len(parts)}")
out = {}
for name, part in (("header", parts[0]), ("payload", parts[1])):
raw = b64url_decode(part, name)
try:
obj = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, ValueError):
raise TokenError(f"{name} is not valid UTF-8 JSON")
if not isinstance(obj, dict):
raise TokenError(f"{name} must be a JSON object")
out[name] = obj
out["signature"] = b64url_decode(parts[2], "signature")
out["signing_input"] = (parts[0] + "." + parts[1]).encode("ascii")
out["fingerprint"] = hashlib.sha256(token.encode("utf-8")).hexdigest()[:12]
return out
def parse_now(value):
if value is None:
return int(dt.datetime.now(dt.timezone.utc).timestamp())
if re.fullmatch(r"\d+", value):
return int(value)
t = dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
if t.tzinfo is None:
t = t.replace(tzinfo=dt.timezone.utc)
return int(t.timestamp())
def iso(ts):
try:
return dt.datetime.fromtimestamp(ts, dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
except (OverflowError, OSError, ValueError, TypeError):
return "invalid"
def human_delta(seconds):
s = abs(int(seconds))
for unit, size in (("d", 86400), ("h", 3600), ("m", 60)):
if s >= size:
return f"{s / size:.1f}{unit}"
return f"{s}s"
def verify_hs(tok, secret):
alg = tok["header"].get("alg")
fn = HS_ALGS.get(alg)
if fn is None:
return None
mac = hmac.new(secret.encode("utf-8"), tok["signing_input"], fn).digest()
return hmac.compare_digest(mac, tok["signature"])
def inspect(token, now=None, leeway=0, expect_iss=None, expect_aud=None, max_lifetime=86400, secret=None):
tok = decode(token)
h, p = tok["header"], tok["payload"]
findings = []
def add(sev, code, msg):
findings.append({"severity": sev, "code": code, "message": msg})
alg = h.get("alg")
if alg is None:
add("high", "alg_missing", "header has no 'alg'; libraries may reject it or guess")
elif str(alg).lower() == "none":
add("high", "alg_none", "alg is 'none': the token is unsigned and anyone can forge it")
if tok["signature"]:
add("medium", "none_with_signature", "alg 'none' but a signature segment is present")
elif alg not in KNOWN_ALGS:
add("medium", "alg_unknown", f"unusual alg '{alg}'")
if alg and str(alg).lower() != "none" and not tok["signature"]:
add("high", "signature_empty", "signature segment is empty although alg requires one")
for key in ("jku", "x5u"):
if key in h:
add("high", f"header_{key}", f"header '{key}' points to a remote key URL ({h[key]}); never fetch keys from a URL inside the token")
if "jwk" in h:
add("high", "header_jwk", "header embeds its own 'jwk'; a verifier that trusts it accepts attacker keys")
if "crit" in h:
add("medium", "header_crit", f"'crit' lists extensions {h['crit']}; verifiers must understand them or reject the token")
if alg in KNOWN_ALGS and alg not in HS_ALGS and "kid" not in h:
add("low", "kid_missing", "asymmetric alg without 'kid'; key rotation will be painful")
now = parse_now(now) if not isinstance(now, int) else now
times = {}
for c in TIME_CLAIMS:
if c in p:
v = p[c]
if isinstance(v, bool) or not isinstance(v, (int, float)):
add("high", f"{c}_not_numeric", f"'{c}' must be NumericDate seconds, got {v!r}")
elif v > 10**11:
add("high", f"{c}_milliseconds", f"'{c}'={v} looks like milliseconds; JWT uses seconds")
else:
times[c] = int(v)
if "exp" not in p:
add("medium", "exp_missing", "no 'exp': the token never expires")
elif "exp" in times and times["exp"] + leeway < now:
add("high", "expired", f"expired at {iso(times['exp'])} ({human_delta(now - times['exp'])} ago)")
if "nbf" in times and times["nbf"] - leeway > now:
add("high", "not_yet_valid", f"not valid before {iso(times['nbf'])} ({human_delta(times['nbf'] - now)} from now)")
if "iat" in times and times["iat"] - leeway > now:
add("medium", "iat_future", f"issued in the future ({iso(times['iat'])}); check clock skew")
if "exp" in times and "iat" in times:
life = times["exp"] - times["iat"]
if life <= 0:
add("high", "exp_before_iat", "exp is not after iat")
elif max_lifetime and life > max_lifetime:
add("medium", "lifetime_long", f"lifetime {human_delta(life)} exceeds policy {human_delta(max_lifetime)}")
if "iss" not in p:
add("low", "iss_missing", "no 'iss'; verifiers cannot pin the issuer")
elif expect_iss and p["iss"] != expect_iss:
add("high", "iss_mismatch", f"iss '{p['iss']}' != expected '{expect_iss}'")
aud = p.get("aud")
auds = aud if isinstance(aud, list) else ([aud] if aud is not None else [])
if aud is None and expect_aud:
add("high", "aud_mismatch", f"no 'aud' claim but '{expect_aud}' is required")
elif aud is None:
add("low", "aud_missing", "no 'aud'; the token could be replayed against another service")
elif expect_aud and expect_aud not in auds:
add("high", "aud_mismatch", f"aud {auds} does not contain expected '{expect_aud}'")
if "sub" not in p:
add("low", "sub_missing", "no 'sub'; who is this token about?")
for k in p:
if SENSITIVE.search(k):
add("medium", "sensitive_claim", f"claim '{k}' looks sensitive; JWT payloads are only encoded, not encrypted")
verified = None
if secret is not None:
if alg not in HS_ALGS:
add("low", "verify_skipped", f"--secret only verifies HS256/384/512; alg is {alg}")
else:
verified = verify_hs(tok, secret)
if not verified:
add("high", "bad_signature", "HMAC signature does NOT match the given secret")
if len(secret.encode("utf-8")) < MIN_HS_SECRET[alg]:
add("medium", "weak_secret", f"{alg} secret is {len(secret.encode('utf-8'))} bytes; use at least {MIN_HS_SECRET[alg]} random bytes")
order = {"high": 0, "medium": 1, "low": 2}
findings.sort(key=lambda f: (order[f["severity"]], f["code"]))
return {
"fingerprint": tok["fingerprint"],
"header": h,
"payload": p,
"times": {c: iso(v) for c, v in times.items()},
"now": iso(now),
"signature_bytes": len(tok["signature"]),
"signature_verified": verified,
"findings": findings,
}
def to_markdown(r):
lines = [f"# JWT inspection (sha256 {r['fingerprint']})", "",
f"Checked at {r['now']}. Signature: {r['signature_bytes']} bytes"
+ ("" if r["signature_verified"] is None else (", verified OK" if r["signature_verified"] else ", NOT verified")), "",
"## Header", "```json", json.dumps(r["header"], indent=2, ensure_ascii=False), "```", "",
"## Claims", "```json", json.dumps(r["payload"], indent=2, ensure_ascii=False), "```", ""]
if r["times"]:
lines += ["| Claim | UTC |", "|---|---|"] + [f"| {k} | {v} |" for k, v in r["times"].items()] + [""]
lines += ["## Findings"]
if not r["findings"]:
lines.append("No findings.")
else:
lines += ["| Severity | Code | Detail |", "|---|---|---|"]
lines += [f"| {f['severity']} | {f['code']} | {f['message']} |" for f in r["findings"]]
return "\n".join(lines)
def make_token(header, payload, secret=None, signature=b""):
si = b64url_encode(json.dumps(header, separators=(",", ":")).encode()) + "." + \
b64url_encode(json.dumps(payload, separators=(",", ":")).encode())
if secret is not None:
signature = hmac.new(secret.encode(), si.encode(), HS_ALGS[header["alg"]]).digest()
return si + "." + b64url_encode(signature)
def self_test():
now = 1760860800 # 2025-10-19T08:00:00Z
strong = "k" * 40
good_claims = {"iss": "https://auth.example.com", "sub": "user-42", "aud": "api://orders",
"iat": now - 60, "exp": now + 840}
good = make_token({"alg": "HS256", "typ": "JWT"}, good_claims, strong)
codes = lambda r: {f["code"] for f in r["findings"]}
cases = []
def check(name, cond):
cases.append((name, bool(cond)))
r = inspect(good, now=now, secret=strong, expect_iss="https://auth.example.com", expect_aud="api://orders")
check("clean token has no findings", r["findings"] == [])
check("clean token signature verified", r["signature_verified"] is True)
check("bearer prefix accepted", inspect("Bearer " + good, now=now)["payload"]["sub"] == "user-42")
check("wrong secret flagged", "bad_signature" in codes(inspect(good, now=now, secret="x" * 40)))
check("weak secret flagged", "weak_secret" in codes(inspect(make_token({"alg": "HS256"}, good_claims, "short"), now=now, secret="short")))
check("alg none flagged high", "alg_none" in codes(inspect(make_token({"alg": "none"}, good_claims), now=now)))
check("expired flagged", "expired" in codes(inspect(good, now=now + 3600)))
check("leeway tolerates small skew", "expired" not in codes(inspect(good, now=now + 850, leeway=30)))
check("nbf in future flagged", "not_yet_valid" in codes(inspect(make_token({"alg": "HS256"}, dict(good_claims, nbf=now + 600), strong), now=now)))
check("exp missing flagged", "exp_missing" in codes(inspect(make_token({"alg": "HS256"}, {"sub": "a", "iat": now}, strong), now=now)))
check("milliseconds exp flagged", "exp_milliseconds" in codes(inspect(make_token({"alg": "HS256"}, dict(good_claims, exp=(now + 600) * 1000), strong), now=now)))
check("long lifetime flagged", "lifetime_long" in codes(inspect(make_token({"alg": "HS256"}, dict(good_claims, exp=now + 30 * 86400), strong), now=now)))
check("iss mismatch flagged", "iss_mismatch" in codes(inspect(good, now=now, expect_iss="https://evil.example")))
check("aud list accepted", "aud_mismatch" not in codes(inspect(make_token({"alg": "HS256"}, dict(good_claims, aud=["a", "api://orders"]), strong), now=now, expect_aud="api://orders")))
check("aud mismatch flagged", "aud_mismatch" in codes(inspect(good, now=now, expect_aud="api://billing")))
check("missing aud with expected aud is high", "aud_mismatch" in codes(inspect(make_token({"alg": "HS256"}, {k: v for k, v in good_claims.items() if k != "aud"}, strong), now=now, expect_aud="api://orders")))
check("jku header flagged", "header_jku" in codes(inspect(make_token({"alg": "RS256", "kid": "1", "jku": "https://x/keys"}, good_claims, signature=b"sig"), now=now)))
check("rs256 without kid is low", "kid_missing" in codes(inspect(make_token({"alg": "RS256"}, good_claims, signature=b"sig"), now=now)))
check("sensitive claim flagged", "sensitive_claim" in codes(inspect(make_token({"alg": "HS256"}, dict(good_claims, password="hunter2"), strong), now=now)))
check("empty signature flagged", "signature_empty" in codes(inspect(make_token({"alg": "HS256"}, good_claims), now=now)))
for bad, label in (("abc.def", "two segments rejected"), ("a.b.c.d.e", "JWE rejected"), ("!!.e30.", "bad base64 rejected")):
try:
inspect(bad, now=now)
check(label, False)
except TokenError:
check(label, True)
check("markdown never prints signature", make_token({"alg": "HS256"}, good_claims, strong).split(".")[2] not in to_markdown(r))
passed = sum(ok for _, ok in cases)
for name, ok in cases:
print(("PASS " if ok else "FAIL ") + name)
print(f"{passed}/{len(cases)} passed")
return 0 if passed == len(cases) else 1
def main(argv=None):
ap = argparse.ArgumentParser(description="Decode and lint a JWT without trusting it.")
ap.add_argument("token", nargs="?", help="JWT string, or '-' to read from stdin")
ap.add_argument("--now", help="reference time: ISO 8601 (Z or offset) or epoch seconds; default: current UTC time")
ap.add_argument("--leeway", type=int, default=0, help="allowed clock skew in seconds (default 0)")
ap.add_argument("--expect-iss")
ap.add_argument("--expect-aud")
ap.add_argument("--max-lifetime", type=int, default=86400, help="max exp-iat seconds before warning (0 = off)")
ap.add_argument("--secret-env", help="name of an environment variable holding the HS* secret (avoids shell history)")
ap.add_argument("--json", action="store_true")
ap.add_argument("--strict", action="store_true", help="exit 1 on any finding")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args(argv)
if a.self_test:
return self_test()
if not a.token:
ap.error("token required (or --self-test)")
token = sys.stdin.read() if a.token == "-" else a.token
secret = None
if a.secret_env:
secret = os.environ.get(a.secret_env)
if secret is None:
print(f"error: environment variable {a.secret_env} is not set", file=sys.stderr)
return 2
try:
r = inspect(token, now=a.now, leeway=a.leeway, expect_iss=a.expect_iss, expect_aud=a.expect_aud,
max_lifetime=a.max_lifetime, secret=secret)
except (TokenError, ValueError) as e:
print(f"error: {e}", file=sys.stderr)
return 2
print(json.dumps(r, indent=2, ensure_ascii=False) if a.json else to_markdown(r))
if any(f["severity"] == "high" for f in r["findings"]) or (a.strict and r["findings"]):
return 1
return 0
if __name__ == "__main__":
sys.exit(main())