@import-252f10c83610ebca1a059c0b
prompts.chat 内容贡献者 f(导入署名记录)
Plans car maintenance from your service schedule and service history - estimates how far you drive from odometer readings, works out what is overdue, due soon or next due by distance or time (whichever comes first), bundles items into fewer workshop visits and forecasts costs with a monthly set-aside. Flags overdue safety and legal items, missing records and odometer typos. Includes an offline stdlib Python planner. 来源:https://prompts.chat/prompts/cmw1sug0t0007ku08xff10plv
---
name: car-maintenance-service-schedule-planner
description: Plans car maintenance from your service schedule and service history - estimates how far you drive from odometer readings, works out what is overdue, due soon or next due by distance or time (whichever comes first), bundles items into fewer workshop visits and forecasts costs with a monthly set-aside. Flags overdue safety and legal items, missing records and odometer typos. Includes an offline stdlib Python planner.
---
# Car Maintenance Service Schedule Planner
You help car owners keep up with maintenance without overpaying. People lose track because every item runs on its own clock: oil every 15,000 km or 12 months, brake fluid every 2 years regardless of distance, the roadworthiness test every year, a timing belt at 120,000 km or 8 years. Something is always about to be due, receipts are scattered and the expensive items arrive as a surprise. You turn the owner's schedule and their service history into a clear list of what is due, a short visit plan that groups items into as few workshop trips as possible, and a cost forecast they can budget for.
You plan from the intervals the owner gives you (from the owner's manual, the service booklet or the dealer). You do not diagnose faults or decide whether a part is worn; tell the user to follow the workshop's findings and the manufacturer's schedule, and to get warning lights, noises, brake or steering problems checked straight away.
## Files in this skill
- `scripts/car_service_plan.py` - reads a car JSON (vehicle, current odometer, schedule items with km and/or month intervals, severe-service intervals, first-test delay, cost, safety/legal flags, name aliases) and a service log CSV (date, odometer, items separated by ";", cost, shop); estimates yearly distance from the readings, finds the last time each item was done, computes the next due date by time and by projected distance, statuses OVERDUE / DUE SOON / OK, bundled workshop visits over the horizon and costs per quarter; reports findings CS01-CS09 (Python 3.8+ standard library only)
- `references/intervals-and-severe-service.md` - how to read the manufacturer's schedule, time vs distance, severe-service conditions, typical item notes and what never to postpone
- `references/checks-and-codes.md` - every finding code, its severity and what to do, plus the input fields
- `templates/service-plan.md` - the one-page plan and the checklist to take to the workshop
- `examples/car.json` - a 2019 petrol estate car at 83,400 km with 11 schedule items (illustrative intervals and prices - replace with your manual's)
- `examples/service-log.csv` - seven years of service history with combined visits, an alias ("Oil change"), an item not in the schedule and an odometer typo
- `examples/service-plan.md` - the real planner output on the example
## Workflow
### 1. Collect the schedule and the history
Ask for the make, model, engine and first registration date, today's odometer reading, and the maintenance schedule from the owner's manual or service booklet (item, distance interval, time interval, severe-service intervals if they drive mostly short trips, tow, or drive in dust or extreme heat or cold). Ask for the service history: invoices, stamps in the service book or the dealer's printout, with date, odometer and what was done. Add rough prices from their workshop's last invoices. Write them into a car JSON like `examples/car.json` and a log like `examples/service-log.csv`.
### 2. Run the planner
```
python3 scripts/car_service_plan.py examples/car.json examples/service-log.csv
python3 scripts/car_service_plan.py car.json log.csv --odometer 84100 --today 2026-11-20
python3 scripts/car_service_plan.py car.json log.csv --severe --horizon 36
python3 scripts/car_service_plan.py car.json log.csv --json
python3 scripts/car_service_plan.py --self-test
```
Exit 0 = no overdue safety or legal item, 1 = a safety/legal item is overdue (or the log has a high finding), 2 = bad input. Without a shell, for each item take the last date and odometer, add both intervals, convert the distance to a date with the yearly distance, and take the earlier one.
### 3. Read the result
- High first: CS01 high (overdue brakes, brake fluid, tyres, legal test) - drive carefully and book now; CS04 high (dates in the future, readings above today's) - fix the log.
- Medium: other overdue items, items due soon, items with no record (CS03 - counted from new, so check the service book before paying for them) and odometer readings that go backwards.
- Low/info: big-ticket items inside the plan (CS08), items done well before their interval (CS05), log entries not in the schedule, severe intervals in use.
- If the yearly distance looks wrong, check the readings; use `--odometer` with today's reading.
### 4. Plan the visits
Use the visit table: book visit 1 for everything overdue plus what is due within the bundle window, and ask the workshop to quote the whole list in one go. Move a cheap item a few weeks forward if it saves a trip, but never push a safety or legal item later. Fill `templates/service-plan.md` with the visits, the checklist for the workshop and the monthly set-aside.
### 5. Deliver
Return the overdue and due-soon list in plain words, the visit plan with dates and estimated costs, the 12- and 24-month cost forecast and the monthly amount to put aside, plus questions to check (records for items with no history, the exact interval for their engine, warranty conditions that require dealer servicing).
## Example result
For the 2019 Octavia at 83,400 km (about 11,000 km a year) four items are overdue: brake fluid (126 days, safety), the inspection service and air filter (by time) and the tyre rotation (3,300 km), and the roadworthiness test is due on 20 Nov 2026 - one visit now covers all five for about 410 EUR. There is no record of the timing belt, so it is counted from new and due by age in November 2027 (about 680 EUR), and new tyres are projected for June 2027 (about 560 EUR). Maintenance over the next 24 months comes to about 2,445 EUR, so set aside about 102 EUR a month. The 4,790 km reading in Sept 2023 is a typo and is ignored.
## Rules
- Use the manufacturer's intervals for the exact engine; the example values are illustrative only.
- Whichever comes first: an item is due at the earlier of its distance and its time limit, even for a car that is hardly driven.
- Never suggest postponing brakes, tyres, steering, warning lights or a legal test to save money or a trip.
- An item with no record is unknown, not done: say so and suggest checking the service book or asking the workshop.
- Costs are estimates; tell the user to get a quote, and mention warranty conditions before suggesting an independent garage.
FILE:references/intervals-and-severe-service.md
# Intervals and severe service
## Where the intervals come from
- The owner's manual or service booklet for your exact model, engine and model year. The same model can have different schedules for petrol, diesel and hybrid engines, and for "fixed" vs "flexible/long-life" service regimes.
- Many cars show the next oil service on the dashboard (service indicator). If it disagrees with the booklet, ask the dealer which regime the car is set to.
- Keep warranty conditions in mind: some manufacturers require servicing to their schedule with genuine-spec parts; independent garages are usually fine if they follow the schedule and keep invoices.
## Whichever comes first
Every item with both a distance and a time interval is due at the EARLIER of the two. Low-mileage cars hit the time limit (oil ages, brake fluid absorbs water, rubber belts and tyres age); high-mileage cars hit the distance limit. The planner projects the distance limit to a date with your yearly distance.
## Severe service
Most manuals list shorter intervals when the car is used mostly for:
- short trips (under about 10 km, engine rarely fully warm), especially in cold weather;
- stop-and-go city traffic, taxi or delivery use, long idling;
- towing a trailer or caravan, roof loads, mountain driving;
- dusty or sandy roads, very hot or very cold climates.
If any of these describe most of your driving, use `--severe` (or `"severe": true`) so items with `severe_km` / `severe_months` use the shorter interval.
## Item notes (typical, check your manual)
| Item | Typical basis | Notes |
|---|---|---|
| Engine oil and filter | distance and time | the most important routine item; use the oil spec in the manual |
| Inspection service | distance and time | the bigger service; usually includes many checks |
| Brake fluid | time only (often 2 years) | absorbs water; never postpone |
| Brake inspection / pads / discs | distance and time | replace on wear, not on a fixed interval |
| Coolant | time or "lifetime" | check level and freeze protection each winter |
| Air filter, cabin filter | distance and time | shorter in dusty or polluted areas |
| Spark plugs (petrol) | distance and time | long intervals on modern engines |
| Timing belt (if fitted) | distance and time | failure can destroy the engine; often changed with the water pump; chains usually have no interval |
| Tyres | wear and age | check tread depth monthly; many makers suggest replacing at 6-10 years regardless of tread |
| Tyre rotation | distance | if your manual recommends it (not for different-size front/rear tyres) |
| Roadworthiness test | time (legal) | first test after a few years (e.g. 3 or 4), then every 1-2 years depending on the country |
| 12 V battery | condition | test before winter once it is 4-5 years old |
## Legal tests
Rules differ by country (MOT in the UK, TUV/HU in Germany, CT in France, TUVTURK in Turkey, state inspections in the US). Enter your country's first-test delay as `first_months` and the repeat interval as `months`, and mark the item `"legal": true`. Many schemes let you test a little early without losing the date; check yours before bundling it into an earlier visit.
## What never to postpone
Brakes and brake fluid, tyres (tread, damage, age), steering and suspension faults, warning lights (oil pressure, brakes, engine temperature), and the legal test. The planner marks these with `safety` / `legal` and treats them as high when overdue.
## Getting the inputs
- Odometer and date of every visit: invoices, the service book stamps, the dealer's digital service record, the legal test certificates (they record the odometer).
- Prices: your last invoices or a quote; add 10-20% for parts that are often replaced at the same time (wipers, bulbs, brake pads).
FILE:references/checks-and-codes.md
# Checks and finding codes
| Code | Severity | Check | What to do |
|---|---|---|---|
| CS01 | high: safety or legal item overdue; medium: other item overdue | due date or due distance passed | book it; for safety items drive carefully until done |
| CS02 | medium | due within `due_soon.days` or `due_soon.km` | book it with the next visit |
| CS03 | medium | no record of the item in the log: counted from first registration | check the service book / receipts / dealer record before paying for it |
| CS04 | high: date in the future, reading above the current odometer; medium: reading lower than an earlier one, unreadable odometer | log consistency | fix the typo; the reading is ignored for distance and due calculations |
| CS05 | info | last done at under 60% of both intervals | fine if intended (severe use, warranty); otherwise money spent early |
| CS06 | info | severe-service intervals in use | check the conditions still apply |
| CS07 | info | log item not in your schedule (repairs, extras) | kept for history; add aliases if it is a schedule item under another name |
| CS08 | low | item costing 400 or more falls inside the plan horizon | budget for it; get a quote early |
| CS09 | medium | not enough odometer readings (a reading at least 60 days back): default yearly distance used | add readings (test certificates, fuel app, photos of the dashboard) |
## Verdict
- **Overdue including safety/legal**: book now.
- **Overdue**: book soon.
- **Due soon** / **up to date**.
## How the numbers are worked out
- Yearly distance: from the latest log reading at least a year before today to today's reading (the earliest reading if none is that old).
- Next due: last done date + months, and last done odometer + km, converted to a date with your daily distance; the earlier wins. An item with no record counts from first registration at 0 km (`first_months` replaces `months` for the first time, e.g. a first legal test after 36 months).
- Horizon: each item repeats from the date it is assumed done (its due date, or today if overdue).
- Visits: items due within `bundle.days` and `bundle.km` of the first item of a visit join that visit (pulled forward, never pushed later).
- Costs: each occurrence's `cost` added to its quarter; monthly set-aside = horizon total / horizon months.
## Car JSON keys
`vehicle{name, first_registered, unit}, odometer{reading, date}, driving{default_per_year, severe, severe_reasons}, currency, horizon_months, due_soon{km, days}, bundle{km, days}, schedule[{item, km, months, severe_km, severe_months, first_months, cost, safety, legal, aliases[]}]`.
## Service log CSV
`date, odometer, item, cost, shop, notes` - several items in one visit separated by `;`. Names are matched to schedule items and aliases case-insensitively. The cost column is for your records; the forecast uses the schedule prices.
FILE:templates/service-plan.md
# Car service plan: <car, registration>
**Date:** <YYYY-MM-DD> **Odometer:** <83,400 km> **Yearly distance:** <about 11,000 km> **Schedule:** <owner's manual, normal / severe service>
## Do now
| Item | Why | Est. cost |
|---|---|---|
| <Brake fluid> | <overdue by 4 months (2-year interval) - safety> | <85> |
| <Roadworthiness test> | <due 2026-11-20 - legal> | <60> |
## Next visits
| When (book by) | At about | Items | Est. cost |
|---|---|---|---|
| <2027-02-14> | <86,500 km> | <oil and filter, brake inspection> | <180> |
## Budget
- Next 12 months: <1,220> <EUR>
- Next 24 months: <2,445> <EUR> -> set aside <102> per month
- Big items ahead: <timing belt kit and water pump, Nov 2027, about 680>
## To check
- [ ] <no record of the timing belt - check the service book / ask the dealer>
- [ ] <exact brake fluid interval for this engine>
- [ ] <warranty: does it require dealer servicing?>
## Checklist for the workshop
- Car: <make, model, engine, VIN if asked>, odometer <...>
- Please do: <list from "Do now">
- Please check and quote before replacing: <brake pads/discs, tyres, wipers, battery>
- Please stamp the service book / send a digital record and note the odometer on the invoice.
*Intervals from <source>; costs are estimates. Get anything unusual (warning lights, noises, pulling, vibration) checked straight away.*
FILE:examples/car.json
{
"vehicle": {"name": "2019 Skoda Octavia Estate 1.5 TSI (petrol)", "first_registered": "2019-11-14", "unit": "km"},
"odometer": {"reading": 83400, "date": "2026-11-04"},
"driving": {"default_per_year": 12000, "severe": false, "severe_reasons": ["mostly short trips in winter", "occasional caravan towing"]},
"currency": "EUR",
"horizon_months": 24,
"due_soon": {"km": 1500, "days": 45},
"bundle": {"km": 2500, "days": 60},
"schedule": [
{"item": "Engine oil and filter", "km": 15000, "months": 12, "severe_km": 7500, "severe_months": 6, "cost": 140, "aliases": ["oil service", "oil change"]},
{"item": "Inspection service", "km": 30000, "months": 24, "severe_km": 20000, "cost": 190, "aliases": ["inspection"]},
{"item": "Brake inspection", "km": 20000, "months": 12, "cost": 40, "safety": true},
{"item": "Brake fluid", "months": 24, "cost": 85, "safety": true},
{"item": "Air filter", "km": 60000, "months": 48, "severe_km": 30000, "cost": 45},
{"item": "Cabin filter", "km": 30000, "months": 24, "cost": 40, "aliases": ["pollen filter"]},
{"item": "Spark plugs", "km": 60000, "months": 72, "cost": 160},
{"item": "Tyre rotation", "km": 10000, "cost": 30},
{"item": "Tyres (set of 4)", "km": 45000, "months": 72, "cost": 560, "safety": true},
{"item": "Timing belt kit and water pump", "km": 120000, "months": 96, "cost": 680},
{"item": "Roadworthiness test", "months": 12, "first_months": 36, "cost": 60, "legal": true, "aliases": ["mot", "periodic inspection"]}
]
}
FILE:examples/service-log.csv
date,odometer,item,cost,shop,notes
2020-11-20,11500,Oil service; Brake inspection,165,Main dealer,first service
2021-11-05,22800,Oil service; Brake inspection; Cabin filter,210,Main dealer,
2022-05-28,33900,Oil service; Inspection service; Brake fluid; Air filter,395,Main dealer,
2022-11-08,38000,Roadworthiness test,55,Test centre,passed
2023-06-15,45150,Oil service; Brake inspection; Tyre rotation; Tyres (set of 4),735,QuickFit,new tyres 205/55 R16
2023-09-02,4790,Windscreen chip repair,60,GlassFix,
2023-11-13,49000,Roadworthiness test,55,Test centre,passed
2024-07-01,56900,Oil service; Inspection service; Brake fluid; Spark plugs; Brake inspection,520,Main dealer,
2024-11-12,59800,Roadworthiness test,58,Test centre,advisory: rear wiper
2025-08-20,70100,Oil service; Brake inspection; Cabin filter; Tyre rotation,240,Corner Garage,
2025-11-20,73000,Roadworthiness test,60,Test centre,passed
2026-02-14,75300,Oil change; Brake inspection,150,Corner Garage,dash light came on
FILE:examples/service-plan.md
# Service plan: 2019 Skoda Octavia Estate 1.5 TSI (petrol)
Today 2026-11-04, odometer 83,400 km. You drive about 11,008 km/year (2025-08-20 70,100 -> 2026-11-04 83,400). Normal-service intervals, plan horizon 24 months. Source: examples/service-log.csv.
**Verdict: 4 item(s) overdue, including safety/legal items - book a visit now**
Next 12 months: about 1,220.00 EUR; next 24 months: 2,445.00 EUR (set aside about 101.88 EUR per month). Findings: 1 high, 6 medium, 2 low, 3 info.
## Items
| Item | Interval | Last done | Due by date | Due at km | Next (projected) | Trigger | Status | Est. cost |
|---|---|---|---|---|---|---|---|---|
| Inspection service | 30,000 km / 24 months | 2024-07-01 at 56,900 | 2026-07-01 | 86,900 | 2026-11-04 | time | **OVERDUE** | 190.00 |
| Brake fluid [safety] | 24 months | 2024-07-01 at 56,900 | 2026-07-01 | - | 2026-11-04 | time | **OVERDUE** | 85.00 |
| Air filter | 60,000 km / 48 months | 2022-05-28 at 33,900 | 2026-05-28 | 93,900 | 2026-11-04 | time | **OVERDUE** | 45.00 |
| Tyre rotation | 10,000 km | 2025-08-20 at 70,100 | - | 80,100 | 2026-11-04 | distance | **OVERDUE** | 30.00 |
| Roadworthiness test [legal] | 12 months | 2025-11-20 at 73,000 | 2026-11-20 | - | 2026-11-20 | time | **DUE SOON** | 60.00 |
| Engine oil and filter | 15,000 km / 12 months | 2026-02-14 at 75,300 | 2027-02-14 | 90,300 | 2027-02-14 | time | OK | 140.00 |
| Brake inspection [safety] | 20,000 km / 12 months | 2026-02-14 at 75,300 | 2027-02-14 | 95,300 | 2027-02-14 | time | OK | 40.00 |
| Tyres (set of 4) [safety] | 45,000 km / 72 months | 2023-06-15 at 45,150 | 2029-06-15 | 90,150 | 2027-06-15 | distance | OK | 560.00 |
| Cabin filter | 30,000 km / 24 months | 2025-08-20 at 70,100 | 2027-08-20 | 100,100 | 2027-08-20 | time | OK | 40.00 |
| Timing belt kit and water pump | 120,000 km / 96 months | no record (counted from new, 2019-11-14) | 2027-11-14 | 120,000 | 2027-11-14 | time | OK | 680.00 |
| Spark plugs | 60,000 km / 72 months | 2024-07-01 at 56,900 | 2030-07-01 | 116,900 | 2029-11-18 | distance | OK | 160.00 |
## Workshop visits (items within 60 days / 2,500 km are bundled)
| # | Book by | At about | Items | Est. cost |
|---|---|---|---|---|
| 1 | 2026-11-04 (now) | 83,400 km | Air filter (overdue), Brake fluid (overdue), Inspection service (overdue), Tyre rotation (overdue), Roadworthiness test | 410.00 |
| 2 | 2027-02-14 | 86,476 km | Brake inspection, Engine oil and filter | 180.00 |
| 3 | 2027-06-15 | 90,125 km | Tyres (set of 4) | 560.00 |
| 4 | 2027-08-20 | 92,116 km | Cabin filter, Tyre rotation | 70.00 |
| 5 | 2027-11-14 | 94,710 km | Timing belt kit and water pump, Roadworthiness test | 740.00 |
| 6 | 2028-02-14 | 97,484 km | Brake inspection, Engine oil and filter | 180.00 |
| 7 | 2028-08-27 | 103,365 km | Tyre rotation | 30.00 |
| 8 | 2028-11-04 | 105,446 km | Brake fluid, Inspection service | 275.00 |
## Cost forecast by quarter
| Quarter | Est. cost |
|---|---|
| 2026-Q4 | 410.00 |
| 2027-Q1 | 180.00 |
| 2027-Q2 | 560.00 |
| 2027-Q3 | 40.00 |
| 2027-Q4 | 770.00 |
| 2028-Q1 | 180.00 |
| 2028-Q3 | 30.00 |
| 2028-Q4 | 275.00 |
| **Total 24 months** | **2,445.00** |
## Findings
- **HIGH CS01** Brake fluid is overdue by 126 days (safety item) - book it now
- **MEDIUM CS01** Air filter is overdue by 160 days - book it now
- **MEDIUM CS01** Inspection service is overdue by 126 days - book it now
- **MEDIUM CS01** Tyre rotation is overdue by 3,300 km - book it now
- **MEDIUM CS02** Roadworthiness test is due soon: 2026-11-20
- **MEDIUM CS03** Timing belt kit and water pump: no record in your log - counted from new; check the service book or receipts
- **MEDIUM CS04** row 7: odometer 4,790 on 2023-09-02 is lower than 45,150 on 2023-06-15 - typo or wrong car? reading ignored
- **LOW CS08** Timing belt kit and water pump (about 680.00) falls in the plan on 2027-11-14 - budget for it now
- **LOW CS08** Tyres (set of 4) (about 560.00) falls in the plan on 2027-06-15 - budget for it now
- **INFO CS05** Brake inspection was last done after 5,200 km / 6 months (under 60% of the interval) - fine for severe use, otherwise money spent early
- **INFO CS05** Engine oil and filter was last done after 5,200 km / 6 months (under 60% of the interval) - fine for severe use, otherwise money spent early
- **INFO CS07** row 7: 'Windscreen chip repair' is not in your schedule - kept in the history only
Intervals and prices come from your car file: check them against the owner's manual or service booklet for your exact engine, and follow your workshop if it finds wear earlier. Costs are estimates; tyres and brakes also depend on wear, not only on distance.
FILE:scripts/car_service_plan.py
#!/usr/bin/env python3
"""Car maintenance service schedule planner (stdlib only, Python 3.8+).
Reads your car's maintenance schedule (items with distance and/or time intervals, from the
owner's manual or service booklet) and your service history (CSV), estimates how far you drive
from the odometer readings in the log, and works out for every item when it was last done, when
it is next due (distance or time, whichever comes first), what is overdue or due soon, which
items can share one workshop visit, and what maintenance will cost over the next months.
Usage:
car_service_plan.py CAR.json SERVICE_LOG.csv [--odometer N] [--today YYYY-MM-DD] [--severe]
[--horizon MONTHS] [--json]
car_service_plan.py --self-test
Exit: 0 = nothing overdue on a safety/legal item, 1 = a safety or legal item is overdue (or the
log has a high finding), 2 = bad input.
"""
import argparse, calendar, csv, datetime, io, json, sys
SEV = {"high": 0, "medium": 1, "low": 2, "info": 3}
CAR_KEYS = {"vehicle", "odometer", "driving", "currency", "horizon_months", "due_soon", "bundle", "schedule"}
class InputError(Exception):
pass
def pdate(s, what):
try:
return datetime.date.fromisoformat(str(s).strip())
except ValueError:
raise InputError("%s must be YYYY-MM-DD, got %r" % (what, s))
def add_months(d, m):
y, mo = divmod(d.month - 1 + int(m), 12)
y += d.year
mo += 1
return datetime.date(y, mo, min(d.day, calendar.monthrange(y, mo)[1]))
def num(x, what):
try:
return float(str(x).replace(",", "").replace(" ", ""))
except ValueError:
raise InputError("%s is not a number: %r" % (what, x))
def fk(x):
return "{:,.0f}".format(x)
def fm(x):
return "{:,.2f}".format(x)
# ---------------------------------------------------------------- load
def load_car(cfg, odometer=None, today=None, severe=None, horizon=None):
if not isinstance(cfg, dict):
raise InputError("car file must be a JSON object")
bad = set(cfg) - CAR_KEYS
if bad:
raise InputError("unknown key(s) in car file: %s" % ", ".join(sorted(bad)))
v = cfg.get("vehicle") or {}
odo = cfg.get("odometer") or {}
drv = cfg.get("driving") or {}
c = {"name": v.get("name", "Car"), "unit": v.get("unit", "km"),
"first": pdate(v["first_registered"], "vehicle.first_registered") if v.get("first_registered") else None,
"today": pdate(today or odo.get("date") or datetime.date.today().isoformat(), "today"),
"odo": num(odometer if odometer is not None else odo.get("reading"), "odometer reading") if (odometer is not None or odo.get("reading") is not None) else None,
"default_per_year": num(drv.get("default_per_year", 12000), "driving.default_per_year"),
"severe": bool(drv.get("severe", False)) if severe is None else severe,
"severe_reasons": drv.get("severe_reasons", []),
"currency": cfg.get("currency", ""),
"horizon": int(horizon or cfg.get("horizon_months", 24)),
"soon_km": num((cfg.get("due_soon") or {}).get("km", 1500), "due_soon.km"),
"soon_days": int((cfg.get("due_soon") or {}).get("days", 45)),
"bundle_km": num((cfg.get("bundle") or {}).get("km", 2500), "bundle.km"),
"bundle_days": int((cfg.get("bundle") or {}).get("days", 60)),
"items": []}
if c["odo"] is None:
raise InputError("give the current odometer reading (odometer.reading or --odometer)")
for i, it in enumerate(cfg.get("schedule") or [], 1):
name = it.get("item")
if not name:
raise InputError("schedule item %d has no 'item' name" % i)
km = it.get("severe_km") if c["severe"] and it.get("severe_km") else it.get("km")
mo = it.get("severe_months") if c["severe"] and it.get("severe_months") else it.get("months")
if not km and not mo:
raise InputError("schedule item '%s' needs km and/or months" % name)
c["items"].append({"item": name, "km": num(km, name + " km") if km else None, "months": int(mo) if mo else None,
"first_months": int(it["first_months"]) if it.get("first_months") else None,
"cost": num(it.get("cost", 0), name + " cost"), "safety": bool(it.get("safety")), "legal": bool(it.get("legal")),
"severe_used": bool(c["severe"] and (it.get("severe_km") or it.get("severe_months"))),
"aliases": [a.lower().strip() for a in it.get("aliases", [])] + [name.lower().strip()]})
if not c["items"]:
raise InputError("car file needs a non-empty 'schedule'")
return c
def load_log(c, text, F):
rows = list(csv.DictReader(io.StringIO(text)))
if rows and not {"date", "odometer", "item"} <= set(rows[0]):
raise InputError("service log needs columns date, odometer, item (has %s)" % ", ".join(rows[0]))
entries, readings = [], []
for n, r in enumerate(rows, 2):
try:
d = pdate(r.get("date"), "date (row %d)" % n)
except InputError as e:
F.append(("high", "CS04", "row %d: %s - row ignored" % (n, e))); continue
if d > c["today"]:
F.append(("high", "CS04", "row %d: date %s is in the future - row ignored" % (n, d))); continue
odo = None
if (r.get("odometer") or "").strip():
try:
odo = num(r["odometer"], "odometer (row %d)" % n)
except InputError as e:
F.append(("medium", "CS04", "%s - distance ignored for this row" % e))
items = [x.strip() for x in (r.get("item") or "").split(";") if x.strip()]
entries.append({"row": n, "date": d, "odo": odo, "items": items, "cost": r.get("cost", ""), "shop": r.get("shop", "")})
entries.sort(key=lambda e: (e["date"], e["row"]))
last = None
for e in entries:
if e["odo"] is None:
continue
if e["odo"] > c["odo"]:
F.append(("high", "CS04", "row %d: odometer %s on %s is higher than the current reading %s - typo? reading ignored" % (e["row"], fk(e["odo"]), e["date"], fk(c["odo"]))))
e["odo"] = None; continue
if last and e["odo"] < last["odo"]:
F.append(("medium", "CS04", "row %d: odometer %s on %s is lower than %s on %s - typo or wrong car? reading ignored" % (e["row"], fk(e["odo"]), e["date"], fk(last["odo"]), last["date"])))
e["odo"] = None; continue
last = e
readings.append((e["date"], e["odo"]))
readings.append((c["today"], c["odo"]))
return entries, readings
def distance_rate(c, readings, F):
"""km per day from the latest reading at least a year before today (else the earliest)."""
end_d, end_o = readings[-1]
older = [r for r in readings[:-1] if (end_d - r[0]).days >= 365]
start = older[-1] if older else (readings[0] if len(readings) > 1 else None)
if not start or (end_d - start[0]).days < 60 or end_o <= start[1]:
F.append(("medium", "CS09", "not enough odometer readings to estimate your yearly distance - using the default %s %s/year" % (fk(c["default_per_year"]), c["unit"])))
return c["default_per_year"] / 365.0, "default"
rate = (end_o - start[1]) / float((end_d - start[0]).days)
return rate, "%s %s -> %s %s" % (start[0], fk(start[1]), end_d, fk(end_o))
# ---------------------------------------------------------------- plan
def plan(c, log_text):
F = []
entries, readings = load_log(c, log_text, F)
rate, basis = distance_rate(c, readings, F)
known = {a: it for it in c["items"] for a in it["aliases"]}
done = {it["item"]: [] for it in c["items"]}
for e in entries:
for name in e["items"]:
it = known.get(name.lower())
if not it:
F.append(("info", "CS07", "row %d: '%s' is not in your schedule - kept in the history only" % (e["row"], name)))
continue
done[it["item"]].append(e)
def date_at_km(km):
return c["today"] + datetime.timedelta(days=max(0.0, (km - c["odo"]) / rate)) if rate > 0 else None
def km_at_date(d):
return c["odo"] + rate * (d - c["today"]).days
end = add_months(c["today"], c["horizon"])
rows, occ = [], []
for it in c["items"]:
hist = done[it["item"]]
last = hist[-1] if hist else None
if last:
base_d, base_km, base_note = last["date"], last["odo"], "%s at %s" % (last["date"], fk(last["odo"]) if last["odo"] is not None else "?")
else:
if not c["first"]:
raise InputError("no record of '%s' and no vehicle.first_registered to count from" % it["item"])
base_d, base_km, base_note = c["first"], 0.0, "no record (counted from new, %s)" % c["first"]
F.append(("medium", "CS03", "%s: no record in your log - counted from new; check the service book or receipts" % it["item"]))
months = it["months"]
if not last and it["first_months"]:
months = it["first_months"]
due_d = add_months(base_d, months) if months else None
due_km = base_km + it["km"] if (it["km"] and base_km is not None) else None
km_d = date_at_km(due_km) if due_km is not None else None
cands = [(x, w) for x, w in ((due_d, "time"), (km_d, "distance")) if x]
if not cands:
due_date, trigger = c["today"], "unknown"
else:
due_date, trigger = min(cands)
overdue = (due_d is not None and due_d < c["today"]) or (due_km is not None and due_km < c["odo"])
soon = not overdue and ((due_d is not None and (due_d - c["today"]).days <= c["soon_days"]) or
(due_km is not None and due_km - c["odo"] <= c["soon_km"]))
status = "OVERDUE" if overdue else ("DUE SOON" if soon else "OK")
if overdue:
late = []
if due_d is not None and due_d < c["today"]:
late.append("%d days" % (c["today"] - due_d).days)
if due_km is not None and due_km < c["odo"]:
late.append("%s %s" % (fk(c["odo"] - due_km), c["unit"]))
sev = "high" if (it["safety"] or it["legal"]) else "medium"
F.append((sev, "CS01", "%s is overdue by %s%s - book it now" % (it["item"], " and ".join(late), " (safety item)" if it["safety"] else (" (legal requirement)" if it["legal"] else ""))))
elif soon:
F.append(("medium", "CS02", "%s is due soon: %s%s" % (it["item"], due_d if due_d and (due_d - c["today"]).days <= c["soon_days"] else "", (" / at %s %s" % (fk(due_km), c["unit"])) if due_km is not None else "")))
if len(hist) >= 2 and hist[-2]["odo"] is not None and hist[-1]["odo"] is not None:
a, b = hist[-2], hist[-1]
fr = []
if it["km"]:
fr.append((b["odo"] - a["odo"]) / it["km"])
if it["months"]:
fr.append(((b["date"] - a["date"]).days / 30.44) / it["months"])
if fr and max(fr) < 0.6:
F.append(("info", "CS05", "%s was last done after %s %s / %d months (under 60%% of the interval) - fine for severe use, otherwise money spent early" % (it["item"], fk(b["odo"] - a["odo"]), c["unit"], round((b["date"] - a["date"]).days / 30.44))))
rows.append({"item": it["item"], "last": base_note, "interval": " / ".join(x for x in [("%s %s" % (fk(it["km"]), c["unit"])) if it["km"] else "", ("%d months" % months) if months else ""] if x),
"due_date": due_d, "due_km": due_km, "next": max(due_date, c["today"]), "trigger": trigger, "status": status, "cost": it["cost"],
"safety": it["safety"] or it["legal"], "flag": "legal" if it["legal"] else ("safety" if it["safety"] else ""), "severe": it["severe_used"]})
# occurrences in the horizon (each one assumed done on its due date)
d, k, first = max(due_date, c["today"]), due_km, True
guard = 0
while d <= end and guard < 50:
guard += 1
occ.append({"item": it["item"], "date": d, "km": km_at_date(d), "cost": it["cost"], "overdue": first and overdue})
first = False
nd = add_months(d, it["months"]) if it["months"] else None
nk = km_at_date(d) + it["km"] if it["km"] else None
cands = [x for x in (nd, date_at_km(nk) if nk is not None else None) if x]
if not cands:
break
d = min(cands)
if (it["cost"] >= 400) and any(o["item"] == it["item"] for o in occ):
o = next(o for o in occ if o["item"] == it["item"])
F.append(("low", "CS08", "%s (about %s) falls in the plan on %s - budget for it now" % (it["item"], fm(it["cost"]), o["date"])))
if c["severe"]:
F.append(("info", "CS06", "severe-service intervals used%s" % ((" (" + ", ".join(c["severe_reasons"]) + ")") if c["severe_reasons"] else "")))
# visits: group occurrences within the bundle window
occ.sort(key=lambda o: (o["date"], o["item"]))
visits = []
for o in occ:
v = visits[-1] if visits else None
if v and (o["date"] - v["date"]).days <= c["bundle_days"] and o["km"] - v["km"] <= c["bundle_km"]:
v["items"].append(o); continue
visits.append({"date": o["date"], "km": o["km"], "items": [o]})
# costs by quarter
quarters = {}
for o in occ:
q = "%d-Q%d" % (o["date"].year, (o["date"].month - 1) // 3 + 1)
quarters[q] = quarters.get(q, 0.0) + o["cost"]
y1 = sum(o["cost"] for o in occ if o["date"] < add_months(c["today"], 12))
total = sum(o["cost"] for o in occ)
F.sort(key=lambda f: (SEV[f[0]], f[1], f[2]))
return {"car": c, "rate": rate, "basis": basis, "rows": rows, "occ": occ, "visits": visits, "quarters": quarters,
"next12": y1, "total": total, "findings": F, "entries": entries}
# ---------------------------------------------------------------- report
def report(res, title):
c, F = res["car"], res["findings"]
cnt = {s: sum(1 for f in F if f[0] == s) for s in SEV}
cur = (" " + c["currency"]) if c["currency"] else ""
ov = [r for r in res["rows"] if r["status"] == "OVERDUE"]
soon = [r for r in res["rows"] if r["status"] == "DUE SOON"]
L = ["# Service plan: %s" % c["name"], ""]
L.append("Today %s, odometer %s %s. You drive about %s %s/year (%s). %s intervals, plan horizon %d months. Source: %s." % (
c["today"], fk(c["odo"]), c["unit"], fk(res["rate"] * 365), c["unit"], res["basis"], "Severe-service" if c["severe"] else "Normal-service", c["horizon"], title))
L.append("")
if any(r["safety"] for r in ov):
L.append("**Verdict: %d item(s) overdue, including safety/legal items - book a visit now**" % len(ov))
elif ov:
L.append("**Verdict: %d item(s) overdue - book a visit soon**" % len(ov))
elif soon:
L.append("**Verdict: nothing overdue, %d item(s) due soon**" % len(soon))
else:
L.append("**Verdict: up to date**")
L.append("")
L.append("Next 12 months: about %s%s; next %d months: %s%s (set aside about %s%s per month). Findings: %d high, %d medium, %d low, %d info." % (
fm(res["next12"]), cur, c["horizon"], fm(res["total"]), cur, fm(res["total"] / c["horizon"]), cur, cnt["high"], cnt["medium"], cnt["low"], cnt["info"]))
L += ["", "## Items", "", "| Item | Interval | Last done | Due by date | Due at %s | Next (projected) | Trigger | Status | Est. cost |" % c["unit"], "|---|---|---|---|---|---|---|---|---|"]
order = {"OVERDUE": 0, "DUE SOON": 1, "OK": 2}
for r in sorted(res["rows"], key=lambda r: (order[r["status"]], r["next"])):
L.append("| %s%s | %s | %s | %s | %s | %s | %s | %s | %s |" % (
r["item"], (" [%s]" % r["flag"]) if r["flag"] else "", r["interval"] + (" (severe)" if r["severe"] else ""), r["last"],
r["due_date"] or "-", fk(r["due_km"]) if r["due_km"] is not None else "-", r["next"], r["trigger"],
"**%s**" % r["status"] if r["status"] != "OK" else "OK", fm(r["cost"])))
L += ["", "## Workshop visits (items within %d days / %s %s are bundled)" % (c["bundle_days"], fk(c["bundle_km"]), c["unit"]), "",
"| # | Book by | At about | Items | Est. cost |", "|---|---|---|---|---|"]
for i, v in enumerate(res["visits"], 1):
L.append("| %d | %s%s | %s %s | %s | %s |" % (i, v["date"], " (now)" if v["date"] == c["today"] else "", fk(v["km"]), c["unit"],
", ".join(o["item"] + (" (overdue)" if o["overdue"] else "") for o in v["items"]), fm(sum(o["cost"] for o in v["items"]))))
L += ["", "## Cost forecast by quarter", "", "| Quarter | Est. cost |", "|---|---|"]
for q in sorted(res["quarters"]):
L.append("| %s | %s |" % (q, fm(res["quarters"][q])))
L.append("| **Total %d months** | **%s** |" % (c["horizon"], fm(res["total"])))
L += ["", "## Findings", ""]
L += ["- **%s %s** %s" % (s.upper(), code, m) for s, code, m in F] or ["- none"]
L += ["", "Intervals and prices come from your car file: check them against the owner's manual or service booklet for your exact engine, and follow your workshop if it finds wear earlier. Costs are estimates; tyres and brakes also depend on wear, not only on distance."]
return "\n".join(L) + "\n"
def to_json(res):
def j(x):
if isinstance(x, datetime.date):
return x.isoformat()
if isinstance(x, float):
return round(x, 2)
if isinstance(x, dict):
return {k: j(v) for k, v in x.items()}
if isinstance(x, list):
return [j(v) for v in x]
return x
return j({"km_per_year": res["rate"] * 365, "items": res["rows"], "visits": [{"date": v["date"], "km": v["km"], "items": [o["item"] for o in v["items"]],
"cost": sum(o["cost"] for o in v["items"])} for v in res["visits"]], "quarters": res["quarters"], "next12": res["next12"], "total": res["total"],
"findings": [{"severity": s, "code": c, "message": m} for s, c, m in res["findings"]]})
# ---------------------------------------------------------------- self-test
def self_test():
tests = []
def t(name, cond):
tests.append((name, bool(cond)))
def car(**kw):
cfg = {"vehicle": {"name": "T", "first_registered": "2020-01-01"}, "odometer": {"reading": 50000, "date": "2026-01-01"},
"schedule": [{"item": "Oil", "km": 15000, "months": 12, "severe_km": 7500, "severe_months": 6, "cost": 100, "aliases": ["oil change"]}]}
sched = kw.pop("schedule", None)
if sched:
cfg["schedule"] = sched
cfg.update(kw)
return cfg
H = "date,odometer,item,cost,shop\n"
def run(cfg, log, **kw):
return plan(load_car(cfg, **kw), H + log)
def row(res, name):
return next(r for r in res["rows"] if r["item"] == name)
def codes(res):
return [(f[0], f[1]) for f in res["findings"]]
log = "2025-01-01,40000,Oil,90,X\n"
r = run(car(), log)
t("rate from readings: 10000 km/year", abs(r["rate"] * 365 - 10000) < 1)
t("due by time first (12 months)", row(r, "Oil")["due_date"] == datetime.date(2026, 1, 1) and row(r, "Oil")["trigger"] == "time")
t("due today counts as due soon", row(r, "Oil")["status"] == "DUE SOON")
r = run(car(), "2025-06-01,40000,Oil,90,X\n")
t("distance triggers first when driving a lot", row(r, "Oil")["due_km"] == 55000 and row(r, "Oil")["trigger"] == "distance")
r = run(car(), "2024-06-01,30000,Oil,90,X\n")
t("overdue by time and distance -> CS01", row(r, "Oil")["status"] == "OVERDUE" and ("medium", "CS01") in codes(r))
sched = [{"item": "Brake fluid", "months": 24, "cost": 80, "safety": True}]
r = run(car(schedule=sched), "2023-06-01,30000,Brake fluid,80,X\n2025-01-01,40000,Other,0,X\n")
t("overdue safety item -> CS01 high", ("high", "CS01") in codes(r))
t("item not in schedule -> CS07", ("info", "CS07") in codes(r))
r = run(car(), "2025-01-01,40000,oil change,90,X\n")
t("aliases match case-insensitively", row(r, "Oil")["last"].startswith("2025-01-01"))
r = run(car(), "2025-01-01,40000,Wipers,20,X\n")
t("no record -> counted from new, CS03", ("medium", "CS03") in codes(r) and "no record" in row(r, "Oil")["last"])
r = run(car(), "2025-01-01,40000,Oil,90,X\n", severe=True)
t("severe intervals used", row(r, "Oil")["due_date"] == datetime.date(2025, 7, 1) and ("info", "CS06") in codes(r))
r = run(car(), "2024-01-01,30000,Oil,90,X\n2024-06-01,20000,Oil,90,X\n2025-01-01,40000,Oil,90,X\n")
t("odometer going down -> CS04, reading ignored", ("medium", "CS04") in codes(r))
r = run(car(), "2025-01-01,40000,Oil,90,X\n2027-01-01,60000,Oil,90,X\n")
t("future date -> CS04 high", ("high", "CS04") in codes(r))
r = run(car(), "2025-01-01,90000,Oil,90,X\n")
t("reading above current odometer -> CS04 high", ("high", "CS04") in codes(r))
r = run(car(), "")
t("no readings -> default rate + CS09", ("medium", "CS09") in codes(r) and abs(r["rate"] * 365 - 12000) < 1)
r = run(car(), "2025-01-01,40000,Oil,90,X\n2025-04-01,42000,Oil,90,X\n")
t("done early -> CS05", ("info", "CS05") in codes(r))
sched = [{"item": "Test", "months": 12, "first_months": 36, "cost": 50, "legal": True}]
r = run(car(vehicle={"first_registered": "2024-01-01"}, schedule=sched), "2025-01-01,40000,Other,0,X\n")
t("first_months for first test from new", row(r, "Test")["due_date"] == datetime.date(2027, 1, 1))
sched = [{"item": "A", "months": 12, "cost": 100}, {"item": "B", "months": 12, "cost": 50}]
r = run(car(schedule=sched), "2025-02-01,41000,A,0,X\n2025-03-15,42000,B,0,X\n")
t("items within the bundle window share a visit", len(r["visits"][0]["items"]) == 2)
t("horizon repeats yearly items", sum(1 for o in r["occ"] if o["item"] == "A") == 2)
t("12-month and total forecast", r["next12"] == 150 and r["total"] == 300)
sched = [{"item": "Belt", "km": 120000, "months": 96, "cost": 650}]
r = run(car(schedule=sched), "2025-01-01,40000,Other,0,X\n")
t("big-ticket item in horizon -> CS08", ("low", "CS08") in codes(r))
t("add_months clamps month end", add_months(datetime.date(2026, 1, 31), 1) == datetime.date(2026, 2, 28))
try:
load_car(car(schedule=[{"item": "X", "cost": 1}])); ok = False
except InputError:
ok = True
t("item without interval rejected", ok)
try:
load_car(dict(car(), colour="red")); ok = False
except InputError:
ok = True
t("unknown key rejected", ok)
r = run(car(), log)
t("report has verdict, visits and forecast", all(s in report(r, "t") for s in ("**Verdict:", "## Workshop visits", "## Cost forecast")))
for name, ok in tests:
print(("PASS " if ok else "FAIL ") + name)
passed = sum(ok for _, ok in tests)
print("%d/%d passed" % (passed, len(tests)))
return 0 if passed == len(tests) else 1
def main(argv=None):
ap = argparse.ArgumentParser(description="Plan car maintenance from a schedule and a service log.")
ap.add_argument("car", nargs="?")
ap.add_argument("log", nargs="?")
ap.add_argument("--odometer", type=float)
ap.add_argument("--today")
ap.add_argument("--severe", action="store_true", default=None, help="use severe-service intervals")
ap.add_argument("--horizon", type=int, help="months to plan (default from car file, else 24)")
ap.add_argument("--json", action="store_true")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args(argv)
if a.self_test:
return self_test()
if not a.car or not a.log:
ap.print_usage(sys.stderr)
return 2
try:
try:
cfg = json.load(open(a.car, encoding="utf-8"))
except json.JSONDecodeError as e:
raise InputError("car file is not valid JSON: %s" % e)
c = load_car(cfg, a.odometer, a.today, a.severe, a.horizon)
res = plan(c, open(a.log, encoding="utf-8-sig").read())
except (OSError, InputError) as e:
print("error: %s" % e, file=sys.stderr)
return 2
if a.json:
print(json.dumps(to_json(res), indent=2))
else:
sys.stdout.write(report(res, a.log))
return 1 if any(f[0] == "high" for f in res["findings"]) else 0
if __name__ == "__main__":
sys.exit(main())Opinionated conventions for building accessible, themeable and well-versioned design systems and component libraries. 来源:https://prompts.chat/prompts/cmw1tjxb9000cl808x8314cqr
# Design System taste of a pragmatic design systems engineer - Define design tokens for color, spacing, typography, radius and motion in one source file, and generate CSS variables and platform outputs from it. Confidence: 0.90 - Name tokens by purpose, such as color-text-muted or space-inset-md, not by raw value like gray-500 or 16px. Confidence: 0.85 - Support light and dark themes by swapping semantic token values, never by overriding individual component styles. Confidence: 0.90 - Build components on accessible headless primitives such as Radix UI or React Aria instead of re-implementing focus and keyboard logic. Confidence: 0.85 - Meet WCAG 2.2 AA contrast for every token pairing and check it automatically when tokens change. Confidence: 0.90 - Keep the component API small: a few well-named variants and sizes beat dozens of boolean props. Confidence: 0.85 - Prefer composition with slots or children over configuration objects for complex components like dialogs and menus. Confidence: 0.80 - Forward refs and spread remaining props onto the root element so components work with forms, tooltips and tests. Confidence: 0.85 - Never hard-code colors, spacing or font sizes inside components; lint for raw values with Stylelint or an ESLint rule. Confidence: 0.85 - Document every component in Storybook with usage guidance, do and don't examples, and live controls. Confidence: 0.85 - Run visual regression tests with Chromatic or Playwright screenshots on every pull request. Confidence: 0.80 - Run automated accessibility checks with axe in component tests, and still test keyboard and screen reader flows by hand before release. Confidence: 0.85 - Publish the library as a versioned package with semantic versioning and a changelog generated by Changesets. Confidence: 0.90 - Treat a renamed prop or removed variant as a breaking change, and ship codemods for large migrations. Confidence: 0.75 - Deprecate before removing: mark the old API, log a development-only warning, and remove it in the next major version. Confidence: 0.85 - Ship ESM with tree-shakeable per-component entry points and mark CSS files as side effects correctly. Confidence: 0.80 - Respect prefers-reduced-motion and keep motion tokens short and purposeful. Confidence: 0.80 - Keep icons as a separate package of optimized SVG components with consistent size and stroke tokens. Confidence: 0.75 - Use logical CSS properties like margin-inline-start so components work in right-to-left layouts. Confidence: 0.75 - Keep Figma components and code components in sync by sharing token names and reviewing drift every release. Confidence: 0.70 - Measure adoption by scanning product repositories for library imports and one-off overrides. Confidence: 0.65 - Accept contributions through a short proposal template that covers the use case, API and accessibility before any code. Confidence: 0.70
Opinionated conventions for structuring, testing and shipping dbt analytics projects. 来源:https://prompts.chat/prompts/cmw1tkqu20009lb08cd32fshv
# dbt Analytics Project taste of a careful analytics engineer - Layer models into staging, intermediate and marts folders, with one staging model per source table. Confidence: 0.90 - Keep staging models to renaming, casting and light cleanup only; put joins and business logic in intermediate and mart models. Confidence: 0.90 - Reference raw data only through source() in staging models and use ref() everywhere else. Confidence: 0.95 - Name models with clear prefixes such as stg_, int_, fct_ and dim_ so their role is obvious in lineage graphs. Confidence: 0.85 - Declare a primary key for every model and test it with unique and not_null tests. Confidence: 0.95 - Add relationships and accepted_values tests on important foreign keys and status columns. Confidence: 0.85 - Configure source freshness checks with warn and error thresholds and run them before the main build. Confidence: 0.85 - Write SQL in lowercase with CTEs at the top, one CTE per logical step, and a final select from the last CTE. Confidence: 0.80 - Lint and format SQL with SQLFluff in CI so style debates never reach code review. Confidence: 0.80 - Use incremental models only when full refreshes are too slow, and always define unique_key and a late-arriving data window. Confidence: 0.85 - Materialize staging models as views and marts as tables or incremental models, unless profiling shows a better choice. Confidence: 0.75 - Keep business metrics defined once in the semantic layer or a single mart, not recalculated in every dashboard. Confidence: 0.85 - Document every mart model and its columns in YAML, including the grain of the table in plain words. Confidence: 0.90 - Run slim CI with state:modified+ and deferral against production artifacts so pull requests build only what changed. Confidence: 0.85 - Build each pull request into its own temporary schema and drop it after merge. Confidence: 0.80 - Pin dbt and package versions in packages.yml and the project requirements, and upgrade them deliberately. Confidence: 0.85 - Keep macros few and well tested; prefer readable SQL over clever Jinja. Confidence: 0.80 - Add unit tests for tricky transformation logic using dbt unit tests with small, explicit input fixtures. Confidence: 0.75 - Use snapshots for slowly changing dimensions only where history is genuinely needed by the business. Confidence: 0.75 - Tag models by owner and domain, and route test failures to the owning team's channel. Confidence: 0.70 - Avoid select * in marts; list columns explicitly so schema changes upstream do not silently leak through. Confidence: 0.85 - Set model contracts on public marts that other teams or tools depend on. Confidence: 0.70
Opinionated conventions for designing reliable, typed and easy-to-use API client SDKs. 来源:https://prompts.chat/prompts/cmw1tlj51000bku08cajuaywd
# API Client SDK taste of a developer-experience focused SDK maintainer - Generate the low-level client from an OpenAPI spec, and hand-write only a thin, idiomatic layer on top. Confidence: 0.85 - Treat the API spec as the source of truth and fail CI when the generated code is out of date. Confidence: 0.85 - Make the first call possible in three lines: install, create a client with an API key, call one method. Confidence: 0.90 - Read the API key from a well-known environment variable by default, and never log it or include it in error messages. Confidence: 0.95 - Expose fully typed request and response models, with TypeScript types or Python type hints shipped in the package. Confidence: 0.90 - Return typed errors with the HTTP status, error code, message and request ID, instead of a generic exception. Confidence: 0.90 - Retry idempotent requests on timeouts, 429 and 5xx responses with exponential backoff and jitter, and respect Retry-After headers. Confidence: 0.90 - Send an idempotency key automatically on retried POST requests when the API supports it. Confidence: 0.80 - Set sensible default timeouts and let callers override timeout and retry settings per client and per request. Confidence: 0.85 - Provide auto-paginating iterators so users can loop over all results without handling cursors themselves. Confidence: 0.85 - Support streaming responses with native async iterators or generators instead of callbacks. Confidence: 0.80 - Offer both sync and async clients in languages where both are common, sharing the same models. Confidence: 0.75 - Keep runtime dependencies minimal and avoid pinning users to a specific HTTP library version. Confidence: 0.85 - Send a User-Agent header with the SDK name, version and runtime so API owners can see adoption and debug issues. Confidence: 0.80 - Follow semantic versioning strictly and document breaking changes with migration examples in the changelog. Confidence: 0.90 - Never remove a method in a minor release; deprecate it with a warning first. Confidence: 0.85 - Test against recorded HTTP fixtures for speed and run a smaller suite against a real sandbox environment nightly. Confidence: 0.80 - Run the test suite on every supported runtime version in CI, such as each active Node.js LTS or Python release. Confidence: 0.85 - Put runnable code examples in the README and docs, and test those examples in CI so they never go stale. Confidence: 0.80 - Expose hooks or middleware for logging, tracing and custom headers instead of adding one-off options for each need. Confidence: 0.75 - Allow a custom base URL and HTTP client so users can point the SDK at proxies, mocks or regional endpoints. Confidence: 0.80 - Publish packages from CI with provenance and signed releases, never from a maintainer's laptop. Confidence: 0.75
A detailed build prompt for AI coding models that produces a real-time, endlessly looping cyberpunk pixel-art city diorama for a 10-hour lo-fi music video: a rain-soaked megacity at 2:37 AM with warm windows, flickering neon, fog, steam and tiny moving details, rendered at 640×360 and upscaled exactly 3× with nearest-neighbor for razor-sharp pixels, all delivered as one standalone HTML file with a clean full-screen mode for recording. 来源:https://prompts.chat/prompts/cmw1ynqh10007l908w6kkni0t
# Build an Immersive, Animated Cyberpunk Pixel-Art Diorama Create a breathtaking, richly detailed, fully animated cyberpunk pixel-art diorama designed to serve as the endlessly looping visual backdrop for a 10-hour lo-fi music video on YouTube. I want something genuinely mesmerizing. A tiny, self-contained world that feels alive. The kind of scene viewers can stare at for hours and continually discover little details they hadn't noticed before. ## Creative Direction Imagine a forgotten corner of a sprawling futuristic megacity, sometime around 2089. It's 2:37 AM. Rain has been falling for days. The streets are soaked, the buildings are ancient and overcrowded, and the city glows with a thousand little signs of life. The atmosphere should feel simultaneously melancholic, cozy, mysterious, and strangely comforting. This is a world where technology has taken over everything, but human warmth still survives in little pockets. Think cinematic cyberpunk meets the intimate charm of meticulously crafted pixel-art environments. Don't settle for a generic neon skyline. Create a place with personality, history, visual storytelling, and a strong sense of depth. ## Visual Composition Design a beautiful, layered environment with a compelling focal point and plenty of environmental storytelling. Consider elements such as: - A tiny apartment or rooftop hideaway overlooking a vast, rain-drenched futuristic city. - Glowing windows revealing glimpses of other people's lives. - Holographic advertisements, animated signage, and distant traffic. - Clusters of air-conditioning units, ventilation ducts, exposed wiring, satellite dishes, and improvised technology. - Warmly illuminated vending machines, computer terminals, and mysterious electronic equipment. - Rainwater collecting in puddles and cascading from rooftops. - Distant elevated trains or flying vehicles silently moving through the mist. - Tiny human silhouettes occasionally visible behind windows. - Overgrown plants reclaiming corners of this technological world. These are creative possibilities, not a mandatory checklist. Make deliberate artistic choices. Prioritize a coherent, memorable composition over cramming everything into the frame. The scene should feel handcrafted, with exquisite attention to small details. ## Color and Lighting Build the entire visual identity around dramatic warm/cold contrast. The outside world is dominated by deep midnight blues, desaturated teals, indigo shadows, and cold cyan rain. Against this darkness, create beautiful pockets of warmth: - Rich amber light spilling from apartment windows. - Soft orange and golden illumination from lamps and electronics. - Occasional saturated magenta, electric violet, and crimson neon. - Warm reflections shimmering across cold, wet surfaces. Use lighting to tell a story. Every illuminated window should feel like a little sanctuary against the enormous, indifferent city. The overall image should be dark and atmospheric without becoming muddy or losing detail. ## Animation: Make the World Feel Alive This is absolutely critical. I don't want a static illustration with a generic rain effect. I want a living pixel-art environment with dozens of subtle, independently animated details. Build a sophisticated system of layered ambient animations: ### Weather and atmosphere - Beautiful multi-depth rainfall with varying speeds and intensities. - Occasional heavier gusts that push rain diagonally. - Thin layers of drifting fog and atmospheric haze. - Smoke rising from vents and chimneys, curling into the night. - Water dripping from ledges and splashing into puddles. - Ripples spreading across reflective surfaces. ### Lighting and electronics - Neon signs flickering irregularly, with convincing electrical behavior. - Screens cycling through mysterious data and advertisements. - Small indicator lights blinking asynchronously. - Warm interior lights gently fluctuating. - Occasional sparks from damaged electrical equipment. - Animated reflections responding to nearby light sources. ### Environmental life - Slowly rotating ventilation fans. - Distant vehicles passing behind buildings. - Tiny silhouettes moving behind illuminated windows. - Steam escaping from pipes. - Hanging cables or signs subtly swaying in the wind. - Occasional small, unexpected details that reward prolonged viewing. Every effect should have its own rhythm. Avoid synchronized, repetitive animation that makes the world feel artificial. ## Seamless Infinite Looping This scene must run beautifully for 10 hours or longer without a visible reset, jump, or discontinuity. Design every animation to be mathematically seamless or continuously procedural. Use independent animation cycles, staggered timing, seeded randomness, and layered motion to create the impression of endless organic variation. No sudden resets. No obvious looping rain textures. No visible animation seams. The scene should remain interesting over time without becoming distracting. This is lo-fi music, so the animation should be hypnotic and relaxing rather than chaotic. Reserve dramatic events for rare, carefully controlled moments. ## Pixel-Art Rendering: Non-Negotiable The visual style must be authentic, razor-sharp pixel art, not a high-resolution illustration with a pixelation filter. Technical requirements: - Internal rendering resolution: 640 × 360 - Final presentation resolution: 1920 × 1080 - Exact integer upscale: 3× - Scaling: nearest-neighbor only - Every source pixel must become a perfectly sharp 3×3 block. - Absolutely no bilinear filtering, blurry edges, antialiasing, or subpixel rendering. - No smooth vector graphics mixed into the pixel-art scene. - All effects, including particles, rain, fog, reflections, and lighting, must respect the pixel grid. - Maintain consistent pixel density across every visual layer. The result should look spectacular on a full-screen 1080p YouTube video, with beautiful, deliberately placed hard pixels. ## Technical Implementation Build this as a complete, self-contained, real-time animated experience. Use whatever rendering architecture delivers the best combination of visual quality, performance, and pixel-perfect consistency. HTML5 Canvas, WebGL, or a hybrid approach are all acceptable. Prioritize: - Smooth, efficient rendering at 1080p. - Deterministic, seamless animation. - Rich visual depth through layered parallax and atmospheric perspective. - Procedural particle systems for weather and environmental effects. - Carefully controlled lighting, blending, and reflections. - Stable performance over extremely long runtimes. - A fixed camera with no unintended movement or jitter. - Correct resizing while preserving the 16:9 composition and pixel-perfect scaling. If useful, implement the world as multiple independently animated layers, all composited at the internal resolution before the final upscale. Include a way to run the scene full-screen and hide any interface elements so it can be captured cleanly for YouTube. ## Artistic Quality Bar This should feel like an exceptional piece of animated pixel art, not a programming demonstration. Composition, atmosphere, color harmony, and environmental storytelling matter just as much as technical sophistication. Avoid generic procedural rectangles masquerading as buildings. Give the architecture intentional shapes, recognizable structures, believable construction details, and visual variety. Use foreground silhouettes, middle-ground architecture, and distant city layers to create a strong sense of scale. Every element should contribute to a cohesive world. The ultimate goal: A mesmerizing cyberpunk night scene that makes someone wish they could climb through their monitor, sit beside a warm window with a cup of coffee, and listen to the rain falling on a futuristic city that never sleeps. Make it beautiful. Make it atmospheric. Make it technically excellent. And above all, make it feel alive. Don't just describe how you would build it. Actually implement the complete working experience. Deliver the finished piece as a single standalone HTML file (all code inline, no external assets or build step) that runs by opening it in a browser.
A production-grade 15-second, four-shot video prompt: an old seaman rows alone through dense dawn fog on cold northern waters until a 140-meter three-masted black-sailed galleon slowly emerges ahead, revealed in layers from a side close-up to two wide shots built to sell its enormous scale. It includes continuity anchors, exact shot timings and lenses, physical rules for boat, sails and fog, cold blue grading, a sound design plan and a list of things to avoid. No battle, no ghosts, no dialogue. 来源:https://prompts.chat/prompts/cmw1yq7d5000bl908atu2t9to
[Title] Black Sails in the Fog [Overall Direction] A 15-second photorealistic maritime film sequence in 16:9, composed with a 2.39:1 widescreen sensibility and natural 24fps motion. The sequence contains four distinct shots. The story takes place in cold northern European waters during the medieval period. An elderly white European man rows a small wooden boat alone into dense fog, where he discovers a black-sailed pirate ship far larger than any ordinary vessel. The sequence focuses on the ship being revealed gradually through the fog and on the extreme difference in scale between the fragile rowboat, the elderly man, and the enormous pirate ship. The atmosphere is cold, silent, and oppressive, but there are no supernatural creatures, ghosts, magical effects, or battles. All timber, canvas, ropes, seawater, fog, and human movement must have convincing weight and physical behavior. The footage should feel captured with a full-scale practical sailing ship, a real ocean location, and a cinema camera—not like a video-game cutscene or fantasy concept art. [Fixed Continuity Anchors] Maintain the same elderly white man, the same face, the same distressed medieval clothing, the same four-meter dark wooden rowboat, the same pair of wooden oars, the same three-masted black-sailed pirate ship, and the same cold blue fog-covered sea throughout the sequence. The pirate ship must remain ahead of the old man and slightly to the right. Its bow faces the rowboat at a slight angle. The ship is almost motionless, moving only slowly with the long ocean swell. The rowboat continues moving toward the pirate ship. Wind, waves, and fog consistently move from the left side of the frame toward the right. Cold morning light always comes from the upper left. The pirate ship must retain exactly three main masts, layered black sails, a high forecastle and sterncastle, a long bowsprit, a dark wooden hull, and dense rigging in every shot. Its design must not change between cuts. [The Old Man’s Appearance] The only character is a 74-year-old white European man. He is thin and slightly stooped, but his shoulders and arms retain the wiry strength of a lifetime of physical labor. His pale, coarse skin is deeply marked by cold wind, saltwater, and years of sun exposure. He has prominent cheekbones, a high and slightly crooked nose, deep-set eyes, and an uneven gray-white beard covering his cheeks and chin. His eyes are gray-blue, naturally moist, with small living catchlights. His forehead, eye corners, and mouth carry authentic age lines. His gray-white hair reaches the back of his neck, dampened by sea fog and loosely tied with an old leather cord. Several wet strands cling to his temples and ears. His skin must retain pores, age spots, and wrinkles. Do not beautify, smooth, or artificially dirty his face. [The Old Man’s Clothing] The old man wears a damaged charcoal-gray coarse-wool cloak. Its edges are frayed and repaired with a few simple hand-sewn patches. Moisture from the sea fog has darkened the fabric. Beneath the cloak is a dirty gray-white linen tunic with a loose collar and worn cuffs. A weathered dark-brown leather belt secures the clothing at his waist. He wears dark gray-brown woven trousers and cracked old leather boots. A short length of faded dark reddish-brown wool is tied around his neck. This scarf is the only faint warm accent in the otherwise cold blue image. The clothing must contain no modern stitching, zippers, plastic, branding, reflective strips, or modern flotation equipment. [Character Behavior] The old man’s immediate objective is to cross the fog-covered water and determine what the enormous shadow ahead actually is. His obstacles are the dense fog and the long ocean swell that repeatedly alters the rowboat’s direction. He rows with the practiced economy of an aging seaman. His center of gravity remains low, his feet planted firmly against the bottom of the boat. His lower back moves first, leaning slowly backward; his shoulders and arms then pull the two wooden oars toward his chest. The strength of his left and right arms differs slightly. Each stroke is accompanied by natural breathing, shoulder-blade movement, friction from the oarlocks, and the forward inertia of the boat. His movement is no longer light, but it remains experienced and efficient. When the deep groan of a massive wooden hull reaches him through the fog, his ongoing rowing stroke slows. The left oar blade remains submerged and creates resistance while the right oar stops in midair. Seawater drips continuously from its edge. His eyes leave the surface of the sea first and move toward the shadow above. He gives one slow blink. Only then does his chin rise and his head follow the direction of his gaze. His lips remain closed, his breathing pauses briefly, and his hands tighten around the oar handles. He does not shout or flee. After confirming that an enormous vessel is hidden in the fog, he adjusts his seated posture, braces both feet, and resumes rowing slowly toward the shadow. His performance conveys caution, exhaustion, and an irresistible need to know—not theatrical fear. [The Rowboat] A medieval wooden rowboat approximately 4.2 meters long, constructed from dark, aged oak. The plank seams and wooden fastening pegs are structurally believable. Most of the black waterproof tar coating has worn away, revealing gray-brown wood grain beneath. The gunwales show wear from years of use. The interior remains sparse, containing only one coil of old hemp rope. Do not add fishing nets, lanterns, barrels, crates, or decorative objects. The two wooden oars must retain the same dimensions, color, material, and position throughout the sequence. The boat rises, falls, and rolls gently with long ocean swells approximately one to one-and-a-half meters high. It must have convincing weight and inertia. Its bow creates a small amount of dark spray as it cuts through the water. It must not float or bounce like a lightweight toy. [The Giant Pirate Ship] The pirate ship is based on the structure of an old European three-masted galleon, but it is far larger than any ordinary historical vessel: approximately 140 meters long, with its tallest mast reaching roughly 80 meters. Its scale is extraordinary, but its timber construction, structural loading, rigging connections, and canvas movement must remain physically believable. The hull is built from almost black, heavily weathered oak. Its timbers are wide and heavy, with visible plank seams and matte iron reinforcement along structural edges. The bow is tall and projects forward with oppressive weight, ending in a long, narrow bowsprit. The stern rises into a multi-level sterncastle. Two rows of dark gunports line the sides of the hull, but no cannons are fired. Salt deposits, dark wet marks, and small amounts of sea growth appear around the waterline. The ship is not rotten and does not glow. Three tall main masts carry multiple levels of square black sails. The canvas is heavy and aged, damaged by wind and salt, with naturally torn edges and visible repairs. The sails are not pure black; they contain subtle layers of deep blue-gray, charcoal, and weathered brown. Dense hemp rigging connects the masts, yards, gunwales, and corners of the sails according to authentic sailing-ship construction. A small faded black flag flies from the highest mast. It bears only a simple, weathered bone-white skull-and-crossbones symbol with no written words. No crew members, torches, or artificial lights are visible on deck. The vessel feels unnaturally quiet but remains completely physical and opaque, never transparent or ghostlike. [Ocean, Fog, and Color] The sequence takes place in cold northern waters at dawn. The boundary between sea and sky is nearly erased by thick blue-gray marine fog. The fog must retain realistic depth: The old man and his rowboat remain relatively clear in the foreground. Water several dozen meters away loses contrast. Only portions of the pirate ship are visible in the middle distance. The upper masts and corners of the sails disappear back into the fog. The seawater consists of low-saturation slate blue, deep blue-gray, and faint gray-green tones. Wave crests do not produce unnaturally bright white foam. The sky is pale cold blue mixed with pearl gray. The pirate ship is near-black wood with deep blue-gray sails. The old man’s faded reddish-brown scarf provides the only very weak warm accent. Wind slowly pushes the fog across the ship, allowing its fixed structure to appear in layers. The fog changes what is visible; the vessel itself never changes shape. The fog must not behave like fast-moving smoke, and it must not make the entire image uniformly blurry. [Shot One | 0–3.5 Seconds | Side Close-Up of the Old Man] A 65mm cinema lens. The camera floats beside the left side of the rowboat, slightly below the old man’s shoulder height, rising and falling gently with the same ocean swell. The old man’s side profile occupies the left half of the frame. The background contains only defocused cold-blue seawater and layered fog. The pirate ship must not appear yet. The old man is already midway through a rowing stroke. His lower back moves slowly backward as both hands pull the oars toward his body. Damp gray hair clings to his face, and the coarse wool cloak responds subtly to the sea wind. A broad swell lifts the wooden boat, and the camera rises with it in a restrained, physically grounded movement. One distant, low note from an old bronze ship’s bell travels through the fog, followed by the deep groan of an enormous timber structure under pressure from the sea. The old man’s rowing motion gradually stops. One oar blade remains submerged while the other pauses in midair. Water drips from the edge of the blade. His eyes move toward the upper-right side of the frame first. He gives one slow blink, then gradually raises his head. Cut directly before the head movement is fully completed. Do not reveal what he sees in this shot. [Shot Two | 3.5–10 Seconds | Rear View and Fog-Shrouded Reveal] Cut to a medium-wide rear view of the old man using a 35mm lens. The camera is positioned close to the stern of the rowboat at approximately shoulder height. Preserve the direction of the boat, the old man’s seated position, the direction of the waves, and the direction of the cold light from the previous shot. The old man occupies the lower center of the frame with his back toward the camera. An enormous dark shape rises inside the fog ahead. At first, only the towering bow and one vague vertical mast line can be distinguished. As the fog moves laterally, three enormous masts, layered black sails, dense rigging, and elevated deck structures gradually become visible. The tops of the masts remain hidden. The pirate ship must not appear completely in a single instant. Its structure remains fixed; only its visibility changes as fog passes across it. After a short pause, the old man resumes rowing. He lowers his center of gravity and completes one slow, deliberate stroke, then begins a second. The rowboat moves forward with the swell. Each time the boat rises onto a wave crest, the ship’s shadow appears taller. When the boat descends into a trough, the lower part of the pirate ship is briefly hidden by water and fog. As the old man continues forward, the bow, the dark gunports, and the lowest black sails become gradually clearer. High-altitude wind causes the enormous black sails to fill and relax with delayed, heavy movement. The rigging tightens and loosens slightly. It must not swing wildly. The pirate ship remains almost motionless, advancing into the swell at an extremely slow speed. [Shot Three | 10–12.5 Seconds | First Wide Shot: Bow Pressure] Cut directly to a low, water-level wide shot using a 28mm lens, observing the rowboat from a distance on its left side. A defocused dark-blue wave crest occupies the foreground. The old man and rowboat sit in the middle ground. The pirate ship’s towering bow, lower black sails, and fog-covered masts dominate the background. The rowboat occupies only about 2% of the frame width. The old man is even smaller, but his charcoal cloak and faded reddish-brown scarf remain consistent. The pirate ship’s bow rises from the sea like a dark timber cliff. Large hull planks, gunports, and matte iron reinforcement provide believable scale references. The foreground wave briefly hides the lower half of the rowboat. As the crest falls, the boat reappears in the same spatial relationship to the ship. The vessel’s position, size, and structure remain stable. The upper black sails and masts are partially swallowed by blue-gray fog. This shot emphasizes the vertical height of the bow and the oppressive distance between the small boat and the ship. [Shot Four | 12.5–15 Seconds | Second Wide Shot: Complete Scale] Cut to a distant, slightly elevated extreme-wide shot using a 50mm long lens to compress spatial depth. The camera remains stable without a fast aerial dive, orbit, or rotation. The enormous three-masted pirate ship appears from a front-side angle and dominates the center-right of the frame. Most of the full hull, long bowsprit, high sterncastle, three main masts, and layered black sails enter the frame. The lower hull remains relatively clear, while the upper masts and sail corners fade in and out of the dense fog. The black pirate flag moves slowly at the top of the highest mast. The old man and his boat remain ahead of the ship and slightly to the left, reduced to a tiny dark mark on the cold-blue sea. The rowboat continues rising and falling with the swell as the old man rows slowly. The pirate ship remains heavy, silent, and almost immovable. Maintain a safe distance between both vessels. No collision occurs. Large areas of cold-blue water and empty fog surround the subjects. There is no land, no other vessel, and no modern structure on the horizon. Hold the final composition for approximately half a second, keeping the old man, the small boat, and the enormous black sails visible together. [Editing Logic] The sequence contains four shots and three direct cuts. The first cut moves from the old man raising his head in side profile to the enormous ship shadow seen from behind him. The second cut moves from the old man continuing to row into the low water-level wide shot, revealing the height of the bow. The third cut moves to a farther, slightly elevated extreme-wide shot, revealing the total length of the pirate ship and the surrounding ocean. The two wide shots have different purposes: The first wide shot establishes the vertical pressure of the bow. The second wide shot reveals the full vessel and the final scale relationship between the ship, the ocean, and the rowboat. Do not insert close-ups of skulls, cannons, eyes, rigging hardware, or pirate crew. [Cinematography and Image Texture] A 35mm theatrical release-print look with soft gate edges, medium-low microcontrast, fine and slightly uneven natural grain, layered deep blues and charcoal blacks, and gentle highlight rolloff. Fog and slight condensation along the edge of the lens are the only dominant image imperfections. Do not simultaneously add rain, dust, smoke, sparks, or strong lens flares. The camera retains small amounts of ocean movement and inertia while keeping the horizon generally stable. Close shots use restrained depth of field. Distant contrast decreases naturally through atmospheric perspective. The pirate ship must not appear unnaturally sharp inside the fog, but the entire image must not be covered by a uniform blur filter. [Lighting] The only primary light source is cold dawn light diffused through cloud cover from the upper left. A faint silver-blue edge light touches the left side of the old man’s face, wet hair, wooden oars, and gunwale. The side of the pirate ship facing the light reveals low-contrast cold-gray structure. The backlit side falls toward deep blue-black while preserving readable timber layers. Do not use moonbeams, torchlight, ghostly green illumination, magical glow, lightning, or warm sunset light. [Sound] No dialogue, narration, or musical score. The soundscape consists of wooden oars entering the sea, low water displacement, friction from the oarlocks, creaking rowboat planks, the old man’s controlled but strained breathing, wind softened by fog, one distant bronze ship-bell note, the deep groan of the enormous wooden hull under wave pressure, rigging tightening, and black sails filling slowly with wind. The bell and timber groan are heard before the pirate ship appears in Shot One. Low wooden resonance expands when the ship is revealed in Shot Two. In the final two wide shots, ocean waves, wind, and large areas of silence again dominate the soundscape. [Negative Constraints] Avoid changes to the old man’s face, age, skin tone, beard length, hairstyle, cloak color, scarf, or clothing between shots. Do not make him suddenly younger. Avoid modern clothing, armor, aristocratic costume, stereotypical pirate clothing, modern life jackets, plastic, zippers, modern shoes, wristwatches, firearms, or electronic equipment. Avoid incorrect anatomy, distorted body proportions, extra arms, extra fingers, changing numbers of oars, hands passing through oar handles, rigid rowing, mechanical looping movements, sliding feet, exaggerated screaming, an open-mouthed expression of terror, theatrical fear, heroic posing, empty eyes, or frozen eyes. Avoid changes to the rowboat’s dimensions, color, design, or material. Do not transform the boat into a modern lifeboat, fishing vessel, sailboat, or plastic boat. Avoid weightless floating, sudden sideways movement, violent bouncing, capsizing, flooding, breaking apart, or colliding with the pirate ship. Avoid changes to the pirate ship’s design, length, hull shape, bow, stern, or color. Maintain exactly three main masts. Do not allow masts to appear or disappear. Avoid changing the black sails’ color, melting canvas, incoherent rigging, swapped bow and stern, moving gunports, shipping containers, steam engines, modern motors, steel superstructures, or electric lights. Avoid a cartoon pirate ship, theme-park attraction, miniature model, plastic wood grain, glowing ghost ship, transparent hull, skeleton crew, ghosts, zombies, sea monsters, tentacles, flying ships, magic, green ghost flames, burning sails, cannon battles, explosions, or lightning. Avoid fog disappearing suddenly between cuts or changing color. Do not make the fog behave like fast-moving smoke. The pirate ship must not deform as fog passes over it. Avoid tsunami waves, vertical walls of water, gelatinous water, fabric-like water, smoke-like water, changing wave direction, sudden daylight changes, warm sunset colors, or night. Avoid fast editing, additional shots, location changes, time jumps, flash transitions, dissolves, morphing transitions, camera teleportation, high-speed drone dives, rotating around the ship, sudden zooms, dolly zooms, slow motion, or speed ramping. Avoid video-game cutscenes, fantasy concept art, pirate-themed advertising, uniform sharpness across the entire frame, excessive sharpening, excessive skin smoothing, oversaturation, commercial teal-and-orange grading, neon, lasers, decorative light beams, excessive lens flare, text, subtitles, brand logos, and modern watermarks. Allow the simple skull-and-crossbones pirate flag at the top of the highest mast, but do not place any readable text on the flag.

A short fill-in-the-blanks image prompt for charming paper cut-out storybook characters: name any subject and a simple action and get a full-body character built from layered handmade paper shapes with visible cut edges, soft shadows between layers, flat colorful paper tones and a gentle stop-motion, folk-tale feel on a clean white background. Great for children's book spreads, greeting cards and stickers. 来源:https://prompts.chat/prompts/cmw1yrm210001l007i3i76c74
Paper cut-out animation of a little brown bear in a patchwork apron watering a tall sunflower, full-body character, layered paper shapes, handmade paper texture, visible cut edges, soft shadows between layers, flat colorful paper tones, slightly imperfect handcrafted details, minimal paper-collage scenery, charming folk story aesthetic, gentle stop-motion feel, poetic children’s book atmosphere, clean white background
Learn to type without looking at the keys with a patient coach: a starting-point check, desk and posture setup, the home row and a finger map, core skills like reaches, capitals and numbers, a week-by-week plan with a speed and accuracy check each week, step-by-step drills, a table of common problems and fixes like looking down and old habits under pressure, a kind feedback method, and practice habits that put accuracy before speed. 来源:https://prompts.chat/prompts/cmw37plzx0004kz08tgx4q29p
Act as a Beginner Touch Typing Coach. You help complete beginners and "two-finger" typists learn to touch type step by step, fitted to my current speed, my goals, my keyboard, my time, and the kind of typing I do every day. You think like a patient, encouraging typing teacher who has helped hundreds of adults unlearn hunt-and-peck habits: you put accuracy before speed, you teach the home row and correct fingers before anything else, you explain why each mistake happens and how to fix it, you keep practice short and regular, you set small weekly goals I can measure, and you give honest, kind feedback on my results. You never shame my speed, never push expensive keyboards or paid apps, and never tell me to speed up while my accuracy is still low. My situation: - My current typing: I look at the keys and use two or three fingers on each hand, about 25 words per minute with many mistakes - Why I want to learn: to write emails and reports faster at work without looking at the keyboard - What I type most: emails, chat messages, meeting notes, and some spreadsheets with numbers - My keyboard: a laptop keyboard at work and a cheap external keyboard at home, US QWERTY layout - Time available: 15 minutes on weekday mornings and 20 minutes on the weekend - Practice tools I can use: free online typing practice sites and a plain text editor - My desk and chair: a kitchen table at home, an adjustable office chair at work - What worries me: getting slower at first, my old habits coming back when I am in a hurry, and sore wrists - How I will share my results for feedback: speed and accuracy scores from practice tests, my most common wrong keys, and written notes - Plan length: 6 weeks First, in one short message, ask me for anything important that is missing: my current speed and accuracy from one honest one-minute test, which fingers I use now and whether I look at the keys, my keyboard layout and language (QWERTY, AZERTY, QWERTZ, or another, and whether I need accented letters), laptop or external keyboard, how much time I really have each week, what I type most (prose, code, numbers, or chat), any hand, wrist, shoulder, or vision limits I should plan around, and how I will share my results. Then coach me through these sections: 1. **Starting Point**: a short summary of my current speed, accuracy, and habits, my goal, and a realistic result for the end of the plan (for example, "type all letters without looking at 30 words per minute with 95 percent accuracy, and use touch typing for all my work emails"). 2. **Setup and Posture**: how to sit, chair and screen height, keeping wrists floating and straight, elbows relaxed, the distance from the keyboard, a simple laptop setup at a table, and lighting. Suggest low-cost fixes such as a cushion, a box under the screen, or a separate keyboard only if I already have one. Never name brands as required or invent prices. 3. **Home Row and Finger Map**: the home row keys and the small bumps on F and J, which finger owns which keys in my layout, the thumbs on the space bar, which little finger presses Shift and Enter, and how to return to the home row after every reach. Include a simple text finger map of the keyboard. 4. **Core Skills**: clear explanations of typing without looking (covering the hands with a cloth or light box if needed), reaches to the top and bottom rows, using the opposite Shift key for capitals, common punctuation, the number row and the numeric keypad if I use numbers, steady rhythm instead of bursts, and when to fix a mistake versus keep going. 5. **Weekly Plan**: a week-by-week plan for my plan length and available time, with each session's goal, a warm-up, the main drills, and one short check at the end of each week (speed, accuracy, and the three most-missed keys), growing from the home row to all letters, capitals, punctuation, numbers, and real sentences from my own kind of work. 6. **Drills**: step-by-step instructions for at least 8 drills (for example, home row letters, home row words, one new key pair per day, top-row and bottom-row reaches, capitals with the opposite Shift, common short words and letter pairs like "th", "ing", and "the", a slow rhythm drill to a steady beat, a numbers drill, and copy-typing a short paragraph from my own emails with private details removed), each with time needed, what it teaches, and what "good enough" looks like before moving on. 7. **Common Problems and Fixes**: a table with columns problem, why it happens, how to fix it now, and how to prevent it next time. Include looking down at the keys, using the wrong finger for a key, mixing up neighbor keys, hitting space with the wrong thumb or too hard, rushing and making more mistakes, losing the home row, getting slower in the first weeks, going back to old habits under time pressure, and tired or sore wrists. 8. **Feedback Method**: what I share with you after each check (speed, accuracy, test length, the keys I miss most, and how it felt), and how you give feedback: first one thing that improved, then one or two specific changes to try, then a short drill to practice them. Never more than two corrections at a time, and never judge me on speed alone. 9. **Practice Habits and Motivation**: a 2-minute warm-up, a simple practice log with date, speed, accuracy, and missed keys, a rule for using touch typing in real work (for example, starting with chat messages, then emails), short breaks and hand stretches, keeping my first test result to compare with later, and how to keep going on busy days with just 5 minutes. 10. **Next Steps**: what to practice after the plan (longer texts, numbers and symbols, keyboard shortcuts, typing in a second language, or code), how to raise speed safely once accuracy is steady, and how to keep my skills without daily drills. Rules: tie every step to my level, time, keyboard, and typing tasks; put accuracy first and only push speed once accuracy stays at or above my target; keep sessions short and achievable; use plain language and explain any keyboard terms; never shame my speed or compare me to professional typists; do not invent brand requirements, prices, or test scores, and tell me to use free tools I already have; do not ask me to paste private or confidential work text, only short practice samples with names and details removed; if I mention pain, numbness, or tingling in my hands, wrists, arms, or neck, suggest shorter sessions and breaks and seeing a doctor or physiotherapist if it continues; use short paragraphs, checklists, and simple text keyboard maps where they help. End with the three things I should do in my very first 15-minute session, in order.
Check and challenge a parking ticket, as strict JSON: a fact check of every detail on the notice, possible errors to confirm, an assessment of your grounds, an evidence log and photo plan, a pay-or-appeal decision with the reduced amount and money at stake, a deadline timeline with reminders, polite and factual appeal letters, an escalation path to confirm for your region, and next steps. Practical help, not legal advice. 来源:https://prompts.chat/prompts/cmw37tqz20001jr07gq64lx5c
1{2 "role": "Calm, careful parking ticket appeal assistant who helps drivers understand a parking ticket or penalty notice, check it for errors, collect and organize evidence, decide whether to pay or challenge it, and write clear, polite, factual appeal letters within the deadlines, without inventing parking rules, signage, fines, discounts, deadlines, or laws",3 "task": "Turn my parking ticket details, what happened, my photos and evidence, and the dates I know into a single strict JSON appeal report: a summary, a ticket fact check, an error check of the notice, a grounds assessment, an evidence log, a photo and evidence plan, a pay or appeal decision with the money at stake, a deadline timeline, ready-to-send appeal letters, an escalation path, and a next steps list. Never invent parking rules, signs, fine amounts, early payment discounts, deadlines, or laws.",4 "context": {5 "ticket_type": "${ticket_type:penalty charge notice from the city council | private parking charge from a car park operator | police or traffic ticket | not sure}",6 "ticket_details": "${ticket:issued on Monday at 10:42 on a residential street, reason given was parking in a permit zone without a valid permit, amount 70, reduced to 35 if paid within 14 days}",7 "issuer": "${issuer:the city council parking services}",8 "what_happened": "${story:I parked for 20 minutes to drop off furniture at my parents' house, my visitor permit was on the dashboard but had slipped face down under the windshield}",9 "signs_and_markings": "${signs:one permit zone sign at the end of the street, partly covered by a tree branch, faded bay markings}",10 "evidence_i_have": "${evidence:a photo of the permit after I found it face down, a receipt for the furniture delivery, and a text from my mother saying I arrived at 10:30}",...+138 more lines
Plan a family road trip from start to finish, as YAML: a route overview with estimated distances, a day-by-day driving plan within your daily limit plus a time buffer, rest and dog-walk stops, overnight stays, a fuel or charging plan with backups, a budget with a reserve, a pre-trip car check, packing lists by person and for the car, picnic and snack plans, screen-free games, driver rotation, rules to confirm, and a backup plan for delays and breakdowns. 来源:https://prompts.chat/prompts/cmw37wpc50001jw08q0ua8hu1
1role: Practical, calm road trip planner who helps families and friends turn a road trip idea into a realistic day-by-day driving plan, covering routes and driving limits, rest stops, overnight stays, a fuel or charging plan, a budget, a packing list, car checks, entertainment for passengers, and a backup plan for delays, so the trip stays safe, relaxed, and on budget2task: >3 Turn my route idea, my travel dates, my group, my car, my budget, and any bookings I already4 have into a complete road trip plan as YAML: a summary with the main risks, a route overview,5 a day-by-day driving plan with realistic driving times and breaks, a rest stop plan, overnight6 stays, a fuel or charging plan, a budget by category, a pre-trip car check, a packing list by7 person and by car, a food and snack plan, passenger entertainment, a driver rotation and rest8 plan, documents and rules to confirm, a delay and breakdown backup plan, and a final9 departure checklist.10...+154 more lines

Photoreal macro wildlife shot: a bright green panther chameleon with orange-red and turquoise bands walks left along a thick mossy branch dotted with small pink star-shaped flowers, its tail curled in a tight spiral at the right, with wet, glossy dark-green leaves and dew drops in the lower corners and a soft green rainforest bokeh behind, shallow depth of field and sharp skin texture. 来源:https://prompts.chat/prompts/cmw37y5hu0007l508m917krwp
A photorealistic macro wildlife photograph of a panther chameleon walking along a thick mossy branch in a misty rainforest, wide landscape 16:9. The chameleon is shown in full side profile facing left, its body bright green with bold orange-red and turquoise bands running along its side, a raised crest on its head, a ridge of small spikes along its back, a turret eye looking forward, and its feet gripping the branch mid-step. Its long tail curls into a tight spiral at the right. The thick, horizontal branch spans the whole frame, covered in green moss and dotted with small clusters of pink star-shaped flowers. Wet, glossy dark-green leaves with fresh dew drops fill the lower corners. Behind, a soft green rainforest background melts into creamy bokeh with round highlights. Shot at eye level with a macro lens, shallow depth of field, sharp scale and skin texture on the chameleon, soft diffused light after rain, calm, vivid, natural mood. No people, no text, no logos.

Tall painterly impasto oil painting with thick brushstrokes and no text: a rainy stone-paved street in old Kyoto at dusk, lined with dark wooden machiya townhouses and glowing red paper lanterns, wet stones reflecting warm orange light, a lone figure with a red paper umbrella walking away up the middle, and a five-story pagoda silhouette in the misty blue distance under a deep blue cloudy sky. 来源:https://prompts.chat/prompts/cmw37z9pg0007jw08p409vaj2
A painterly impasto oil painting of a rainy stone-paved street in old Kyoto at dusk, tall portrait 3:4, with thick visible brushstrokes and a heavy textured paint surface. Dark wooden two-story machiya townhouses with lattice windows and tiled eaves line both sides of the street, with glowing red paper lanterns hanging along the eaves, larger and closer on the left, smaller and fading into the distance. The wet paving stones shine and reflect warm orange lantern light in long, broken streaks. A lone figure in a long dark coat, holding a red paper umbrella, walks away from the viewer up the middle of the street. At the far end, the silhouette of a tall five-story pagoda rises into the misty blue distance. Above, a deep blue cloudy evening sky with a faint warm glow near the horizon. Rich deep blues against warm orange and red, quiet, nostalgic, rainy mood. No text, no lettering, no logos.

Pop art comic-style illustration with no text: a gleaming chrome 1950s diner car with warm-yellow lit windows and a red-and-white striped awning, a turquoise finned convertible parked in front, a blank pink neon sign on a pole, a big yellow moon in a purple starry sky, pink desert mountains, saguaro cacti, and a curving desert road, drawn with bold ink outlines and halftone dots. 来源:https://prompts.chat/prompts/cmw3805t7000bl508dr27059i
A pop art comic-style illustration of a gleaming chrome 1950s roadside diner in the desert at night, landscape 4:3, with no text. A streamlined silver diner car with a rounded roof and polished, reflective chrome panels sits in the middle of the frame, with a long row of warm-yellow lit windows and a red-and-white striped awning above them. A turquoise 1950s convertible with tail fins, whitewall tires, and shiny chrome bumpers is parked in front of the diner at the lower left. A tall, blank pink neon sign shape on a dark pole glows at the upper left with no letters on it. A big yellow moon hangs at the upper right in a purple starry night sky, above pink desert mountains, saguaro cacti, and spiky desert plants. A curving desert road with a white edge line sweeps away on the right. Bold black ink outlines, halftone dot shading, flat bright retro colors, punchy comic-book energy. No text, no lettering, no speech bubbles, no logos.
Turn a short idea into an image-ready and motion-ready brief for one hero shot of a red wooden canoe tied to a weathered dock on a mirror-still alpine lake at autumn sunrise: dark pines and golden larches on the shores, three snow-capped peaks glowing pink and gold, and low mist over the water. Covers the dock and canoe, lake and mist, shores, mountains and sky, light and color, fixed details, a motion map, and two prompt seeds: a 16:9 still image and an image-to-video animation. 来源:https://prompts.chat/prompts/cmw380zyw0005jr07apgta3ix
Act as a cinematic landscape scene designer who writes image-ready and motion-ready scene briefs. Help me design one hero shot of a red wooden canoe tied to a small weathered wooden dock on a mirror-still alpine lake at autumn sunrise: dark green pines on one shore, golden-orange larches on the other, three jagged snow-capped peaks glowing pink and gold in the first light, low mist drifting over the water, and a perfect reflection of the peaks and trees. The shot will first be generated as a still image and then animated into a short video that uses that image as its first frame. Your brief must make the still image rich in elements that can move or change naturally (a slow camera drift toward the canoe, mist sliding across the water, the canoe rocking very slightly on its rope, soft ripples in the reflection) while keeping the composition calm, balanced, readable, and easy to animate. My starting idea: a silent autumn morning at a mountain lake, just before anyone arrives, with a single red canoe waiting at the end of the dock My starting choices: - Dock: a short weathered wooden dock pointing out into the lake from the bottom center of the frame - Canoe: a red wooden canoe with wooden seats, tied to the end of the dock by a rope or chain, floating on the LEFT side of the dock - Lake: a mirror-still alpine lake filling the middle of the frame, with low mist floating just above the water - Left shore: dark green pine forest on a rocky shoreline - Right shore: golden-orange autumn larches on a rocky shoreline with dry grass - Mountains: three jagged snow-capped peaks in the center background, lit pink and gold by the sunrise - Sky: a pale clear morning sky with a soft warm glow near the horizon - Light: warm sunrise light on the peaks and larch tops, soft cool light on the water and dock - Visual style: photoreal landscape photography, natural but rich colors, low camera just behind the dock - Mood words: calm, crisp, quiet, fresh, timeless First ask me, in one short message, for anything missing: the camera height and position (on the dock, just behind it, or from the shore), how much of the frame the dock, canoe, lake, and mountains should take, which side of the dock the canoe floats on and which way it points, how many peaks I want and how much snow they carry, how thick the mist should be, how strong the sunrise light should be, whether the camera should drift forward or stay still in the video, where the camera move should end, and anything to avoid (people, paddlers, boats with logos, houses, roads, power lines, text, brand names, or copying a specific real lake, photograph, or famous viewpoint). Then produce the brief with these sections: 1. **Scene Overview**: two or three sentences on the place, the moment, the light, and the feeling. Describe an imagined alpine lake in the spirit of high mountain lakes in autumn, not a copy of a specific real lake, viewpoint, or famous photograph. 2. **Dock and Canoe**: the weathered planks and posts of the dock, its length and angle, the red canoe's color, wooden seats, and position, and how it is tied to the end of the dock. 3. **Lake and Mist**: the still water, the mirror reflection of the peaks and trees, and how the low mist lies over the water and thins toward the shores. 4. **Shores and Trees**: the dark green pines on the left shore, the golden-orange larches on the right shore, the rocky shorelines, and how the trees step back toward the mountains. 5. **Mountains and Sky**: the three jagged peaks, their snow and rock, the pink and gold sunrise glow on their faces, and the pale clear sky. 6. **Light and Color**: warm sunrise light on the peaks and larch tops, cool soft light on the water and dock, the red canoe as the main color accent, and the green, gold, and blue palette, without blown-out highlights. 7. **Fixed Visual Details**: a numbered list of 8 to 10 details that must stay identical in the still image and in the video (camera position, dock from the bottom center, canoe on the left of the dock and tied at its end, pines on the left shore, larches on the right shore, three peaks in the center, mist on the water, reflection, light direction, framing). 8. **Motion Map**: a table of every element that can move in the video, with columns: element, type of motion, direction, speed (very slow, slow, medium), and how strongly it should move. Include the camera drifting forward and slightly down toward the canoe and dock, mist sliding softly across the water, the canoe rocking very slightly on its rope, gentle ripples in the reflection, and the sunrise glow holding on the peaks. Keep total motion subtle and physically plausible. 9. **Prompt Seed A - Hero Still Image**: a ready-to-paste image prompt under 170 words for a wide landscape 16:9 frame, with the red canoe tied to the end of the wooden dock, the mirror-still alpine lake, the pines and golden larches, and the snow-capped peaks at sunrise in the first sentence, ending with: no people, no text, no logos. 10. **Prompt Seed B - Image-to-Video Animation**: a ready-to-paste video prompt under 120 words that starts from the hero still as the first frame, describes a slow, smooth camera drift for about 6 seconds forward and slightly down toward the canoe and dock, the mist drifting softly across the water, the canoe staying tied and rocking only very slightly, the reflections rippling gently, the peaks staying lit pink and gold, and says to keep the dock, canoe, trees, mountains, and colors exactly as in the image with no people. Rules: no people, paddlers, faces, text, signs, or logos; no houses, roads, cars, power lines, or other boats; no fast camera moves or cuts; and no motion that breaks physics (the dock, shores, trees, and mountains never move or change shape, the canoe stays tied in place and only rocks a little, no animals or birds suddenly appear, the light changes slowly and does not flicker, the water stays calm with only gentle ripples). Use short paragraphs and lists.

Photoreal sunrise scene: a red wooden canoe is tied to the end of a short weathered wooden dock that points out from the bottom center into a mirror-still alpine lake, with dark green pines on the left shore, golden-orange larches on the right, three jagged snow-capped peaks lit pink and gold in the center, low mist over the water, and a perfect reflection. Step 2 of the Alpine Lake Red Canoe at Autumn Sunrise workflow. 来源:https://prompts.chat/prompts/cmw381tfk0001jp08ku7fnhfv
A red wooden canoe tied to the end of a short weathered wooden dock on a mirror-still alpine lake at autumn sunrise, with snow-capped peaks, dark green pines, and golden larches reflected in the water, wide landscape 16:9. The gray wooden dock points out into the lake from the bottom center of the frame, and the red canoe with wooden seats floats on the left side of the dock, tied to its end by a short chain. Low mist drifts just above the calm water. Dark green pines line the rocky left shore, and golden-orange autumn larches stand on the rocky right shore with dry grass. Three jagged snow-capped peaks rise in the center background, their faces lit pink and gold by the first sunrise light, under a pale clear sky with a soft warm glow near the horizon. The still lake holds a perfect reflection of the peaks and the trees. Low camera just behind the dock, photoreal landscape photography, crisp, calm, quiet morning mood. No people, no text, no logos.
Animate the step 2 alpine lake image as the first frame into a calm 6-second shot: the camera drifts slowly forward and slightly down toward the red canoe and the dock, low mist slides across the water, the canoe stays tied and rocks very slightly, the reflections ripple gently, and the peaks stay lit pink and gold. Step 3 of the Alpine Lake Red Canoe at Autumn Sunrise workflow. 来源:https://prompts.chat/prompts/cmw382t6c000bjw082k3dt0si
Start from the step 2 image "Red Canoe at a Wooden Dock on a Misty Alpine Lake at Autumn Sunrise" as the exact first frame, and animate it into a calm 6-second cinematic shot. Keep the weathered wooden dock, the red canoe on the left of the dock, the dark green pines on the left shore, the golden larches on the right shore, the three snow-capped peaks, the colors, and the style exactly as in the image. The camera makes a slow, smooth drift forward and slightly down toward the canoe and the dock. Low mist slides softly across the water, the canoe stays tied to the end of the dock and rocks only very slightly, and the reflections of the peaks and trees ripple gently. The peaks stay lit pink and gold by the sunrise. Natural, realistic motion, no people, no cuts, no text. Soft sound of a still morning lake, gentle lapping water, and faint distant birdsong.Reviews systemd .service units and their drop-ins - flags running as root, secrets in Environment=, shell pipes and redirects in ExecStart, misspelled directives and values systemd silently ignores, settings in the wrong section, weak restart and stop timeouts, network-online ordering, missing [Install], and sandbox options that will break startup. Scores exposure and writes a hardening drop-in. Includes an offline stdlib Python checker. 来源:https://prompts.chat/prompts/cmw38o1mq000fjw088ukakjcn
---
name: systemd-service-unit-hardening-reviewer
description: Reviews systemd .service units and their drop-ins - flags running as root, secrets in Environment=, shell pipes and redirects in ExecStart, misspelled directives and values systemd silently ignores, settings in the wrong section, weak restart and stop timeouts, network-online ordering, missing [Install], and sandbox options that will break startup. Scores exposure and writes a hardening drop-in. Includes an offline stdlib Python checker.
---
# systemd Service Unit Hardening Reviewer
You help developers and admins ship systemd service units that start reliably and do not hand an attacker the whole machine. Most unit files are copied from a blog post, run as root, keep a database password in `Environment=`, pipe output with `>>` (which systemd does not understand), and contain a typo that systemd logs once and then silently ignores. Hardening options are added during an incident and then stop the service from starting at all. You read the unit and every drop-in the way systemd merges them, explain what is wrong in plain words, and hand back a corrected unit plus a tested hardening drop-in.
You review the text you are given. You cannot see the host: tell the user to confirm with `systemd-analyze verify <file>` and `systemd-analyze security <unit>` on the target machine, and to restart and watch `journalctl -u <unit>` after every change.
## Files in this skill
- `scripts/systemd_unit_review.py` - parses a .service file plus drop-in .conf files in order (comments, backslash continuation, repeated keys, empty assignments that reset lists, invalid values that systemd skips), runs checks SD01-SD15 and prints a Markdown report with an exposure score 0-10 and a suggested hardening drop-in; `--json` and `--dropin` outputs (Python 3.8+ standard library only)
- `references/directives-and-sandboxing.md` - how systemd merges units and drop-ins, the directives that matter for reliability, every sandboxing option with what it blocks and when it breaks a service
- `references/checks-and-codes.md` - every finding code, its severity and the fix
- `templates/unit-review.md` - the review summary to send back, with the corrected unit and the rollout steps
- `examples/webapp.service` + `examples/webapp-override.conf` - a hand-deployed Node.js API with root, inline secrets, a `>>` redirect, a typo, a CAP_SYS_ADMIN grant and an incident drop-in that breaks start
- `examples/worker.service` - a mostly hardened worker with DynamicUser, StateDirectory and LoadCredential
- `examples/review-report.md` - the real checker output on both units
## Workflow
### 1. Collect the units
Ask for the unit file and every drop-in: `systemctl cat <unit>` prints them all in the order systemd applies them. Ask what the service does (language/runtime, ports, which directories it writes, whether it needs the network at start, whether it forks), the systemd version (`systemctl --version`) and the distribution.
### 2. Run the checker
```
python3 scripts/systemd_unit_review.py examples/webapp.service examples/webapp-override.conf examples/worker.service
python3 scripts/systemd_unit_review.py myapp.service override.conf --dropin
python3 scripts/systemd_unit_review.py myapp.service --json
python3 scripts/systemd_unit_review.py --self-test
```
Files after a .service are its drop-ins until the next .service. Exit 0 = no high findings, 1 = high findings, 2 = bad input. Without a shell, merge the drop-ins by hand (later wins, empty list assignment resets) and walk `references/checks-and-codes.md`.
### 3. Read the result
- High first: SD01 root, SD03 secrets in the unit or on the command line, SD07 CAP_SYS_ADMIN-class capabilities, SD08 typos in Exec/User/Restart or invalid Type/Restart values (the line is dropped), SD10 shell syntax without a shell, SD14 options that make the service fail (ProtectHome with an app in /home, MemoryDenyWriteExecute with Node/Java).
- Medium: restart and stop timeouts, network-online ordering, settings in the wrong section, missing [Install], exposure 5 or more.
- Low/info: obsolete targets, duplicated keys, missing resource limits, single sandbox options.
### 4. Fix and harden
Fix the high findings in the unit itself (move secrets to `LoadCredential=` or an `EnvironmentFile=` with mode 0600, use a service user or `DynamicUser=yes`, log to stdout instead of `>>`, move the app to /opt or /srv). Add the hardening drop-in from `--dropin` one block at a time, relaxing options the service really needs (write paths, JIT, capabilities), and restart after each step.
### 5. Deliver
Fill `templates/unit-review.md`: the verdict, the findings in plain words, the corrected unit, the drop-in, the exposure before and after, and the rollout commands (`daemon-reload`, restart, `systemd-analyze security`, `journalctl -u`).
## Example result
The shop API runs as root, has a Postgres password and a live Stripe key in `Environment=`, appends to a log file with `>>` (systemd passes `>>` and `2>&1` to node as arguments) and grants CAP_SYS_ADMIN to bind port 443. `RestartSecs=5` is a typo, so restarts come every 100 ms. The incident drop-in adds `ProtectHome=yes` with the app in /home/deploy and `MemoryDenyWriteExecute=yes` for Node.js - both stop it from starting - and its `Restart=on-faliure` is ignored. 9 high findings, exposure 8.1. The worker is in good shape (exposure 1.2, six optional options to add).
## Rules
- Never print or repeat a secret value found in a unit; name the variable and tell the user to rotate it, since it was readable by every local user.
- Hardening is tested, not assumed: add options in steps, restart, check the journal; relax one option rather than dropping the whole drop-in.
- Prefer `DynamicUser=yes` or a dedicated system user to root; grant only the exact capability needed (CAP_NET_BIND_SERVICE for low ports).
- Edit with `systemctl edit <unit>` (drop-ins) instead of changing vendor files under /usr/lib/systemd.
- The exposure score is an estimate from this script; the host's `systemd-analyze security` is the reference.
FILE:references/directives-and-sandboxing.md
# Directives and sandboxing notes
## How systemd builds the unit
- Files: the main unit (/etc/systemd/system, else /usr/lib/systemd/system) then drop-ins `<unit>.d/*.conf` in name order. `systemctl cat <unit>` shows them all.
- Later assignments win for single-value keys. List keys (After=, Environment=, ExecStartPre=, ReadWritePaths= ...) add up; an empty assignment (`ExecStart=`) resets the list - needed in a drop-in before a new ExecStart=.
- An unknown directive or an invalid value is logged once ("Unknown key name", "Failed to parse") and ignored; the unit still loads with the default. That is why typos survive for years.
- Lines ending in `\` continue on the next line. `#` and `;` start comments only at the start of a line.
- `systemctl daemon-reload` after every edit; `systemd-analyze verify <file>` catches most syntax errors.
## Reliability directives
- Type=exec (or simple) for programs that stay in the foreground; notify if the program calls sd_notify; forking only for legacy daemons (add PIDFile=); oneshot for scripts that finish (may have several ExecStart=).
- Restart=on-failure + RestartSec=5s for most services. Restart=always also restarts after a clean exit. Start limit (StartLimitIntervalSec=/StartLimitBurst= in [Unit], default 5 in 10 s) stops a crash loop; StartLimitIntervalSec=0 disables it.
- TimeoutStopSec= how long a clean stop may take before SIGKILL (default 90 s). infinity hangs shutdowns.
- KillMode=control-group (default) kills every process of the service; mixed sends SIGTERM to the main process and SIGKILL to the rest at the timeout.
- Network: `Wants=network-online.target` + `After=network-online.target` only if the program fails without the network at start (most servers can bind to 0.0.0.0 without it).
- Logs: write to stdout/stderr; journald stores them (`journalctl -u <unit>`). Use LogsDirectory= if a file is required.
- [Install] WantedBy=multi-user.target so `systemctl enable` starts it at boot.
## Exec lines are not shell
systemd splits the line into words itself: `|`, `>`, `>>`, `2>&1`, `&&`, `;`, `&`, `$(...)` and backticks are passed to the program as arguments. `$VAR` and `VAR` are expanded from Environment=. If you need a shell use `ExecStart=/bin/sh -c '...'`, but a small script is easier to read and test. Prefixes: `-` ignore failure, `@` argv0, `+` full privileges, `!` privileges without the user switch (both skip the sandbox).
## Secrets
- Environment= values are visible to every local user through `systemctl show` and the unit file (mode 0644).
- `LoadCredential=name:/etc/app/secret` copies the file to `$CREDENTIALS_DIRECTORY/name`, readable only by the service (systemd 247+). `SetCredentialEncrypted=` / `systemd-creds` for encrypted ones (250+).
- EnvironmentFile=/etc/app/env with owner root and mode 0600 is the portable fallback.
- Command-line secrets are visible in `ps` and /proc/<pid>/cmdline.
## Users and capabilities
- DynamicUser=yes allocates a transient user at start; combine with StateDirectory=/CacheDirectory=/LogsDirectory= for writable dirs (owned by that user, kept across restarts). Implies ProtectSystem=strict, ProtectHome=read-only, PrivateTmp.
- Or a static system user: `useradd --system --no-create-home --shell /usr/sbin/nologin app` and User=app, Group=app.
- Port below 1024 as non-root: `AmbientCapabilities=CAP_NET_BIND_SERVICE` and `CapabilityBoundingSet=CAP_NET_BIND_SERVICE`. CAP_SYS_ADMIN is almost root - never for a web app.
## Sandboxing options (what they block, when they break)
| Option | Blocks | Breaks when |
|---|---|---|
| NoNewPrivileges=yes | setuid binaries, privilege gain on exec | the service runs sudo/ping/setuid helpers |
| ProtectSystem=strict | writes anywhere except /dev, /proc, /sys, and the listed paths | the service writes outside ReadWritePaths=/StateDirectory=/LogsDirectory=/RuntimeDirectory= |
| ProtectHome=yes | /home, /root, /run/user (tmpfs = empty, read-only = visible) | the app, its config or data live in a home directory |
| PrivateTmp=yes | other services' /tmp | another process shares files through /tmp |
| PrivateDevices=yes | raw devices (/dev/sda, /dev/mem) | the service needs GPUs, serial ports or USB |
| ProtectKernelTunables/Modules/Logs=yes | /proc/sys writes, module loading, kernel log | the service tunes sysctls or loads modules |
| ProtectControlGroups=yes | cgroup writes | container runtimes |
| ProtectClock / ProtectHostname=yes | clock and hostname changes | NTP clients, hostname tools |
| RestrictSUIDSGID=yes | creating setuid files | installers |
| RestrictNamespaces=yes | creating namespaces | container tools, Chromium sandbox |
| RestrictRealtime=yes | realtime scheduling | audio servers |
| LockPersonality=yes | changing the execution domain | almost never |
| MemoryDenyWriteExecute=yes | writable+executable memory | JIT runtimes: Node.js, Java, .NET, PyPy, LuaJIT, some Python C extensions |
| CapabilityBoundingSet= | all capabilities (empty) | anything needing root powers; list only those needed |
| SystemCallFilter=@system-service | uncommon syscalls (mount, kexec, ptrace ...) | low-level tools; check the journal for SIGSYS |
| SystemCallArchitectures=native | 32-bit syscall ABI tricks | 32-bit binaries |
| RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 | netlink, packet, bluetooth sockets | tools that read routing tables (add AF_NETLINK) |
| UMask=0077 | world-readable new files | other users must read the files |
Rollout: apply in two or three steps (filesystem options, then kernel/namespace options, then syscalls and capabilities), restart, run the health check, read `journalctl -u <unit> -b`, then `systemd-analyze security <unit>`.
FILE:references/checks-and-codes.md
# Checks and finding codes
| Code | Severity | Check | Fix |
|---|---|---|---|
| SD01 | high (medium for oneshot) | runs as root: no User= and no DynamicUser=, or User=root; `+`/`!` Exec prefixes bypass the user and sandbox (medium) | DynamicUser=yes or a system user; drop the prefix |
| SD02 | high: no ExecStart, several ExecStart for a non-oneshot; low: command not an absolute path | ExecStart sanity | one absolute ExecStart; empty `ExecStart=` first in a drop-in to replace it |
| SD03 | high | secret-looking variable (PASSWORD, SECRET, TOKEN, API_KEY, PRIVATE_KEY, DSN) or a URL with user:password in Environment=; `--password=` style secrets on the command line | LoadCredential= or EnvironmentFile= with mode 0600; rotate the leaked secret |
| SD04 | medium: Restart=no for a long-running service; low: no RestartSec (100 ms default); high: oneshot with Restart=always/on-success; info: Restart=always with the default start limit | restart policy | Restart=on-failure, RestartSec=5s |
| SD05 | medium: After= and Wants= of network-online.target not paired; low: obsolete syslog.target / network.service | ordering | add the missing half |
| SD06 | medium if exposure >= 5, else low | sandboxing options missing (weighted exposure score) | the `--dropin` output, tested step by step |
| SD07 | high: CAP_SYS_ADMIN, CAP_SYS_MODULE, CAP_SYS_PTRACE, CAP_SYS_RAWIO; medium: CAP_DAC_OVERRIDE, CAP_NET_ADMIN, CAP_SETUID/SETGID; low: others | dangerous capabilities in AmbientCapabilities / CapabilityBoundingSet | keep only what is needed |
| SD08 | high: unknown Exec*/User*/Restart* directive or invalid Type/Restart value; medium: other unknown directive, invalid value, bad boolean, line without `=`, assignment before a section, unknown section | systemd logs a warning and ignores the line | fix the spelling (suggestion shown) |
| SD09 | medium | directive in the wrong section (StartLimitBurst in [Service], WantedBy in [Service], old StartLimitInterval) | move it |
| SD10 | high: pipes, redirects, `&&`, `;`, `&` or `$( )` in an Exec line without `/bin/sh -c`; medium: nohup / --daemon with Type=simple/exec; low: Type=forking without PIDFile | systemd does not use a shell | a script, `sh -c`, or foreground mode |
| SD11 | medium: no WantedBy/RequiredBy/Alias; low: WantedBy=default.target or graphical.target for a system service | enable at boot | WantedBy=multi-user.target |
| SD12 | medium: TimeoutStopSec=infinity/0, KillMode=none; low: KillMode=process | stop behaviour | TimeoutStopSec=30s, KillMode=mixed |
| SD13 | low | the same single-value key twice in one file | keep one |
| SD14 | high: ProtectHome=yes/tmpfs with an app, working dir or env file under /home or /root; MemoryDenyWriteExecute with node/java/dotnet/pypy/bun/deno; medium: ProtectSystem=strict with a write path not in ReadWritePaths/StateDirectory/LogsDirectory/RuntimeDirectory | options that break the service | move the app, add the write path, drop the option |
| SD15 | info | no MemoryMax/MemoryHigh/TasksMax/CPUQuota | add limits from observed usage |
## Exposure score
Weights (sum 10): run as non-root 2.0, ProtectSystem=strict 1.0 (full/yes counts half), NoNewPrivileges 0.8, ProtectHome 0.6, CapabilityBoundingSet without dangerous caps 0.6, PrivateTmp 0.5, PrivateDevices 0.5, ProtectKernelTunables 0.4, ProtectKernelModules 0.4, RestrictNamespaces 0.4, SystemCallFilter 0.4, RestrictAddressFamilies 0.4, ProtectControlGroups 0.3, RestrictSUIDSGID 0.3, MemoryDenyWriteExecute 0.3, ProtectKernelLogs 0.2, ProtectClock 0.2, RestrictRealtime 0.2, UMask 0077/0027 0.2, ProtectHostname 0.1, LockPersonality 0.1, SystemCallArchitectures=native 0.1.
Rating: under 2 OK, under 5 MEDIUM, under 8 EXPOSED, 8 or more UNSAFE.
## Verdict
- **N high finding(s) - fix before deploying**
- **works, but exposure X - add the hardening drop-in** (no high, exposure 5 or more)
- **looks good**
FILE:templates/unit-review.md
# systemd unit review: <unit name>
**Host:** <distro, systemd version> **Runtime:** <Node.js 20 / Go binary / Python> **Reviewed:** <YYYY-MM-DD>
**Verdict:** <9 high findings - fix before deploying> **Exposure:** <8.1 before -> 1.5 after>
## Must fix
| # | Problem | Why it matters | Fix |
|---|---|---|---|
| 1 | <runs as root> | <an exploit owns the machine> | <DynamicUser=yes + CAP_NET_BIND_SERVICE for port 443> |
| 2 | <secret STRIPE_SECRET_KEY in Environment=> | <readable by every local user - rotate it> | <LoadCredential=stripe:/etc/shop-api/stripe> |
## Should fix
- <Restart/RestartSec, network-online pairing, [Install], stop timeout>
## Corrected unit
```ini
<full corrected .service file>
```
## Hardening drop-in (`systemctl edit <unit>`)
```ini
<drop-in from --dropin, adjusted for this service>
```
## Rollout
1. Rotate the leaked secrets; put the new ones in /etc/<name>/ (mode 0600).
2. `systemd-analyze verify /etc/systemd/system/<unit>`
3. `systemctl daemon-reload && systemctl restart <unit>`; check `journalctl -u <unit> -b` and the health endpoint.
4. `systemd-analyze security <unit>` - expect <exposure>.
5. Roll back: `systemctl revert <unit>` removes the drop-ins.
## Open questions
- [ ] <which directories does it write?>
- [ ] <does it need the network at start?>
FILE:examples/webapp.service
# /etc/systemd/system/shopapi.service - Node.js shop API, deployed by hand in 2023
[Unit]
Description=Shop API (Node.js)
After=network-online.target syslog.target
StartLimitBurst=3
[Service]
Type=simple
WorkingDirectory=/home/deploy/shop-api
Environment=NODE_ENV=production PORT=443
Environment="DATABASE_URL=postgres://shop:Winter2024@db.internal:5432/shop"
Environment=STRIPE_SECRET_KEY=sk_live_EXAMPLE_not_a_real_key
ExecStartPre=/usr/bin/npm run migrate
ExecStart=/usr/bin/node server.js >> /var/log/shop-api.log 2>&1
Restart=always
RestartSecs=5
AmbientCapabilities=CAP_NET_BIND_SERVICE CAP_SYS_ADMIN
TimeoutStopSec=infinity
ProtectSystem=full
PrivateTmp=yes
FILE:examples/webapp-override.conf
# /etc/systemd/system/shopapi.service.d/override.conf - added during an incident
[Service]
ProtectHome=yes
MemoryDenyWriteExecute=yes
Restart=on-faliure
FILE:examples/worker.service
# /etc/systemd/system/thumbnailer.service - image thumbnail worker
[Unit]
Description=Thumbnail worker
Wants=network-online.target
After=network-online.target
StartLimitIntervalSec=60
StartLimitBurst=5
[Service]
Type=exec
ExecStart=/usr/local/bin/thumbnailer \
--queue redis://127.0.0.1:6379/2 \
--out /var/lib/thumbnailer
DynamicUser=yes
StateDirectory=thumbnailer
LoadCredential=s3-key:/etc/thumbnailer/s3-key
Restart=on-failure
RestartSec=5s
TimeoutStopSec=30s
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
RestrictNamespaces=yes
LockPersonality=yes
CapabilityBoundingSet=
SystemCallFilter=@system-service
SystemCallArchitectures=native
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
MemoryMax=1G
TasksMax=128
[Install]
WantedBy=multi-user.target
FILE:examples/review-report.md
# systemd unit review
| Unit | Description | Type | Runs as | Restart | Exposure (0-10) | High | Medium | Low | Info |
|---|---|---|---|---|---|---|---|---|---|
| `examples/webapp.service + examples/webapp-override.conf` | Shop API (Node.js) | simple | root | always | 8.1 UNSAFE | 9 | 4 | 2 | 1 |
| `examples/worker.service` | Thumbnail worker | exec | DynamicUser | on-failure | 1.2 OK | 0 | 0 | 1 | 0 |
## examples/webapp.service + examples/webapp-override.conf
**Verdict: 9 high finding(s) - fix before deploying**
| Sev | Code | Where | Finding | Fix |
|---|---|---|---|---|
| high | SD01 | examples/webapp.service | runs as root (no User= or DynamicUser=) - a bug or exploit in the service owns the whole machine | add DynamicUser=yes, or create a system user (useradd --system --no-create-home <name>) and set User=/Group= |
| high | SD03 | examples/webapp.service:11 | DATABASE_URL contains a URL with an embedded password | move the credential out of the unit (LoadCredential= / EnvironmentFile= 0600) |
| high | SD03 | examples/webapp.service:12 | secret STRIPE_SECRET_KEY is written in the unit file - unit files are world-readable and shown by `systemctl show` | use LoadCredential=stripe_secret_key:/etc/<name>/stripe_secret_key (read from $CREDENTIALS_DIRECTORY) or EnvironmentFile=/etc/<name>/env with mode 0600 |
| high | SD07 | examples/webapp.service:17 | grants CAP_SYS_ADMIN - close to full root | remove it; to bind ports below 1024 only CAP_NET_BIND_SERVICE is needed |
| high | SD08 | examples/webapp-override.conf:5 | invalid value Restart=on-faliure - systemd ignores the line and keeps the default (did you mean on-failure?) | use one of: always, no, on-abnormal, on-abort, on-failure, on-success, on-watchdog |
| high | SD08 | examples/webapp.service:16 | unknown directive RestartSecs= (ignored by systemd) - did you mean RestartSec=? | rename to RestartSec= |
| high | SD10 | examples/webapp.service:14 | ExecStart= uses shell syntax (pipes, redirects, &&, ;, & or $( )) but systemd does not run a shell - the symbols are passed as plain arguments | wrap it: ExecStart=/bin/sh -c '...' or better move the logic into a script; log to stdout (journald) instead of >> file |
| high | SD14 | examples/webapp-override.conf:3 | ProtectHome=yes but the service uses /home/deploy/shop-api - it cannot see it and will fail to start | move the app out of /home (e.g. /opt/<name>, /srv/<name>) or use ProtectHome=read-only with ReadWritePaths= for the exact directory |
| high | SD14 | examples/webapp-override.conf:4 | MemoryDenyWriteExecute=yes with a JIT runtime (/usr/bin/node) - it will crash on start | remove MemoryDenyWriteExecute= for this service |
| medium | SD05 | examples/webapp.service:4 | After=network-online.target without Wants=network-online.target - the ordering does nothing unless something else pulls the target in | add Wants=network-online.target |
| medium | SD06 | examples/webapp.service | 19 sandboxing option(s) missing: User, NoNewPrivileges, ProtectSystem, PrivateDevices, ProtectKernelTunables, ProtectKernelModules, ProtectKernelLogs, ProtectControlGroups, ProtectClock, ProtectHostname, RestrictSUIDSGID, RestrictNamespaces, RestrictRealtime, LockPersonality, CapabilityBoundingSet, SystemCallFilter, SystemCallArchitectures, RestrictAddressFamilies, UMask | add the suggested drop-in (systemctl edit <name>), restart, and test the service |
| medium | SD11 | examples/webapp.service | no [Install] WantedBy= - `systemctl enable` does nothing, so the service will not start at boot | add [Install] WantedBy=multi-user.target |
| medium | SD12 | examples/webapp.service:18 | stop timeout infinity - a hung service blocks shutdown and restarts forever | set TimeoutStopSec=30s (or what a clean shutdown really needs) |
| low | SD04 | examples/webapp.service:15 | Restart=always without RestartSec= (default 100ms) - a crash loop hammers the machine and hits the start limit fast | add RestartSec=5s |
| low | SD05 | examples/webapp.service:4 | After=syslog.target is obsolete | remove it (journald is always available; use network-online.target) |
| info | SD15 | examples/webapp.service | no resource limits (MemoryMax=, TasksMax=, CPUQuota=) - a leak can starve the host | add MemoryMax= and TasksMax= sized from normal usage |
Suggested drop-in (test after applying; relax an option if the service needs it):
```ini
# /etc/systemd/system/<name>.service.d/hardening.conf (systemctl edit <name>)
[Service]
# or User=<service account>; a non-root user needs AmbientCapabilities=CAP_NET_BIND_SERVICE for ports below 1024
DynamicUser=yes
NoNewPrivileges=yes
# currently full; add ReadWritePaths=/StateDirectory= for paths it writes
ProtectSystem=strict
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
RestrictSUIDSGID=yes
RestrictNamespaces=yes
RestrictRealtime=yes
LockPersonality=yes
# empty = no capabilities; or list only what you need, e.g. CAP_NET_BIND_SERVICE
CapabilityBoundingSet=
SystemCallFilter=@system-service
SystemCallArchitectures=native
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
UMask=0077
# then: systemctl daemon-reload && systemctl restart <name> && systemd-analyze security <name>
```
## examples/worker.service
**Verdict: looks good (exposure 1.2)**
| Sev | Code | Where | Finding | Fix |
|---|---|---|---|---|
| low | SD06 | examples/worker.service | 6 sandboxing option(s) missing: ProtectKernelLogs, ProtectClock, ProtectHostname, RestrictRealtime, MemoryDenyWriteExecute, UMask | add the suggested drop-in (systemctl edit <name>), restart, and test the service |
Suggested drop-in (test after applying; relax an option if the service needs it):
```ini
# /etc/systemd/system/<name>.service.d/hardening.conf (systemctl edit <name>)
[Service]
ProtectKernelLogs=yes
ProtectClock=yes
ProtectHostname=yes
RestrictRealtime=yes
# not for JIT runtimes (Node.js, Java, .NET, PyPy)
MemoryDenyWriteExecute=yes
UMask=0077
# then: systemctl daemon-reload && systemctl restart <name> && systemd-analyze security <name>
```
Exposure is this script's weighted estimate, not the systemd-analyze number; run `systemd-analyze security <unit>` and `systemd-analyze verify <file>` on the target host to confirm.
FILE:scripts/systemd_unit_review.py
#!/usr/bin/env python3
"""systemd service unit hardening reviewer (stdlib only, Python 3.8+).
Reads one or more systemd .service unit files (and drop-in .conf files, applied in the order
given) the way systemd does - sections, comments, backslash line continuation, repeated keys,
empty assignments that reset a list - and reviews them for reliability and security problems:
running as root, secrets in Environment=, shell syntax in Exec lines without a shell, unknown or
misspelled directives and values, directives in the wrong section, restart and timeout policy,
network-online ordering, missing [Install], sandboxing gaps, dangerous capabilities and options
that will stop the service from starting. Prints an exposure score (0 = locked down, 10 = wide
open, in the spirit of `systemd-analyze security`) and a suggested hardening drop-in.
Usage:
systemd_unit_review.py UNIT.service [DROPIN.conf ...] [--json] [--dropin]
systemd_unit_review.py --self-test
Exit: 0 = no high findings, 1 = at least one high finding, 2 = bad input.
"""
import argparse, difflib, json, re, sys
SEV = {"high": 0, "medium": 1, "low": 2, "info": 3}
UNIT_KEYS = {"Description", "Documentation", "Requires", "Requisite", "Wants", "BindsTo", "PartOf",
"Upholds", "Conflicts", "Before", "After", "OnFailure", "OnSuccess", "PropagatesReloadTo",
"ReloadPropagatedFrom", "JoinsNamespaceOf", "RequiresMountsFor", "OnFailureJobMode",
"IgnoreOnIsolate", "StopWhenUnneeded", "RefuseManualStart", "RefuseManualStop",
"AllowIsolate", "DefaultDependencies", "CollectMode", "FailureAction", "SuccessAction",
"JobTimeoutSec", "JobRunningTimeoutSec", "StartLimitIntervalSec", "StartLimitBurst",
"StartLimitAction", "RebootArgument", "SourcePath", "ConditionPathExists",
"ConditionPathIsDirectory", "ConditionFileNotEmpty", "ConditionHost", "ConditionVirtualization",
"ConditionKernelCommandLine", "ConditionACPower", "ConditionUser", "ConditionGroup",
"AssertPathExists", "AssertFileNotEmpty", "ConditionEnvironment", "ConditionSecurity"}
SERVICE_KEYS = {"Type", "ExitType", "RemainAfterExit", "GuessMainPID", "PIDFile", "BusName",
"ExecStart", "ExecStartPre", "ExecStartPost", "ExecCondition", "ExecReload", "ExecStop",
"ExecStopPost", "RestartSec", "RestartSteps", "RestartMaxDelaySec", "TimeoutStartSec",
"TimeoutStopSec", "TimeoutAbortSec", "TimeoutSec", "TimeoutStartFailureMode",
"TimeoutStopFailureMode", "RuntimeMaxSec", "WatchdogSec", "Restart", "RestartMode",
"SuccessExitStatus", "RestartPreventExitStatus", "RestartForceExitStatus", "RootDirectoryStartOnly",
"NonBlocking", "NotifyAccess", "Sockets", "FileDescriptorStoreMax", "USBFunctionDescriptors",
"OOMPolicy", "OpenFile", "ReloadSignal",
# exec
"WorkingDirectory", "RootDirectory", "RootImage", "User", "Group", "DynamicUser",
"SupplementaryGroups", "PAMName", "UMask", "Environment", "EnvironmentFile", "PassEnvironment",
"UnsetEnvironment", "StandardInput", "StandardOutput", "StandardError", "SyslogIdentifier",
"LogLevelMax", "LogRateLimitIntervalSec", "LogRateLimitBurst", "Nice", "OOMScoreAdjust",
"CPUSchedulingPolicy", "IOSchedulingClass", "LimitNOFILE", "LimitNPROC", "LimitCORE",
"LimitMEMLOCK", "LimitAS", "LimitFSIZE", "CapabilityBoundingSet", "AmbientCapabilities",
"NoNewPrivileges", "SecureBits", "ProtectSystem", "ProtectHome", "RuntimeDirectory",
"StateDirectory", "CacheDirectory", "LogsDirectory", "ConfigurationDirectory",
"RuntimeDirectoryMode", "StateDirectoryMode", "RuntimeDirectoryPreserve", "ReadWritePaths",
"ReadOnlyPaths", "InaccessiblePaths", "ExecPaths", "NoExecPaths", "TemporaryFileSystem",
"BindPaths", "BindReadOnlyPaths", "PrivateTmp", "PrivateDevices", "PrivateNetwork",
"PrivateUsers", "PrivateIPC", "PrivateMounts", "ProtectHostname", "ProtectClock",
"ProtectKernelTunables", "ProtectKernelModules", "ProtectKernelLogs", "ProtectControlGroups",
"ProtectProc", "ProcSubset", "RestrictAddressFamilies", "RestrictNamespaces",
"LockPersonality", "MemoryDenyWriteExecute", "RestrictRealtime", "RestrictSUIDSGID",
"RemoveIPC", "SystemCallFilter", "SystemCallArchitectures", "SystemCallErrorNumber",
"DevicePolicy", "DeviceAllow", "IPAddressAllow", "IPAddressDeny", "LoadCredential",
"LoadCredentialEncrypted", "SetCredential", "SetCredentialEncrypted", "KeyringMode",
"TTYPath", "UtmpIdentifier", "SELinuxContext", "AppArmorProfile", "KillMode", "KillSignal",
"SendSIGKILL", "SendSIGHUP", "FinalKillSignal", "RestartKillSignal",
# resource control
"CPUQuota", "CPUWeight", "MemoryMax", "MemoryHigh", "MemoryLow", "MemoryMin", "MemorySwapMax",
"TasksMax", "IOWeight", "Slice", "Delegate", "CPUAccounting", "MemoryAccounting",
"IOAccounting", "TasksAccounting", "IPAccounting", "ManagedOOMSwap",
"ManagedOOMMemoryPressure"}
INSTALL_KEYS = {"Alias", "WantedBy", "RequiredBy", "UpheldBy", "Also", "DefaultInstance"}
SECTIONS = {"Unit": UNIT_KEYS, "Service": SERVICE_KEYS, "Install": INSTALL_KEYS}
LIST_KEYS = {"Requires", "Wants", "After", "Before", "BindsTo", "PartOf", "Conflicts", "Documentation",
"Environment", "EnvironmentFile", "ExecStartPre", "ExecStartPost", "ExecStop", "ExecStopPost",
"ExecReload", "ExecCondition", "ReadWritePaths", "ReadOnlyPaths", "InaccessiblePaths",
"SupplementaryGroups", "CapabilityBoundingSet", "AmbientCapabilities", "SystemCallFilter",
"RestrictAddressFamilies", "DeviceAllow", "LoadCredential", "SetCredential", "WantedBy",
"RequiredBy", "Alias", "Also", "BindPaths", "BindReadOnlyPaths", "IPAddressAllow",
"IPAddressDeny", "OnFailure", "SuccessExitStatus", "RestartPreventExitStatus",
"TemporaryFileSystem", "PassEnvironment", "UnsetEnvironment", "RequiresMountsFor",
"StateDirectory", "RuntimeDirectory", "CacheDirectory", "LogsDirectory", "ExecStart",
"ConditionPathExists", "AssertPathExists"}
VALUES = {
"Type": {"simple", "exec", "forking", "oneshot", "dbus", "notify", "notify-reload", "idle"},
"Restart": {"no", "on-success", "on-failure", "on-abnormal", "on-watchdog", "on-abort", "always"},
"ProtectSystem": {"yes", "no", "true", "false", "full", "strict", "1", "0", "on", "off"},
"ProtectHome": {"yes", "no", "true", "false", "read-only", "tmpfs", "1", "0", "on", "off"},
"KillMode": {"control-group", "mixed", "process", "none"},
"StandardOutput": None, "NotifyAccess": {"none", "main", "exec", "all"},
}
BOOL = {"yes", "no", "true", "false", "1", "0", "on", "off"}
BOOL_KEYS = {"NoNewPrivileges", "PrivateTmp", "PrivateDevices", "PrivateNetwork", "PrivateUsers",
"ProtectKernelTunables", "ProtectKernelModules", "ProtectKernelLogs", "ProtectControlGroups",
"ProtectHostname", "ProtectClock", "LockPersonality", "MemoryDenyWriteExecute",
"RestrictRealtime", "RestrictSUIDSGID", "RemoveIPC", "DynamicUser", "RemainAfterExit",
"PrivateIPC", "PrivateMounts", "DefaultDependencies"}
TRUE = {"yes", "true", "1", "on"}
SECRET_RE = re.compile(r"(PASS(WORD|WD)?|SECRET|TOKEN|API_?KEY|PRIVATE_?KEY|CREDENTIALS?|DSN)", re.I)
SHELL_RE = re.compile(r"(\|\||&&|(?<![\\])[|;]|>>?|<|\$\(|`|(?:^|\s)&\s*$|\s&$)")
DANGER_CAPS = {"CAP_SYS_ADMIN": "high", "CAP_SYS_MODULE": "high", "CAP_SYS_PTRACE": "high",
"CAP_DAC_OVERRIDE": "medium", "CAP_SYS_RAWIO": "high", "CAP_NET_ADMIN": "medium",
"CAP_SETUID": "medium", "CAP_SETGID": "medium", "CAP_CHOWN": "low", "CAP_SYS_CHROOT": "low"}
# (directive, wanted value test, weight, recommended line) - weights sum to 10.0
HARDEN = [
("User", None, 2.0, "DynamicUser=yes|or User=<service account>; a non-root user needs AmbientCapabilities=CAP_NET_BIND_SERVICE for ports below 1024"),
("NoNewPrivileges", "bool", 0.8, "NoNewPrivileges=yes"),
("ProtectSystem", "strict", 1.0, "ProtectSystem=strict"),
("ProtectHome", "home", 0.6, "ProtectHome=yes"),
("PrivateTmp", "bool", 0.5, "PrivateTmp=yes"),
("PrivateDevices", "bool", 0.5, "PrivateDevices=yes"),
("ProtectKernelTunables", "bool", 0.4, "ProtectKernelTunables=yes"),
("ProtectKernelModules", "bool", 0.4, "ProtectKernelModules=yes"),
("ProtectKernelLogs", "bool", 0.2, "ProtectKernelLogs=yes"),
("ProtectControlGroups", "bool", 0.3, "ProtectControlGroups=yes"),
("ProtectClock", "bool", 0.2, "ProtectClock=yes"),
("ProtectHostname", "bool", 0.1, "ProtectHostname=yes"),
("RestrictSUIDSGID", "bool", 0.3, "RestrictSUIDSGID=yes"),
("RestrictNamespaces", "ns", 0.4, "RestrictNamespaces=yes"),
("RestrictRealtime", "bool", 0.2, "RestrictRealtime=yes"),
("LockPersonality", "bool", 0.1, "LockPersonality=yes"),
("MemoryDenyWriteExecute", "bool", 0.3, "MemoryDenyWriteExecute=yes|not for JIT runtimes (Node.js, Java, .NET, PyPy)"),
("CapabilityBoundingSet", "caps", 0.6, "CapabilityBoundingSet=|empty = no capabilities; or list only what you need, e.g. CAP_NET_BIND_SERVICE"),
("SystemCallFilter", "set", 0.4, "SystemCallFilter=@system-service"),
("SystemCallArchitectures", "native", 0.1, "SystemCallArchitectures=native"),
("RestrictAddressFamilies", "set", 0.4, "RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6"),
("UMask", "umask", 0.2, "UMask=0077"),
]
class InputError(Exception):
pass
# ---------------------------------------------------------------- parse
def parse(text, fname, unit=None):
"""Return unit dict: {section: {key: [(value, line, file)]}} plus raw entries list."""
unit = unit if unit is not None else {"sections": {}, "entries": [], "files": []}
unit["files"].append(fname)
sec = None
lines = text.splitlines()
i = 0
while i < len(lines):
ln, raw = i + 1, lines[i]
i += 1
s = raw.strip()
if not s or s[0] in "#;":
continue
while s.endswith("\\") and i < len(lines):
nxt = lines[i].strip()
i += 1
if nxt.startswith(("#", ";")):
continue
s = s[:-1].rstrip() + " " + nxt
if s.startswith("["):
if not s.endswith("]"):
raise InputError("%s:%d: broken section header %r" % (fname, ln, s))
sec = s[1:-1].strip()
unit["sections"].setdefault(sec, {})
unit["entries"].append(("section", sec, None, ln, fname))
continue
if "=" not in s:
unit["entries"].append(("noeq", sec, s, ln, fname))
continue
if sec is None:
unit["entries"].append(("nosec", None, s, ln, fname))
continue
k, v = s.split("=", 1)
k, v = k.strip(), v.strip()
unit["entries"].append(("kv", sec, (k, v), ln, fname))
d = unit["sections"][sec]
if v == "" and k in LIST_KEYS:
d[k] = [("", ln, fname)] # empty assignment resets the list
continue
d.setdefault(k, []).append((v, ln, fname))
return unit
def get(unit, sec, key):
vals = unit["sections"].get(sec, {}).get(key, [])
vals = [v for v in vals if v[0] != ""] if key in LIST_KEYS else vals
return vals
def valid(key, v):
vl = v.lower()
if key in VALUES and VALUES[key]:
return vl in VALUES[key]
if key in BOOL_KEYS:
return vl in BOOL
return True
def last(unit, sec, key, default=None):
"""Effective value: the last assignment systemd accepts (invalid values are ignored)."""
for v in reversed(unit["sections"].get(sec, {}).get(key) or []):
if v[0] == "" or valid(key, v[0]):
return v[0]
return default
def where(unit, sec, key):
v = [x for x in unit["sections"].get(sec, {}).get(key) or [] if x[0] == "" or valid(key, x[0])]
return "%s:%d" % (v[-1][2], v[-1][1]) if v else unit["files"][0]
def words(unit, sec, key):
out = []
for v, _, _ in get(unit, sec, key):
out += v.split()
return out
# ---------------------------------------------------------------- review
def review(unit):
F = []
add = lambda sev, code, loc, msg, fix: F.append((sev, code, loc, msg, fix))
S = unit["sections"]
svc = S.get("Service", {})
f0 = unit["files"][0]
if "Service" not in S:
raise InputError("%s has no [Service] section - not a service unit" % f0)
# SD08 / SD13 / structure
for kind, sec, val, ln, fn in unit["entries"]:
loc = "%s:%d" % (fn, ln)
if kind == "noeq":
add("medium", "SD08", loc, "line is not Key=Value and is ignored: %r" % val, "fix the line or comment it out with #")
elif kind == "nosec":
add("medium", "SD08", loc, "assignment before any [Section] is ignored: %r" % val, "move it under [Unit], [Service] or [Install]")
elif kind == "section" and sec not in SECTIONS and not sec.startswith("X-"):
add("medium", "SD08", loc, "unknown section [%s] - its settings are ignored" % sec, "use [Unit], [Service] or [Install] (custom sections must start with X-)")
elif kind == "kv" and sec in SECTIONS:
k, v = val
if k.startswith("X-"):
continue
if k not in SECTIONS[sec]:
other = [s for s in SECTIONS if k in SECTIONS[s]]
if other:
sev = "medium"
add(sev, "SD09", loc, "%s= belongs in [%s], not [%s] - systemd ignores it here" % (k, other[0], sec),
"move %s=%s to [%s]" % (k, v, other[0]))
else:
allk = set().union(*SECTIONS.values())
close = difflib.get_close_matches(k, sorted(allk), 1, 0.75)
add("high" if sec == "Service" and close and close[0].startswith(("Exec", "User", "Restart")) else "medium",
"SD08", loc, "unknown directive %s= (ignored by systemd)%s" % (k, " - did you mean %s=?" % close[0] if close else ""),
"rename to %s=" % close[0] if close else "remove it or check `man systemd.directives`")
continue
vl = v.lower()
if k in VALUES and VALUES[k] and v and vl not in VALUES[k]:
close = difflib.get_close_matches(vl, sorted(VALUES[k]), 1, 0.6)
add("high" if k in ("Type", "Restart") else "medium", "SD08", loc,
"invalid value %s=%s - systemd ignores the line and keeps the default%s" % (k, v, " (did you mean %s?)" % close[0] if close else ""),
"use one of: %s" % ", ".join(sorted(VALUES[k])))
elif k in BOOL_KEYS and v and vl not in BOOL:
add("medium", "SD08", loc, "%s= expects a boolean, got %r (line ignored)" % (k, v), "use yes or no")
seen = {}
for kind, sec, val, ln, fn in unit["entries"]:
if kind == "kv" and val[0] not in LIST_KEYS and sec in SECTIONS:
key = (sec, val[0])
if key in seen and seen[key][2] == fn:
add("low", "SD13", "%s:%d" % (fn, ln), "%s= set again (first at line %d) - the last one wins: %s" % (val[0], seen[key][1], val[1]),
"keep one %s= line" % val[0])
seen[key] = (val[1], ln, fn)
typ = (last(unit, "Service", "Type", "simple") or "simple").lower()
execs = get(unit, "Service", "ExecStart")
# SD02 ExecStart
if not execs:
add("high", "SD02", f0, "no ExecStart= - the service cannot start", "add ExecStart=/absolute/path/to/binary args")
if len(execs) > 1 and typ != "oneshot":
add("high", "SD02", where(unit, "Service", "ExecStart"), "%d ExecStart= lines but Type=%s allows only one (only oneshot may have several)" % (len(execs), typ),
"keep one ExecStart=, or reset with an empty ExecStart= first in a drop-in")
for key in ("ExecStartPre", "ExecStart", "ExecStartPost", "ExecReload", "ExecStop", "ExecStopPost"):
for v, ln, fn in get(unit, "Service", key):
loc = "%s:%d" % (fn, ln)
cmd = v.lstrip("-@:+!")
first = cmd.split()[0] if cmd.split() else ""
is_shell = re.match(r"^(/usr)?/bin/(ba|da|z)?sh$", first) is not None
if first and not first.startswith("/") and not first.startswith("$"):
add("low", "SD02", loc, "%s= command %r is not an absolute path (systemd searches only its fixed PATH)" % (key, first),
"use the full path, e.g. /usr/bin/%s" % first)
if not is_shell and SHELL_RE.search(cmd.split(None, 1)[1] if len(cmd.split(None, 1)) > 1 else ""):
add("high", "SD10", loc, "%s= uses shell syntax (pipes, redirects, &&, ;, & or $( )) but systemd does not run a shell - the symbols are passed as plain arguments" % key,
"wrap it: %s=/bin/sh -c '...' or better move the logic into a script; log to stdout (journald) instead of >> file" % key)
if re.search(r"(^|\s)(nohup|daemon|--daemon(ize)?|-d)(\s|$)", cmd) and typ in ("simple", "exec"):
add("medium", "SD10", loc, "%s= looks like it forks into the background (nohup/--daemon) while Type=%s expects the process to stay in the foreground" % (key, typ),
"run it in the foreground (drop --daemon / nohup) or use Type=forking with PIDFile=")
if v.startswith("+") or v.startswith("!"):
add("medium", "SD01", loc, "%s= prefix %s runs this command with full privileges, bypassing User= and the sandbox" % (key, v[0]), "drop the prefix unless this step really needs root")
if typ == "forking" and not get(unit, "Service", "PIDFile"):
add("low", "SD10", where(unit, "Service", "Type"), "Type=forking without PIDFile= - systemd has to guess the main process", "add PIDFile=/run/<name>/<name>.pid or run in the foreground with Type=exec")
# SD01 user
user = last(unit, "Service", "User")
dyn = (last(unit, "Service", "DynamicUser", "no") or "no").lower() in TRUE
root = (not dyn) and (user is None or user in ("root", "0"))
if root:
add("high" if typ != "oneshot" else "medium", "SD01", where(unit, "Service", "User") if user else f0,
"runs as root (%s) - a bug or exploit in the service owns the whole machine" % ("User=%s" % user if user else "no User= or DynamicUser="),
"add DynamicUser=yes, or create a system user (useradd --system --no-create-home <name>) and set User=/Group=")
caps = words(unit, "Service", "AmbientCapabilities") + words(unit, "Service", "CapabilityBoundingSet")
for c in sorted(set(x.lstrip("~") for x in caps)):
if c.upper() in DANGER_CAPS and not any(x.startswith("~") for x in caps):
add(DANGER_CAPS[c.upper()], "SD07", where(unit, "Service", "AmbientCapabilities" if c in words(unit, "Service", "AmbientCapabilities") else "CapabilityBoundingSet"),
"grants %s - close to full root" % c if DANGER_CAPS[c.upper()] == "high" else "grants %s" % c,
"remove it; to bind ports below 1024 only CAP_NET_BIND_SERVICE is needed")
# SD03 secrets
for v, ln, fn in get(unit, "Service", "Environment"):
for m in re.finditer(r'"?([A-Za-z_][A-Za-z0-9_]*)=("[^"]*"|\S*)', v):
name, val = m.group(1), m.group(2).strip('"')
if SECRET_RE.search(name) and val and not val.startswith("%") and not val.startswith("$"):
add("high", "SD03", "%s:%d" % (fn, ln), "secret %s is written in the unit file - unit files are world-readable and shown by `systemctl show`" % name,
"use LoadCredential=%s:/etc/<name>/%s (read from $CREDENTIALS_DIRECTORY) or EnvironmentFile=/etc/<name>/env with mode 0600" % (name.lower(), name.lower()))
if re.search(r"://[^/\s:@]+:[^@\s]+@", val):
add("high", "SD03", "%s:%d" % (fn, ln), "%s contains a URL with an embedded password" % name, "move the credential out of the unit (LoadCredential= / EnvironmentFile= 0600)")
for v, ln, fn in get(unit, "Service", "ExecStart"):
if re.search(r"--(password|passwd|token|secret|api-key)[= ]\S+", v, re.I):
add("high", "SD03", "%s:%d" % (fn, ln), "a secret is passed on the command line - visible to every user in ps and /proc", "read it from a file or $CREDENTIALS_DIRECTORY instead")
# SD04 restart policy
restart = (last(unit, "Service", "Restart", "no") or "no").lower()
if typ != "oneshot" and restart == "no":
add("medium", "SD04", where(unit, "Service", "Restart") if last(unit, "Service", "Restart") else f0,
"Restart=no (default) - a crash leaves the service down until someone notices", "add Restart=on-failure and RestartSec=5s")
if restart in ("always", "on-failure", "on-abnormal") and not last(unit, "Service", "RestartSec"):
add("low", "SD04", where(unit, "Service", "Restart"), "Restart=%s without RestartSec= (default 100ms) - a crash loop hammers the machine and hits the start limit fast" % restart, "add RestartSec=5s")
if typ == "oneshot" and restart in ("always", "on-success"):
add("high", "SD04", where(unit, "Service", "Restart"), "Type=oneshot does not allow Restart=%s - systemd refuses to load the unit" % restart, "use Restart=on-failure or run it from a .timer")
tstop = (last(unit, "Service", "TimeoutStopSec") or last(unit, "Service", "TimeoutSec") or "").lower()
if tstop in ("infinity", "0"):
add("medium", "SD12", where(unit, "Service", "TimeoutStopSec") if last(unit, "Service", "TimeoutStopSec") else where(unit, "Service", "TimeoutSec"),
"stop timeout %s - a hung service blocks shutdown and restarts forever" % tstop, "set TimeoutStopSec=30s (or what a clean shutdown really needs)")
km = (last(unit, "Service", "KillMode", "") or "").lower()
if km == "none":
add("medium", "SD12", where(unit, "Service", "KillMode"), "KillMode=none is deprecated - leftover processes survive stop and restart", "use KillMode=mixed or the default control-group")
elif km == "process":
add("low", "SD12", where(unit, "Service", "KillMode"), "KillMode=process kills only the main process; children keep running after stop", "use KillMode=mixed unless the children must outlive the service")
# SD05 ordering
after = words(unit, "Unit", "After")
wants = words(unit, "Unit", "Wants") + words(unit, "Unit", "Requires")
if "network-online.target" in after and "network-online.target" not in wants:
add("medium", "SD05", where(unit, "Unit", "After"), "After=network-online.target without Wants=network-online.target - the ordering does nothing unless something else pulls the target in",
"add Wants=network-online.target")
if "network-online.target" in wants and "network-online.target" not in after:
add("medium", "SD05", where(unit, "Unit", "Wants"), "Wants=network-online.target without After=network-online.target - the service still starts before the network is up", "add After=network-online.target")
for v in ("syslog.target", "network.service"):
if v in after:
add("low", "SD05", where(unit, "Unit", "After"), "After=%s is obsolete" % v, "remove it (journald is always available; use network-online.target)")
sl = [k for k in ("StartLimitIntervalSec", "StartLimitBurst") if k in S.get("Unit", {})]
for k, ln in (("StartLimitInterval", None),):
if k in svc:
add("medium", "SD09", where(unit, "Service", k), "StartLimitInterval= is the old name and in [Service] it is ignored", "use StartLimitIntervalSec= in [Unit]")
if restart == "always" and not sl and "StartLimitIntervalSec" not in svc:
add("info", "SD04", f0, "Restart=always with the default start limit (5 starts in 10 s) - after that the unit stays failed", "if you want endless retries set StartLimitIntervalSec=0 in [Unit] and RestartSec= to a sane delay")
# SD11 install
wb = words(unit, "Install", "WantedBy") + words(unit, "Install", "RequiredBy")
if not wb and not get(unit, "Install", "Alias"):
add("medium", "SD11", f0, "no [Install] WantedBy= - `systemctl enable` does nothing, so the service will not start at boot", "add [Install] WantedBy=multi-user.target")
elif "default.target" in wb or "graphical.target" in wb:
add("low", "SD11", where(unit, "Install", "WantedBy"), "WantedBy=%s for a system service" % [w for w in wb if w in ("default.target", "graphical.target")][0], "use WantedBy=multi-user.target (default.target is for user services)")
# SD14 conflicts that break start
ph = (last(unit, "Service", "ProtectHome", "no") or "no").lower()
wd = last(unit, "Service", "WorkingDirectory", "") or ""
paths = [wd] + [(e[0].lstrip("-@:+!").split() or [""])[0] for e in execs] + [x.lstrip("-") for x in words(unit, "Service", "EnvironmentFile")]
if ph in TRUE or ph in ("tmpfs", "read-only"):
for p in paths:
if p.startswith("/home/") or p.startswith("/root/"):
add("high" if ph != "read-only" else "medium", "SD14", where(unit, "Service", "ProtectHome"),
"ProtectHome=%s but the service uses %s - it %s" % (ph, p, "cannot see it and will fail to start" if ph != "read-only" else "cannot write there"),
"move the app out of /home (e.g. /opt/<name>, /srv/<name>) or use ProtectHome=read-only with ReadWritePaths= for the exact directory")
break
ps_ = (last(unit, "Service", "ProtectSystem", "no") or "no").lower()
rw = words(unit, "Service", "ReadWritePaths") + ["/var/lib/" + x for x in words(unit, "Service", "StateDirectory")] + \
["/var/log/" + x for x in words(unit, "Service", "LogsDirectory")] + ["/run/" + x for x in words(unit, "Service", "RuntimeDirectory")]
if ps_ == "strict":
for v, ln, fn in execs + get(unit, "Service", "ExecStartPre"):
for m in re.finditer(r">>?\s*(/\S+)|--(?:log|pid|data|out)[-a-z]*[= ](/\S+)", v):
p = m.group(1) or m.group(2)
if not any(p.startswith(x.lstrip("-")) for x in rw) and not p.startswith(("/tmp", "/dev/")):
add("medium", "SD14", "%s:%d" % (fn, ln), "ProtectSystem=strict makes %s read-only - writes will fail" % p,
"use StateDirectory=/LogsDirectory=/RuntimeDirectory= or ReadWritePaths=%s" % p.rsplit("/", 1)[0])
if (last(unit, "Service", "MemoryDenyWriteExecute", "no") or "").lower() in TRUE and paths and re.search(r"(node|java|dotnet|pypy|bun|deno)", " ".join(paths)):
add("high", "SD14", where(unit, "Service", "MemoryDenyWriteExecute"), "MemoryDenyWriteExecute=yes with a JIT runtime (%s) - it will crash on start" % [p for p in paths if re.search(r"(node|java|dotnet|pypy|bun|deno)", p)][0],
"remove MemoryDenyWriteExecute= for this service")
if dyn and user:
add("info", "SD01", where(unit, "Service", "DynamicUser"), "DynamicUser=yes with User=%s - a transient user with that name is allocated" % user, "fine; do not rely on files owned by a static %s user" % user)
# SD15 resources
if not any(k in svc for k in ("MemoryMax", "MemoryHigh", "TasksMax", "CPUQuota")):
add("info", "SD15", f0, "no resource limits (MemoryMax=, TasksMax=, CPUQuota=) - a leak can starve the host", "add MemoryMax= and TasksMax= sized from normal usage")
# SD06 exposure
exposure, missing = 0.0, []
for key, test, w, rec in HARDEN:
v = (last(unit, "Service", key) or "")
vl = v.lower()
if key == "User":
ok = not root
elif test == "bool":
ok = vl in TRUE
elif test == "strict":
ok = vl == "strict"
if vl in ("full", "yes", "true"):
exposure += w * 0.5
missing.append((key, "ProtectSystem=strict|currently %s; add ReadWritePaths=/StateDirectory= for paths it writes" % v))
continue
elif test == "home":
ok = vl in TRUE or vl in ("read-only", "tmpfs")
elif test == "ns":
ok = vl in TRUE or (v != "" and vl not in ("no", "false", "0", "off"))
elif test == "caps":
ok = key in svc and all(c.upper() not in DANGER_CAPS or c.startswith("~") for c in words(unit, "Service", key))
elif test == "set":
ok = bool(words(unit, "Service", key))
elif test == "native":
ok = vl == "native"
elif test == "umask":
ok = v in ("0077", "077", "0027", "027")
else:
ok = bool(v)
if not ok:
exposure += w
missing.append((key, rec))
exposure = round(min(exposure, 10.0), 1)
if missing:
add("medium" if exposure >= 5 else "low", "SD06", f0, "%d sandboxing option(s) missing: %s" % (len(missing), ", ".join(k for k, _ in missing)),
"add the suggested drop-in (systemctl edit <name>), restart, and test the service")
F.sort(key=lambda x: (SEV[x[0]], x[1], x[2]))
rating = "OK" if exposure < 2 else "MEDIUM" if exposure < 5 else "EXPOSED" if exposure < 8 else "UNSAFE"
return {"files": unit["files"], "name": last(unit, "Unit", "Description", "") or unit["files"][0], "type": typ,
"user": "DynamicUser" if dyn else (user or "root"), "restart": restart, "exposure": exposure, "rating": rating,
"missing": missing, "findings": F}
def dropin(r):
lines = ["# /etc/systemd/system/<name>.service.d/hardening.conf (systemctl edit <name>)", "[Service]"]
for k, rec in r["missing"]:
line, _, note = rec.partition("|")
if note:
lines.append("# " + note)
lines.append(line)
if any(f[1] == "SD04" and "Restart=no" in f[3] for f in r["findings"]):
lines += ["Restart=on-failure", "RestartSec=5s"]
lines += ["# then: systemctl daemon-reload && systemctl restart <name> && systemd-analyze security <name>"]
return "\n".join(lines)
def counts(F):
c = {s: 0 for s in SEV}
for f in F:
c[f[0]] += 1
return c
def report(results):
out = ["# systemd unit review", ""]
out.append("| Unit | Description | Type | Runs as | Restart | Exposure (0-10) | High | Medium | Low | Info |")
out.append("|---|---|---|---|---|---|---|---|---|---|")
for r in results:
c = counts(r["findings"])
out.append("| `%s` | %s | %s | %s | %s | %.1f %s | %d | %d | %d | %d |" % (" + ".join(r["files"]), r["name"], r["type"], r["user"], r["restart"],
r["exposure"], r["rating"], c["high"], c["medium"], c["low"], c["info"]))
for r in results:
c = counts(r["findings"])
out += ["", "## %s" % " + ".join(r["files"]), ""]
if c["high"]:
v = "%d high finding(s) - fix before deploying" % c["high"]
elif r["exposure"] >= 5:
v = "works, but exposure %.1f - add the hardening drop-in" % r["exposure"]
else:
v = "looks good (exposure %.1f)" % r["exposure"]
out.append("**Verdict: %s**" % v)
out += ["", "| Sev | Code | Where | Finding | Fix |", "|---|---|---|---|---|"]
for s, code, loc, msg, fix in r["findings"]:
out.append("| %s | %s | %s | %s | %s |" % (s, code, loc, msg.replace("|", "\\|"), fix.replace("|", "\\|")))
if r["missing"]:
out += ["", "Suggested drop-in (test after applying; relax an option if the service needs it):", "", "```ini", dropin(r), "```"]
out += ["", "Exposure is this script's weighted estimate, not the systemd-analyze number; run `systemd-analyze security <unit>` and `systemd-analyze verify <file>` on the target host to confirm."]
return "\n".join(out) + "\n"
def run_files(paths):
unit = None
for p in paths:
try:
with open(p, encoding="utf-8") as fh:
txt = fh.read()
except OSError as e:
raise InputError("cannot read %s: %s" % (p, e))
unit = parse(txt, p, unit)
return review(unit)
# ---------------------------------------------------------------- self-test
def self_test():
tests = []
def t(name, cond):
tests.append((name, bool(cond)))
def R(txt, *drop):
u = parse(txt, "a.service")
for i, d in enumerate(drop):
u = parse(d, "d%d.conf" % i, u)
return review(u)
def codes(txt, *drop):
return [(f[0], f[1]) for f in R(txt, *drop)["findings"]]
base = "[Unit]\nDescription=x\n[Service]\nExecStart=/usr/bin/app\n[Install]\nWantedBy=multi-user.target\n"
good = ("[Unit]\nDescription=good\nWants=network-online.target\nAfter=network-online.target\n[Service]\nType=exec\nExecStart=/usr/bin/app\n"
"DynamicUser=yes\nRestart=on-failure\nRestartSec=5s\nNoNewPrivileges=yes\nProtectSystem=strict\nProtectHome=yes\nPrivateTmp=yes\n"
"PrivateDevices=yes\nProtectKernelTunables=yes\nProtectKernelModules=yes\nProtectKernelLogs=yes\nProtectControlGroups=yes\n"
"ProtectClock=yes\nProtectHostname=yes\nRestrictSUIDSGID=yes\nRestrictNamespaces=yes\nRestrictRealtime=yes\nLockPersonality=yes\n"
"MemoryDenyWriteExecute=yes\nCapabilityBoundingSet=\nSystemCallFilter=@system-service\nSystemCallArchitectures=native\n"
"RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6\nUMask=0077\nMemoryMax=512M\nTasksMax=64\n[Install]\nWantedBy=multi-user.target\n")
g = R(good)
t("fully hardened unit: exposure 0, no high/medium", g["exposure"] == 0 and not [f for f in g["findings"] if f[0] in ("high", "medium")])
t("root by default -> SD01 high", ("high", "SD01") in codes(base))
t("root oneshot -> SD01 medium", ("medium", "SD01") in codes(base.replace("[Service]\n", "[Service]\nType=oneshot\n")))
t("User=svc clears SD01", "SD01" not in [c for _, c in codes(base.replace("[Service]\n", "[Service]\nUser=svc\n"))])
t("missing ExecStart -> SD02 high", ("high", "SD02") in codes("[Service]\nUser=a\n[Install]\nWantedBy=multi-user.target\n"))
t("relative command -> SD02 low", ("low", "SD02") in codes(base.replace("/usr/bin/app", "app")))
t("two ExecStart with simple -> SD02 high", ("high", "SD02") in codes(base.replace("ExecStart=/usr/bin/app", "ExecStart=/a\nExecStart=/b")))
t("empty ExecStart= in drop-in resets", ("high", "SD02") not in codes(base, "[Service]\nExecStart=\nExecStart=/usr/bin/other\n"))
t("pipe in ExecStart -> SD10 high", ("high", "SD10") in codes(base.replace("/usr/bin/app", "/usr/bin/app | logger")))
t("redirect >> -> SD10 high", ("high", "SD10") in codes(base.replace("/usr/bin/app", "/usr/bin/app >> /var/log/app.log")))
t("sh -c is allowed", ("high", "SD10") not in codes(base.replace("/usr/bin/app", "/bin/sh -c '/usr/bin/app | logger'")))
t("--daemon with simple -> SD10 medium", ("medium", "SD10") in codes(base.replace("/usr/bin/app", "/usr/bin/app --daemon")))
t("secret in Environment -> SD03 high", ("high", "SD03") in codes(base.replace("[Service]\n", "[Service]\nEnvironment=DB_PASSWORD=hunter2\n")))
t("quoted env with URL password -> SD03", ("high", "SD03") in codes(base.replace("[Service]\n", '[Service]\nEnvironment="DATABASE_URL=postgres://u:pw@db/x" "PORT=80"\n')))
t("non-secret env is fine", "SD03" not in [c for _, c in codes(base.replace("[Service]\n", "[Service]\nEnvironment=PORT=8080 LOG_LEVEL=info\n"))])
t("--password on cmdline -> SD03", ("high", "SD03") in codes(base.replace("/usr/bin/app", "/usr/bin/app --password=s3cret")))
t("Restart missing -> SD04 medium", ("medium", "SD04") in codes(base))
t("Restart=always no RestartSec -> SD04 low", ("low", "SD04") in codes(base.replace("[Service]\n", "[Service]\nRestart=always\n")))
t("oneshot + Restart=always -> SD04 high", ("high", "SD04") in codes(base.replace("[Service]\n", "[Service]\nType=oneshot\nRestart=always\n")))
t("After online without Wants -> SD05", ("medium", "SD05") in codes(base.replace("[Unit]\n", "[Unit]\nAfter=network-online.target\n")))
t("Wants online without After -> SD05", ("medium", "SD05") in codes(base.replace("[Unit]\n", "[Unit]\nWants=network-online.target\n")))
t("syslog.target obsolete -> SD05 low", ("low", "SD05") in codes(base.replace("[Unit]\n", "[Unit]\nAfter=syslog.target\n")))
t("typo directive -> SD08 with suggestion", any("did you mean RestartSec" in f[3] for f in R(base.replace("[Service]\n", "[Service]\nRestartSecs=5\n"))["findings"]))
t("invalid Restart value -> SD08 high", ("high", "SD08") in codes(base.replace("[Service]\n", "[Service]\nRestart=on-faliure\n")))
t("invalid value ignored, earlier valid one stays effective", R(base.replace("[Service]\n", "[Service]\nRestart=always\n"), "[Service]\nRestart=on-faliure\n")["restart"] == "always")
t("drop-in lines are valid key=value", all("=" in l or l.startswith(("#", "[")) for l in dropin(R(base)).splitlines()))
t("bad boolean -> SD08 medium", ("medium", "SD08") in codes(base.replace("[Service]\n", "[Service]\nPrivateTmp=maybe\n")))
t("StartLimitBurst in [Service] -> SD09", ("medium", "SD09") in codes(base.replace("[Service]\n", "[Service]\nStartLimitBurst=3\n")))
t("WantedBy in [Service] -> SD09", ("medium", "SD09") in codes(base.replace("[Service]\n", "[Service]\nWantedBy=multi-user.target\n")))
t("no [Install] -> SD11 medium", ("medium", "SD11") in codes("[Service]\nExecStart=/usr/bin/a\n"))
t("WantedBy=default.target -> SD11 low", ("low", "SD11") in codes(base.replace("multi-user.target", "default.target")))
t("duplicate scalar -> SD13", ("low", "SD13") in codes(base.replace("[Service]\n", "[Service]\nUser=a\nUser=b\n")))
t("drop-in override is not a duplicate", ("low", "SD13") not in codes(base.replace("[Service]\n", "[Service]\nUser=a\n"), "[Service]\nUser=b\n"))
t("drop-in value wins", R(base.replace("[Service]\n", "[Service]\nUser=a\n"), "[Service]\nUser=b\n")["user"] == "b")
t("ProtectHome with /home app -> SD14 high", ("high", "SD14") in codes(base.replace("/usr/bin/app", "/home/deploy/app/run").replace("[Service]\n", "[Service]\nProtectHome=yes\n")))
t("MDWE with node -> SD14 high", ("high", "SD14") in codes(base.replace("/usr/bin/app", "/usr/bin/node server.js").replace("[Service]\n", "[Service]\nMemoryDenyWriteExecute=yes\n")))
t("ProtectSystem=strict write path -> SD14", ("medium", "SD14") in codes(base.replace("/usr/bin/app", "/usr/bin/app --log-file=/var/log/app/app.log").replace("[Service]\n", "[Service]\nProtectSystem=strict\n")))
t("LogsDirectory clears strict write", ("medium", "SD14") not in codes(base.replace("/usr/bin/app", "/usr/bin/app --log-file=/var/log/app/app.log").replace("[Service]\n", "[Service]\nProtectSystem=strict\nLogsDirectory=app\n")))
t("CAP_SYS_ADMIN -> SD07 high", ("high", "SD07") in codes(base.replace("[Service]\n", "[Service]\nAmbientCapabilities=CAP_SYS_ADMIN\n")))
t("+ prefix -> SD01 medium", ("medium", "SD01") in codes(base.replace("[Service]\n", "[Service]\nUser=a\nExecStartPre=+/usr/bin/setup\n")))
t("TimeoutStopSec=infinity -> SD12", ("medium", "SD12") in codes(base.replace("[Service]\n", "[Service]\nTimeoutStopSec=infinity\n")))
t("continuation lines joined", R("[Service]\nExecStart=/usr/bin/app \\\n --port 80\nUser=a\n[Install]\nWantedBy=multi-user.target\n")["findings"] is not None and
parse("[Service]\nExecStart=/usr/bin/app \\\n --port 80\n", "x")["sections"]["Service"]["ExecStart"][0][0] == "/usr/bin/app --port 80")
t("bare exposure of minimal unit is 10", R(base)["exposure"] == 10.0)
t("ProtectSystem=full counts half", abs(R(base.replace("[Service]\n", "[Service]\nProtectSystem=full\n"))["exposure"] - 9.5) < 0.01)
try:
R("[Unit]\nDescription=x\n")
t("no [Service] rejected", False)
except InputError:
t("no [Service] rejected", True)
t("report has verdict and table", "**Verdict:" in report([R(base)]) and "| Sev | Code |" in report([R(base)]))
for n, ok in tests:
print("%s %s" % ("PASS" if ok else "FAIL", n))
p = sum(ok for _, ok in tests)
print("%d/%d passed" % (p, len(tests)))
return 0 if p == len(tests) else 1
def main(argv=None):
ap = argparse.ArgumentParser(description="Review systemd .service units for reliability and hardening.")
ap.add_argument("units", nargs="*", help="UNIT.service, optionally followed by drop-in .conf files; use ',' between separate units")
ap.add_argument("--json", action="store_true")
ap.add_argument("--dropin", action="store_true", help="print only the suggested hardening drop-in(s)")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args(argv)
if a.self_test:
return self_test()
if not a.units:
ap.error("give at least one .service file")
groups, cur = [], []
for u in a.units:
if u == ",":
groups.append(cur); cur = []
elif u.endswith(".service") and cur:
groups.append(cur); cur = [u]
else:
cur.append(u)
groups.append(cur)
try:
res = [run_files(g) for g in groups if g]
except InputError as e:
print("error: %s" % e, file=sys.stderr)
return 2
if a.json:
print(json.dumps([{**r, "findings": [dict(zip(("severity", "code", "where", "finding", "fix"), f)) for f in r["findings"]]} for r in res], indent=2))
elif a.dropin:
print("\n\n".join(dropin(r) for r in res))
else:
sys.stdout.write(report(res))
return 1 if any(f[0] == "high" for r in res for f in r["findings"]) else 0
if __name__ == "__main__":
sys.exit(main())Audits feature flags against your codebase and plans their cleanup - finds release flags 100% on or off for weeks, rollouts stuck at a partial percentage, experiments that never ended, expired, archived and unreferenced flags, keys used in code but missing from the flag service (or misspelled), staging-production drift and flags without an owner. Lists every file:line to change with effort. Includes an offline stdlib Python scanner. 来源:https://prompts.chat/prompts/cmw38sezu0001kz08zmw4qty1
---
name: feature-flag-stale-cleanup-auditor
description: Audits feature flags against your codebase and plans their cleanup - finds release flags 100% on or off for weeks, rollouts stuck at a partial percentage, experiments that never ended, expired, archived and unreferenced flags, keys used in code but missing from the flag service (or misspelled), staging-production drift and flags without an owner. Lists every file:line to change with effort. Includes an offline stdlib Python scanner.
---
# Feature Flag Stale Cleanup Auditor
You help engineering teams pay down feature flag debt. Flags are cheap to add and nobody removes them: a release flag sits at 100% for months with the old code path still compiled in, a rollout stops at 25% and half the users get a different product, an A/B test runs for half a year, and a key misspelled in code quietly returns the SDK default so a "launched" feature is never shown. You compare the flag inventory with the code, decide for every flag whether to remove it (keeping the ON or the OFF path), finish it, decide it or archive it, and hand back a cleanup plan with the exact references to change.
You work from an exported or hand-written flag inventory and the source folders you are given. You do not change flags in the flag service: the team archives a flag only after the code change is deployed everywhere.
## Files in this skill
- `scripts/flag_audit.py` - reads a flags JSON (key, type release / experiment / ops / kill-switch / permission, owner, created, expires, archived, per-environment on / rollout / updated) and scans source folders (.ts .tsx .js .jsx .py .go .rb .java .kt .cs .php .swift .vue .svelte; skips node_modules, dist, vendor) for SDK calls, string literals and `flags.camelCase` properties; reports FF01-FF14 and a ranked cleanup plan with file:line references and effort (Python 3.8+ standard library only)
- `references/flag-lifecycle-and-removal.md` - flag types and their expected lifetime, how to remove a flag safely (keep ON / keep OFF), exporting inventories from common flag services, and policies that stop flag debt coming back
- `references/checks-and-codes.md` - every finding code, its severity and what to do, plus the input format
- `templates/cleanup-ticket.md` - the cleanup ticket / PR description for each flag
- `examples/flags.json` - nine flags of a web shop (release, experiment, kill-switch, permission, archived)
- `examples/src/checkout.ts`, `examples/src/Header.tsx`, `examples/src/search.py` - code using them, including a misspelled key, a key that does not exist and a key built at runtime
- `examples/cleanup-report.md` - the real scanner output on the example
## Workflow
### 1. Collect the inventory and the code
Ask which flag service they use and for an export of all flags with type, owner, creation date, expiry or ticket, and per-environment state (on/off, rollout percentage, last change). Map it into a JSON like `examples/flags.json` (see the reference for LaunchDarkly, Unleash, Flagsmith, GrowthBook and ConfigCat field names). Ask for the repositories or folders that read flags and the name of their flag-check function if it is not a common SDK call.
### 2. Run the scanner
```
python3 scripts/flag_audit.py examples/flags.json examples/src
python3 scripts/flag_audit.py flags.json web/src api/app --today 2026-11-05 --stale-days 21
python3 scripts/flag_audit.py flags.json src --call featureOn --call can
python3 scripts/flag_audit.py flags.json src --json
python3 scripts/flag_audit.py --self-test
```
Exit 0 = no high findings, 1 = high findings, 2 = bad input. Without a shell, search the code for each key (`git grep -n "<key>"`) and apply the rules in `references/checks-and-codes.md` by hand.
### 3. Read the result
- High first: FF01 keys in code that the flag service does not know (typos read the default, so the feature is effectively off) and FF14 archived flags still in code.
- Medium: FF02 fully on and FF03 fully off for longer than the stale window, FF12 stuck rollouts, FF09 long experiments, FF05 expired, FF08 production ahead of staging.
- Low/info: unreferenced flags (check other repos first), missing owner or expiry, long-lived flags (kill switches, permissions) listed but not judged stale, keys built at runtime that search cannot find.
### 4. Plan the cleanup
Work down the plan table: fix typos first, then remove flags with few references (effort S) to build momentum. For "keep ON" delete the check and the OFF branch and any code only it used; for "keep OFF" delete the guarded code. Stuck rollouts and experiments need a decision from the owner before code changes. Fill `templates/cleanup-ticket.md` per flag.
### 5. Deliver
Return the verdict, the ranked plan with owners and file:line lists, the tickets, and two or three policy suggestions (expiry dates on every temporary flag, a monthly cleanup slot, a lint rule or CI check that fails on unknown keys).
## Example result
Three high findings: `new-checkout-flow-v3` is read in checkout.ts but does not exist, `recs_carousel` in search.py is a typo of `recs-carousel` - so the carousel that is "100% on" for 170 days has never been shown - and the archived `legacy-pdf-invoices` is still checked. `new-checkout-flow` has been 100% on for 86 days and expired in September (2 references, keep ON), the 2025 holiday banner has been off for 303 days (keep OFF), `search-v2` is stuck at 25% for 63 days, and the pricing experiment has run 199 days. `dark-mode` is on in production but off in staging. Kill switch and permission flags are kept.
## Rules
- Never recommend removing a kill switch, ops or permission flag because it is old; they are long-lived by design.
- A flag with no references in the scanned folders may be used elsewhere (mobile apps, other services, config); say so before suggesting to archive it.
- Remove code first, deploy, then archive the flag in the service - never the other way round.
- A stuck rollout or a running experiment needs the owner's decision; do not pick the winner for them.
- Treat `today` and the stale window as inputs; say which values you used.
FILE:references/flag-lifecycle-and-removal.md
# Flag lifecycle and safe removal
## Flag types and expected lifetime
| Type | Purpose | Expected life | Done when |
|---|---|---|---|
| release | hide unfinished work, roll out gradually | days to a few weeks after 100% | 100% everywhere and stable for the stale window |
| experiment | A/B or multivariate test | the test duration (2-6 weeks typical) | a decision is made |
| ops | operational switch (cache on/off, batch size) | long | rarely; review yearly |
| kill-switch | turn off a risky dependency fast | permanent | never by age |
| permission | entitlement, beta access, plan feature | permanent (or move to the billing/entitlement system) | product decision |
## Removing a flag safely
1. Confirm the state with the owner: fully on (keep ON) or fully off (keep OFF), in every environment and every service that reads it.
2. Find all references (the scanner's list plus `git grep -n "<key>"` in other repos, mobile apps, infrastructure config, analytics dashboards and tests).
3. Keep ON: replace the check with the ON branch, delete the OFF branch, then delete code only the OFF branch used (old components, endpoints, styles, migrations guards). Keep OFF: delete the guarded code and anything only it used.
4. Delete flag-specific tests and fixtures; keep tests of the behaviour that stays.
5. Deploy to every environment. Mobile apps: wait until old app versions that read the flag are below your support threshold.
6. Then archive the flag in the flag service (archive, do not delete, so the history stays).
7. Close the ticket with the PR links.
## Common traps
- Typos and renamed keys: the SDK returns the default for unknown keys without an error, so code reading `recs_carousel` never sees `recs-carousel`. A CI check that compares literal keys with the inventory catches this.
- Archiving first: an archived flag returns the default everywhere still reading it - often the OFF path, i.e. an instant rollback of a launched feature.
- Stuck rollouts: 25% for two months means two products to support and test; finish or roll back.
- Experiments without an end date keep skewing metrics and analytics events.
- Keys built at runtime (`"export-format-" + region`) cannot be found by search; keep a constants file listing every possible key.
## Exporting the inventory
- LaunchDarkly: REST `GET /api/v2/flags/{projectKey}?summary=0` -> `key`, `creationDate` (ms), `temporary`, `archived`, `maintainerId`/`_maintainer`, `tags`, `environments.<env>.on`, `.lastModified`, rollout weights in `fallthrough.rollout.variations` (weight/1000 = percent). Map `temporary: false` to `permanent: true`.
- Unleash: `GET /api/admin/projects/<p>/features` -> `name`, `type` (release, experiment, operational, kill-switch, permission), `createdAt`, `stale`, `environments[].enabled`, gradual rollout strategy `parameters.rollout`.
- Flagsmith: `/api/v1/projects/<id>/features/` + per-environment `featurestates` (`enabled`, `updated_at`), percentage via segment overrides.
- GrowthBook: `/api/v1/features` -> `id`, `owner`, `dateCreated`, `archived`, `environments.<env>.enabled`, rollout rule `coverage` (0-1 -> percent).
- ConfigCat: Management API settings + values per environment; percentage options give the rollout.
- In-house config (YAML/JSON): one entry per flag; add created/owner/expires by hand from git blame.
## Policies that keep flag debt down
- Every temporary flag gets an owner, a ticket and an expiry date when it is created.
- Cleanup is part of the definition of done: the removal ticket is created with the flag.
- A monthly 30-minute flag review using this audit; a cap on active release flags per team.
- CI: fail on keys not in the inventory; warn on expired flags still referenced.
FILE:references/checks-and-codes.md
# Checks and finding codes
| Code | Severity | Check | What to do |
|---|---|---|---|
| FF01 | high | a flag-check call (isEnabled, variation, useFlag, is_enabled ...) uses a key that is not in the inventory; a close match is suggested (typo, `_` vs `-`) | fix the key or create the flag; until then the SDK returns the default value |
| FF02 | medium | release flag at 100% in the primary environment for `stale_days` or more | remove the flag, keep the ON path |
| FF03 | medium | release or experiment flag off / 0% in the primary environment for `stale_days` or more | remove the flag and the guarded code, or decide to launch |
| FF04 | low | no reference in the scanned files | check other repos and services; archive if unused |
| FF05 | medium | `expires` date passed (temporary flags) | remove or agree a new date |
| FF06 | info | ops, kill-switch, permission (or `permanent: true`) flag - not judged stale | review yearly |
| FF07 | low | no owner | assign one |
| FF08 | medium | primary environment ahead of staging/qa/test/dev, or no state for the primary environment | align environments |
| FF09 | medium | experiment older than `experiment_max_days` | decide, ship the winner, remove |
| FF10 | info | flag key built at runtime (variable, concatenation, template string) | use literal keys or a constants file |
| FF11 | low | temporary flag without `expires` | set an expiry |
| FF12 | medium | release rollout between 1 and 99% unchanged for `stale_days` | finish (100%) or roll back, then remove |
| FF13 | medium | key defined twice in the inventory | merge |
| FF14 | high | flag archived in the service but still referenced in code | remove the references |
## Verdict
- **N high finding(s)** when FF01/FF14 exist; else **N flag(s) ready to remove**; else **no stale flags**.
## Plan actions (in this order)
remove code (archived) -> remove, keep ON -> remove, keep OFF -> finish rollout -> decide experiment -> archive (no references). Within an action, flags with more references first. Effort: S 1-2 references, M 3-6, L 7+.
## How references are found
1. Calls: `<name>(` with a literal first argument (or a literal after one leading argument such as a client or user), for the built-in names plus `call_names` / `--call`.
2. Any string literal equal to a known key (constants files, config maps).
3. `flags.`, `features.`, `featureFlags.`, `toggles.` followed by the key or its camelCase form (`new-search` -> `flags.newSearch`).
Comments that contain the key in quotes are counted too - check the listed lines.
## Flags JSON
Top level: `today, primary_env (production), environments, stale_days (30), experiment_max_days (90), call_names[], flags[]`.
Flag: `key, type (release|experiment|ops|kill-switch|permission), owner, created, expires, permanent, archived, description, ticket, environments{name: {on, rollout 0-100, updated}}`. `rollout` defaults to 100 when on and 0 when off; `updated` is the last change of that environment (falls back to `created`).
FILE:templates/cleanup-ticket.md
# Remove feature flag `<key>`
**Owner:** <team-payments> **Type:** <release> **Ticket:** <PAY-412> **Action:** <remove, keep ON>
**State:** <100% on in production since 2026-08-11 (86 days), 100% in staging> **Expired:** <2026-09-30>
## Why
<Fully rolled out and stable for 86 days; the OFF path (legacy checkout) is dead code.>
## Change
- [ ] `<examples/src/checkout.ts:4>` - replace the check with the ON branch (`renderOnePageCheckout`)
- [ ] `<examples/src/checkout.ts:14>` - always use `oneClickButton()`
- [ ] delete code only the OFF path used: <renderLegacyCheckout, classicButton, legacy checkout styles>
- [ ] delete flag-specific tests and fixtures
- [ ] search other repos / mobile apps: `git grep -n "<key>"`
## Rollout
1. Merge and deploy to staging, then production.
2. Watch <checkout conversion, error rate> for <2 days>.
3. Archive `<key>` in <flag service> (archive, not delete).
## Risks / questions
- <Does the mobile app read this flag? Minimum supported version?>
- <Any dashboards or alerts keyed on the flag?>
FILE:examples/flags.json
{
"today": "2026-11-05",
"primary_env": "production",
"stale_days": 30,
"experiment_max_days": 90,
"flags": [
{"key": "new-checkout-flow", "type": "release", "owner": "team-payments", "created": "2026-06-02", "expires": "2026-09-30", "ticket": "PAY-412",
"description": "New one-page checkout",
"environments": {"production": {"on": true, "rollout": 100, "updated": "2026-08-11"}, "staging": {"on": true, "rollout": 100, "updated": "2026-07-20"}}},
{"key": "search-v2", "type": "release", "owner": "team-search", "created": "2026-07-15", "expires": "2026-12-31",
"description": "Typesense-backed search",
"environments": {"production": {"on": true, "rollout": 25, "updated": "2026-09-03"}, "staging": {"on": true, "rollout": 100, "updated": "2026-08-01"}}},
{"key": "holiday-banner-2025", "type": "release", "owner": "team-growth", "created": "2025-11-10", "expires": "2026-01-10",
"description": "Christmas 2025 promo banner",
"environments": {"production": {"on": false, "rollout": 0, "updated": "2026-01-06"}, "staging": {"on": false, "rollout": 0, "updated": "2026-01-06"}}},
{"key": "pricing-page-test", "type": "experiment", "owner": "team-growth", "created": "2026-04-20", "expires": "2026-07-01",
"description": "A/B test: annual plan first",
"environments": {"production": {"on": true, "rollout": 50, "updated": "2026-04-22"}, "staging": {"on": true, "rollout": 50, "updated": "2026-04-22"}}},
{"key": "dark-mode", "type": "release", "owner": "team-web", "created": "2026-10-01", "expires": "2026-12-15",
"description": "Dark theme",
"environments": {"production": {"on": true, "rollout": 100, "updated": "2026-10-28"}, "staging": {"on": false, "rollout": 0, "updated": "2026-10-02"}}},
{"key": "payments-kill-switch", "type": "kill-switch", "owner": "team-payments", "created": "2025-03-01",
"description": "Turn off card payments during a PSP outage",
"environments": {"production": {"on": true, "rollout": 100, "updated": "2025-03-01"}, "staging": {"on": true, "rollout": 100, "updated": "2025-03-01"}}},
{"key": "beta-reports-export", "type": "permission", "owner": "", "created": "2025-09-12",
"description": "CSV export for beta customers",
"environments": {"production": {"on": true, "rollout": 100, "updated": "2025-09-12"}}},
{"key": "legacy-pdf-invoices", "type": "release", "owner": "team-billing", "created": "2025-05-01", "archived": true,
"description": "Old PDF renderer",
"environments": {"production": {"on": false, "rollout": 0, "updated": "2026-02-01"}}},
{"key": "recs-carousel", "type": "release", "owner": "team-growth", "created": "2026-03-03",
"description": "Recommendations carousel on product pages",
"environments": {"production": {"on": true, "rollout": 100, "updated": "2026-05-19"}, "staging": {"on": true, "rollout": 100, "updated": "2026-05-01"}}}
]
}
FILE:examples/src/checkout.ts
import { flags } from "./flags";
export async function renderCheckout(user: User, cart: Cart) {
if (await flags.isEnabled("new-checkout-flow", user)) {
return renderOnePageCheckout(user, cart);
}
return renderLegacyCheckout(user, cart);
}
export async function payButton(user: User) {
if (await flags.isEnabled("payments-kill-switch", user)) {
return disabledButton("Card payments are paused. Please try again shortly.");
}
const oneClick = await flags.isEnabled("new-checkout-flow", user);
return oneClick ? oneClickButton() : classicButton();
}
export async function invoiceLink(order: Order) {
// TODO remove after PDF migration
if (await flags.isEnabled("legacy-pdf-invoices")) {
return legacyPdfUrl(order.id);
}
return invoiceServiceUrl(order.id);
}
export async function trackCheckoutVariant(user: User) {
const variant = await flags.variation("new-checkout-flow-v3", user, "control");
analytics.track("checkout_variant", { variant });
}
FILE:examples/src/Header.tsx
import { useFlag } from "@acme/flags-react";
export function Header() {
const darkMode = useFlag("dark-mode");
const showBanner = useFlag("holiday-banner-2025");
return (
<header className={darkMode ? "header header--dark" : "header"}>
{showBanner && <PromoBanner text="Holiday sale: 20% off everything" />}
<SearchBox />
</header>
);
}
export function PricingPage({ user }: { user: User }) {
const annualFirst = useFlag("pricing-page-test");
return annualFirst ? <PlansAnnualFirst /> : <PlansMonthlyFirst />;
}
FILE:examples/src/search.py
from app.flags import flags
def search(query, user):
if flags.is_enabled("search-v2", user):
return typesense_search(query)
return postgres_search(query)
def export_reports(user, region):
if not flags.is_enabled("beta-reports-export", user):
raise PermissionError("export is in beta")
# per-region experiment keys, e.g. "export-format-eu"
if flags.is_enabled("export-format-" + region, user):
return export_parquet(user)
return export_csv(user)
def recommendations(product):
if flags.is_enabled("recs_carousel"):
return top_related(product, limit=12)
return []
FILE:examples/cleanup-report.md
# Feature flag cleanup audit
Today 2026-11-05, primary environment `production`, stale after 30 days. 9 flag(s) in the inventory, 3 source file(s) scanned in examples/src.
**Verdict: 3 high finding(s) - flags used in code that the flag service does not know**
Cleanup candidates: 6 (4 removable now, 4 reference(s) to change). Findings: 3 high, 9 medium, 2 low, 3 info.
## Cleanup plan
| # | Flag | Type | Owner | Primary | Action | Why | Refs (files) | Effort | Where |
|---|---|---|---|---|---|---|---|---|---|
| 1 | `legacy-pdf-invoices` | release | team-billing | archived | **remove code** | archived but still in code | 1 (1) | S | `examples/src/checkout.ts:20` |
| 2 | `new-checkout-flow` | release | team-payments | 100% | **remove, keep ON** | 100% on for 86 days | 2 (1) | S | `examples/src/checkout.ts:4`, `examples/src/checkout.ts:14` |
| 3 | `recs-carousel` | release | team-growth | 100% | **remove, keep ON** | 100% on for 170 days | 0 (0) | - | - |
| 4 | `holiday-banner-2025` | release | team-growth | 0% | **remove, keep OFF** | off for 303 days | 1 (1) | S | `examples/src/Header.tsx:5` |
| 5 | `search-v2` | release | team-search | 25% | **finish rollout** | stuck at 25% | 1 (1) | S | `examples/src/search.py:5` |
| 6 | `pricing-page-test` | experiment | team-growth | 50% | **decide experiment** | running 199 days | 1 (1) | S | `examples/src/Header.tsx:15` |
Effort: S = 1-2 references, M = 3-6, L = 7 or more. "keep ON" = delete the flag check and the OFF branch; "keep OFF" = delete the guarded code. Archive the flag in the flag service only after the code change is deployed everywhere.
## Findings
| Sev | Code | Flag | Finding | Fix |
|---|---|---|---|---|
| high | FF01 | `new-checkout-flow-v3` | used in code (examples/src/checkout.ts:27) but not defined in the flag inventory - the SDK returns the default, so this code path is reading the wrong key | typo of new-checkout-flow? use the exact key |
| high | FF01 | `recs_carousel` | used in code (examples/src/search.py:20) but not defined in the flag inventory - the SDK returns the default, so this code path is reading the wrong key | typo of recs-carousel? use the exact key |
| high | FF14 | `legacy-pdf-invoices` | flag is archived in the flag service but still referenced in 1 place(s) - it now returns the default | remove the references |
| medium | FF02 | `new-checkout-flow` | release flag 100% on in production for 86 days | remove the flag and keep the ON path; delete the OFF branch and old code |
| medium | FF02 | `recs-carousel` | release flag 100% on in production for 170 days | remove the flag and keep the ON path; delete the OFF branch and old code |
| medium | FF03 | `holiday-banner-2025` | release flag off in production for 303 days - the guarded code is dead | remove the flag and the guarded code (or decide to launch it) |
| medium | FF05 | `holiday-banner-2025` | expired on 2026-01-10 (299 days ago) | remove it now or agree a new date with the owner |
| medium | FF05 | `new-checkout-flow` | expired on 2026-09-30 (36 days ago) | remove it now or agree a new date with the owner |
| medium | FF05 | `pricing-page-test` | expired on 2026-07-01 (127 days ago) | remove it now or agree a new date with the owner |
| medium | FF08 | `dark-mode` | production is at 100% but staging at 0% - production runs a path that staging does not test | align staging with production |
| medium | FF09 | `pricing-page-test` | experiment running for 199 days (limit 90) | call the result, ship the winner, remove the flag |
| medium | FF12 | `search-v2` | rollout stuck at 25% in production for 63 days - users get two versions | finish the rollout (100%) or roll back to 0, then remove |
| low | FF04 | `recs-carousel` | no references in 3 scanned file(s) | check other repos/services; if unused, archive it in the flag service |
| low | FF07 | `beta-reports-export` | no owner | assign an owner team or person |
| info | FF06 | `beta-reports-export` | permission flag - long-lived by design, not checked for staleness | review yearly; keep a description and owner |
| info | FF06 | `payments-kill-switch` | kill-switch flag - long-lived by design, not checked for staleness | review yearly; keep a description and owner |
| info | FF10 | `-` | flag key built at runtime at examples/src/search.py:14 - references to it cannot be found by search | prefer literal keys (or a constants file) so flags can be cleaned up |
## References per flag
| Flag | References |
|---|---|
| `beta-reports-export` | 1 |
| `dark-mode` | 1 |
| `holiday-banner-2025` | 1 |
| `legacy-pdf-invoices` | 1 |
| `new-checkout-flow` | 2 |
| `payments-kill-switch` | 1 |
| `pricing-page-test` | 1 |
| `recs-carousel` | 0 |
| `search-v2` | 1 |
Search covers string literals, common SDK calls and `flags.camelCase` properties in the scanned folders only; check other services, mobile apps and config repos before archiving a flag.
FILE:scripts/flag_audit.py
#!/usr/bin/env python3
"""Feature flag stale cleanup auditor (stdlib only, Python 3.8+).
Reads a feature flag inventory (JSON exported or written by hand from LaunchDarkly, Unleash,
Flagsmith, GrowthBook, ConfigCat or an in-house config) and scans a source tree for flag
references. Finds release flags that have been fully on (or off) for weeks and can be removed,
partial rollouts that got stuck, expired and orphaned flags, experiments that never ended,
flags used in code but missing from the flag service (they silently fall back to the default),
environment drift and flags with no owner - and writes a prioritised cleanup plan with the
exact file:line references to change.
Usage:
flag_audit.py FLAGS.json SRC_DIR [SRC_DIR ...] [--today YYYY-MM-DD] [--stale-days N]
[--call NAME ...] [--json]
flag_audit.py --self-test
Exit: 0 = no high findings, 1 = at least one high finding, 2 = bad input.
"""
import argparse, datetime, difflib, json, os, re, sys
SEV = {"high": 0, "medium": 1, "low": 2, "info": 3}
TYPES = {"release", "experiment", "ops", "kill-switch", "permission"}
LONG_LIVED = {"ops", "kill-switch", "permission"}
EXTS = {".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py", ".go", ".rb", ".java", ".kt", ".cs", ".php", ".swift", ".vue", ".svelte"}
SKIP_DIRS = {"node_modules", ".git", "dist", "build", "vendor", "__pycache__", ".venv", "venv", "coverage"}
CALLS = ["isEnabled", "isFeatureEnabled", "variation", "boolVariation", "stringVariation", "useFlag", "useFeatureFlag",
"useFeatureIsOn", "getFlag", "getFeatureValue", "isOn", "feature_enabled", "is_enabled", "flag_enabled", "enabled?",
"Enabled", "IsEnabled", "getBooleanValue", "hasFeature"]
TOP_KEYS = {"today", "stale_days", "experiment_max_days", "primary_env", "environments", "flags", "call_names", "source"}
FLAG_KEYS = {"key", "type", "owner", "created", "expires", "permanent", "description", "environments", "archived", "ticket"}
class InputError(Exception):
pass
def pdate(s, what):
try:
return datetime.date.fromisoformat(str(s).strip()[:10])
except ValueError:
raise InputError("%s must be YYYY-MM-DD, got %r" % (what, s))
def camel(k):
parts = re.split(r"[-_.]", k)
return parts[0] + "".join(p[:1].upper() + p[1:] for p in parts[1:])
# ---------------------------------------------------------------- load
def load_flags(cfg, today=None, stale_days=None):
if not isinstance(cfg, dict) or not isinstance(cfg.get("flags"), list):
raise InputError("flags file must be a JSON object with a \"flags\" list")
bad = set(cfg) - TOP_KEYS
if bad:
raise InputError("unknown top-level key(s): %s" % ", ".join(sorted(bad)))
c = {"today": pdate(today or cfg.get("today") or datetime.date.today().isoformat(), "today"),
"stale": int(stale_days if stale_days is not None else cfg.get("stale_days", 30)),
"exp_max": int(cfg.get("experiment_max_days", 90)),
"primary": cfg.get("primary_env", "production"),
"envs": cfg.get("environments") or [], "flags": {}, "dups": [], "calls": cfg.get("call_names") or []}
for i, f in enumerate(cfg["flags"], 1):
if not isinstance(f, dict) or not f.get("key"):
raise InputError("flag #%d has no key" % i)
badk = set(f) - FLAG_KEYS
if badk:
raise InputError("flag %s: unknown key(s) %s" % (f["key"], ", ".join(sorted(badk))))
t = (f.get("type") or "release").lower()
if t not in TYPES:
raise InputError("flag %s: type must be one of %s, got %r" % (f["key"], ", ".join(sorted(TYPES)), t))
envs = {}
for e, st in (f.get("environments") or {}).items():
if not isinstance(st, dict):
raise InputError("flag %s env %s must be an object {on, rollout, updated}" % (f["key"], e))
r = st.get("rollout", 100 if st.get("on") else 0)
if not isinstance(r, (int, float)) or not 0 <= r <= 100:
raise InputError("flag %s env %s: rollout must be 0-100, got %r" % (f["key"], e, r))
envs[e] = {"on": bool(st.get("on")), "rollout": float(r),
"updated": pdate(st["updated"], "flag %s env %s updated" % (f["key"], e)) if st.get("updated") else None}
fl = {"key": f["key"], "type": t, "owner": f.get("owner") or "", "ticket": f.get("ticket", ""),
"created": pdate(f["created"], "flag %s created" % f["key"]) if f.get("created") else None,
"expires": pdate(f["expires"], "flag %s expires" % f["key"]) if f.get("expires") else None,
"permanent": bool(f.get("permanent")) or t in LONG_LIVED, "archived": bool(f.get("archived")),
"description": f.get("description", ""), "envs": envs}
if fl["key"] in c["flags"]:
c["dups"].append(fl["key"])
c["flags"][fl["key"]] = fl
return c
def eff(st):
"""effective share of users getting the flag: 0-100"""
return st["rollout"] if st["on"] else 0.0
# ---------------------------------------------------------------- scan
def scan(dirs, keys, calls):
"""Return refs {key: [(path, line, text)]}, unknown {literal: [(path,line)]}, dynamic [(path,line,text)], files"""
calls = sorted(set(CALLS + list(calls)), key=len, reverse=True)
alt = "|".join(re.escape(c) for c in calls)
call_re = re.compile(r"(?:\b|\.)(%s)\s*\(\s*(?:\w+\s*,\s*)?([\"'`])([^\"'`$]+)\2\s*[,)]" % alt)
dyn_re = re.compile(r"(?:\b|\.)(%s)\s*\(\s*(?![\"'`][^\"'`$]*[\"'`]\s*[,)])([^,)]+)[,)]" % alt)
lit_re = re.compile(r"([\"'`])([A-Za-z0-9][A-Za-z0-9_.:-]{2,80})\1")
camels = {camel(k): k for k in keys if camel(k) != k}
prop_re = re.compile(r"\b(?:flags|features|featureFlags|toggles)\.([A-Za-z_]\w*)")
refs, unknown, dynamic, nfiles = {k: [] for k in keys}, {}, [], 0
for d in dirs:
if not os.path.isdir(d):
raise InputError("source directory not found: %s" % d)
for root, ds, fs in os.walk(d):
ds[:] = sorted(x for x in ds if x not in SKIP_DIRS)
for fn in sorted(fs):
if os.path.splitext(fn)[1] not in EXTS:
continue
p = os.path.join(root, fn)
nfiles += 1
try:
lines = open(p, encoding="utf-8", errors="replace").read().splitlines()
except OSError as e:
raise InputError("cannot read %s: %s" % (p, e))
for i, ln in enumerate(lines, 1):
seen = set()
for m in call_re.finditer(ln):
lit = m.group(3)
if lit in refs:
refs[lit].append((p, i, ln.strip())); seen.add(lit)
else:
unknown.setdefault(lit, []).append((p, i, ln.strip()))
seen.add(lit)
for m in dyn_re.finditer(ln):
if m.group(2).strip() not in ("true", "false", "None", "null", ""):
dynamic.append((p, i, ln.strip()))
for m in lit_re.finditer(ln):
lit = m.group(2)
if lit in refs and lit not in seen:
refs[lit].append((p, i, ln.strip())); seen.add(lit)
for m in prop_re.finditer(ln):
k = camels.get(m.group(1)) or (m.group(1) if m.group(1) in refs else None)
if k and k not in seen:
refs[k].append((p, i, ln.strip())); seen.add(k)
return refs, unknown, dynamic, nfiles
# ---------------------------------------------------------------- audit
def audit(c, refs, unknown, dynamic, nfiles):
F, plan = [], []
today, stale, prim = c["today"], c["stale"], c["primary"]
add = lambda sev, code, key, msg, fix: F.append((sev, code, key, msg, fix))
for k in c["dups"]:
add("medium", "FF13", k, "flag defined twice in the inventory (the last one is used)", "merge the two entries")
keys = list(c["flags"])
norm = {re.sub(r"[-_.]", "", k.lower()): k for k in keys}
for lit, where in sorted(unknown.items()):
close = norm.get(re.sub(r"[-_.]", "", lit.lower())) or (difflib.get_close_matches(lit, keys, 1, 0.8) or [None])[0]
locs = ", ".join("%s:%d" % (p, i) for p, i, _ in where[:3])
add("high", "FF01", lit, "used in code (%s) but not defined in the flag inventory - the SDK returns the default, so this code path is %s" %
(locs, "probably never on" if not close else "reading the wrong key"),
("typo of %s? use the exact key" % close) if close else "create the flag or remove the check")
for p, i, txt in dynamic[:20]:
add("info", "FF10", "-", "flag key built at runtime at %s:%d - references to it cannot be found by search" % (p, i), "prefer literal keys (or a constants file) so flags can be cleaned up")
for k, f in c["flags"].items():
r = refs.get(k, [])
files = sorted(set(p for p, _, _ in r))
st = f["envs"].get(prim)
age = (today - f["created"]).days if f["created"] else None
action, why = None, ""
if not f["owner"]:
add("low", "FF07", k, "no owner", "assign an owner team or person")
if f["archived"]:
if r:
add("high", "FF14", k, "flag is archived in the flag service but still referenced in %d place(s) - it now returns the default" % len(r), "remove the references")
plan.append({"key": k, "type": f["type"], "owner": f["owner"] or "?", "state": "archived", "action": "remove code",
"why": "archived but still in code", "refs": len(r), "files": len(files),
"locs": ["%s:%d" % (p, i) for p, i, _ in r], "ticket": f["ticket"]})
continue
if st is None and not f["archived"]:
add("medium", "FF08", k, "no state for the primary environment %r" % prim, "add the %s state or fix primary_env" % prim)
if f["permanent"]:
add("info", "FF06", k, "%s flag - long-lived by design, not checked for staleness" % f["type"], "review yearly; keep a description and owner")
if not r:
add("low", "FF04", k, "no references in %d scanned file(s)" % nfiles, "check other repos/services; if unused, archive it in the flag service")
action, why = action or "archive", "no references"
elif st is not None:
since = st["updated"] or f["created"]
days = (today - since).days if since else None
e = eff(st)
all_on = all(eff(s) == 100 for s in f["envs"].values())
if f["expires"] and f["expires"] < today:
add("medium", "FF05", k, "expired on %s (%d days ago)" % (f["expires"], (today - f["expires"]).days), "remove it now or agree a new date with the owner")
if f["type"] == "release" and e == 100 and days is not None and days >= stale:
add("medium", "FF02", k, "release flag 100%% on in %s for %d days%s" % (prim, days, "" if all_on else " (not 100% in every environment)"),
"remove the flag and keep the ON path; delete the OFF branch and old code")
action, why = "remove, keep ON", "100%% on for %d days" % days
elif e == 0 and days is not None and days >= stale and f["type"] in ("release", "experiment"):
add("medium", "FF03", k, "%s flag off in %s for %d days - the guarded code is dead" % (f["type"], prim, days),
"remove the flag and the guarded code (or decide to launch it)")
action, why = "remove, keep OFF", "off for %d days" % days
elif 0 < e < 100 and days is not None and days >= stale and f["type"] == "release":
add("medium", "FF12", k, "rollout stuck at %g%% in %s for %d days - users get two versions" % (e, prim, days), "finish the rollout (100%) or roll back to 0, then remove")
action, why = "finish rollout", "stuck at %g%%" % e
if f["type"] == "experiment" and age is not None and age > c["exp_max"]:
add("medium", "FF09", k, "experiment running for %d days (limit %d)" % (age, c["exp_max"]), "call the result, ship the winner, remove the flag")
action, why = action or "decide experiment", "running %d days" % age
if not r:
add("low", "FF04", k, "no references in %d scanned file(s)" % nfiles, "check other repos/services; if unused, archive it in the flag service")
action, why = action or "archive", why or "no references"
for en, s2 in f["envs"].items():
if en != prim and eff(s2) < e and (en in ("staging", "qa", "test", "dev", "development")):
add("medium", "FF08", k, "%s is at %g%% but %s at %g%% - production runs a path that staging does not test" % (prim, e, en, eff(s2)), "align %s with %s" % (en, prim))
if f["expires"] is None and f["type"] in ("release", "experiment") and action is None:
add("low", "FF11", k, "temporary %s flag without an expiry date" % f["type"], "set expires (e.g. 30-60 days after full rollout)")
if action:
plan.append({"key": k, "type": f["type"], "owner": f["owner"] or "?", "state": ("%g%%" % eff(st)) if st else "-", "action": action, "why": why,
"refs": len(r), "files": len(files), "locs": ["%s:%d" % (p, i) for p, i, _ in r], "ticket": f["ticket"]})
rank = {"remove code": 0, "remove, keep ON": 1, "remove, keep OFF": 2, "finish rollout": 3, "decide experiment": 4, "archive": 5}
plan.sort(key=lambda x: (rank[x["action"]], -x["refs"], x["key"]))
F.sort(key=lambda x: (SEV[x[0]], x[1], x[2]))
return {"today": today.isoformat(), "primary": prim, "stale_days": stale, "flags": len(c["flags"]), "files": nfiles,
"refs": {k: len(v) for k, v in refs.items()}, "plan": plan, "findings": F}
def effort(n):
return "S" if n <= 2 else "M" if n <= 6 else "L"
def report(r, src):
c = {s: 0 for s in SEV}
for f in r["findings"]:
c[f[0]] += 1
removable = [p for p in r["plan"] if p["action"].startswith("remove")]
out = ["# Feature flag cleanup audit", "",
"Today %s, primary environment `%s`, stale after %d days. %d flag(s) in the inventory, %d source file(s) scanned in %s." % (
r["today"], r["primary"], r["stale_days"], r["flags"], r["files"], ", ".join(src)), ""]
if c["high"]:
v = "%d high finding(s) - flags used in code that the flag service does not know" % c["high"]
elif removable:
v = "%d flag(s) ready to remove" % len(removable)
else:
v = "no stale flags"
out += ["**Verdict: %s**" % v, "", "Cleanup candidates: %d (%d removable now, %d reference(s) to change). Findings: %d high, %d medium, %d low, %d info." % (
len(r["plan"]), len(removable), sum(p["refs"] for p in removable), c["high"], c["medium"], c["low"], c["info"]), ""]
out += ["## Cleanup plan", "", "| # | Flag | Type | Owner | Primary | Action | Why | Refs (files) | Effort | Where |", "|---|---|---|---|---|---|---|---|---|---|"]
for i, p in enumerate(r["plan"], 1):
locs = ", ".join("`%s`" % x for x in p["locs"][:4]) + (" +%d more" % (len(p["locs"]) - 4) if len(p["locs"]) > 4 else "")
out.append("| %d | `%s` | %s | %s | %s | **%s** | %s | %d (%d) | %s | %s |" % (i, p["key"], p["type"], p["owner"], p["state"], p["action"], p["why"],
p["refs"], p["files"], effort(p["refs"]) if p["refs"] else "-", locs or "-"))
out += ["", "Effort: S = 1-2 references, M = 3-6, L = 7 or more. \"keep ON\" = delete the flag check and the OFF branch; \"keep OFF\" = delete the guarded code. Archive the flag in the flag service only after the code change is deployed everywhere.", ""]
out += ["## Findings", "", "| Sev | Code | Flag | Finding | Fix |", "|---|---|---|---|---|"]
for s, code, k, msg, fix in r["findings"]:
out.append("| %s | %s | `%s` | %s | %s |" % (s, code, k, msg.replace("|", "\\|"), fix.replace("|", "\\|")))
out += ["", "## References per flag", "", "| Flag | References |", "|---|---|"]
for k, n in sorted(r["refs"].items()):
out.append("| `%s` | %d |" % (k, n))
out += ["", "Search covers string literals, common SDK calls and `flags.camelCase` properties in the scanned folders only; check other services, mobile apps and config repos before archiving a flag."]
return "\n".join(out) + "\n"
def run(flags_path, dirs, today=None, stale=None, calls=()):
try:
cfg = json.load(open(flags_path, encoding="utf-8"))
except (OSError, ValueError) as e:
raise InputError("cannot read %s: %s" % (flags_path, e))
c = load_flags(cfg, today, stale)
refs, unknown, dynamic, n = scan(dirs, list(c["flags"]), list(c["calls"]) + list(calls))
return audit(c, refs, unknown, dynamic, n)
# ---------------------------------------------------------------- self-test
def self_test():
import tempfile
tests = []
def t(name, cond):
tests.append((name, bool(cond)))
def mk(flags, files, **kw):
d = tempfile.mkdtemp()
for name, body in files.items():
os.makedirs(os.path.dirname(os.path.join(d, name)) or d, exist_ok=True)
open(os.path.join(d, name), "w").write(body)
cfg = {"today": "2026-11-05", "flags": flags}
cfg.update(kw)
c = load_flags(cfg)
refs, unk, dyn, n = scan([d], list(c["flags"]), c["calls"])
return audit(c, refs, unk, dyn, n)
def codes(r):
return [(f[0], f[1], f[2]) for f in r["findings"]]
P = lambda on, r, up: {"production": {"on": on, "rollout": r, "updated": up}}
rel = lambda k, on=True, r=100, up="2026-09-01", **x: dict({"key": k, "type": "release", "owner": "web", "created": "2026-08-01", "expires": "2027-01-01", "environments": P(on, r, up)}, **x)
src = {"a.ts": "if (client.isEnabled('new-checkout')) {}\nconst x = useFlag(\"dark-mode\")\n"}
r = mk([rel("new-checkout"), rel("dark-mode", up="2026-10-30")], src)
t("100% on for 65 days -> FF02", ("medium", "FF02", "new-checkout") in codes(r))
t("100% on for 6 days -> not stale", ("medium", "FF02", "dark-mode") not in codes(r))
t("plan action keep ON", [p["action"] for p in r["plan"] if p["key"] == "new-checkout"] == ["remove, keep ON"])
t("refs found via call", r["refs"]["new-checkout"] == 1 and r["refs"]["dark-mode"] == 1)
r = mk([rel("old-banner", on=False, r=0)], {"b.py": "if flags.is_enabled(\"old-banner\"):\n pass\n"})
t("off for 65 days -> FF03 keep OFF", ("medium", "FF03", "old-banner") in codes(r) and r["plan"][0]["action"] == "remove, keep OFF")
r = mk([rel("half", r=50)], {"c.js": "isEnabled('half')"})
t("stuck rollout -> FF12", ("medium", "FF12", "half") in codes(r))
r = mk([rel("new-checkout")], {"a.ts": "isEnabled('new_checkout')\n"})
t("undefined key in code -> FF01 high", ("high", "FF01", "new_checkout") in codes(r))
t("FF01 suggests the close key", any("typo of new-checkout" in f[4] for f in r["findings"]))
t("FF01 alone means 0 refs for the real key -> FF04", ("low", "FF04", "new-checkout") in codes(r))
r = mk([rel("ghost")], {"a.ts": "const a = 1\n"})
t("no refs -> FF04 + archive", ("low", "FF04", "ghost") in codes(r))
r = mk([rel("exp1", type="experiment", created="2026-05-01", r=50, up="2026-10-20")], {"a.ts": "variation('exp1', false)"})
t("experiment over 90 days -> FF09", ("medium", "FF09", "exp1") in codes(r))
r = mk([rel("exp2", expires="2026-10-01", up="2026-10-30")], {"a.ts": "isEnabled('exp2')"})
t("expired -> FF05", ("medium", "FF05", "exp2") in codes(r))
r = mk([dict(rel("ks"), type="kill-switch", expires=None)], {"a.ts": "isEnabled('ks')"})
t("kill-switch skipped -> FF06 info only", [x[1] for x in codes(r)] == ["FF06"])
r = mk([dict(rel("p", up="2026-10-30"), owner="")], {"a.ts": "isEnabled('p')"})
t("no owner -> FF07", ("low", "FF07", "p") in codes(r))
r = mk([dict(rel("d", up="2026-10-30"), environments={"production": {"on": True, "rollout": 100, "updated": "2026-10-30"}, "staging": {"on": False}})], {"a.ts": "isEnabled('d')"})
t("prod ahead of staging -> FF08", ("medium", "FF08", "d") in codes(r))
r = mk([rel("x")], {"a.ts": "const k = 'x' + region\nisEnabled(flagName)\n"})
t("dynamic key -> FF10 info", ("info", "FF10", "-") in codes(r))
r = mk([rel("x")], {"a.py": "is_enabled('export-' + region)\n"})
t("literal + variable is dynamic, not undefined", ("info", "FF10", "-") in codes(r) and not [f for f in r["findings"] if f[1] == "FF01"])
r = mk([rel("new-search", up="2026-10-30")], {"s.tsx": "if (flags.newSearch) {}\n"})
t("flags.camelCase property counted", r["refs"]["new-search"] == 1)
r = mk([rel("new-search", up="2026-10-30")], {"node_modules/x.js": "isEnabled('new-search')", "a.md": "isEnabled('new-search')"})
t("node_modules and non-code files skipped", r["refs"]["new-search"] == 0)
r = mk([rel("t", up="2026-10-30", expires=None)], {"a.ts": "isEnabled('t')"})
t("no expiry -> FF11", ("low", "FF11", "t") in codes(r))
r = mk([rel("arch", archived=True)], {"a.ts": "isEnabled('arch')"})
t("archived but referenced -> FF14 high", ("high", "FF14", "arch") in codes(r) and r["plan"][0]["action"] == "remove code")
r = mk([rel("dup", up="2026-10-30"), rel("dup", up="2026-10-30")], {"a.ts": "isEnabled('dup')"})
t("duplicate key -> FF13", ("medium", "FF13", "dup") in codes(r))
r = mk([rel("cust", up="2026-10-30")], {"a.ts": "myFlags.check('cust')\nmyFlags.check('nope')\n"}, call_names=["check"])
t("custom call name finds undefined keys", ("high", "FF01", "nope") in codes(r))
r = mk([rel("stale14", up="2026-10-20")], {"a.ts": "isEnabled('stale14')"}, stale_days=14)
t("stale_days setting respected", ("medium", "FF02", "stale14") in codes(r))
for bad, name in (({"flags": [{"key": "a", "type": "temp"}]}, "bad type"), ({"flags": [{"key": "a", "environments": {"production": {"on": True, "rollout": 150}}}]}, "rollout > 100"),
({"flags": [{"key": "a", "colour": 1}]}, "unknown flag key"), ({"flag": []}, "missing flags list")):
try:
load_flags(bad); t("rejected: " + name, False)
except InputError:
t("rejected: " + name, True)
r = mk([rel("new-checkout")], src)
t("report has verdict and plan", "**Verdict:" in report(r, ["x"]) and "## Cleanup plan" in report(r, ["x"]))
for n, ok in tests:
print("%s %s" % ("PASS" if ok else "FAIL", n))
p = sum(ok for _, ok in tests)
print("%d/%d passed" % (p, len(tests)))
return 0 if p == len(tests) else 1
def main(argv=None):
ap = argparse.ArgumentParser(description="Find stale feature flags and plan their removal.")
ap.add_argument("flags", nargs="?")
ap.add_argument("src", nargs="*")
ap.add_argument("--today")
ap.add_argument("--stale-days", type=int)
ap.add_argument("--call", action="append", default=[], help="extra flag-check function name (repeatable)")
ap.add_argument("--json", action="store_true")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args(argv)
if a.self_test:
return self_test()
if not a.flags or not a.src:
ap.error("give FLAGS.json and at least one source directory")
try:
r = run(a.flags, a.src, a.today, a.stale_days, a.call)
except InputError as e:
print("error: %s" % e, file=sys.stderr)
return 2
if a.json:
print(json.dumps({**r, "findings": [dict(zip(("severity", "code", "flag", "finding", "fix"), f)) for f in r["findings"]]}, indent=2))
else:
sys.stdout.write(report(r, a.src))
return 1 if any(f[0] == "high" for f in r["findings"]) else 0
if __name__ == "__main__":
sys.exit(main())Builds a seating chart for weddings, dinners and team events from your tables and guest list - keeps couples and families together, honours sit-with and keep-apart requests and head-table seats, puts mobility needs at accessible tables, seats nobody among strangers and fills tables by group. Flags misspelled names, duplicates, conflicting requests and missing seats; prints place cards and meal counts. Includes an offline stdlib Python planner. 来源:https://prompts.chat/prompts/cmw38vhod000bjp08aekfwtb2
---
name: event-seating-chart-planner
description: Builds a seating chart for weddings, dinners and team events from your tables and guest list - keeps couples and families together, honours sit-with and keep-apart requests and head-table seats, puts mobility needs at accessible tables, seats nobody among strangers and fills tables by group. Flags misspelled names, duplicates, conflicting requests and missing seats; prints place cards and meal counts. Includes an offline stdlib Python planner.
---
# Event Seating Chart Planner
You help hosts turn a guest list into a seating chart without the spreadsheet agony. Seating is the job everyone leaves until the last week: the divorced uncle and aunt must not share a table, grandma needs a step-free spot by the door, the plus-one knows nobody, two RSVPs are still missing, the caterer wants vegan and child meals per table, and every change breaks something else. You collect the tables and the guest list, check the list for problems first, build a chart that meets the hard requests, explain the trade-offs and produce place cards and a caterer sheet.
You work only from the information the host gives you. You do not judge family politics: when two requests conflict, say so and ask the host which one wins. Keep the guest list private - do not repeat notes about relationships beyond what the chart needs.
## Files in this skill
- `scripts/seating_plan.py` - reads an event JSON (tables with seats, accessible, kids, fixed head-table guests; rules) and a guest CSV (name, party, group, rsvp, child, needs, diet, sit_with, keep_apart), merges parties and sit-with requests, places them table by table and improves the plan by moving and swapping groups; reports SC01-SC10 and prints the chart, a caterer summary and place cards; `--csv` place-card export, `--json`, `--include-pending` (Python 3.8+ standard library only)
- `references/seating-rules-and-etiquette.md` - how to collect the list, what counts as a hard or soft request, head table and family conventions, accessibility, children, dietary needs and room layout tips
- `references/checks-and-codes.md` - every finding code, its severity and what to do, plus the input format and how the planner scores a chart
- `templates/seating-brief.md` - the summary to send the host (and the venue / caterer)
- `examples/event.json` - a wedding dinner with a head table, an accessible table, four tables of 8 and a kids table
- `examples/guests.csv` - 32 rows with families, friends, work colleagues, a declined and a pending RSVP, a duplicate row, a misspelled sit-with name, a keep-apart pair, a wheelchair user and dietary needs
- `examples/seating-chart.md` - the real planner output on the example
## Workflow
### 1. Collect the tables and the list
Ask for the venue's table plan (number of tables, seats per table, which are step-free or near the exit, a kids table, the head table and who sits there). Ask for the guest list with RSVP, party (people who arrive together: couple, family), group (how they know the hosts: bride family, work, school friends), children, mobility or other needs, meal choice, and any "must sit with" or "keep apart" requests. Put them in `examples/event.json` and `examples/guests.csv` form.
### 2. Run the planner
```
python3 scripts/seating_plan.py examples/event.json examples/guests.csv
python3 scripts/seating_plan.py event.json guests.csv --include-pending
python3 scripts/seating_plan.py event.json guests.csv --csv > place-cards.csv
python3 scripts/seating_plan.py event.json guests.csv --json
python3 scripts/seating_plan.py --self-test
```
Exit 0 = all hard requests met, 1 = a hard request failed or the list has a high finding, 2 = bad input. Without a shell, seat fixed guests first, then the accessibility needs, then the largest groups, and check every keep-apart pair at the end.
### 3. Read the result
- High: SC01 not enough seats (or a group that fits nowhere), SC02 conflicting or broken keep-apart requests, SC03 names in requests or fixed seats that are not on the attending list (spelling suggestions shown).
- Medium: SC04 a guest at a table with nobody they know, SC05 a mobility need at a non-accessible table, SC08 duplicate rows, SC09 parties or requests too big for one table, SC10 a child without an adult from their party.
- Low/info: half-empty or empty tables (SC06), pending RSVPs seated provisionally (SC07).
### 4. Adjust with the host
Fix the list problems first (spelling, duplicates, RSVPs) and re-run. Then walk the host through each table: who is there and why, the lonely guests and the trade-offs. Make manual moves the host asks for and re-run to confirm the keep-apart pairs still hold. Fill `templates/seating-brief.md`.
### 5. Deliver
Return the chart per table, the open questions, the place-card list A-Z, the meal counts per table and in total, and the special needs list for the venue (wheelchair space, high chairs, allergies).
## Example result
29 guests attend (1 declined, 1 pending left out) for 44 seats. The couple, maid of honour, best man and Zeynep (who asked to sit with Selin) fill the head table. Grandma Fatma (wheelchair) sits at accessible Table 1 with the Kaya family; Linda and Richard Moore are kept apart (Tables 2 and 3); the work colleagues share Table 4 with the bride's friends. Sam's request to sit with "Olivr Grant" is a typo of Oliver Grant, Merve Sahin is listed twice, and neighbour Kemal knows nobody at his table. The kids table is empty because children stay with their parents.
## Rules
- Hard requests (keep apart, fixed seats, accessibility) beat soft ones (groups, balance); say clearly which request was dropped and why.
- Never split a couple or a parent and a young child unless the host asks.
- Do not guess at conflicts: ask the host, and keep sensitive notes out of anything printed.
- Re-run after every manual change; the chart is only valid for the list it was made from.
- Pending RSVPs are left out unless the host says to seat them; chase them before printing place cards.
FILE:references/seating-rules-and-etiquette.md
# Seating rules and etiquette
## Collecting the list
- One row per person, including children and plus-ones (use "Guest of <name>" until you have a name).
- Party = who arrives together and must sit together (couple, parent with young children). Group = how they know the hosts; it decides who shares a table.
- Ask the hosts directly for keep-apart pairs (exes, family feuds) and keep this column out of anything printed or shared with the venue.
- RSVP status: seat only confirmed guests; set a deadline for pending ones about 3 weeks before the event, when most venues need final numbers.
## Hard vs soft requests
- Hard: keep apart, fixed seats (head table, speakers, VIPs), accessibility, a young child next to a parent, capacity.
- Soft: sit with friends, tables by group, balanced tables, mixing groups at networking events.
When a soft request breaks a hard one, the hard one wins; tell the host.
## Common conventions
- Weddings: the head table (couple, wedding party, sometimes parents) faces the room; parents and grandparents at the tables closest to it; families of both sides near the front; friends and colleagues further back. Sweetheart table = couple only.
- Family dinners and birthdays: the guest of honour in the middle of the long side, with the people they see least often nearby.
- Corporate dinners: a host from the company at every table; mix departments if networking is the goal (`mix_groups: true`), seat speakers near the stage and the exit.
- Round tables of 8-10 work best for conversation; 12+ means people only talk to their neighbours.
- Avoid a table where one guest is the only one from their group - pair singles with someone outgoing or a similar-age group.
## Accessibility and needs
- Wheelchair users: a table on a step-free route near the door and the toilets, with a chair removed at the place (mark it on the room plan).
- Hearing impairment: away from speakers and the band, facing the room.
- Older guests: away from loudspeakers and the dance floor, near the exit and toilets.
- High chairs and booster seats: tell the venue per table.
- Allergies: list name, table and allergen for the caterer; a printed card at the place for severe allergies.
## Children
- Toddlers stay with their parents. A kids table works for ages about 5-12 when it is next to the parents' tables and has an adult or sitter nearby.
- Count child meals separately (and high chairs) in the caterer summary.
## Room layout tips
- Number tables in a way guests can find (signs on every table, an alphabetical escort-card list at the entrance).
- Keep the path from the door to the accessible table clear.
- Leave 1-2 empty seats across the room for late changes; venues usually allow 1-2 extra chairs per round table, but check the fire-safety capacity.
## Final week checklist
- Final RSVPs and meal choices -> re-run the planner -> host approval.
- Send the venue: table plan, guest count per table, high chairs, wheelchair spaces.
- Send the caterer: meals per table and in total, allergies with names and tables.
- Print place cards and the escort-card list from `--csv`.
FILE:references/checks-and-codes.md
# Checks and finding codes
| Code | Severity | Check | What to do |
|---|---|---|---|
| SC01 | high | more attending guests than seats, or a party/group that fits at no table with enough free seats | add a table or seats, confirm RSVPs, split the group over neighbouring tables |
| SC02 | high | a sit-with request joins two guests who must be kept apart (request dropped); keep-apart inside one party; a keep-apart pair seated together | ask the host which request wins |
| SC03 | high | a name in sit_with / keep_apart or a fixed seat is not on the attending list (did-you-mean suggestion) | fix the spelling or add the guest |
| SC04 | medium | guest at a table with fewer than `min_known` people from their party, group or sit-with list | swap, or seat them next to a sociable guest |
| SC05 | medium | wheelchair / step-free / walker / mobility / crutches need at a table not marked accessible | swap with someone at an accessible table |
| SC06 | low: table under half full; info: empty table | balance | merge tables or remove one |
| SC07 | info | RSVP pending, seated because of `--include-pending` | chase the RSVP |
| SC08 | medium | the same name twice (seated once), row without a name | check if two different people; add an initial |
| SC09 | medium | a party larger than the largest table (split), a sit-with group that would exceed the largest table (request dropped), a party split over tables | neighbouring tables; free seats at one table |
| SC10 | medium | child at a non-kids table without an adult from their party | move next to their parents |
## Verdict
- **N hard request(s) could not be met** - any high finding.
- **all hard requests met, N soft issue(s)** - medium findings only.
- **all requests met**.
## How the planner works
1. Attending guests only (rsvp yes; pending with `--include-pending`; no/declined skipped).
2. Parties are merged (when `keep_parties_together`), then sit-with requests, unless that joins a keep-apart pair or exceeds the largest table.
3. Groups are placed in order: fixed head-table guests (only they and their sit-with partners use a table with `fixed`), mobility needs, then by group size.
4. Each group goes to the table where the chart's penalty rises least; then the planner moves single groups, whole groups and swaps pairs while the penalty falls.
5. Penalty: keep-apart pair at one table 100, mobility need at a non-accessible table 40, a guest who knows fewer than `min_known` people 12, each extra group at a table 2 (unless `mix_groups`), each extra table a group is spread over 3, an adult at the kids table 3.
The plan is deterministic: the same input gives the same chart.
## Event JSON
`event, date, rules{keep_parties_together (true), mix_groups (false), min_known (1)}, tables[{name, seats, accessible, kids, fixed[names], notes}]`.
## Guest CSV
`name, party, group, rsvp (yes/no/pending), child (yes), needs (wheelchair; high chair; nut allergy), diet (vegetarian, vegan, gluten-free, child menu ...), sit_with (names separated by ;), keep_apart (names separated by ;)`. Only `name` is required; party defaults to the name, group to "-", rsvp to yes.
FILE:templates/seating-brief.md
# Seating brief: <event> (<date>)
**Guests:** <29 attending (1 declined, 1 pending)> **Seats:** <44 at 6 tables> **Status:** <draft 2 - waiting for 1 RSVP>
## Open questions for the host
- [ ] <Sam asked to sit with "Olivr Grant" - Oliver Grant? (seated together after the fix)>
- [ ] <Merve Sahin is on the list twice - one person or two?>
- [ ] <Kemal knows nobody at Table 3 - who should he sit next to?>
- [ ] <Chris Evans: RSVP pending - seat at Table 3 if he comes>
## Tables
| Table | Who | Notes |
|---|---|---|
| <Head table> | <Elif, Daniel, Selin, Tom, Zeynep> | <facing the room> |
| <Table 1 (accessible)> | <Kaya family, Grandma Fatma> | <wheelchair place on the aisle, high chair for Ela> |
## For the venue
- Tables and seat counts: <...>
- Wheelchair places: <Table 1 x1> High chairs: <Table 1 x1>
- Remove: <kids table (empty)>
## For the caterer
| Table | Standard | Vegetarian | Vegan | Gluten-free | Child | Other |
|---|---|---|---|---|---|---|
| <Head table> | <3> | <2> | <0> | <0> | <0> | <-> |
| **Total** | <18> | <3> | <2> | <1> | <3> | <pescatarian 1, soft food 1> |
Allergies: <Max Hill (Table 2) - nut allergy>
## Place cards
<attach place-cards.csv from `seating_plan.py --csv`>
FILE:examples/event.json
{
"event": "Elif and Daniel's wedding dinner",
"date": "2027-06-12",
"rules": {"keep_parties_together": true, "mix_groups": false, "min_known": 1},
"tables": [
{"name": "Head table", "seats": 6, "fixed": ["Elif Kaya", "Daniel Moore"], "notes": "facing the room"},
{"name": "Table 1", "seats": 8, "accessible": true, "notes": "next to the terrace door, step-free"},
{"name": "Table 2", "seats": 8},
{"name": "Table 3", "seats": 8},
{"name": "Table 4", "seats": 8},
{"name": "Kids table", "seats": 6, "kids": true, "notes": "near Table 2, crayons and placemats"}
]
}
FILE:examples/guests.csv
name,party,group,rsvp,child,needs,diet,sit_with,keep_apart
Elif Kaya,Couple,couple,yes,,,vegetarian,,
Daniel Moore,Couple,couple,yes,,,standard,,
Selin Kaya,Maid of honour,bride friends,yes,,,standard,Elif Kaya,
Tom Moore,Best man,groom family,yes,,,standard,Daniel Moore,
Ayse Kaya,Kaya parents,bride family,yes,,,standard,,
Mehmet Kaya,Kaya parents,bride family,yes,,,standard,,
Fatma Yilmaz,Grandma Fatma,bride family,yes,,wheelchair,soft food,Ayse Kaya,
Can Kaya,Can family,bride family,yes,,,standard,,
Deniz Kaya,Can family,bride family,yes,,,vegan,,
Ela Kaya,Can family,bride family,yes,yes,high chair,child menu,,
Linda Moore,Linda,groom family,yes,,,gluten-free,,Richard Moore
Richard Moore,Richard and Jo,groom family,yes,,,standard,,Linda Moore
Jo Parker,Richard and Jo,groom family,yes,,,standard,,
Sarah Moore,Sarah family,groom family,yes,,,standard,,
James Hill,Sarah family,groom family,yes,,,standard,,
Lily Hill,Sarah family,groom family,yes,yes,,child menu,,
Max Hill,Sarah family,groom family,yes,yes,nut allergy,child menu,,
Zeynep Arslan,Zeynep,bride friends,yes,,,vegetarian,Selin Kaya,
Burak Demir,Burak and Ece,bride friends,yes,,,standard,,
Ece Demir,Burak and Ece,bride friends,yes,,,standard,,
Merve Sahin,Merve,bride friends,yes,,,vegan,,
Oliver Grant,Oliver and Priya,groom friends,yes,,,standard,,
Priya Grant,Oliver and Priya,groom friends,yes,,,vegetarian,,
Sam Turner,Sam,groom friends,yes,,,standard,Olivr Grant,
Chris Evans,Chris,groom friends,pending,,,standard,,
Nina Patel,Nina,work,yes,,,standard,,
Leo Fischer,Leo and Mia,work,yes,,,standard,,
Mia Fischer,Leo and Mia,work,yes,,,pescatarian,,
Hannah Lee,Hannah,work,yes,,,standard,,
Peter Novak,Peter,work,no,,,standard,,
Kemal Ozturk,Kemal,neighbours,yes,,,standard,,
Merve Sahin,Merve,bride friends,yes,,,vegan,,
FILE:examples/seating-chart.md
# Seating chart: Elif and Daniel's wedding dinner (2027-06-12)
29 attending guest(s) (1 declined; 1 pending left out), 44 seat(s) at 6 table(s). Source: examples/guests.csv.
**Verdict: 1 hard request(s) could not be met - see findings**
Findings: 1 high, 2 medium, 0 low, 1 info.
## Tables
| Table | Seats | Guests | Groups | Meals |
|---|---|---|---|---|
| Head table [fixed] | 5/6 | Tom Moore, Elif Kaya, Daniel Moore, Selin Kaya, Zeynep Arslan | bride friends 2, couple 2, groom family 1 | standard 3, vegetarian 2 |
| Table 1 [accessible] | 6/8 | Can Kaya, Deniz Kaya, Ela Kaya (child), Fatma Yilmaz *, Ayse Kaya, Mehmet Kaya | bride family 6 | child menu (child) 1, soft food 1, standard 3, vegan 1 |
| Table 2 | 5/8 | Linda Moore, Sarah Moore, James Hill, Lily Hill (child), Max Hill (child) | groom family 5 | child menu (child) 2, gluten-free 1, standard 2 |
| Table 3 | 6/8 | Kemal Ozturk, Oliver Grant, Priya Grant, Richard Moore, Jo Parker, Sam Turner | groom family 2, groom friends 3, neighbours 1 | standard 5, vegetarian 1 |
| Table 4 | 7/8 | Burak Demir, Ece Demir, Hannah Lee, Leo Fischer, Mia Fischer, Merve Sahin, Nina Patel | bride friends 3, work 4 | pescatarian 1, standard 5, vegan 1 |
| Kids table [kids] | 0/6 | - | - | - |
`*` = mobility need (seat on the aisle side, nearest the door).
## Findings
| Sev | Code | Who | Finding | Fix |
|---|---|---|---|---|
| high | SC03 | Sam Turner | sit_with 'Olivr Grant' is not on the (attending) guest list - did you mean Oliver Grant? | fix the spelling |
| medium | SC04 | Kemal Ozturk | at Table 3 without anyone from their party or group (neighbours) | seat them next to the most sociable guest there, or swap |
| medium | SC08 | Merve Sahin | listed twice (lines 22 and 33) - seated once | check whether these are two different people; add a surname or initial |
| info | SC06 | Kids table | empty table | remove it from the room plan |
## Caterer summary
| Meal | Count |
|---|---|
| child menu (child) | 3 |
| gluten-free | 1 |
| pescatarian | 1 |
| soft food | 1 |
| standard | 18 |
| vegan | 2 |
| vegetarian | 3 |
Special needs: Ela Kaya (Table 1) - high chair; Fatma Yilmaz (Table 1) - wheelchair; Max Hill (Table 2) - nut allergy.
## Place cards (A-Z)
| Guest | Table |
|---|---|
| Ayse Kaya | Table 1 |
| Burak Demir | Table 4 |
| Can Kaya | Table 1 |
| Daniel Moore | Head table |
| Deniz Kaya | Table 1 |
| Ece Demir | Table 4 |
| Ela Kaya | Table 1 |
| Elif Kaya | Head table |
| Fatma Yilmaz | Table 1 |
| Hannah Lee | Table 4 |
| James Hill | Table 2 |
| Jo Parker | Table 3 |
| Kemal Ozturk | Table 3 |
| Leo Fischer | Table 4 |
| Lily Hill | Table 2 |
| Linda Moore | Table 2 |
| Max Hill | Table 2 |
| Mehmet Kaya | Table 1 |
| Merve Sahin | Table 4 |
| Mia Fischer | Table 4 |
| Nina Patel | Table 4 |
| Oliver Grant | Table 3 |
| Priya Grant | Table 3 |
| Richard Moore | Table 3 |
| Sam Turner | Table 3 |
| Sarah Moore | Table 2 |
| Selin Kaya | Head table |
| Tom Moore | Head table |
| Zeynep Arslan | Head table |
The chart is a starting point: move people by hand where you know better, then re-run to check the requests still hold.
FILE:scripts/seating_plan.py
#!/usr/bin/env python3
"""Event seating chart planner (stdlib only, Python 3.8+).
Reads an event file (tables with seat counts, accessible / kids / fixed head-table seats, rules)
and a guest list CSV (name, party, group, RSVP, child, needs, diet, sit_with, keep_apart) and
builds a seating chart: parties stay together, "sit with" requests share a table, "keep apart"
pairs never share one, guests with mobility needs go to accessible tables, nobody is seated
without someone they know, and tables are filled by group where possible. Checks the list first
(unknown or misspelled names, duplicates, impossible or conflicting requests, not enough seats)
and prints the chart, place cards and a per-table meal count for the caterer.
Usage:
seating_plan.py EVENT.json GUESTS.csv [--include-pending] [--csv] [--json]
seating_plan.py --self-test
Exit: 0 = all hard requests met, 1 = a hard request could not be met (or a high finding), 2 = bad input.
"""
import argparse, csv, difflib, io, json, sys
SEV = {"high": 0, "medium": 1, "low": 2, "info": 3}
EVENT_KEYS = {"event", "date", "tables", "rules"}
TABLE_KEYS = {"name", "seats", "accessible", "kids", "fixed", "notes"}
RULE_KEYS = {"keep_parties_together", "mix_groups", "min_known"}
MOBILITY = ("wheelchair", "step-free", "walker", "mobility", "crutches")
W_APART, W_ACCESS, W_LONELY, W_GROUP, W_SPREAD = 100, 40, 12, 2, 3
class InputError(Exception):
pass
def norm(s):
return " ".join(str(s or "").split()).lower()
def split_list(s):
return [x.strip() for x in str(s or "").replace(",", ";").split(";") if x.strip()]
# ---------------------------------------------------------------- load
def load_event(cfg):
if not isinstance(cfg, dict) or not isinstance(cfg.get("tables"), list) or not cfg["tables"]:
raise InputError("event file must be a JSON object with a non-empty \"tables\" list")
bad = set(cfg) - EVENT_KEYS
if bad:
raise InputError("unknown key(s) in event file: %s" % ", ".join(sorted(bad)))
rules = cfg.get("rules") or {}
badr = set(rules) - RULE_KEYS
if badr:
raise InputError("unknown rule(s): %s" % ", ".join(sorted(badr)))
tables, names = [], set()
for i, t in enumerate(cfg["tables"], 1):
badt = set(t) - TABLE_KEYS
if badt:
raise InputError("table #%d: unknown key(s) %s" % (i, ", ".join(sorted(badt))))
name = str(t.get("name") or "Table %d" % i)
if name in names:
raise InputError("table name %r used twice" % name)
names.add(name)
seats = t.get("seats")
if not isinstance(seats, int) or seats < 1:
raise InputError("table %s: seats must be a positive whole number, got %r" % (name, seats))
tables.append({"name": name, "seats": seats, "accessible": bool(t.get("accessible")), "kids": bool(t.get("kids")),
"fixed": [str(x) for x in t.get("fixed") or []], "notes": t.get("notes", "")})
return {"event": cfg.get("event", "Event"), "date": cfg.get("date", ""), "tables": tables,
"together": rules.get("keep_parties_together", True), "mix": bool(rules.get("mix_groups", False)),
"min_known": int(rules.get("min_known", 1))}
def load_guests(text, include_pending=False):
rows = list(csv.DictReader(io.StringIO(text)))
if not rows:
raise InputError("guest list is empty")
cols = {c.strip().lower() for c in rows[0].keys() if c}
if "name" not in cols:
raise InputError("guest list needs a 'name' column")
guests, skipped, F, seen = [], {"no": 0, "pending": 0}, [], {}
for ln, r in enumerate(rows, 2):
r = {(k or "").strip().lower(): (v or "").strip() for k, v in r.items()}
name = " ".join(r.get("name", "").split())
if not name:
F.append(("medium", "SC08", "line %d" % ln, "row without a name skipped", "fill in the name or delete the row"))
continue
rsvp = norm(r.get("rsvp") or "yes")
if rsvp in ("no", "declined", "n"):
skipped["no"] += 1
continue
if rsvp in ("pending", "maybe", "?", "invited", ""):
if not include_pending:
skipped["pending"] += 1
continue
if norm(name) in seen:
F.append(("medium", "SC08", name, "listed twice (lines %d and %d) - seated once" % (seen[norm(name)], ln), "check whether these are two different people; add a surname or initial"))
continue
seen[norm(name)] = ln
needs = [norm(x) for x in split_list(r.get("needs"))]
guests.append({"name": name, "party": r.get("party") or name, "group": r.get("group") or "-",
"child": norm(r.get("child")) in ("yes", "y", "true", "1", "child"),
"needs": needs, "mobility": any(any(m in n for m in MOBILITY) for n in needs),
"diet": norm(r.get("diet")) or "standard", "sit_with": split_list(r.get("sit_with")),
"keep_apart": split_list(r.get("keep_apart")), "line": ln, "pending": rsvp != "yes" and rsvp not in ("y", "accepted")})
return guests, skipped, F
# ---------------------------------------------------------------- plan
class DSU:
def __init__(self, n):
self.p = list(range(n))
def find(self, a):
while self.p[a] != a:
self.p[a] = self.p[self.p[a]]
a = self.p[a]
return a
def union(self, a, b):
self.p[self.find(a)] = self.find(b)
def plan(ev, guests, F=None):
F = list(F or [])
add = lambda sev, code, who, msg, fix: F.append((sev, code, who, msg, fix))
idx = {norm(g["name"]): i for i, g in enumerate(guests)}
allnames = [g["name"] for g in guests]
def resolve(who, ref, kind):
j = idx.get(norm(ref))
if j is None:
close = difflib.get_close_matches(ref, allnames, 1, 0.7)
add("high", "SC03", who, "%s %r is not on the (attending) guest list%s" % (kind, ref, " - did you mean %s?" % close[0] if close else ""),
"fix the spelling" if close else "add the guest or remove the request")
return j
n = len(guests)
seats = sum(t["seats"] for t in ev["tables"])
maxt = max(t["seats"] for t in ev["tables"])
if n > seats:
add("high", "SC01", "-", "%d guests but only %d seats (%d short)" % (n, seats, n - seats), "add a table or seats, or confirm the RSVPs")
dsu = DSU(n)
party_of = {}
for i, g in enumerate(guests):
party_of.setdefault(norm(g["party"]), []).append(i)
if ev["together"]:
for p, mem in party_of.items():
if len(mem) > maxt:
add("medium", "SC09", guests[mem[0]]["party"], "party of %d is larger than the largest table (%d) - it is split" % (len(mem), maxt), "seat the party on neighbouring tables")
for a, b in zip(mem, mem[1:]):
if (mem.index(b)) % maxt:
dsu.union(a, b)
else:
for a, b in zip(mem, mem[1:]):
dsu.union(a, b)
apart = set()
for i, g in enumerate(guests):
for ref in g["keep_apart"]:
j = resolve(g["name"], ref, "keep_apart")
if j is not None and j != i:
apart.add(tuple(sorted((i, j))))
# sit_with links, refusing those that would exceed the largest table or join a keep-apart pair
for i, g in enumerate(guests):
for ref in g["sit_with"]:
j = resolve(g["name"], ref, "sit_with")
if j is None or j == i:
continue
a, b = dsu.find(i), dsu.find(j)
if a == b:
continue
size = sum(1 for k in range(n) if dsu.find(k) in (a, b))
clash = [p for p in apart if {dsu.find(p[0]), dsu.find(p[1])} == {a, b}]
if clash:
x, y = clash[0]
add("high", "SC02", g["name"], "asks to sit with %s, but %s and %s must be kept apart - the request is dropped" % (guests[j]["name"], guests[x]["name"], guests[y]["name"]),
"ask the host which request wins")
continue
if size > maxt:
add("medium", "SC09", g["name"], "sitting with %s would make a group of %d (largest table %d) - request dropped" % (guests[j]["name"], size, maxt), "seat them on neighbouring tables")
continue
dsu.union(i, j)
for x, y in sorted(apart):
if dsu.find(x) == dsu.find(y):
add("high", "SC02", guests[x]["name"], "must be kept apart from %s but they are in the same party - cannot be met" % guests[y]["name"], "change the party or the request")
links = set()
for i, g in enumerate(guests):
for ref in g["sit_with"]:
j = idx.get(norm(ref))
if j is not None:
links.add((i, j)); links.add((j, i))
def knows(a, b):
ga, gb = guests[a], guests[b]
return (norm(ga["party"]) == norm(gb["party"]) or (ga["group"] == gb["group"] and ga["group"] != "-") or (a, b) in links)
clusters = {}
for i in range(n):
clusters.setdefault(dsu.find(i), []).append(i)
cl = list(clusters.values())
T = ev["tables"]
fixed_at = {}
for ti, t in enumerate(T):
for nm in t["fixed"]:
j = idx.get(norm(nm))
if j is None:
close = difflib.get_close_matches(nm, allnames, 1, 0.7)
add("high", "SC03", t["name"], "fixed seat for %r who is not on the attending list%s" % (nm, " - did you mean %s?" % close[0] if close else ""), "fix the name in the event file")
else:
fixed_at[j] = ti
assign = {}
load = [0] * len(T)
reserved = {ti for ti, t in enumerate(T) if t["fixed"]}
def pen_table(ti, members):
p = 0
mem = set(members)
for x, y in apart:
if x in mem and y in mem:
p += W_APART
groups = {}
for m in members:
g = guests[m]
if g["mobility"] and not T[ti]["accessible"]:
p += W_ACCESS
groups[g["group"]] = groups.get(g["group"], 0) + 1
if not ev["mix"]:
p += W_GROUP * max(0, len(groups) - 1)
for m in members:
g = guests[m]
known = sum(1 for o in members if o != m and knows(m, o))
if known < ev["min_known"]:
p += W_LONELY
if T[ti]["kids"]:
p += sum(3 for m in members if not guests[m]["child"])
return p
def members_of(ti):
return [m for c, t in assign.items() if t == ti for m in cl[c]]
def total():
p = sum(pen_table(ti, members_of(ti)) for ti in range(len(T)))
spread = {}
for c, ti in assign.items():
for m in cl[c]:
if guests[m]["group"] != "-":
spread.setdefault(guests[m]["group"], set()).add(ti)
return p + W_SPREAD * sum(len(v) - 1 for v in spread.values())
gsize = {}
for g in guests:
gsize[g["group"]] = gsize.get(g["group"], 0) + 1
order = sorted(range(len(cl)), key=lambda c: (-any(i in fixed_at for i in cl[c]), -any(guests[i]["mobility"] for i in cl[c]),
-gsize[guests[cl[c][0]]["group"]], guests[cl[c][0]]["group"], -len(cl[c]), cl[c][0]))
unseated = []
for c in order:
size = len(cl[c])
fx = [fixed_at[i] for i in cl[c] if i in fixed_at]
cands = [fx[0]] if fx else range(len(T))
best = None
for ti in cands:
if load[ti] + size > T[ti]["seats"] or (T[ti]["fixed"] and not fx and ti in reserved):
continue
assign[c] = ti
delta = total()
del assign[c]
key = (delta, -(load[ti] > 0), ti)
if best is None or key < best[0]:
best = (key, ti)
if best is None:
unseated.append(c)
continue
assign[c] = best[1]
load[best[1]] += size
# local search: move or swap clusters while it lowers the penalty (deterministic)
fixed_cl = {c for c in assign if any(i in fixed_at for i in cl[c])}
improved, rounds = True, 0
while improved and rounds < 50:
improved, rounds = False, rounds + 1
cur = total()
for c in sorted(assign):
if c in fixed_cl:
continue
for ti in range(len(T)):
if ti == assign[c] or load[ti] + len(cl[c]) > T[ti]["seats"] or ti in reserved:
continue
old = assign[c]
assign[c] = ti
new = total()
if new < cur:
load[old] -= len(cl[c]); load[ti] += len(cl[c]); cur = new; improved = True
else:
assign[c] = old
# move every cluster of one group (from all tables) to one table
for gname in sorted({guests[cl[c][0]]["group"] for c in assign if c not in fixed_cl}):
block = [c for c in assign if c not in fixed_cl and guests[cl[c][0]]["group"] == gname]
for b in range(len(T)):
if b in reserved:
continue
old = {c: assign[c] for c in block}
moving = sum(len(cl[c]) for c in block if old[c] != b)
if not moving or load[b] + moving > T[b]["seats"]:
continue
for c in block:
assign[c] = b
new = total()
if new < cur:
for c in block:
if old[c] != b:
load[old[c]] -= len(cl[c]); load[b] += len(cl[c])
cur = new; improved = True
break
for c in block:
assign[c] = old[c]
# move a whole group block (all clusters of one group at one table) to another table
for a in range(len(T)):
groups_here = sorted({guests[cl[c][0]]["group"] for c, x in assign.items() if x == a and c not in fixed_cl})
for gname in groups_here:
block = [c for c, x in assign.items() if x == a and c not in fixed_cl and guests[cl[c][0]]["group"] == gname]
bsize = sum(len(cl[c]) for c in block)
for b in range(len(T)):
if b == a or b in reserved or load[b] + bsize > T[b]["seats"]:
continue
for c in block:
assign[c] = b
new = total()
if new < cur:
load[a] -= bsize; load[b] += bsize; cur = new; improved = True
break
for c in block:
assign[c] = a
for c in sorted(assign):
for d in sorted(assign):
if d <= c or c in fixed_cl or d in fixed_cl or assign[c] == assign[d] or assign[c] in reserved or assign[d] in reserved:
continue
a, b = assign[c], assign[d]
if load[a] - len(cl[c]) + len(cl[d]) > T[a]["seats"] or load[b] - len(cl[d]) + len(cl[c]) > T[b]["seats"]:
continue
assign[c], assign[d] = b, a
new = total()
if new < cur:
load[a] += len(cl[d]) - len(cl[c]); load[b] += len(cl[c]) - len(cl[d]); cur = new; improved = True
else:
assign[c], assign[d] = a, b
seat = {}
for c, ti in assign.items():
for i in cl[c]:
seat[i] = ti
for c in unseated:
add("high", "SC01", ", ".join(guests[i]["name"] for i in cl[c]), "no table with %d free seats together - not seated" % len(cl[c]), "add seats or split the group across neighbouring tables")
# findings on the result
for x, y in sorted(apart):
if x in seat and y in seat and seat[x] == seat[y]:
add("high", "SC02", guests[x]["name"], "seated at %s together with %s although they must be kept apart" % (T[seat[x]]["name"], guests[y]["name"]), "move one of them by hand")
for i, g in enumerate(guests):
if i not in seat:
continue
ti = seat[i]
if g["mobility"] and not T[ti]["accessible"]:
add("medium", "SC05", g["name"], "needs %s but %s is not marked accessible" % ("/".join(g["needs"]), T[ti]["name"]), "swap with a guest at an accessible table")
mem = [m for m, t in seat.items() if t == ti and m != i]
known = [m for m in mem if knows(i, m)]
if len(known) < ev["min_known"]:
add("medium", "SC04", g["name"], "at %s without anyone from their party or group (%s)" % (T[ti]["name"], g["group"]), "seat them next to the most sociable guest there, or swap")
if g["child"] and not T[ti]["kids"] and not any(norm(guests[m]["party"]) == norm(g["party"]) and not guests[m]["child"] for m in mem):
add("medium", "SC10", g["name"], "child seated without an adult from their party", "move next to their parents or to the kids table")
if g["pending"]:
add("info", "SC07", g["name"], "RSVP still pending - seated provisionally", "chase the RSVP before printing place cards")
for ti, t in enumerate(T):
used = sum(1 for i in seat if seat[i] == ti)
if 0 < used < t["seats"] / 2:
add("low", "SC06", t["name"], "only %d of %d seats used" % (used, t["seats"]), "merge with another table or take it out")
if used == 0:
add("info", "SC06", t["name"], "empty table", "remove it from the room plan")
for p, mem in party_of.items():
ts = {seat[i] for i in mem if i in seat}
if len(ts) > 1 and len(mem) <= maxt:
add("medium", "SC09", guests[mem[0]]["party"], "party split over %s" % ", ".join(T[x]["name"] for x in sorted(ts)), "free seats at one table and move them together")
F.sort(key=lambda x: (SEV[x[0]], x[1], x[2]))
return {"seat": seat, "tables": T, "guests": guests, "findings": F, "penalty": total(), "seats": seats}
# ---------------------------------------------------------------- output
def report(ev, r, skipped, src):
G, T, seat = r["guests"], r["tables"], r["seat"]
c = {s: 0 for s in SEV}
for f in r["findings"]:
c[f[0]] += 1
hard = [f for f in r["findings"] if f[0] == "high"]
out = ["# Seating chart: %s%s" % (ev["event"], (" (%s)" % ev["date"]) if ev["date"] else ""), "",
"%d attending guest(s) (%d declined; %s), %d seat(s) at %d table(s). Source: %s." % (
len(G), skipped["no"], ("%d pending seated provisionally" % sum(1 for g in G if g["pending"])) if any(g["pending"] for g in G)
else "%d pending left out" % skipped["pending"], r["seats"], len(T), src), ""]
v = ("%d hard request(s) could not be met - see findings" % len(hard)) if hard else ("all hard requests met, %d soft issue(s) to look at" % c["medium"] if c["medium"] else "all requests met")
out += ["**Verdict: %s**" % v, "", "Findings: %d high, %d medium, %d low, %d info." % (c["high"], c["medium"], c["low"], c["info"]), ""]
out += ["## Tables", "", "| Table | Seats | Guests | Groups | Meals |", "|---|---|---|---|---|"]
for ti, t in enumerate(T):
mem = sorted((i for i in seat if seat[i] == ti), key=lambda i: (norm(G[i]["party"]), G[i]["child"], G[i]["line"]))
tags = [x for x in ("accessible" if t["accessible"] else "", "kids" if t["kids"] else "", "fixed" if t["fixed"] else "") if x]
names = ", ".join(G[i]["name"] + (" (child)" if G[i]["child"] else "") + (" *" if G[i]["mobility"] else "") for i in mem) or "-"
groups = {}
meals = {}
for i in mem:
groups[G[i]["group"]] = groups.get(G[i]["group"], 0) + 1
k = G[i]["diet"] + (" (child)" if G[i]["child"] else "")
meals[k] = meals.get(k, 0) + 1
out.append("| %s%s | %d/%d | %s | %s | %s |" % (t["name"], " [%s]" % ", ".join(tags) if tags else "", len(mem), t["seats"], names,
", ".join("%s %d" % kv for kv in sorted(groups.items())) or "-", ", ".join("%s %d" % kv for kv in sorted(meals.items())) or "-"))
out += ["", "`*` = mobility need (seat on the aisle side, nearest the door).", ""]
out += ["## Findings", "", "| Sev | Code | Who | Finding | Fix |", "|---|---|---|---|---|"]
for s, code, who, msg, fix in r["findings"]:
out.append("| %s | %s | %s | %s | %s |" % (s, code, who, msg.replace("|", "\\|"), fix.replace("|", "\\|")))
meals = {}
for i in seat:
k = G[i]["diet"] + (" (child)" if G[i]["child"] else "")
meals[k] = meals.get(k, 0) + 1
out += ["", "## Caterer summary", "", "| Meal | Count |", "|---|---|"]
for k, v_ in sorted(meals.items()):
out.append("| %s | %d |" % (k, v_))
notes = [(G[i]["name"], T[seat[i]]["name"], ", ".join(x for x in G[i]["needs"])) for i in sorted(seat, key=lambda i: G[i]["name"]) if G[i]["needs"]]
if notes:
out += ["", "Special needs: " + "; ".join("%s (%s) - %s" % n for n in notes) + "."]
out += ["", "## Place cards (A-Z)", "", "| Guest | Table |", "|---|---|"]
for i in sorted(seat, key=lambda i: norm(G[i]["name"])):
out.append("| %s | %s |" % (G[i]["name"], T[seat[i]]["name"]))
out += ["", "The chart is a starting point: move people by hand where you know better, then re-run to check the requests still hold."]
return "\n".join(out) + "\n"
def run(event_path, guests_path, include_pending=False):
try:
ev = load_event(json.load(open(event_path, encoding="utf-8")))
text = open(guests_path, encoding="utf-8-sig").read()
except (OSError, ValueError) as e:
raise InputError("cannot read input: %s" % e)
guests, skipped, F = load_guests(text, include_pending)
return ev, plan(ev, guests, F), skipped
# ---------------------------------------------------------------- self-test
def self_test():
tests = []
def t(name, cond):
tests.append((name, bool(cond)))
H = "name,party,group,rsvp,child,needs,diet,sit_with,keep_apart\n"
def P(tables, rows, pending=False, **rules):
ev = load_event({"tables": tables, "rules": rules})
g, sk, F = load_guests(H + rows, pending)
return plan(ev, g, F), g
def codes(r):
return [(f[0], f[1]) for f in r["findings"]]
def tab(r, g, name):
i = [k for k, x in enumerate(g) if x["name"] == name][0]
return r["seat"].get(i)
T2 = [{"name": "A", "seats": 4}, {"name": "B", "seats": 4}]
rows = "Ann,P1,work,yes\nBob,P1,work,yes\nCid,P2,family,yes\nDee,P2,family,yes\nEve,P3,work,yes\nFay,P4,family,yes\n"
r, g = P(T2, rows)
t("parties stay together", tab(r, g, "Ann") == tab(r, g, "Bob") and tab(r, g, "Cid") == tab(r, g, "Dee"))
t("groups fill tables", tab(r, g, "Eve") == tab(r, g, "Ann") and tab(r, g, "Fay") == tab(r, g, "Cid"))
t("clean plan has no high/medium", not [f for f in r["findings"] if f[0] in ("high", "medium")])
r, g = P(T2, "Ann,P1,work,yes,,,,,Bob\nBob,P2,work,yes\nCid,P3,work,yes\nDee,P4,work,yes\n")
t("keep_apart respected", tab(r, g, "Ann") != tab(r, g, "Bob") and ("high", "SC02") not in codes(r))
r, g = P(T2, "Ann,P1,work,yes,,,,Dee\nBob,P2,family,yes\nCid,P3,family,yes\nDee,P4,friends,yes\n")
t("sit_with respected", tab(r, g, "Ann") == tab(r, g, "Dee"))
r, g = P(T2, "Ann,P1,work,yes,,,,Bob,Bob\nBob,P2,work,yes\n")
t("sit_with vs keep_apart conflict -> SC02 high", ("high", "SC02") in codes(r))
r, g = P(T2, "Ann,P1,work,yes,,,,,Bob\nBob,P1,work,yes\n")
t("keep apart inside one party -> SC02", ("high", "SC02") in codes(r))
r, g = P(T2, "Ann,P1,work,yes,,,,Bobb\nBob,P2,work,yes\n")
t("misspelled name -> SC03 with suggestion", any(f[1] == "SC03" and "did you mean Bob" in f[3] for f in r["findings"]))
r, g = P([{"name": "A", "seats": 2}], "Ann,P1,w,yes\nBob,P2,w,yes\nCid,P3,w,yes\n")
t("not enough seats -> SC01 high", ("high", "SC01") in codes(r))
r, g = P(T2, "Ann,P1,w,yes\nAnn,P1,w,yes\nBob,P1,w,yes\n")
t("duplicate -> SC08 and seated once", ("medium", "SC08") in codes(r) and len(g) == 2)
r, g = P(T2, "Ann,P1,w,no\nBob,P2,w,pending\nCid,P3,w,yes\nDee,P3,w,yes\n")
t("declined and pending excluded", len(g) == 2)
r, g = P(T2, "Bob,P2,w,pending\nCid,P3,w,yes\n", pending=True)
t("--include-pending seats pending -> SC07", len(g) == 2 and ("info", "SC07") in codes(r))
r, g = P([{"name": "A", "seats": 4}, {"name": "B", "seats": 4, "accessible": True}], "Ann,P1,w,yes\nBob,P2,x,yes,,wheelchair\n")
t("wheelchair -> accessible table", tab(r, g, "Bob") == 1)
r, g = P([{"name": "A", "seats": 4}, {"name": "B", "seats": 4}], "Ann,P1,w,yes\nBob,P2,x,yes,,wheelchair\n")
t("no accessible table -> SC05", ("medium", "SC05") in codes(r))
r, g = P(T2, "Ann,P1,work,yes\nBob,P1,work,yes\nZed,P9,gym,yes\n")
t("lonely guest -> SC04", ("medium", "SC04") in codes(r))
r, g = P([{"name": "Head", "seats": 4, "fixed": ["Bride", "Groom"]}, {"name": "B", "seats": 4}], "Bride,C,couple,yes\nGroom,C,couple,yes\nBest,Best,groom family,yes,,,,Groom\n")
t("sit_with partner counts as someone they know", ("medium", "SC04") not in codes(r) and tab(r, g, "Best") == 0)
r, g = P([{"name": "A", "seats": 4}, {"name": "B", "seats": 4}, {"name": "C", "seats": 4}],
"A1,a1,fam,yes\nA2,a1,fam,yes\nA3,a1,fam,yes\nW1,w1,work,yes\nB1,b1,fam,yes\nB2,b1,fam,yes\nB3,b1,fam,yes\nW2,w2,work,yes\n")
t("group split by greedy is pulled together", tab(r, g, "W1") == tab(r, g, "W2"))
r, g = P([{"name": "A", "seats": 3}, {"name": "B", "seats": 3}], "A1,Big,w,yes\nA2,Big,w,yes\nA3,Big,w,yes\nA4,Big,w,yes\n")
t("party bigger than a table is split -> SC09", ("medium", "SC09") in codes(r) and len(r["seat"]) == 4)
r, g = P([{"name": "Head", "seats": 2, "fixed": ["Cid"]}, {"name": "B", "seats": 4}], "Ann,P1,w,yes\nBob,P1,w,yes\nCid,P3,w,yes\n")
t("fixed seat honoured", tab(r, g, "Cid") == 0)
r, g = P([{"name": "Head", "seats": 2, "fixed": ["Cyd"]}, {"name": "B", "seats": 4}], "Cid,P3,w,yes\n")
t("fixed seat typo -> SC03", ("high", "SC03") in codes(r))
r, g = P([{"name": "A", "seats": 10}, {"name": "B", "seats": 10}], "Ann,P1,w,yes\nBob,P1,w,yes\n")
t("half-empty and empty tables -> SC06", ("low", "SC06") in codes(r) and ("info", "SC06") in codes(r))
r, g = P([{"name": "A", "seats": 4}, {"name": "Kids", "seats": 4, "kids": True}], "Mum,F,fam,yes\nTim,F,fam,yes,yes\nLia,G,fam,yes,yes\nDad,G,fam,yes\n")
t("children stay with their party by default", tab(r, g, "Tim") == tab(r, g, "Mum"))
r, g = P(T2, "Ann,P1,w,yes,,,vegetarian\nBob,P1,w,yes,,,\n")
t("diet counted", "vegetarian 1" in report({"event": "x", "date": ""}, r, {"no": 0, "pending": 0}, "x"))
r, g = P([{"name": "A", "seats": 2}, {"name": "B", "seats": 2}], "Ann,P1,w,yes,,,,Bob;Cid\nBob,P2,w,yes\nCid,P3,w,yes\n")
t("sit_with group larger than any table -> SC09", ("medium", "SC09") in codes(r))
for bad, name in (({"tables": []}, "no tables"), ({"tables": [{"name": "A", "seats": 0}]}, "zero seats"),
({"tables": [{"name": "A", "seats": 4}, {"name": "A", "seats": 4}]}, "duplicate table"),
({"tables": [{"name": "A", "seats": 4, "colour": "red"}]}, "unknown table key")):
try:
load_event(bad); t("rejected: " + name, False)
except InputError:
t("rejected: " + name, True)
try:
load_guests("first,last\nA,B\n"); t("rejected: no name column", False)
except InputError:
t("rejected: no name column", True)
r, g = P(T2, rows)
rep = report({"event": "x", "date": ""}, r, {"no": 0, "pending": 0}, "x")
t("report has verdict, tables and place cards", "**Verdict:" in rep and "## Tables" in rep and "## Place cards" in rep)
for n, ok in tests:
print("%s %s" % ("PASS" if ok else "FAIL", n))
p = sum(ok for _, ok in tests)
print("%d/%d passed" % (p, len(tests)))
return 0 if p == len(tests) else 1
def main(argv=None):
ap = argparse.ArgumentParser(description="Build a seating chart from tables and a guest list.")
ap.add_argument("event", nargs="?")
ap.add_argument("guests", nargs="?")
ap.add_argument("--include-pending", action="store_true", help="seat guests whose RSVP is pending")
ap.add_argument("--csv", action="store_true", help="print name,table CSV (place cards)")
ap.add_argument("--json", action="store_true")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args(argv)
if a.self_test:
return self_test()
if not a.event or not a.guests:
ap.error("give EVENT.json and GUESTS.csv")
try:
ev, r, skipped = run(a.event, a.guests, a.include_pending)
except InputError as e:
print("error: %s" % e, file=sys.stderr)
return 2
G, T = r["guests"], r["tables"]
if a.json:
print(json.dumps({"tables": [{"name": t["name"], "seats": t["seats"], "guests": [G[i]["name"] for i in sorted(r["seat"]) if r["seat"][i] == ti]} for ti, t in enumerate(T)],
"unseated": [g["name"] for i, g in enumerate(G) if i not in r["seat"]],
"findings": [dict(zip(("severity", "code", "who", "finding", "fix"), f)) for f in r["findings"]]}, indent=2))
elif a.csv:
w = csv.writer(sys.stdout, lineterminator="\n")
w.writerow(["name", "table", "diet"])
for i in sorted(r["seat"], key=lambda i: norm(G[i]["name"])):
w.writerow([G[i]["name"], T[r["seat"][i]]["name"], G[i]["diet"]])
else:
sys.stdout.write(report(ev, r, skipped, a.guests))
return 1 if any(f[0] == "high" for f in r["findings"]) else 0
if __name__ == "__main__":
sys.exit(main())Opinionated conventions for a fast, maintainable and portable Lua-based Neovim configuration. 来源:https://prompts.chat/prompts/cmw39qm0g0001jw07gkdolly4
# Neovim Config taste of a minimalist terminal-first developer - Write the whole config in Lua, starting from a single init.lua that requires small modules under lua/config and lua/plugins. Confidence: 0.90 - Use lazy.nvim as the plugin manager and commit lazy-lock.json so every machine gets the same plugin versions. Confidence: 0.90 - Lazy-load plugins by event, command, filetype or key mapping, and keep startup time under 50 ms measured with :Lazy profile. Confidence: 0.80 - Prefer the built-in LSP client with nvim-lspconfig over heavier all-in-one language plugins. Confidence: 0.85 - Install language servers, formatters and linters with mason.nvim, but pin the tool list in config instead of installing by hand. Confidence: 0.80 - Format on save with conform.nvim and lint with nvim-lint, using the same tools and settings the project runs in CI. Confidence: 0.85 - Use nvim-treesitter for highlighting, indentation and text objects, and list the parsers you need explicitly. Confidence: 0.85 - Pick one fuzzy finder, such as telescope.nvim or fzf-lua, and route files, grep, buffers and LSP symbols through it. Confidence: 0.85 - Set the leader key to space before loading any plugin so every mapping uses it. Confidence: 0.90 - Give every custom mapping a desc field so which-key and :map show what it does. Confidence: 0.85 - Group mappings by prefix, like leader f for find, leader g for git and leader l for LSP, and avoid overriding core Vim motions. Confidence: 0.80 - Keep options in one options.lua file using vim.opt, with a short comment for anything that is not obvious. Confidence: 0.80 - Use relative line numbers, a sign column that is always shown, and persistent undo files. Confidence: 0.75 - Use autocommand groups with clear = true so reloading the config never duplicates handlers. Confidence: 0.90 - Respect .editorconfig files and avoid hard-coding indentation per language unless a project has no settings. Confidence: 0.80 - Show git changes in the sign column with gitsigns.nvim and stage hunks from the editor. Confidence: 0.80 - Use the built-in completion engine or blink.cmp, with LSP and snippet sources only, to keep the popup fast and relevant. Confidence: 0.70 - Choose one colorscheme with good Tree-sitter and LSP semantic token support, and do not chase new themes every week. Confidence: 0.65 - Add a plugin only when it replaces a repeated manual step, and remove plugins you have not used in a month. Confidence: 0.80 - Keep machine-specific settings in a git-ignored local.lua file so the main config stays portable. Confidence: 0.75 - Run :checkhealth after upgrades and fix warnings before adding new plugins. Confidence: 0.80 - Store the config in a dotfiles repository and upgrade plugins deliberately, reviewing the lazy.nvim changelog first. Confidence: 0.85
Opinionated conventions for planning, running and reading load and performance tests for web services. 来源:https://prompts.chat/prompts/cmw39rnsc0001l608fyi7qdsr
# Load Testing taste of a performance-minded reliability engineer - Start every load test from a written goal, such as p95 latency under 300 ms at 500 requests per second, not from a vague "see what breaks". Confidence: 0.95 - Write load tests as code with k6, Gatling or Locust and keep them in the same repository as the service. Confidence: 0.90 - Model realistic traffic from production logs: the mix of endpoints, payload sizes and think time between requests. Confidence: 0.90 - Use an open workload model with a fixed arrival rate when testing services that face the internet, since closed models hide queueing delays. Confidence: 0.85 - Run separate test types on purpose: smoke, load at expected peak, stress beyond peak, spike, and a long soak test for memory leaks. Confidence: 0.90 - Report latency as percentiles, at least p50, p95 and p99, and never rely on the average alone. Confidence: 0.95 - Define pass or fail thresholds in the test script so CI can fail the build automatically. Confidence: 0.85 - Run load tests against a production-like environment with the same instance sizes, data volume and configuration. Confidence: 0.90 - Seed test data that matches production cardinality, because a tiny database makes every query look fast. Confidence: 0.85 - Warm up caches and connection pools before measuring, and exclude the warm-up period from results. Confidence: 0.75 - Generate load from machines with enough CPU and network headroom, and watch the load generator's own metrics. Confidence: 0.85 - Correlate test results with server-side metrics, traces and logs to find the real bottleneck instead of guessing. Confidence: 0.90 - Track error rate and timeouts next to latency; a fast response that returns an error is not a success. Confidence: 0.90 - Change one variable per run and record the commit, configuration and environment with each result. Confidence: 0.85 - Run a short performance smoke test on every merge to main and the full suite before major releases. Confidence: 0.80 - Compare results with a stored baseline and flag regressions larger than an agreed percentage. Confidence: 0.80 - Mock or sandbox third-party APIs that charge per call or enforce rate limits, but test your own dependencies for real. Confidence: 0.80 - Find the breaking point and document how the system fails: graceful rejection with 429 or 503 is better than collapse. Confidence: 0.85 - Verify that autoscaling reacts in time by ramping load gradually and measuring how long new capacity takes to help. Confidence: 0.75 - Announce load tests to the team and on-call engineers, and never point them at shared production systems without approval. Confidence: 0.95 - Turn findings into capacity plans with clear headroom targets, such as running at no more than 60 percent of the tested limit. Confidence: 0.70
Opinionated conventions for building clear, honest and accessible charts and dashboards. 来源:https://prompts.chat/prompts/cmw39sgpe0001kz08tr62xhcp
# Data Visualization taste of a clarity-first data visualization developer - Start from the question the chart must answer, and write that question as the chart title or subtitle. Confidence: 0.90 - Choose the chart by the comparison: bars for categories, lines for trends over time, scatter plots for relationships. Confidence: 0.90 - Start bar chart value axes at zero; line charts may use a tighter range when the change is what matters. Confidence: 0.90 - Avoid pie charts with more than three slices, and never use 3D charts. Confidence: 0.85 - Sort categorical bars by value unless the categories have a natural order like months or ratings. Confidence: 0.85 - Label lines and series directly at the end of the line instead of making readers match colors with a legend. Confidence: 0.80 - Use a sequential palette for ordered data, a diverging palette around a meaningful midpoint, and a categorical palette for groups. Confidence: 0.85 - Keep categorical palettes to about seven colors and check them with a color-blindness simulator. Confidence: 0.85 - Never encode meaning with color alone; add labels, patterns, shapes or positions as well. Confidence: 0.90 - Remove chart junk: heavy gridlines, borders, shadows and decorative backgrounds. Confidence: 0.85 - Format numbers for humans with thousands separators, sensible rounding and units such as 1.2M or 35 percent. Confidence: 0.85 - Show uncertainty with confidence bands or error bars when the data comes from samples or forecasts. Confidence: 0.75 - Use a declarative library such as Vega-Lite, Observable Plot or ECharts for standard charts, and reach for D3 only for custom visuals. Confidence: 0.80 - Render with SVG for small and medium datasets, and switch to Canvas or WebGL above roughly ten thousand marks. Confidence: 0.75 - Make charts responsive by redesigning for small screens, for example fewer ticks or a horizontal bar layout, not just scaling down. Confidence: 0.80 - Provide a text summary and an accessible data table for every important chart. Confidence: 0.85 - Make tooltips keyboard accessible, and never hide key information only in hover states. Confidence: 0.80 - Aggregate or sample data on the server before sending it to the browser, rather than plotting millions of raw points. Confidence: 0.85 - Keep the same color for the same category across every chart in a dashboard. Confidence: 0.90 - Annotate notable events, like a release or an outage, directly on time series charts. Confidence: 0.75 - Show the data source and the last refresh time under each chart. Confidence: 0.85 - Test charts with real and edge-case data: empty series, single points, very long labels and negative values. Confidence: 0.80

A detailed style-transfer prompt that turns the character in your reference image into a rough, cute Japanese anime doodle: wobbly uneven black crayon lines, big flat color blocks that spill past the outlines, quick white highlights, pink blush strokes and visible crayon grain, as a half-body portrait on a pure white 1:1 canvas. It keeps the character's hairstyle, hair color, eyes, outfit and signature expression recognizable while banning polished line art, gradients and 3D shading. 来源:https://prompts.chat/prompts/cmw3e120p0001l608b9e023t9
Convert the character in the reference image into a rough, Japanese-style digital doodle. Overall style: An extremely casual, rough and naive cute Japanese anime character doodle, as if drawn quickly with the rough crayon and marker brushes of an ordinary painting app. Keep the original character recognizable: hairstyle, hair color, facial features, outfit, accessories and signature expression. Line art: * Outline the character with obvious, rough black strokes. * Line weight is very uneven: some lines suddenly get thicker, some break off. * Contours are loose, inaccurate and slightly crooked. * Lines have a clear hand-drawn grainy, smudged texture. * Do not aim for precise outlines; misaligned, overlapping and incomplete lines are allowed. * No fine, smooth vector line art. Coloring: * Use large areas of simple, opaque flat color. * Colors may spill slightly outside the lines; missed spots and white gaps are allowed. * Shadows use a few rough gray strokes or darker strokes of the same hue. * Highlights are casual, short white strokes. * Keep the rough texture of mixed crayon, pastel and marker. * No complex gradients, detailed lighting or rich layers of color. Facial features: * Keep the original character's eye color, eye shape and signature look. * Simplify the features; draw the eyes with thick black lines and simple color blocks. * Keep the mouth as simple as possible; the nose can be left out. * A few casual pink blush strokes can be added to the cheeks. * Keep the character's expression and personality; do not change their temperament. Hair and outfit: * Fully keep the character's most recognizable hairstyle and hair color. * Build the hair from a few large color blocks; do not draw lots of fine strands. * Keep the outfit's original colors, silhouette and signature accessories. * Heavily simplify clothing folds and decorations. * Do not change the outfit or add accessories that are not in the original image. Composition: * 1:1 square canvas. * Half-body portrait of the character, with a slightly oversized head. * Character centered, with natural white space around them. * Pure white background; no text, complex scenery or decorative borders. Most important style constraint: The image must be rough, scribbly, simple and naive, like a cute anime doodle drawn on a whim, not a polished illustration deliberately imitating a sketch. No automatic retouching, no fine line art, no complex hair structure, no subtle gradients, no 3D effects, no heavy lighting, no added complex outfit details. Final result: a low-finish Japanese anime digital doodle with rough black outlines, large flat color blocks, casual white highlights and an obvious crayon grain.

A compact Midjourney prompt for a striking vertical anime-style poster: a girl with a blue wolf-cut hairstyle caught in a playful moment against planets and alien spacecraft, pushed through an extreme 180-degree fisheye lens with heavy barrel distortion, so the frame edges bend inward and the center bulges toward you. Bold graphic shapes, cinematic lighting, deep shadows and a large word of your choice in the background. Tuned with chaos 50, raw mode, stylize 400 and a 1:2 aspect ratio. 来源:https://prompts.chat/prompts/cmw3e2cw80007lf08q21o0627
Digital animation style illustration. beautiful blue wolfcut hair girl, space planet background, alien spacecraft, funny moment, wide Extreme perspective effect, lens distortion, strong sense of depth, minimalist vibe, text "STARDUST" in the background, featuring strong circular barrel distortion. The edges of the frame bend drastically inward while the center bulges out, delivering an extreme 180-degree fisheye perspective, cinematic lighting, and deep shadows. --chaos 50 --ar 1:2 --raw --stylize 400 --hd