prompts.chatprompts.chatprompts.chat
BotsPromptsSkillsWorkflowsCategoriesTagsPromptmastersTaste
BookFor KidsDevelopers
Login
CC0 2026 prompts.chat
DeepWikiHow to...DocsAPIPrivacyTermsSupportAboutGitHub
F

Fatih Kadir Akın

@f

developer. github star. head of AX at automattic.com for spacefast.com, builder of prompts.chat

fka.devgithubsupport me
363prompts
331bots
62upvotes received
7contributions
Joined 11 months ago
951 contributions in the last year
Nov
Dec
Jan
Feb
Mar
Apr
May
Jun
Jul
Aug
Sep
Oct
M
W
F
Less
More
Feature Flag Rollout taste
Taste

Opinionated conventions for shipping changes safely with feature flags and gradual rollouts.

# Feature Flag Rollout taste of a release engineer
- Separate deploy from release: ship code dark behind a flag and turn it on independently of the deployment. Confidence: 0.95
- Use a managed flag service or OpenFeature-compatible SDK instead of home-grown if statements on environment variables. Confidence: 0.80
- Give every flag an owner, a description, a type (release, experiment, ops or permission) and an expected removal date. Confidence: 0.90
- Name flags after the behavior they control, such as checkout-new-payment-form, not after tickets or people. Confidence: 0.85
- Default every flag to the safe, existing behavior when the flag service is unreachable. Confidence: 0.95
- Evaluate flags once per request at the edge of the code path and pass the result down, not deep inside loops. Confidence: 0.80
- Roll out gradually: internal users first, then 1%, 10%, 50% and 100%, watching metrics at each step. Confidence: 0.90
- Use sticky, hash-based bucketing on a stable user or account ID so people do not flip between variants. Confidence: 0.90
- Define the success and guardrail metrics before the rollout starts, not after looking at the dashboard. Confidence: 0.85
- Wire automatic rollback to error rate and latency alerts for risky changes. Confidence: 0.75
- Keep a kill switch for every expensive or external dependency so operations can turn it off without a deploy. Confidence: 0.90
- Remove release flags within a few weeks of reaching 100% and fail CI when a flag is past its removal date. Confidence: 0.80
- Test both the on and off paths in CI for every active flag. Confidence: 0.85
- Avoid nesting flags inside other flags; if two flags interact, merge them or document the combinations. Confidence: 0.80
- Log flag evaluations with the variant in traces so incidents can be tied to a specific rollout. Confidence: 0.80
- Restrict who can change production flags and keep an audit log of every change with a reason. Confidence: 0.85
- Never use feature flags as a substitute for authorization checks. Confidence: 0.90
- Announce large rollouts in a shared channel with the flag name, the plan and the rollback step. Confidence: 0.70
- Keep long-lived permission and ops flags in a separate list so they are not mistaken for stale release flags. Confidence: 0.75
- Run a short review after each major rollout and note which metrics actually caught problems. Confidence: 0.65
DevOpsTestingBest Practices+1
F@f
0
LLM Application taste
Taste

Opinionated conventions for building reliable LLM-powered apps, RAG and agents.

# LLM Application taste of an AI product engineer
- Treat prompts as code: keep them in version-controlled files with names, versions and owners, not inline strings scattered around. Confidence: 0.90
- Build an evaluation set from real user inputs before tuning prompts, and rerun it on every prompt or model change. Confidence: 0.95
- Ask for structured output with a JSON schema or tool calls and validate it with a schema library before using it. Confidence: 0.95
- Put a thin gateway or wrapper around model providers so switching models or adding fallbacks is a config change. Confidence: 0.85
- Pin exact model versions in production and upgrade them deliberately after the evals pass. Confidence: 0.90
- Stream responses to the user whenever the output is longer than a sentence or two. Confidence: 0.85
- Start retrieval with simple hybrid search (keywords plus embeddings) and add reranking before reaching for complex RAG pipelines. Confidence: 0.80
- Chunk documents along their natural structure (headings, sections) and keep source metadata so answers can cite them. Confidence: 0.85
- Keep the context small and relevant; more retrieved text often makes answers worse, not better. Confidence: 0.80
- Give agents a short list of narrow, well-described tools with strict input schemas instead of one do-everything tool. Confidence: 0.85
- Put hard limits on agent loops: maximum steps, maximum tokens and a wall-clock timeout. Confidence: 0.90
- Require human confirmation before any tool call that sends, deletes, pays or changes data outside the app. Confidence: 0.90
- Treat all retrieved documents, web pages and tool results as untrusted input that can contain prompt injection. Confidence: 0.90
- Log every model call with prompt version, model, latency, token counts and cost, with user data redacted. Confidence: 0.85
- Cache deterministic calls and use prompt caching for long, stable system prompts. Confidence: 0.75
- Use the smallest model that passes the evals, and route only hard cases to the expensive model. Confidence: 0.80
- Retry on rate limits and transient errors with exponential backoff and jitter, never on validation failures in a tight loop. Confidence: 0.85
- Keep temperature low for extraction and classification, and higher only for creative generation. Confidence: 0.75
- Show users where an answer came from and make it easy to report a bad response. Confidence: 0.80
- Collect thumbs-up and thumbs-down feedback and feed the bad cases back into the eval set. Confidence: 0.85
- Do not fine-tune until prompting, retrieval and evals have clearly hit their limits. Confidence: 0.70
LLMRAGAI Agent+2
F@f
0
FastAPI Service taste
Taste

Opinionated conventions for building and running production FastAPI services in Python.

# FastAPI Service taste of a Python API engineer
- Define request and response bodies with Pydantic v2 models and always set response_model so nothing leaks by accident. Confidence: 0.95
- Keep separate schemas for create, update and read instead of reusing the ORM model as the API shape. Confidence: 0.90
- Use async def endpoints only when every call inside is truly async; otherwise write plain def and let the threadpool handle it. Confidence: 0.85
- Inject database sessions, settings and the current user through Depends, never through module-level globals. Confidence: 0.90
- Load configuration with pydantic-settings from environment variables and fail fast at startup if anything is missing. Confidence: 0.90
- Split routes into APIRouter modules by domain and keep main.py limited to app creation and wiring. Confidence: 0.85
- Put business logic in service functions that know nothing about HTTP, so they can be reused from workers and tests. Confidence: 0.85
- Use SQLAlchemy 2.0 style with async sessions and manage the schema with Alembic migrations. Confidence: 0.80
- Use the lifespan context manager for startup and shutdown instead of the deprecated on_event hooks. Confidence: 0.85
- Raise HTTPException only at the edge and map domain errors to status codes with registered exception handlers. Confidence: 0.80
- Return a consistent error body with a machine-readable code, a message and optional details. Confidence: 0.85
- Version the API in the path (/v1) and treat the generated OpenAPI schema as a contract checked in CI. Confidence: 0.75
- Use cursor-based pagination for large lists and cap the page size on the server. Confidence: 0.80
- Test with pytest and httpx.AsyncClient, overriding dependencies instead of patching internals. Confidence: 0.90
- Run uvicorn workers behind gunicorn or a process manager in production, with --reload only in development. Confidence: 0.80
- Offload slow work to a real task queue such as Celery, Arq or Dramatiq; BackgroundTasks is only for small fire-and-forget jobs. Confidence: 0.85
- Add request IDs, structured JSON logs and OpenTelemetry tracing middleware from day one. Confidence: 0.80
- Protect endpoints with OAuth2 bearer tokens or API keys through security dependencies so the docs show the auth scheme. Confidence: 0.80
- Expose separate /healthz and /readyz endpoints, where readiness checks the database and other hard dependencies. Confidence: 0.85
- Manage dependencies with uv and a lock file, and lint and format with Ruff. Confidence: 0.75
- Type check with mypy or pyright in strict mode on the service package. Confidence: 0.70
APIBackendTesting+2
F@f
0
Home Appliance Energy Cost Estimator
Skill

Estimates what each home appliance costs to run from its watts, hours per day and your electricity tariff. Reads a CSV, works out kWh and cost per day, month and year, standby (vampire) loads, each appliance's share and off-peak pricing, compares the total with a real bill, and ranks savings ideas such as LED bulbs, 30 C washes and cutting standby by yearly savings. Includes a stdlib Python estimator with --self-test.

---
name: home-appliance-energy-cost-estimator
description: Estimates what each home appliance costs to run from its watts, hours per day and your electricity tariff. Reads a CSV, works out kWh and cost per day, month and year, standby (vampire) loads, each appliance's share and off-peak pricing, compares the total with a real bill, and ranks savings ideas such as LED bulbs, 30 C washes and cutting standby by yearly savings. Includes a stdlib Python estimator with --self-test.
---

# Home Appliance Energy Cost Estimator

You help households see where their electricity money goes, appliance by appliance, and which few changes are worth making. You turn watts and habits into kWh and money, show your arithmetic, and keep the advice practical: no shaming, no expensive upgrades that never pay back.

## Files in this skill

- `scripts/energy_cost.py` - reads an appliance CSV and a tariff, prints a cost table, findings and ranked savings ideas (Python 3 standard library only)
- `references/energy-math.md` - formulas, duty cycles, standby, energy labels, tariffs and how to measure real consumption
- `references/typical-wattages.md` - typical running and standby watts and daily hours for common appliances, to fill gaps
- `templates/energy-report.md` - the household report: totals, top consumers, standby, action plan with savings and cost
- `examples/apartment-appliances.csv` - a two-person apartment with 15 appliances (fridge from 2011, halogen spots, tumble dryer, game console standby, seasonal space heater)

## Workflow

### 1. Build the appliance list
Ask for the appliances, their watts (nameplate sticker, manual, or a plug-in energy meter), how long each runs per day and on how many days a week, and the electricity price per kWh (plus off-peak price and standing charge if any) from a recent bill. For fridges and freezers prefer the energy-label kWh/year; for anything with a thermostat set a duty cycle. Fill gaps from `references/typical-wattages.md` and say which values are assumptions. If they have a bill, ask for the monthly kWh to cross-check.

### 2. Run the estimator
```
python3 scripts/energy_cost.py examples/apartment-appliances.csv --rate 0.32 --offpeak-rate 0.21 --standing-charge 0.40 --bill-kwh 330 --currency EUR
python3 scripts/energy_cost.py my-home.csv --rate 0.15 --currency USD --json
python3 scripts/energy_cost.py --self-test
```
Exit code 1 means HIGH findings (standby above 10% of use or the estimate far from the bill), 2 means bad input.

### 3. Review with the user
If BILL_GAP appears, fix the inputs first (missing water heater or electric heating, seasonal use via `months_per_year`, hours that are too generous). Then go through the top consumers, the standby list and the ideas. Savings overlap (a dryer used less also saves less by moving off-peak), so pick a plan rather than adding every idea. Use `references/energy-math.md` to explain any number.

### 4. Deliver
Fill `templates/energy-report.md`: totals, top 5 consumers, standby, and an action plan sorted into free habits, cheap fixes (LED bulbs, smart plugs) and bigger purchases with payback years (price / yearly saving).

## Example result
For `examples/apartment-appliances.csv` at EUR 0.32/kWh (off-peak 0.21, standing charge 0.40/day): 12.3 kWh/day, 373 kWh/month, 4479 kWh/year, energy EUR 118.38/month or EUR 1420.54/year (EUR 1566.54 with the standing charge). Six halogen spots are the biggest consumer (548 kWh, 12%), then the space heater used 5 months (479 kWh). Standby 319 kWh (7%), with MEDIUM findings for the TV (12 W) and game console (15 W). Within 13% of the 330 kWh bill, so no BILL_GAP. Top ideas: LED spots about EUR 149/year, line-drying half the loads EUR 63, 30 C washes EUR 48, off-peak laundry, cutting console standby EUR 40.

## Rules

- Show the formula once and the assumptions for every guessed value.
- Use the user's tariff and currency; never assume a country's price without saying so.
- Estimates are not measurements: suggest a plug-in meter for the top 3 consumers when numbers matter.
- Rank actions by money saved per effort; give payback in years for anything that costs money.
- Safety first: never suggest disabling fridge or freezer cooling, smoke alarms, medical devices or frost protection to save energy.
FILE:references/energy-math.md
# Energy Math

## Core formulas
```
kWh per day (running) = watts x quantity x hours per day x duty% / 1000 x days per week / 7 x months per year / 12
kWh per day (standby) = standby watts x quantity x (24 - average hours running) / 1000
kWh per year = kWh per day x 365        kWh per month = kWh per year / 12
cost = kWh x price per kWh (+ standing charge per day x 365, listed separately)
```
Example: a 2000 W kettle used 15 minutes a day = 2000 x 0.25 / 1000 = 0.5 kWh/day = 183 kWh/year; at 0.32 per kWh = 58.40 a year.

## Watts vs other numbers on the label
- W (watts) is what you need. kW x 1000 = W.
- VA on chargers and UPS units is apparent power; real watts are usually 60-90% of it.
- mAh or Wh on batteries is capacity, not power draw.
- Nameplate watts are the maximum. Heaters, ovens, kettles run near it; TVs, computers and consoles usually draw 40-80% of it. A plug-in meter gives the truth in a day.

## Duty cycle
Anything with a thermostat or compressor switches on and off. The fraction of time it is really on is the duty cycle.
| Appliance | Typical duty cycle |
|---|---|
| Fridge, fridge-freezer | 30-50% (higher in a warm kitchen or with a poor seal) |
| Chest freezer | 35-50% |
| Oil-filled or panel heater on thermostat | 50-80% in cold weather |
| Oven at temperature (after preheat) | 50-60% |
| Washing machine (heats water at the start) | 50-70% of nameplate over a cycle |
| Dishwasher | 50-60% of nameplate over a cycle |
Better: use the energy label's kWh/year (EU label, US EnergyGuide), which already includes cycling.

## Standby
Devices that are "off" but still powered: TV and console instant-on modes (5-15 W), set-top boxes (10-20 W), printers, microwaves with clocks (2-3 W), chargers left plugged in (0.1-0.5 W). Always-on devices such as a Wi-Fi router are running load, not standby, so list them with 24 hours. 10 W of standby around the clock = 88 kWh/year. A switched power strip, a smart plug with a schedule or disabling "quick start" removes most of it.

## Tariffs
- Flat rate: one price per kWh.
- Time of use: cheaper off-peak hours (often night). Set `offpeak_pct` to the share already used off-peak; the script values shifting the rest of the shiftable loads (washer, dishwasher, dryer, EV charger, water heater, pool pump) at (standard - off-peak) per kWh.
- Tiered/block rates: price rises with monthly use. Use the marginal (top tier) price to value savings.
- Standing charge (fixed daily fee): paid even at zero use, so savings never reduce it.
- Taxes and levies: use the all-in price per kWh from the bill (total energy cost / kWh).

## Checking against the bill
Compare the estimated monthly kWh with the bill's kWh (not money). Within 25% is good for a habit-based estimate. Common reasons for an estimate that is too low: electric water heating (often 15-25% of a home's electricity), electric heating or cooling, an EV, a forgotten second fridge, a pool or aquarium. Too high: hours guessed generously, seasonal loads counted all year (use `months_per_year`), nameplate watts used for electronics.

## Measuring
- Plug-in energy meter (cheap): measures kWh over a week; multiply by 52.
- Smart meter app or utility portal: hourly data shows the night-time base load (everything always on); base load x 8760 h / 1000 = kWh/year that never switches off.
- Read the meter before and after a known run (e.g. one oven hour) with everything else steady.

## Payback
payback years = purchase price / yearly saving. LED bulbs: months. Smart plugs for standby: 1-2 years. Heat-pump dryer or new fridge: often 5-10 years, so suggest them when the old unit needs replacing anyway.
FILE:references/typical-wattages.md
# Typical Wattages and Use

Starting values when the user does not know. Always label them as assumptions; real models vary a lot.

| Appliance | Running W | Typical use | Standby W | Notes |
|---|---|---|---|---|
| Fridge-freezer | 100-200 | 24 h at 30-50% duty | - | Label: 150-350 kWh/year (new A-E class lower) |
| Chest freezer | 60-120 | 24 h at 35-50% duty | - | Label: 150-250 kWh/year |
| Washing machine | 1800-2400 | 1-2 h per cycle, 3-5 cycles/week | 0.5-2 | 0.5-1.0 kWh per 40 C cycle |
| Tumble dryer (vented/condenser) | 2000-3000 | 1-1.5 h per load | 0.5-1 | Heat-pump dryer about half |
| Dishwasher | 1200-2000 | 1-2 h per cycle | 0.5-1 | 0.8-1.4 kWh per cycle; eco programs longer but use less |
| Electric oven | 2000-3500 | 0.5-1 h | 2-4 (clock) | Preheat at full power, then cycles |
| Induction or ceramic hob (per zone) | 1200-2500 | 0.5-1 h | 0-2 | |
| Microwave | 800-1200 | 5-15 min | 2-4 | |
| Air fryer | 1200-1800 | 15-30 min | 0 | Cheaper than an oven for small portions |
| Kettle | 2000-3000 | 10-20 min total | 0 | |
| Coffee machine | 1000-1500 | 10-20 min | 1-3 | Keep-warm plates add up |
| Incandescent / halogen bulb | 40-60 | 3-6 h | 0 | LED equivalent 5-9 W |
| LED bulb | 5-12 | 3-6 h | 0-0.5 (smart bulbs) | |
| TV 43-65 inch LED | 60-150 | 3-5 h | 0.5-15 | Quick-start modes raise standby |
| Game console | 100-220 | 1-3 h | 1-15 | Rest/instant-on mode about 10-15 W |
| Desktop PC + monitor | 100-400 | 4-8 h | 2-5 | Gaming PC under load 300-600 W |
| Laptop | 30-70 | 4-8 h | 0.5 | |
| Wi-Fi router + modem | 8-20 | 24 h | - | Always on: list as running load |
| Set-top box / streaming box | 10-20 | 4 h | 5-15 | |
| Space heater (fan / oil-filled) | 1000-2000 | 2-6 h in winter, 50-80% duty | 0 | Use months_per_year |
| Portable air conditioner | 800-1500 | 4-8 h in summer | 1-2 | |
| Dehumidifier | 200-500 | 6-12 h | 1 | |
| Electric water heater (tank) | 2000-3000 | 2-4 h | - | Often the hidden big load |
| Electric shower | 7000-10000 | 10-20 min | 0 | |
| EV charger (home, 7 kW) | 7000-7400 | per km driven: ~0.15-0.2 kWh/km | 2-5 | Use kWh/year = km/year x 0.17 |
| Pool pump | 750-1500 | 6-10 h in season | 0 | Variable-speed pumps save about half |
| Aquarium (heater + filter + light) | 50-200 | 24 h at 30-60% | - | |

Sources for ranges: manufacturer data sheets and energy labels, plus common national energy agency appliance calculators. Check the user's model when a value drives a decision.
FILE:templates/energy-report.md
# Home electricity report: <household name or address>

**Tariff:** <EUR 0.32/kWh, off-peak 0.21 (23:00-07:00), standing charge 0.40/day>   **Bill check:** <estimate 373 kWh/month vs bill 330 (-13%)>
**Summary:** <one sentence: e.g. "Lighting and laundry are 40% of the bill; LED spots and 30 C washes save about EUR 200 a year for EUR 40.">

## Where the money goes
| Appliance | kWh/year | Cost/year | Share | Assumption |
|---|---|---|---|---|
| <Halogen spotlights x6> | <548> | <175.20> | <12%> | <50 W, 5 h/day> |
| <...> | | | | |

Total: <4479 kWh/year, EUR 1420.54 energy + EUR 146 standing charge>

## Standby (always paid, nothing gained)
<319 kWh/year, about EUR 102. Biggest: game console 15 W, TV 12 W.>

## Action plan
### Free habits
| Action | Saving/year |
|---|---|
| <Wash at 30 C, full loads> | <EUR 48> |
| <Run laundry and dishwasher off-peak> | <EUR 70-110> |

### Cheap fixes
| Action | Cost | Saving/year | Payback |
|---|---|---|---|
| <6 LED spots> | <EUR 30> | <EUR 149> | <2-3 months> |
| <Smart plug for TV + console> | <EUR 15> | <EUR 68> | <3 months> |

### Bigger purchases (only when replacing anyway)
| Action | Cost | Saving/year | Payback |
|---|---|---|---|
| <Heat-pump dryer> | <EUR 600> | <EUR 63> | <~10 years> |

## Next steps
- <Measure the space heater and fridge with a plug-in meter for one week>
- <Re-run the estimate after the LED swap and compare the next two bills>

Numbers are estimates from rated watts and typical use; savings overlap and are not additive.
FILE:examples/apartment-appliances.csv
appliance,watts,hours_per_day,days_per_week,quantity,duty_cycle_pct,standby_watts,kwh_per_year,offpeak_pct,months_per_year
Fridge-freezer (2011),,,7,1,,,320,,
Chest freezer,90,24,7,1,45,,,,
Washing machine,2000,1.5,4,1,60,1.5,,,
Tumble dryer,2500,1,3,1,,0.5,,,
Dishwasher,1800,1.5,5,1,55,1,,30,
Electric oven,2400,1,4,1,60,3,,,
Kettle,2200,0.25,7,1,,,,,
Microwave,1100,0.2,7,1,,3,,,
Halogen spotlight,50,5,7,6,,,,,
LED bulb,9,5,7,8,,,,,
TV (55 inch),110,4,7,1,,12,,,
Game console,160,1.5,5,1,,15,,,
Desktop computer,180,6,5,1,,4,,,
Wi-Fi router,10,24,7,1,,,,,
Space heater (bedroom),1500,3,7,1,70,,,,5
FILE:scripts/energy_cost.py
#!/usr/bin/env python3
"""Home appliance energy cost estimator (Python 3 standard library only).

Reads a CSV of appliances and works out daily, monthly and yearly kWh and cost, standby
("vampire") loads, each appliance's share, and savings ideas with estimated yearly savings.

CSV columns (header required; comma or semicolon; only the first three are required):
  appliance        name, e.g. "Fridge-freezer" (keywords drive the savings ideas)
  watts            power while running (nameplate or plug-in meter), per unit
  hours_per_day    hours running on a day it is used
  days_per_week    days used per week (default 7)
  quantity         number of identical units (default 1), e.g. 8 bulbs
  duty_cycle_pct   share of running time the motor/heater is really on (default 100;
                   fridges ~35-50, thermostatic heaters ~50-70)
  standby_watts    power when off but plugged in, per unit (default 0)
  kwh_per_year     energy-label figure per unit; replaces watts x hours when given
  offpeak_pct      share of running energy already used in off-peak hours (default 0)
  months_per_year  months in use, for seasonal loads like heaters or air conditioning (default 12)
Formulas:
  running kWh/day  = watts x quantity x hours x duty% / 1000 x days_per_week / 7 x months / 12
                     (or kwh_per_year x quantity / 365), averaged over the whole year
  standby kWh/day  = standby_watts x quantity x (24 - hours x days_per_week / 7) / 1000
  month = year / 12, year = day x 365; cost = kWh x rate (off-peak share x --offpeak-rate);
  standby is charged at the standard rate; the standing charge is listed separately.
Findings (severity):
  HIGH   STANDBY_SHARE    standby is >= 10% of all estimated kWh
  HIGH   BILL_GAP         estimate differs from --bill-kwh (monthly) by more than 25%
  MEDIUM BIG_CONSUMER     one appliance uses >= 25% of all kWh
  MEDIUM STANDBY_DEVICE   a unit draws >= 5 W on standby
  LOW    CHECK_DUTY_CYCLE fridge/freezer/heater running 24 h at 100% duty (likely overestimated)
  LOW    HIGH_WATTS       > 3500 W for an appliance that is not a known heavy load
Usage:
  python3 energy_cost.py appliances.csv --rate 0.32 --currency EUR
  python3 energy_cost.py appliances.csv --rate 0.32 --offpeak-rate 0.21 --standing-charge 0.40 --bill-kwh 260 --json
  python3 energy_cost.py --self-test
Exit code: 0 no HIGH findings, 1 HIGH findings, 2 bad input.
"""
import argparse
import csv
import io
import json
import sys

SEV_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
HEAVY = ("heater", "oven", "stove", "hob", "cooktop", "dryer", "kettle", " ev ", "charger", "boiler",
         "water heater", "sauna", "heat pump", "air conditioner", " ac ", "furnace", "pool")
SHIFTABLE = ("wash", "dishwasher", "dryer", " ev ", "ev charger", "pool", "water heater", "boiler")
# keyword -> (idea, share of that appliance's running cost saved)
IDEAS = [
    (("bulb", "lamp", "light"), "replace with LED (about 85% less power for the same light)", 0.85, "watts>=25"),
    (("dryer",), "line-dry half the loads or switch to a heat-pump dryer (about 50%)", 0.5, None),
    (("water heater", "boiler"), "set to 55-60 C with a timer and insulate the tank (about 10%)", 0.10, None),
    (("space heater", "heater", "radiator"), "heat only the room in use and lower the thermostat 1 C (about 10%); a heat pump uses 2-4x less", 0.10, None),
    (("fridge", "freezer"), "set 4 C / -18 C, clean coils, check door seals (about 10%); a 15-year-old unit may use twice a new A-class one", 0.10, None),
    (("dishwasher",), "use the eco program and skip pre-rinsing with hot water (about 20%)", 0.20, None),
    (("washing machine", "washer"), "wash at 30 C instead of 40-60 C and run full loads (about 40%)", 0.40, None),
    (("kettle",), "boil only the water you need (about 25%)", 0.25, None),
    (("oven",), "use a microwave or air fryer for small meals (about 30%)", 0.30, None),
    (("pool",), "run the pump fewer hours or fit a variable-speed pump (about 50%)", 0.50, None),
    (("air conditioner", " ac ", "aircon"), "set 1-2 C warmer and clean filters (about 10%)", 0.10, None),
    (("dehumidifier",), "use a humidistat at 50-55% instead of running continuously (about 30%)", 0.30, None),
    (("desktop", "gaming pc", "computer"), "enable sleep after 15 minutes idle (about 20%)", 0.20, None),
]


class DataError(ValueError):
    pass


def num(row, key, default, line):
    v = (row.get(key) or "").strip().replace(",", ".") if isinstance(row.get(key), str) else row.get(key)
    if v in (None, ""):
        if default is None:
            raise DataError(f"line {line}: '{key}' is required")
        return float(default)
    try:
        x = float(v)
    except ValueError:
        raise DataError(f"line {line}: '{key}' must be a number, got '{v}'")
    if x < 0:
        raise DataError(f"line {line}: '{key}' cannot be negative")
    return x


def read_csv(text):
    first = text.splitlines()[0] if text.strip() else ""
    delim = ";" if first.count(";") > first.count(",") else ","
    rd = csv.DictReader(io.StringIO(text), delimiter=delim)
    if not rd.fieldnames or not {"appliance", "watts", "hours_per_day"} <= {f.strip().lower() for f in rd.fieldnames}:
        if not rd.fieldnames or "kwh_per_year" not in [f.strip().lower() for f in rd.fieldnames]:
            raise DataError("CSV needs columns appliance, watts, hours_per_day (or kwh_per_year)")
    items = []
    for line, raw in enumerate(rd, 2):
        row = {(k or "").strip().lower(): v for k, v in raw.items()}
        name = (row.get("appliance") or "").strip()
        if not name:
            continue
        label = (row.get("kwh_per_year") or "").strip()
        it = {"appliance": name,
              "watts": num(row, "watts", 0 if label else None, line),
              "hours": num(row, "hours_per_day", 24 if label else None, line),
              "days": num(row, "days_per_week", 7, line),
              "qty": num(row, "quantity", 1, line),
              "duty": num(row, "duty_cycle_pct", 100, line),
              "standby_w": num(row, "standby_watts", 0, line),
              "label_kwh": num(row, "kwh_per_year", 0, line) if label else None,
              "offpeak": num(row, "offpeak_pct", 0, line) / 100,
              "months": num(row, "months_per_year", 12, line)}
        if it["hours"] > 24:
            raise DataError(f"line {line}: hours_per_day {it['hours']:g} > 24")
        if it["days"] > 7 or it["duty"] > 100 or it["offpeak"] > 1 or it["months"] > 12:
            raise DataError(f"line {line}: days_per_week <= 7, months_per_year <= 12, duty_cycle_pct and offpeak_pct <= 100")
        items.append(it)
    if not items:
        raise DataError("no appliances in the CSV")
    return items


def has(name, words):
    low = " " + name.lower() + " "
    return any(w in low for w in words)


def estimate(items, rate, offpeak_rate=None, standing=0.0, bill_kwh=None):
    off = rate if offpeak_rate is None else offpeak_rate
    rows, findings, ideas = [], [], []

    def add(sev, code, msg):
        findings.append({"severity": sev, "code": code, "message": msg})
    for it in items:
        avg_hours = it["hours"] * it["days"] / 7
        if it["label_kwh"] is not None:
            run_day = it["label_kwh"] * it["qty"] / 365
        else:
            run_day = it["watts"] * it["qty"] * avg_hours * it["duty"] / 100 / 1000 * it["months"] / 12
        sb_day = it["standby_w"] * it["qty"] * (24 - avg_hours) / 1000
        run_cost_y = run_day * 365 * ((1 - it["offpeak"]) * rate + it["offpeak"] * off)
        sb_cost_y = sb_day * 365 * rate
        rows.append({"appliance": it["appliance"], "qty": it["qty"], "kwh_day": run_day + sb_day,
                     "kwh_month": (run_day + sb_day) * 365 / 12, "kwh_year": (run_day + sb_day) * 365,
                     "standby_kwh_year": sb_day * 365, "cost_month": (run_cost_y + sb_cost_y) / 12,
                     "cost_year": run_cost_y + sb_cost_y, "standby_cost_year": sb_cost_y,
                     "run_kwh_year": run_day * 365, "run_cost_year": run_cost_y})
        name = it["appliance"]
        if it["standby_w"] >= 5:
            add("MEDIUM", "STANDBY_DEVICE", f"{name}: {it['standby_w']:g} W standby per unit = {sb_day * 365:.0f} kWh/year; a switched power strip or unplugging saves {sb_cost_y:.2f}/year")
        if has(name, ("fridge", "freezer", "heater")) and it["label_kwh"] is None and it["hours"] >= 24 and it["duty"] >= 100:
            add("LOW", "CHECK_DUTY_CYCLE", f"{name}: 24 h at 100% duty; compressors and thermostats cycle, so set duty_cycle_pct (fridge ~40) or use the energy-label kWh/year")
        if it["watts"] > 3500 and not has(name, HEAVY):
            add("LOW", "HIGH_WATTS", f"{name}: {it['watts']:g} W is unusually high; check the nameplate (W, not mAh or VA)")
        for words, text, share, cond in IDEAS:
            if has(name, words) and (cond is None or it["watts"] >= 25):
                ideas.append({"appliance": name, "idea": text, "saving_year": run_cost_y * share,
                              "kwh_year": run_day * 365 * share})
                break
        if sb_cost_y > 0 and it["standby_w"] >= 1:
            ideas.append({"appliance": name, "idea": "cut standby with a switched strip or smart plug", "saving_year": sb_cost_y,
                          "kwh_year": sb_day * 365})
        if offpeak_rate is not None and offpeak_rate < rate and has(name, SHIFTABLE) and it["offpeak"] < 1:
            kwh = run_day * 365 * (1 - it["offpeak"])
            ideas.append({"appliance": name, "idea": "run in off-peak hours (timer or delay start)",
                          "saving_year": kwh * (rate - offpeak_rate), "kwh_year": 0.0})
    tot_kwh = sum(r["kwh_year"] for r in rows)
    tot_sb = sum(r["standby_kwh_year"] for r in rows)
    for r in rows:
        r["share"] = r["kwh_year"] / tot_kwh if tot_kwh else 0
        if r["share"] >= 0.25:
            add("MEDIUM", "BIG_CONSUMER", f"{r['appliance']} is {r['share']:.0%} of the estimated use ({r['kwh_year']:.0f} kWh/year); check it first")
    if tot_kwh and tot_sb / tot_kwh >= 0.10:
        add("HIGH", "STANDBY_SHARE", f"standby is {tot_sb / tot_kwh:.0%} of all use ({tot_sb:.0f} kWh/year, {tot_sb * rate:.2f}/year) - the cheapest saving to get")
    if bill_kwh:
        est_m = tot_kwh / 12
        gap = (bill_kwh - est_m) / bill_kwh
        if abs(gap) > 0.25:
            add("HIGH", "BILL_GAP", f"estimate {est_m:.0f} kWh/month vs bill {bill_kwh:g} kWh/month ({gap:+.0%}); "
                + ("missing appliances (water heating, electric heating, EV?) or hours too low" if gap > 0 else "hours or watts are too high, or some appliances are not used every day"))
    rows.sort(key=lambda r: -r["kwh_year"])
    ideas.sort(key=lambda x: -x["saving_year"])
    findings.sort(key=lambda f: SEV_ORDER[f["severity"]])
    totals = {"kwh_day": tot_kwh / 365, "kwh_month": tot_kwh / 12, "kwh_year": tot_kwh,
              "standby_kwh_year": tot_sb, "energy_cost_year": sum(r["cost_year"] for r in rows),
              "standing_charge_year": standing * 365}
    totals["energy_cost_month"] = totals["energy_cost_year"] / 12
    totals["total_cost_year"] = totals["energy_cost_year"] + totals["standing_charge_year"]
    totals["total_cost_month"] = totals["total_cost_year"] / 12
    totals["savings_year"] = sum(i["saving_year"] for i in ideas)
    return rows, totals, findings, ideas


def render(rows, totals, findings, ideas, rate, cur, offpeak_rate, standing, top):
    c = (cur + " ") if cur else ""
    out = ["# Home appliance energy cost estimate", "",
           f"Tariff: {c}{rate:g}/kWh" + (f", off-peak {c}{offpeak_rate:g}/kWh" if offpeak_rate is not None else "")
           + (f", standing charge {c}{standing:g}/day" if standing else ""), "",
           f"Total: {totals['kwh_day']:.1f} kWh/day, {totals['kwh_month']:.0f} kWh/month, {totals['kwh_year']:,.0f} kWh/year; "
           f"energy {c}{totals['energy_cost_month']:,.2f}/month, {c}{totals['energy_cost_year']:,.2f}/year"
           + (f"; with standing charge {c}{totals['total_cost_year']:,.2f}/year" if standing else ""),
           f"Standby: {totals['standby_kwh_year']:.0f} kWh/year ({totals['standby_kwh_year'] / totals['kwh_year']:.0%})" if totals["kwh_year"] else "", "",
           "| Appliance | Qty | kWh/day | kWh/month | kWh/year | Standby kWh/yr | Cost/month | Cost/year | Share |",
           "|---|---|---|---|---|---|---|---|---|"]
    for r in rows:
        out.append(f"| {r['appliance']} | {r['qty']:g} | {r['kwh_day']:.2f} | {r['kwh_month']:.1f} | {r['kwh_year']:.0f} | "
                   f"{r['standby_kwh_year']:.0f} | {r['cost_month']:.2f} | {r['cost_year']:.2f} | {r['share']:.0%} |")
    out += ["", "## Findings"] + ([f"- {f['severity']} {f['code']}: {f['message']}" for f in findings] or ["- none"])
    out += ["", f"## Savings ideas (top {min(top, len(ideas))}, estimated)"]
    for i in ideas[:top]:
        out.append(f"- {i['appliance']}: {i['idea']} -> about {c}{i['saving_year']:.2f}/year" + (f", {i['kwh_year']:.0f} kWh" if i["kwh_year"] else ""))
    if len(ideas) > top:
        out.append(f"- ... {len(ideas) - top} more with --top {len(ideas)}")
    out += ["", "Estimates from rated watts and typical use; a plug-in energy meter or the smart meter gives real numbers. Savings overlap, so do not add them all up."]
    return "\n".join(out)


def self_test():
    results = []

    def check(name, cond):
        results.append(cond)
        print(("PASS " if cond else "FAIL ") + name)

    def run(csv_text, **kw):
        return estimate(read_csv(csv_text), kw.pop("rate", 0.30), **kw)
    rows, t, f, i = run("appliance,watts,hours_per_day\nKettle,2000,0.5\n")
    check("1 kWh/day kettle", abs(rows[0]["kwh_day"] - 1.0) < 1e-9)
    check("365 kWh/year", abs(t["kwh_year"] - 365) < 1e-9)
    check("cost per year at 0.30", abs(t["energy_cost_year"] - 109.5) < 1e-6)
    check("month = year/12", abs(t["kwh_month"] - 365 / 12) < 1e-9)
    rows, t, f, i = run("appliance,watts,hours_per_day,days_per_week,quantity\nWashing machine,1000,2,3.5,1\nLED bulb,9,5,7,6\n")
    w = [r for r in rows if r["appliance"] == "Washing machine"][0]
    check("days_per_week scales", abs(w["kwh_day"] - 1.0) < 1e-9)
    b = [r for r in rows if r["appliance"] == "LED bulb"][0]
    check("quantity scales", abs(b["kwh_day"] - 0.27) < 1e-9)
    check("LED idea skipped under 25 W", not any("LED" in x["idea"] for x in i))
    rows, t, f, i = run("appliance,watts,hours_per_day,duty_cycle_pct\nFridge,150,24,40\n")
    check("duty cycle", abs(rows[0]["kwh_day"] - 1.44) < 1e-9)
    rows, t, f, i = run("appliance,watts,hours_per_day,standby_watts\nTV,100,4,10\n")
    check("standby hours = 24 - use", abs(rows[0]["standby_kwh_year"] - 10 * 20 / 1000 * 365) < 1e-9)
    codes = [x["code"] for x in f]
    check("STANDBY_DEVICE", "STANDBY_DEVICE" in codes)
    check("STANDBY_SHARE HIGH", "STANDBY_SHARE" in codes)
    rows, t, f, i = run("appliance,watts,hours_per_day,kwh_per_year,quantity\nFreezer,,,250,2\n")
    check("energy label kWh used", abs(t["kwh_year"] - 500) < 1e-9)
    rows, t, f, i = run("appliance,watts,hours_per_day,offpeak_pct\nDishwasher,1200,1,50\n", offpeak_rate=0.15)
    check("off-peak share priced", abs(rows[0]["cost_year"] - 1.2 * 365 * (0.5 * 0.30 + 0.5 * 0.15)) < 1e-6)
    check("shift idea for rest", any("off-peak" in x["idea"] for x in i))
    rows, t, f, i = run("appliance,watts,hours_per_day,months_per_year\nSpace heater,1000,4,6\n")
    check("months_per_year seasonal", abs(t["kwh_year"] - 4 * 365 / 2) < 1e-9)
    rows, t, f, i = run("appliance,watts,hours_per_day,days_per_week\nDishwasher,1000,1,7\n")
    check("dishwasher gets eco idea, not washer idea", "eco" in i[0]["idea"])
    rows, t, f, i = run("appliance,watts,hours_per_day\nHeater,2000,6\nTV,80,3\n")
    check("BIG_CONSUMER", any(x["code"] == "BIG_CONSUMER" for x in f))
    rows, t, f, i = run("appliance,watts,hours_per_day\nKettle,2000,0.5\n", bill_kwh=60)
    check("BILL_GAP HIGH", any(x["code"] == "BILL_GAP" for x in f))
    rows, t, f, i = run("appliance,watts,hours_per_day\nKettle,2000,0.5\n", bill_kwh=32)
    check("no BILL_GAP within 25%", not any(x["code"] == "BILL_GAP" for x in f))
    rows, t, f, i = run("appliance,watts,hours_per_day\nFridge,150,24\n")
    check("CHECK_DUTY_CYCLE LOW", any(x["code"] == "CHECK_DUTY_CYCLE" for x in f))
    rows, t, f, i = run("appliance,watts,hours_per_day\nAquarium pump,5000,1\n")
    check("HIGH_WATTS LOW", any(x["code"] == "HIGH_WATTS" for x in f))
    rows, t, f, i = run("appliance,watts,hours_per_day,standby_watts\nTelevision,90,4,0.5\nWater heater,2000,3,0\n", offpeak_rate=0.15)
    check("keywords: TV not shiftable, water heater gets tank idea", not any(x["appliance"] == "Television" and "off-peak" in x["idea"] for x in i)
          and any(x["appliance"] == "Water heater" and "tank" in x["idea"] for x in i))
    rows, t, f, i = run("appliance;watts;hours_per_day\nHalogen lamp;60,0;4\n")
    check("semicolon + decimal comma", abs(rows[0]["kwh_day"] - 0.24) < 1e-9 and any("LED" in x["idea"] for x in i))
    rows, t, f, i = run("appliance,watts,hours_per_day\nTumble dryer,2500,1\n", standing=0.5)
    check("standing charge separate", abs(t["standing_charge_year"] - 182.5) < 1e-9 and abs(t["total_cost_year"] - t["energy_cost_year"] - 182.5) < 1e-9)
    check("dryer idea 50%", abs(i[0]["saving_year"] - 2.5 * 365 * 0.30 * 0.5) < 1e-6)
    for bad, name in (("appliance,watts,hours_per_day\nX,100,25\n", "hours > 24 rejected"),
                      ("appliance,watts,hours_per_day\nX,abc,2\n", "non-number rejected"),
                      ("name,power\nX,100\n", "missing columns rejected"),
                      ("appliance,watts,hours_per_day\nX,-5,2\n", "negative rejected")):
        try:
            read_csv(bad)
            check(name, False)
        except DataError:
            check(name, True)
    print(f"{sum(results)}/{len(results)} passed")
    return 0 if all(results) else 1


def main():
    ap = argparse.ArgumentParser(description="Home appliance energy cost estimator")
    ap.add_argument("csv", nargs="?", help="appliances CSV")
    ap.add_argument("--rate", type=float, help="price per kWh (standard rate)")
    ap.add_argument("--offpeak-rate", type=float, help="price per kWh in off-peak hours")
    ap.add_argument("--standing-charge", type=float, default=0.0, help="fixed charge per day")
    ap.add_argument("--currency", default="", help="label, e.g. EUR, USD, TRY")
    ap.add_argument("--bill-kwh", type=float, help="actual monthly kWh from a bill, to compare")
    ap.add_argument("--top", type=int, default=8, help="savings ideas to show (default 8)")
    ap.add_argument("--json", action="store_true")
    ap.add_argument("--self-test", action="store_true")
    a = ap.parse_args()
    if a.self_test:
        return self_test()
    if not a.csv or a.rate is None:
        ap.error("give a CSV and --rate, or --self-test")
    try:
        with open(a.csv, encoding="utf-8-sig") as fh:
            items = read_csv(fh.read())
        rows, totals, findings, ideas = estimate(items, a.rate, a.offpeak_rate, a.standing_charge, a.bill_kwh)
    except (OSError, DataError) as e:
        print(f"error: {e}", file=sys.stderr)
        return 2
    if a.json:
        print(json.dumps({"appliances": rows, "totals": totals, "findings": findings, "ideas": ideas}, indent=2))
    else:
        print(render(rows, totals, findings, ideas, a.rate, a.currency, a.offpeak_rate, a.standing_charge, a.top))
    return 1 if any(f["severity"] == "HIGH" for f in findings) else 0


if __name__ == "__main__":
    sys.exit(main())
Personal FinanceSustainabilityPython+1
F@f
0
Password Policy and Entropy Checker
Skill

Reviews a password policy config against NIST SP 800-63B-4, covering length minimums, no composition rules or forced expiry, blocklist checks, rate limiting, paste and Unicode support, no hints or security questions, and safe hashing parameters. Estimates the entropy of made-up sample passwords and shows which weak ones the current policy still accepts. Never asks for real passwords. Includes a stdlib Python checker with --self-test.

---
name: password-policy-and-entropy-checker
description: Reviews a password policy config against NIST SP 800-63B-4, covering length minimums, no composition rules or forced expiry, blocklist checks, rate limiting, paste and Unicode support, no hints or security questions, and safe hashing parameters. Estimates the entropy of made-up sample passwords and shows which weak ones the current policy still accepts. Never asks for real passwords. Includes a stdlib Python checker with --self-test.
---

# Password Policy and Entropy Checker

You help developers, IT admins and security reviewers bring a password policy in line with current NIST guidance (SP 800-63B-4, 2025) and explain why the old rules (8 characters, upper+lower+digit+symbol, change every 90 days) produce weak, predictable passwords. You show it with numbers: entropy estimates for made-up sample passwords and a list of weak samples the current policy lets through.

## Files in this skill

- `scripts/pw_policy.py` - checks a policy JSON against NIST SP 800-63B-4 and OWASP hashing minimums and estimates entropy for synthetic samples, masked in the output by default (Python 3 standard library only; no network, nothing stored)
- `references/nist-800-63b-rules.md` - the password requirements (SHALL / SHOULD) with what each means in practice
- `references/entropy-and-hashing.md` - how the two entropy estimates work, their limits, guess-rate assumptions and hashing parameters
- `templates/policy-recommendation.md` - the recommendation you hand back: current vs recommended setting, rationale, rollout plan
- `examples/legacy-portal-policy.json` and `examples/synthetic-samples.txt` - a legacy customer-portal policy and 8 made-up sample passwords

## Workflow

### 1. Collect the policy, never real passwords
Ask for the policy settings (from the identity provider, Active Directory fine-grained policy, framework config such as Django `AUTH_PASSWORD_VALIDATORS`, or the written policy) and convert them to the JSON keys in the script docstring. Ask whether passwords are the only factor or are always combined with MFA (`single_factor`). If the user wants to test strength, ask them to invent samples that look like what users pick. If someone pastes what looks like a real password, do not repeat it, tell them to change it and continue with made-up samples.

### 2. Run the checker
```
python3 scripts/pw_policy.py examples/legacy-portal-policy.json --samples examples/synthetic-samples.txt --context northwind
python3 scripts/pw_policy.py policy.json --samples synthetic.txt --blocklist top-10k.txt --json
python3 scripts/pw_policy.py --self-test
```
`--context` adds the service name, brand and similar words to the blocklist check. Exit code 1 means HIGH findings, 2 means bad input.

### 3. Explain the findings
Start with what changes for users: longer minimum (15 alone, 8 with MFA), no character-class rules, no forced expiry, blocklist rejection with a clear reason. Then the back end: rate limiting, hashing algorithm and parameters, hints and security questions removed. Use the samples table to show that `Password1!`-style passwords pass composition rules while a four-word passphrase is rejected by them. Use `references/nist-800-63b-rules.md` for the exact requirement and `references/entropy-and-hashing.md` when the user asks "how strong is strong enough".

### 4. Deliver
Fill `templates/policy-recommendation.md` with each setting (current, recommended, NIST reference), the hashing migration (rehash on next login) and a rollout plan: new rules apply at the next password change, expiry removed at once, breached-password check at login where possible.

## Example result
For the legacy portal policy with the 8 synthetic samples and `--context northwind`: 11 HIGH, 5 MEDIUM, 2 LOW. HIGH: minimum 8 (needs 15 without MFA), composition rules, 90-day expiry, no blocklist, hints, security questions, and 5 weak samples the policy accepts (Password1!, Summer2025!, Northwind#1, Qwerty123$, P@ssw0rd2026, each ~15-20 bits). MEDIUM: PBKDF2 at 10000 iterations, max length 20, spaces and paste blocked, no strength guidance. The 4-word passphrase (~54 bits) and the 5-word one (~67 bits) are rejected by the current rules but fine under NIST.

## Rules

- Never ask for, store or repeat real passwords, hashes or password files. Work only with made-up samples.
- Entropy numbers are estimates for comparison, not guarantees; say so.
- Quote NIST SHALL vs SHOULD correctly; do not present OWASP hashing numbers as NIST requirements.
- Phishing-resistant MFA or passkeys beat any password rule; recommend them where the risk justifies it.
- Do not recommend weakening a control (e.g. rate limiting) to improve usability without a replacement.
FILE:references/nist-800-63b-rules.md
# NIST SP 800-63B-4 Password Rules (final, 2025)

Section 3.1.1.2 "Password Verifiers" applies to centrally verified passwords (a login form checked by the server). Local unlock PINs are "activation secrets" with different rules (Sec. 3.2.10). SHALL = required, SHOULD = recommended.

| # | Requirement | Level | Script code | In practice |
|---|---|---|---|---|
| 1 | Minimum 15 characters when the password is the only factor; minimum 8 if only used with MFA | SHALL | MIN_LENGTH | Set 15 for password-only logins; 8 is acceptable only if MFA is always enforced |
| 2 | Allow a maximum of at least 64 characters | SHOULD | MAX_LENGTH | Passphrases and password-manager output need room; storage size is fixed by the hash anyway |
| 3 | Accept all printing ASCII characters and space | SHOULD | NO_SPACES | Do not reject `"`, `'`, `<`, spaces; escape correctly instead |
| 4 | Accept Unicode; count each code point as one character | SHOULD | NO_UNICODE | Apply NFC normalization before hashing (NO_NORMALIZATION) |
| 5 | No other composition rules (character-class mixes) | SHALL NOT | COMPOSITION_RULES | Remove "1 upper, 1 digit, 1 symbol" |
| 6 | No periodic forced changes; force a change on evidence of compromise | SHALL NOT | PERIODIC_EXPIRY | Remove 90-day expiry; reset on breach evidence |
| 7 | No hints accessible to unauthenticated users | SHALL NOT | HINTS | Delete stored hints |
| 8 | No knowledge-based authentication / security questions | SHALL NOT | SECURITY_QUESTIONS | Use recovery codes or a verified channel |
| 9 | Verify the entire password, no truncation, no "enter characters 3, 5 and 8" | SHALL | TRUNCATION | Watch out for bcrypt's 72-byte limit (BCRYPT_72_BYTES) |
| - | Compare the full password with a blocklist of common, expected and compromised values; give the reason when rejecting | SHALL | NO_BLOCKLIST, BLOCKLIST_THIN | Breached corpora (e.g. a k-anonymity range API or a downloaded list), dictionary words, service name, username |
| - | Offer guidance to help users choose a strong password, especially after a rejection | SHALL | NO_GUIDANCE | Strength meter and plain-language rejection reasons |
| - | Rate-limit failed attempts: no more than 100 consecutive failures per account (Sec. 3.2.2) | SHALL | NO_RATE_LIMIT | Growing delays, CAPTCHA, risk checks; hard lockouts after 3 tries invite denial of service (LOCKOUT_STRICT) |
| - | Allow password managers and autofill; permit paste | see Sec. 3.1.1.2 | PASTE_BLOCKED | Never block paste on password fields |
| - | Store passwords salted and hashed with a suitable password hashing scheme (Sec. 3.1.1.2, last part) | SHALL | WEAK_STORAGE | Argon2id, scrypt, bcrypt or PBKDF2 with a unique salt; parameters as high as practical |

## Blocklist sizing
NIST notes the blocklist mainly stops online guessing, which is already throttled, so it should cover the passwords an attacker would try before the limit (thousands to a few hundred thousand entries); very large lists mostly frustrate users. Check the whole password, not substrings, but include obvious derivatives of the service name and username.

## Why composition rules and expiry go
Users meet "upper + digit + symbol" with `Password1!`; forced changes produce `Password2!`, `Password3!`. Attack tools model these patterns, so the rules add friction without adding real entropy. Length plus a blocklist does more.

## Sources
- NIST SP 800-63B-4, Digital Identity Guidelines: Authentication and Authenticator Management (July 2025), https://pages.nist.gov/800-63-4/sp800-63b.html
- Appendix A "Strength of Passwords" in the same document
FILE:references/entropy-and-hashing.md
# Entropy Estimates and Password Hashing

## Two estimates
**Charset entropy** = length x log2(pool), pool = 26 lower + 26 upper + 10 digits + 33 symbols (+100 if non-ASCII), counting only the classes used. It is correct for passwords generated at random from that pool and badly overstates human choices: `Password1!` scores 65.7 bits.

**Pattern entropy** (script) approximates how a cracking tool guesses: it walks the password left to right and charges
| Piece | Cost |
|---|---|
| common word from the built-in list (incl. leet like `P@ssw0rd`, context words) | log2(list size) ~ 7.6 bits, +1 if capitalized, +2 if leet |
| year 1950-2039 | log2(90) ~ 6.5 bits |
| keyboard or alphabet run (`qwerty`, `1234`, `zyx`) | log2(26) + log2(length) |
| repeated character (`aaaa`) | one character + log2(count) |
| digits/symbol appended at the end after a word | -1.5 bits (the most common pattern) |
| any other character | log2(pool) |
| passphrase of 3+ words | 12.9 bits per uncommon word (Diceware list of 7776), 1 bit per common word |

The passphrase figure is an upper bound: it holds only if words were picked at random (dice or a generator). "my dog loves pizza" is far weaker. The built-in word list is small; a real cracker uses billions of breached passwords, so treat every estimate as optimistic for human-chosen input.

## Rating scale (pattern bits)
| Bits | Rating | Meaning |
|---|---|---|
| < 28 | very weak | Falls to online guessing or instantly offline |
| 28-35 | weak | Online throttling helps; offline it is minutes |
| 36-59 | fair | OK against online attacks with rate limiting; weak if a fast hash leaks |
| 60-79 | strong | Resists offline attacks on slow hashes |
| >= 80 | very strong | Resists offline attacks on fast hashes |

## Guess rates used for times
- Offline, fast hash (MD5/SHA-x on GPUs): 1e10 guesses per second.
- Offline, slow hash (Argon2id/bcrypt at recommended cost): about 1e4 guesses per second per attacker rig.
- Online with NIST throttling (<= 100 failures per account, then delays): only the first few hundred to thousands of guesses matter, which is why the blocklist is the key online control.
Time shown = median case, 2^(bits-1) / rate.

## Hashing parameters (OWASP Password Storage Cheat Sheet minimums)
| Algorithm | Minimum | Notes |
|---|---|---|
| Argon2id | m = 19456 KiB (19 MiB), t = 2, p = 1 | First choice |
| scrypt | N = 2^17, r = 8, p = 1 | |
| bcrypt | cost 10 | Input limited to 72 bytes; pre-hash with HMAC-SHA256 + base64 or cap the length |
| PBKDF2-HMAC-SHA256 | 600000 iterations | Use where FIPS-140 validated crypto is required |
Every hash needs a unique random salt (>= 16 bytes). An optional pepper (secret key kept outside the database) adds defense in depth. NIST requires salting and a suitable one-way password hashing function; OWASP numbers are a widely used baseline, not a NIST rule.

## Migrating weak hashes
1. Add the new algorithm next to the old one.
2. On successful login, verify with the old hash, then rehash with the new one.
3. For accounts that never log in, wrap the old hash (Argon2id(old_hash)) and mark them, or force a reset after a deadline.
4. Re-check breached-password status at login if your provider supports it.
FILE:templates/policy-recommendation.md
# Password policy recommendation: <system name>

**Scope:** <customer portal / workforce SSO / admin console>   **Factor:** <password only / password + MFA>
**Basis:** NIST SP 800-63B-4 (2025); hashing parameters from the OWASP Password Storage Cheat Sheet
**Summary:** <one sentence: e.g. "Replace complexity and expiry rules with a 15-character minimum and a breached-password blocklist; move hashes to Argon2id.">

## Settings
| Setting | Current | Recommended | NIST | Why |
|---|---|---|---|---|
| Minimum length | <8> | <15 (8 if MFA always on)> | SHALL | <...> |
| Maximum length | <20> | <64 or more> | SHOULD | <...> |
| Composition rules | <upper, lower, digit, symbol> | none | SHALL NOT | <...> |
| Expiry | <90 days> | none; reset on compromise | SHALL NOT | <...> |
| Blocklist | <none> | breached + dictionary + context words, full-password match | SHALL | <...> |
| Spaces / Unicode | <rejected> | accepted, NFC normalized | SHOULD | <...> |
| Paste / password managers | <blocked> | allowed | | <...> |
| Hints / security questions | <yes> | removed | SHALL NOT | <...> |
| Failed attempts | <lock after 3 for 60 min> | throttle, <= 100 consecutive failures | SHALL | <...> |
| Strength guidance | <none> | meter + rejection reason | SHALL | <...> |
| Storage | <PBKDF2-SHA256, 10000> | <Argon2id m=19 MiB t=2 p=1> | SHALL (salted hash) | <...> |

## What the samples show (made-up passwords only)
<Short table or sentence: e.g. "4 of 5 typical patterns (Password1!, Summer2025!) pass today's rules at ~20 bits; a random 4-word passphrase (~54 bits) is rejected today and accepted under the new policy.">

## Rollout
1. <Day 0: remove expiry, allow paste and spaces, enable strength meter>
2. <Week 1: blocklist on new passwords and changes; show the rejection reason>
3. <Week 2: raise minimum length for new passwords; existing users at next change>
4. <Rehash to Argon2id at next login; force reset for dormant accounts after 6 months>
5. <Remove hints and security questions; switch recovery to verified email + recovery codes>

## Communication to users
<2-3 sentences: longer is stronger, passphrases welcome, password managers welcome, no more forced changes>

## Open questions
<MFA coverage, identity provider limits, compliance frameworks that still mention expiry (check current versions; many now follow NIST)>
FILE:examples/legacy-portal-policy.json
{
  "name": "Northwind customer portal (legacy policy)",
  "single_factor": true,
  "min_length": 8,
  "max_length": 20,
  "composition": {"upper": true, "lower": true, "digit": true, "symbol": true},
  "expiry_days": 90,
  "blocklist": {"enabled": false},
  "allow_spaces": false,
  "allow_unicode": false,
  "allow_paste": false,
  "truncate_at": null,
  "hints": true,
  "security_questions": true,
  "strength_guidance": false,
  "rate_limit": {"max_failed_attempts": 3, "lockout_minutes": 60},
  "storage": {"algorithm": "PBKDF2-SHA256", "iterations": 10000, "salted": true}
}
FILE:examples/synthetic-samples.txt
# Made-up sample passwords for the review. NEVER put real passwords in this file.
Password1!
Summer2025!
Northwind#1
Qwerty123$
P@ssw0rd2026
tundra velvet orbit cactus
vT9#qLm2!xR4wZp7
glacier-mango-quietly-violin-ember
FILE:scripts/pw_policy.py
#!/usr/bin/env python3
"""Password policy and entropy checker (Python 3 standard library only).

Checks a password policy config against NIST SP 800-63B-4 (2025) password guidance and estimates
the strength of SYNTHETIC sample passwords. Never use real passwords: samples are masked in the
output unless --show-samples is given, and nothing is stored or sent anywhere.

Policy JSON (all keys optional except min_length):
  {"name": "Customer portal", "single_factor": true,        # password alone, no MFA
   "min_length": 8, "max_length": 20,
   "composition": {"upper": true, "lower": true, "digit": true, "symbol": true},
   "expiry_days": 90,
   "blocklist": {"enabled": true, "breached": true, "context_words": ["acme", "portal"]},
   "allow_spaces": true, "allow_unicode": true, "unicode_normalization": "NFC",
   "allow_paste": true, "truncate_at": null, "hints": false, "security_questions": false,
   "strength_guidance": true,
   "rate_limit": {"max_failed_attempts": 10, "lockout_minutes": 15},
   "storage": {"algorithm": "argon2id", "memory_kib": 19456, "iterations": 2, "salted": true}}
Policy findings (severity):
  HIGH   MIN_LENGTH          < 15 for single-factor passwords, < 8 with MFA (SHALL)
  HIGH   COMPOSITION_RULES   required character classes (SHALL NOT)
  HIGH   PERIODIC_EXPIRY     forced change every N days (SHALL NOT; change only on compromise)
  HIGH   NO_BLOCKLIST        no blocklist check of the full password (SHALL)
  HIGH   HINTS / SECURITY_QUESTIONS   password hints or KBA prompts (SHALL NOT)
  HIGH   TRUNCATION          only part of the password is verified (SHALL verify the whole)
  HIGH   NO_RATE_LIMIT       no throttling of failed attempts (SHALL; at most 100 in a row)
  HIGH   WEAK_STORAGE        plaintext, reversible encryption, MD5/SHA-x or unsalted hashes
  MEDIUM WEAK_KDF_PARAMS     below OWASP minimums (PBKDF2-SHA256 600000, bcrypt cost 10,
                             Argon2id 19 MiB / t=2, scrypt N=2^17)
  MEDIUM BCRYPT_72_BYTES     bcrypt ignores bytes after 72 but max length allows more
  MEDIUM MAX_LENGTH          maximum below 64 (SHOULD allow at least 64)
  MEDIUM NO_SPACES           spaces not accepted (SHOULD accept printing ASCII and space)
  MEDIUM PASTE_BLOCKED       paste / password managers blocked (SHOULD allow)
  MEDIUM NO_GUIDANCE         no strength guidance shown to users (SHALL offer guidance)
  MEDIUM BLOCKLIST_THIN      blocklist without breached passwords or context words
  LOW    NO_UNICODE          Unicode not accepted (SHOULD)
  LOW    NO_NORMALIZATION    Unicode accepted without NFC normalization (SHOULD)
  LOW    LOCKOUT_STRICT      lockout after < 5 attempts: easy denial of service, use throttling
Sample findings:
  HIGH   SAMPLE_WEAK_ACCEPTED  a blocklisted or < 36-bit sample passes the current policy
Entropy (bits): charset = length x log2(pool of character classes used); pattern = a simple
guess-model estimate that charges little for common words, leet, years, keyboard runs, repeats
and passphrase words, and log2(pool) for every other character (per word log2(7776) = 12.9 bits at most). Rating uses the pattern estimate:
< 28 very weak, < 36 weak, < 60 fair, < 80 strong, else very strong.
Usage:
  python3 pw_policy.py policy.json
  python3 pw_policy.py policy.json --samples synthetic.txt [--context northwind,portal] [--blocklist list.txt] [--json]
  python3 pw_policy.py --self-test
Exit code: 0 no HIGH findings, 1 HIGH findings, 2 bad input.
"""
import argparse
import json
import math
import re
import sys
import unicodedata

SEV_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
COMMON = """password passw0rd welcome letmein qwerty qwertz azerty admin administrator login master
dragon monkey sunshine princess football baseball soccer hockey shadow superman batman iloveyou
trustno1 secret summer winter spring autumn fall monday friday january october december love
hello freedom whatever charlie michael jessica jordan pepper ginger cookie chocolate flower
computer internet google apple samsung starwars pokemon matrix killer hunter ranger thunder
tiger lion eagle dolphin falcon soccer liverpool chelsea arsenal barcelona madrid london paris
berlin newyork america canada company office service portal change changeme default guest user
test testing temp temporary access security system server database root toor manager support
student school teacher family mother father daddy mommy baby angel blessed jesus god heaven
money dollar euro bitcoin crypto summer2024 welcome1 password1 password123 abc123 iloveyou1
""".split()
COMMON_SET = set(COMMON)
TOP_BLOCK = set(COMMON) | {"123456", "12345678", "123456789", "1234567890", "111111", "000000",
                           "123123", "654321", "qwerty123", "1q2w3e4r", "zaq12wsx", "p@ssw0rd",
                           "password1!", "welcome123", "admin123", "letmein1", "qwertyuiop"}
LEET = str.maketrans({"@": "a", "4": "a", "0": "o", "1": "i", "!": "i", "3": "e", "$": "s",
                      "5": "s", "7": "t", "+": "t", "8": "b", "9": "g"})
ROWS = ["qwertyuiop", "asdfghjkl", "zxcvbnm", "1234567890", "abcdefghijklmnopqrstuvwxyz", "qwertzuiop", "azertyuiop"]
WORD_BITS = math.log2(7776)


class DataError(ValueError):
    pass


def char_pool(pw):
    pool = 0
    if re.search(r"[a-z]", pw):
        pool += 26
    if re.search(r"[A-Z]", pw):
        pool += 26
    if re.search(r"[0-9]", pw):
        pool += 10
    if re.search(r"[ -/:-@\[-`{-~]", pw):
        pool += 33
    if any(ord(c) > 126 for c in pw):
        pool += 100
    return pool or 1


def class_bits(c):
    if c.islower() and c.isascii():
        return math.log2(26)
    if c.isupper() and c.isascii():
        return math.log2(26)
    if c.isdigit():
        return math.log2(10)
    if ord(c) > 126:
        return math.log2(100)
    return math.log2(33)


def charset_bits(pw):
    return len(pw) * math.log2(char_pool(pw))


def seq_len(s, i):
    """Length of a keyboard/alphabet run (forward or backward) starting at i."""
    best = 1
    low = s.lower()
    for row in ROWS:
        for r in (row, row[::-1]):
            j = r.find(low[i]) if low[i] in r else -1
            if j < 0:
                continue
            n = 1
            while i + n < len(low) and j + n < len(r) and low[i + n] == r[j + n]:
                n += 1
            best = max(best, n)
    return best


def pattern_bits(pw, context=()):
    """Greedy left-to-right guess model. Returns (bits, notes)."""
    notes = []
    vocab = COMMON_SET | {w.lower() for w in context if len(w) >= 3}
    words = [w for w in re.split(r"[\s\-_.]+", pw) if w]
    if len(words) >= 3 and all(re.fullmatch(r"[A-Za-z]{3,}", w) for w in words):
        bits = sum(1.0 if w.lower() in vocab else WORD_BITS for w in words) + math.log2(4)
        notes.append(f"passphrase of {len(words)} words (upper bound if words were picked at random)")
        return min(bits, charset_bits(pw)), notes
    i, bits, low = 0, 0.0, pw.lower()
    brute = math.log2(char_pool(pw))
    while i < len(pw):
        # dictionary word (with leet), longest first
        hit = None
        for L in range(min(len(pw) - i, 14), 3, -1):
            chunk = low[i:i + L]
            plain = chunk.translate(LEET)
            if plain in vocab:
                hit = (L, plain, chunk != plain)
                break
        run = seq_len(pw, i)
        if hit and run < hit[0]:
            L, word, leet = hit
            b = math.log2(len(COMMON)) + (1 if pw[i].isupper() else 0) + (2 if leet else 0)
            notes.append(f"common word '{word}'" + (" (leet)" if leet else ""))
            bits += b
            i += L
            continue
        m = re.match(r"(19[5-9]\d|20[0-3]\d)", pw[i:])
        if m:
            notes.append(f"year {m.group(1)}")
            bits += math.log2(90)
            i += 4
            continue
        n = run
        if n >= 3:
            notes.append(f"keyboard/alphabet run '{pw[i:i + n]}'")
            bits += math.log2(26) + math.log2(n)
            i += n
            continue
        r = 1
        while i + r < len(pw) and pw[i + r] == pw[i]:
            r += 1
        if r >= 3:
            notes.append(f"repeated '{pw[i]}' x{r}")
            bits += class_bits(pw[i]) + math.log2(r)
            i += r
            continue
        bits += brute
        i += 1
    if notes and re.search(r"[0-9!?.]{1,3}$", pw) and len(pw) > 4:
        bits -= 1.5
        notes.append("digits/symbol appended at the end (most common pattern)")
    return max(bits, 0.0), notes


def rating(bits):
    return ("very weak" if bits < 28 else "weak" if bits < 36 else "fair" if bits < 60
            else "strong" if bits < 80 else "very strong")


def crack_time(bits, rate):
    sec = 2 ** (bits - 1) / rate
    for unit, n in (("years", 31557600), ("days", 86400), ("hours", 3600), ("minutes", 60)):
        if sec >= n:
            v = sec / n
            return f"{v:.3g} {unit}" if v < 100 else f"{v:,.0f} {unit}" if v < 1e6 else f"{v:.1e} {unit}"
    return "instant" if sec < 1 else f"{sec:.0f} seconds"


def mask_pw(pw):
    if len(pw) <= 4:
        return "*" * len(pw)
    return pw[:2] + "*" * (len(pw) - 3) + pw[-1:]


def policy_accepts(policy, pw):
    reasons = []
    if len(pw) < int(policy.get("min_length", 0)):
        reasons.append("too short")
    mx = policy.get("max_length")
    if mx and len(pw) > int(mx):
        reasons.append("too long")
    comp = policy.get("composition") or {}
    for key, rx in (("upper", r"[A-Z]"), ("lower", r"[a-z]"), ("digit", r"[0-9]"), ("symbol", r"[^A-Za-z0-9\s]")):
        if comp.get(key) and not re.search(rx, pw):
            reasons.append(f"needs {key}")
    if " " in pw and policy.get("allow_spaces") is False:
        reasons.append("space not allowed")
    if any(ord(c) > 126 for c in pw) and policy.get("allow_unicode") is False:
        reasons.append("unicode not allowed")
    if (policy.get("blocklist") or {}).get("enabled") and pw.lower() in TOP_BLOCK:
        reasons.append("blocklisted")
    return not reasons, reasons


def core_word(low):
    """Strip leading/trailing digits and symbols, undo leet, keep letters: 'P@ssw0rd2026!' -> 'password'."""
    core = re.sub(r"^[\d\W_]+|[\d\W_]+$", "", low)
    return re.sub(r"[^a-z]", "", core.translate(LEET))


def blocklisted(pw, extra=(), context=()):
    low = unicodedata.normalize("NFC", pw).lower()
    if low in TOP_BLOCK or low in extra:
        return "common/breached password"
    core = core_word(low)
    for w in context:
        if w and len(w) >= 3 and w.lower() in core and len(low) - len(w) <= 6:
            return f"based on context word '{w}'"
    if core in COMMON_SET and len(low) - len(core) <= 6:
        return "common word with a few digits/symbols"
    return None


def check_policy(p):
    f = []

    def add(sev, code, msg):
        f.append({"severity": sev, "code": code, "message": msg})
    if "min_length" not in p:
        raise DataError("policy needs min_length")
    single = p.get("single_factor", True)
    mn = int(p["min_length"])
    need = 15 if single else 8
    if mn < need:
        add("HIGH", "MIN_LENGTH", f"minimum {mn} characters; NIST requires >= {need} for passwords used {'alone' if single else 'with MFA'}")
    comp = [k for k, v in (p.get("composition") or {}).items() if v]
    if comp:
        add("HIGH", "COMPOSITION_RULES", f"requires {', '.join(comp)}; composition rules SHALL NOT be imposed (they produce 'Password1!' patterns); rely on length + blocklist")
    if int(p.get("expiry_days") or 0) > 0:
        add("HIGH", "PERIODIC_EXPIRY", f"forced change every {p['expiry_days']} days; SHALL NOT expire periodically, force a change only on evidence of compromise")
    bl = p.get("blocklist") or {}
    if not bl.get("enabled"):
        add("HIGH", "NO_BLOCKLIST", "no blocklist; new passwords SHALL be checked in full against common, expected and breached passwords")
    elif not bl.get("breached") or not bl.get("context_words"):
        miss = [x for x, ok in (("breached passwords", bl.get("breached")), ("context words (service name, username)", bl.get("context_words"))) if not ok]
        add("MEDIUM", "BLOCKLIST_THIN", f"blocklist lacks {' and '.join(miss)}")
    if p.get("hints"):
        add("HIGH", "HINTS", "password hints are stored; SHALL NOT be accessible to unauthenticated users - remove them")
    if p.get("security_questions"):
        add("HIGH", "SECURITY_QUESTIONS", "security questions (KBA) SHALL NOT be used; offer recovery via a verified email/phone or recovery codes")
    if p.get("truncate_at"):
        add("HIGH", "TRUNCATION", f"only the first {p['truncate_at']} characters are checked; the whole password SHALL be verified")
    rl = p.get("rate_limit") or p.get("lockout")
    if not rl:
        add("HIGH", "NO_RATE_LIMIT", "no throttling of failed attempts; SHALL limit to at most 100 consecutive failures per account")
    else:
        n = int(rl.get("max_failed_attempts") or 0)
        if n > 100:
            add("HIGH", "NO_RATE_LIMIT", f"{n} failed attempts allowed; at most 100 consecutive failures")
        elif 0 < n < 5:
            add("LOW", "LOCKOUT_STRICT", f"lockout after {n} attempts lets anyone lock users out; prefer growing delays, CAPTCHA or risk checks before a hard lock")
    st = p.get("storage") or {}
    alg = str(st.get("algorithm", "")).lower().replace("-", "").replace("_", "")
    if not alg:
        add("MEDIUM", "WEAK_STORAGE", "storage algorithm not stated; confirm a salted password hashing function (Argon2id, scrypt, bcrypt, PBKDF2)")
    elif alg in ("plaintext", "plain", "none", "aes", "encrypted", "base64", "md5", "sha1", "sha256", "sha512", "ntlm"):
        add("HIGH", "WEAK_STORAGE", f"{st.get('algorithm')} is not a password hashing function; use Argon2id (or scrypt, bcrypt, PBKDF2) with a unique salt and rehash on next login")
    else:
        if st.get("salted") is False:
            add("HIGH", "WEAK_STORAGE", "hashes are not salted; every password needs a unique random salt")
        it = int(st.get("iterations") or 0)
        if alg.startswith("pbkdf2") and it < 600000:
            add("MEDIUM", "WEAK_KDF_PARAMS", f"PBKDF2 with {it} iterations; OWASP suggests >= 600000 for PBKDF2-HMAC-SHA256")
        if alg == "bcrypt":
            if int(st.get("cost") or 0) < 10:
                add("MEDIUM", "WEAK_KDF_PARAMS", f"bcrypt cost {st.get('cost')}; use >= 10 (tune to ~250 ms)")
            if int(p.get("max_length") or 999) > 72:
                add("MEDIUM", "BCRYPT_72_BYTES", "bcrypt ignores input after 72 bytes; pre-hash (HMAC-SHA256, base64) or switch to Argon2id")
        if alg.startswith("argon2") and (int(st.get("memory_kib") or 0) < 19456 or it < 2):
            add("MEDIUM", "WEAK_KDF_PARAMS", "Argon2id below m=19456 KiB, t=2, p=1 (OWASP minimum)")
        if alg == "scrypt" and int(st.get("n") or 0) < 2 ** 17:
            add("MEDIUM", "WEAK_KDF_PARAMS", "scrypt N below 2^17 (r=8, p=1)")
    mx = p.get("max_length")
    if mx and int(mx) < 64:
        add("MEDIUM", "MAX_LENGTH", f"maximum {mx} characters blocks passphrases and generated passwords; allow at least 64")
    if p.get("allow_spaces") is False:
        add("MEDIUM", "NO_SPACES", "spaces rejected; accept all printing ASCII characters and space so passphrases work")
    if p.get("allow_paste") is False:
        add("MEDIUM", "PASTE_BLOCKED", "paste blocked; allow paste and autofill so password managers work")
    if p.get("strength_guidance") is False:
        add("MEDIUM", "NO_GUIDANCE", "no guidance when a password is rejected; SHALL help users choose (strength meter, reason for rejection)")
    if p.get("allow_unicode") is False:
        add("LOW", "NO_UNICODE", "Unicode rejected; SHOULD accept it (count each code point as one character)")
    elif p.get("allow_unicode") and str(p.get("unicode_normalization", "")).upper() != "NFC":
        add("LOW", "NO_NORMALIZATION", "Unicode accepted without NFC normalization before hashing; the same password typed on two devices may not match")
    return f


def check_samples(policy, samples, extra=(), context=()):
    rows, f = [], []
    ctx = (policy.get("blocklist") or {}).get("context_words") or []
    if isinstance(ctx, bool):
        ctx = []
    ctx = list(ctx) + list(context)
    for pw in samples:
        cb = charset_bits(pw)
        pb, notes = pattern_bits(pw, ctx)
        bl = blocklisted(pw, extra, ctx)
        ok, why = policy_accepts(policy, pw)
        single = policy.get("single_factor", True)
        nist_ok = bl is None and len(pw) >= (15 if single else 8)
        row = {"sample": pw, "length": len(pw), "charset_bits": round(cb, 1), "pattern_bits": round(pb, 1),
               "rating": "very weak" if bl else rating(pb), "blocklist": bl, "policy_accepts": ok,
               "policy_reasons": why, "nist_ok": nist_ok, "notes": notes,
               "offline_fast": crack_time(0 if bl else pb, 1e10), "offline_slow": crack_time(0 if bl else pb, 1e4)}
        rows.append(row)
        if ok and (bl or pb < 36):
            f.append({"severity": "HIGH", "code": "SAMPLE_WEAK_ACCEPTED", "sample": pw,
                      "message": f"sample {{s}} passes the current policy but is {'blocklisted (' + bl + ')' if bl else rating(pb) + f' (~{pb:.0f} bits)'}"})
    return rows, f


def render(policy, findings, rows, show):
    def s(pw):
        return pw if show else mask_pw(pw)
    out = [f"# Password policy review: {policy.get('name', 'policy')}", "",
           f"Basis: NIST SP 800-63B-4 (2025), passwords used {'as the only factor' if policy.get('single_factor', True) else 'with MFA'}",
           f"Findings: {sum(x['severity'] == 'HIGH' for x in findings)} HIGH, {sum(x['severity'] == 'MEDIUM' for x in findings)} MEDIUM, {sum(x['severity'] == 'LOW' for x in findings)} LOW", ""]
    for x in findings:
        msg = x["message"].replace("{s}", s(x["sample"])) if "sample" in x else x["message"]
        out.append(f"- {x['severity']} {x['code']}: {msg}")
    if rows:
        out += ["", "## Synthetic samples" + ("" if show else " (masked; --show-samples to print)"),
                "| Sample | Len | Charset bits | Pattern bits | Rating | Current policy | NIST verdict | Offline fast hash / slow hash |",
                "|---|---|---|---|---|---|---|---|"]
        for r in rows:
            cur = "accepts" if r["policy_accepts"] else "rejects (" + ", ".join(r["policy_reasons"]) + ")"
            nist = "ok" if r["nist_ok"] else ("reject: " + r["blocklist"] if r["blocklist"] else "reject: too short")
            out.append(f"| {s(r['sample'])} | {r['length']} | {r['charset_bits']} | {r['pattern_bits']} | {r['rating']} | {cur} | {nist} | {r['offline_fast']} / {r['offline_slow']} |")
        out += ["", "Charset bits overstate human-chosen passwords; pattern bits are the better guide. Offline times assume 1e10 guesses/s (fast hash) and 1e4 guesses/s (Argon2id/bcrypt), median case."]
    out += ["", "Samples must be made up for the review. Never paste or store real passwords."]
    return "\n".join(out)


def self_test():
    results = []

    def check(name, cond):
        results.append(cond)
        print(("PASS " if cond else "FAIL ") + name)

    def codes(**over):
        p = {"min_length": 15, "max_length": 64, "blocklist": {"enabled": True, "breached": True, "context_words": ["acme"]},
             "allow_spaces": True, "allow_unicode": True, "unicode_normalization": "NFC", "allow_paste": True,
             "strength_guidance": True, "rate_limit": {"max_failed_attempts": 10},
             "storage": {"algorithm": "argon2id", "memory_kib": 19456, "iterations": 2, "salted": True}}
        p.update(over)
        return [x["code"] for x in check_policy(p)]
    check("compliant policy has no findings", codes() == [])
    check("min 8 single factor HIGH", "MIN_LENGTH" in codes(min_length=8))
    check("min 8 with MFA ok", "MIN_LENGTH" not in codes(min_length=8, single_factor=False))
    check("min 6 with MFA HIGH", "MIN_LENGTH" in codes(min_length=6, single_factor=False))
    check("composition HIGH", "COMPOSITION_RULES" in codes(composition={"upper": True, "symbol": True}))
    check("expiry HIGH", "PERIODIC_EXPIRY" in codes(expiry_days=90))
    check("no blocklist HIGH", "NO_BLOCKLIST" in codes(blocklist={"enabled": False}))
    check("thin blocklist MEDIUM", "BLOCKLIST_THIN" in codes(blocklist={"enabled": True}))
    check("hints + KBA HIGH", {"HINTS", "SECURITY_QUESTIONS"} <= set(codes(hints=True, security_questions=True)))
    check("truncation HIGH", "TRUNCATION" in codes(truncate_at=16))
    check("no rate limit HIGH", "NO_RATE_LIMIT" in codes(rate_limit=None))
    check("lockout 3 LOW", "LOCKOUT_STRICT" in codes(rate_limit={"max_failed_attempts": 3}))
    check("sha256 HIGH", "WEAK_STORAGE" in codes(storage={"algorithm": "SHA-256", "salted": True}))
    check("unsalted HIGH", "WEAK_STORAGE" in codes(storage={"algorithm": "pbkdf2-sha256", "iterations": 600000, "salted": False}))
    check("pbkdf2 low iterations MEDIUM", "WEAK_KDF_PARAMS" in codes(storage={"algorithm": "PBKDF2-SHA256", "iterations": 10000}))
    check("bcrypt cost 8 + 72 bytes", {"WEAK_KDF_PARAMS", "BCRYPT_72_BYTES"} <= set(codes(storage={"algorithm": "bcrypt", "cost": 8}, max_length=128)))
    check("max 20 MEDIUM", "MAX_LENGTH" in codes(max_length=20))
    check("spaces/paste/guidance MEDIUM", {"NO_SPACES", "PASTE_BLOCKED", "NO_GUIDANCE"} <= set(codes(allow_spaces=False, allow_paste=False, strength_guidance=False)))
    check("no unicode LOW", "NO_UNICODE" in codes(allow_unicode=False))
    check("no NFC LOW", "NO_NORMALIZATION" in codes(unicode_normalization=None))
    check("charset bits 8 lowercase", abs(charset_bits("abcdefgh") - 8 * math.log2(26)) < 1e-9)
    check("charset pool all classes", char_pool("aA1!") == 95)
    check("leet word cheap", pattern_bits("P@ssw0rd")[0] < 15)
    check("year + word weak", pattern_bits("Summer2025!")[0] < 28)
    check("keyboard run cheap", pattern_bits("qwertyuiop")[0] < 10)
    check("repeat cheap", pattern_bits("aaaaaaaaaa")[0] < 10)
    check("random 16 strong", pattern_bits("vT9#qLm2!xR4wZp7")[0] > 80)
    check("4 random words ~ 53 bits", 50 < pattern_bits("tundra-velvet-orbit-cactus")[0] < 56)
    check("common words passphrase weak", pattern_bits("love summer football")[0] < 10)
    check("blocklisted exact", blocklisted("Password1!") is not None)
    check("context word blocked", blocklisted("Acme2026!", context=["acme"]) is not None)
    check("leet + year blocked", blocklisted("P@ssw0rd2026") is not None)
    check("context word cheap in pattern", pattern_bits("Northwind#1", ["northwind"])[0] < 28)
    check("long random not blocked", blocklisted("vT9#qLm2!xR4wZp7") is None)
    pol = {"min_length": 8, "composition": {"upper": True, "lower": True, "digit": True, "symbol": True}}
    rows, f = check_samples(pol, ["Password1!", "tundra velvet orbit cactus"])
    check("composition policy accepts Password1!", rows[0]["policy_accepts"] and f and f[0]["code"] == "SAMPLE_WEAK_ACCEPTED")
    check("composition policy rejects passphrase", not rows[1]["policy_accepts"] and rows[1]["nist_ok"])
    check("mask hides middle", mask_pw("Password1!") == "Pa*******!")
    check("crack time units", crack_time(0, 1e10) == "instant" and "years" in crack_time(80, 1e10))
    try:
        check_policy({})
        check("missing min_length rejected", False)
    except DataError:
        check("missing min_length rejected", True)
    print(f"{sum(results)}/{len(results)} passed")
    return 0 if all(results) else 1


def main():
    ap = argparse.ArgumentParser(description="Password policy and entropy checker (NIST SP 800-63B-4)")
    ap.add_argument("policy", nargs="?", help="policy JSON")
    ap.add_argument("--samples", help="file with SYNTHETIC sample passwords, one per line (# = comment)")
    ap.add_argument("--blocklist", help="extra blocklist file, one password per line")
    ap.add_argument("--context", default="", help="comma-separated context words (service name, brand, usernames)")
    ap.add_argument("--show-samples", action="store_true", help="print samples unmasked")
    ap.add_argument("--json", action="store_true")
    ap.add_argument("--self-test", action="store_true")
    a = ap.parse_args()
    if a.self_test:
        return self_test()
    if not a.policy:
        ap.error("give a policy JSON or --self-test")
    try:
        with open(a.policy, encoding="utf-8") as fh:
            policy = json.load(fh)
        findings = check_policy(policy)
        rows = []
        if a.samples:
            with open(a.samples, encoding="utf-8") as fh:
                samples = [l.rstrip("\n") for l in fh if l.strip() and not l.startswith("#")]
            extra = set()
            if a.blocklist:
                with open(a.blocklist, encoding="utf-8") as fh:
                    extra = {l.strip().lower() for l in fh if l.strip()}
            rows, sf = check_samples(policy, samples, extra, [w.strip() for w in a.context.split(",") if w.strip()])
            findings += sf
    except (OSError, ValueError, DataError) as e:
        print(f"error: {e}", file=sys.stderr)
        return 2
    findings.sort(key=lambda x: SEV_ORDER[x["severity"]])
    if a.json:
        if not a.show_samples:
            for r in rows:
                r["sample"] = mask_pw(r["sample"])
            for x in findings:
                if "sample" in x:
                    x["sample"] = mask_pw(x["sample"])
                    x["message"] = x["message"].replace("{s}", x["sample"])
        print(json.dumps({"findings": findings, "samples": rows}, indent=2))
    else:
        print(render(policy, findings, rows, a.show_samples))
    return 1 if any(x["severity"] == "HIGH" for x in findings) else 0


if __name__ == "__main__":
    sys.exit(main())
SecurityCompliancePython+1
F@f
0
SQL Query Performance Reviewer
Skill

Reviews SQL queries and optional EXPLAIN output for common performance problems such as SELECT *, functions or casts on indexed columns, leading-wildcard LIKE, N+1 query loops, OR across columns vs UNION ALL, AND/OR precedence slips, NOT IN subqueries, deep OFFSET paging, seq scans and disk sorts. Explains each problem and gives a rewritten query. Includes a stdlib Python checker with --self-test.

---
name: sql-query-performance-reviewer
description: Reviews SQL queries and optional EXPLAIN output for common performance problems such as SELECT *, functions or casts on indexed columns, leading-wildcard LIKE, N+1 query loops, OR across columns vs UNION ALL, AND/OR precedence slips, NOT IN subqueries, deep OFFSET paging, seq scans and disk sorts. Explains each problem and gives a rewritten query. Includes a stdlib Python checker with --self-test.
---

# SQL Query Performance Reviewer

You review SQL the way an experienced database engineer does in a code review: you read the query and its plan, find the patterns that make it slow (or quietly wrong), and hand back a rewritten query with a short reason for every change. You work for PostgreSQL, MySQL/MariaDB, SQL Server and SQLite and say when advice differs between them.

## Files in this skill

- `scripts/sql_review.py` - scans a .sql file (one or many statements) and optional EXPLAIN output, prints findings by severity (Python 3 standard library only; never connects to a database or runs the SQL)
- `references/anti-patterns.md` - each finding with why it hurts, a before/after rewrite and engine differences
- `references/reading-explain.md` - how to capture and read EXPLAIN in PostgreSQL and MySQL, and which numbers matter
- `templates/review-report.md` - the review you hand back: summary, findings table, rewritten queries, what to measure
- `examples/order-history.sql`, `examples/order-history-explain.txt`, `examples/indexes.txt` - an online shop's order history page with 7 statements, a PostgreSQL plan and the known indexes

## Workflow

### 1. Collect the inputs
Ask for: the query or a log excerpt with the real statements, the database engine and version, rough table sizes, and if possible `EXPLAIN (ANALYZE, BUFFERS)` (PostgreSQL) or `EXPLAIN ANALYZE` / `EXPLAIN ... \G` (MySQL 8) output. Index definitions help (`\d table` or `SHOW INDEX FROM table`); write them as `table(col1, col2)` lines for `--indexes`. Ask them to replace customer data in literals with fake values before pasting.

### 2. Run the checker
```
python3 scripts/sql_review.py examples/order-history.sql --explain examples/order-history-explain.txt --indexes examples/indexes.txt
python3 scripts/sql_review.py slow.sql --json
python3 scripts/sql_review.py --self-test
```
Exit code 1 means HIGH findings, 2 means bad input. The script is a regex heuristic: it can miss problems in very complex SQL and can raise false positives. Read every statement yourself as well.

### 3. Review
Order the work: correctness first (AND_OR_PRECEDENCE, NOT_IN_SUBQUERY, NO_WHERE_WRITE), then the biggest cost from the plan (SEQ_SCAN_LARGE, ROWS_REMOVED, DISK_SORT), then N_PLUS_ONE (often the largest total time in an app), then the rest. For each finding give the rewrite from `references/anti-patterns.md` adapted to the user's columns. When an index is the fix, propose it but keep index design short (column order: equality, then sort, then range) and remind them every index slows writes.

### 4. Deliver
Fill `templates/review-report.md`: one-line summary, the findings table, each rewritten query, and the before/after measurement plan (run EXPLAIN ANALYZE again, compare execution time and rows read). Never claim a speed-up you did not measure; say "expected".

## Example result
For the example files: 7 statements, 6 HIGH, 7 MEDIUM, 2 LOW. HIGH: `status = 'paid' OR status = 'shipped'` without parentheses returns every shipped order of every customer; LOWER(customer_email) and DATE(created_at) hide columns from indexes; 3 single-order item queries form an N+1 loop (one `WHERE order_id IN (...)` query instead); `LIKE '%wireless%'`; the plan shows a Seq Scan reading 412560 rows. MEDIUM: SELECT *, OFFSET 4000, sku compared with a number, sku without an index, NOT IN subquery, external merge sort on disk, estimate 19930 vs actual 412560 rows (run ANALYZE).

## Rules

- Never run SQL against the user's database yourself and never ask for production credentials.
- Ask for anonymized literals; do not repeat personal data from pasted queries in the report.
- Correctness beats speed: point out queries that return wrong rows even if they are fast.
- Prefer rewriting the query over adding an index; add an index only for a query that runs often or is slow.
- Name the engine when advice is engine-specific (e.g. expression indexes, ILIKE, LIMIT a,b).
- Measured plans beat heuristics: if EXPLAIN ANALYZE disagrees with a finding, trust the plan.
FILE:references/anti-patterns.md
# SQL Performance Anti-Patterns

Each section: what the checker flags, why it hurts, and a rewrite. "Sargable" means the database can use an index to find the rows (Search ARGument ABLE).

## AND_OR_PRECEDENCE (HIGH, correctness)
AND binds tighter than OR, so `customer_id = 7 AND status = 'paid' OR status = 'shipped'` means `(customer_id = 7 AND status = 'paid') OR status = 'shipped'`: every shipped order of every customer, and a full scan.
```
-- after
WHERE customer_id = 7 AND status IN ('paid', 'shipped')
```

## FUNC_ON_COLUMN (HIGH)
`WHERE LOWER(email) = ?`, `YEAR(created_at) = 2026`, `DATE(created_at) = ?`, `price * 2 > 100`: the index stores raw values, so the function must run on every row.
```
-- dates: use a half-open range on the raw column
WHERE created_at >= '2026-10-01' AND created_at < '2026-10-02'
-- arithmetic: move it to the constant side
WHERE price > 50
-- case-insensitive search: store normalized data or index the expression
CREATE INDEX ON customers (LOWER(email));          -- PostgreSQL, SQLite 3.9+
ALTER TABLE customers ADD email_lc VARCHAR(255) AS (LOWER(email)) STORED, ADD INDEX (email_lc);  -- MySQL 5.7+
-- MySQL 8.0.13+ also supports functional indexes: CREATE INDEX ix ON customers ((LOWER(email)));
-- PostgreSQL alternative: the citext type
```

## LEADING_WILDCARD (HIGH)
`LIKE '%wireless%'` cannot use a B-tree index (only a prefix like `'wireless%'` can). Options: full-text search (PostgreSQL `tsvector` + GIN, MySQL `FULLTEXT` + `MATCH ... AGAINST`), a trigram index (`CREATE EXTENSION pg_trgm; CREATE INDEX ... USING gin (name gin_trgm_ops)`), a search engine for product search, or a reversed column for suffix searches (`'%@example.com'`).

## N_PLUS_ONE (HIGH)
One query for a list, then one query per row (an ORM lazy-loading a relation). 50 orders = 51 round trips; each is fast, the page is slow.
```
-- before (x N)
SELECT product_id, qty FROM order_items WHERE order_id = 98211;
-- after (1 query)
SELECT order_id, product_id, qty FROM order_items WHERE order_id IN (98211, 98340, 98502);
-- or join it into the list query
```
ORM fixes: Django `prefetch_related`/`select_related`, Rails `includes`, SQLAlchemy `selectinload`, Hibernate `JOIN FETCH` / `@BatchSize`, Prisma `include`. The checker needs the repeated statements in the file (paste a log excerpt).

## ORDER_BY_RANDOM (HIGH)
`ORDER BY RAND() LIMIT 1` reads and sorts the whole table. Pick a random id in range and take the next row (`WHERE id >= ? ORDER BY id LIMIT 1`), keep a random-number column with an index, or use `TABLESAMPLE SYSTEM (1)` in PostgreSQL.

## NO_WHERE_WRITE (HIGH)
`UPDATE`/`DELETE` without WHERE locks and rewrites every row. If that is intended, do it in batches (`DELETE ... WHERE id IN (SELECT id ... LIMIT 5000)` in a loop) or use `TRUNCATE`.

## SELECT_STAR (MEDIUM)
Reads every column (including large text/JSON), sends more bytes, prevents index-only scans and breaks callers when columns change. List the columns. `SELECT *` inside `EXISTS (...)` is fine.

## OR_ACROSS_COLUMNS (MEDIUM) and OR_SAME_COLUMN (LOW)
`WHERE email = ? OR phone = ?`: one index cannot serve both. PostgreSQL may combine two indexes with a BitmapOr and MySQL with index_merge; if the plan shows a scan instead, split:
```
SELECT id FROM users WHERE email = ?
UNION ALL
SELECT id FROM users WHERE phone = ? AND email IS DISTINCT FROM ?;  -- avoid duplicates (MySQL: NOT (email <=> ?))
```
Same column repeated: `status IN ('paid', 'shipped')` is shorter and plans the same or better.

## IMPLICIT_CAST (MEDIUM) and QUOTED_NUMBER (LOW)
A text column compared with a number (`sku = 40017` on a VARCHAR): MySQL converts every row's value to a number, so the index is skipped and '040017' also matches; PostgreSQL raises an operator error. Quote the value. A numeric column compared with a quoted number (`customer_id = '1042'`) is converted once and is usually fine, but it often means the application binds ids as strings; a parameter typed as text can force a cast on the column side in some drivers (JDBC `setString` on SQL Server NVARCHAR vs VARCHAR is the classic case).

## NOT_IN_SUBQUERY (MEDIUM, correctness)
If the subquery returns a single NULL, `NOT IN` returns no rows at all. Use:
```
SELECT c.id FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);
```
NOT EXISTS plans as an anti-join in every major engine.

## DEEP_OFFSET (MEDIUM)
`LIMIT 20 OFFSET 4000` reads 4020 rows and throws away 4000; page 500 is 25x slower than page 1. Keyset (seek) pagination:
```
SELECT id, created_at, total FROM orders
WHERE customer_id = ? AND (created_at, id) < (?, ?)      -- last row of the previous page
ORDER BY created_at DESC, id DESC
LIMIT 20;
```
Index `(customer_id, created_at, id)`. MySQL supports row comparisons too; SQL Server: `created_at < ? OR (created_at = ? AND id < ?)`.

## COUNT_FOR_EXISTS (LOW)
`SELECT COUNT(*) ... > 0` counts all matches. `SELECT EXISTS (SELECT 1 FROM ... WHERE ...)` stops at the first.

## DISTINCT_JOIN (LOW)
`SELECT DISTINCT u.* FROM users u JOIN orders o ...` builds all user x order rows then removes duplicates. Use `WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id)`.

## NO_INDEX_FOR_FILTER (MEDIUM, needs --indexes)
A column used in WHERE/ON is not the first column of any listed index. Not every filter needs an index (small tables, low-selectivity flags, rare queries). Composite index order: equality columns first, then the ORDER BY column, then range columns; include selected columns for an index-only scan only when the query is hot.
FILE:references/reading-explain.md
# Reading EXPLAIN

## Capture
| Engine | Command | Notes |
|---|---|---|
| PostgreSQL | `EXPLAIN (ANALYZE, BUFFERS) <query>;` | ANALYZE runs the query; wrap writes in `BEGIN; ... ROLLBACK;` |
| MySQL 8 | `EXPLAIN ANALYZE <query>;` or `EXPLAIN <query>\G` | The checker parses the `\G` vertical format |
| MariaDB | `ANALYZE <query>;` / `EXPLAIN <query>\G` | |
| SQL Server | `SET STATISTICS IO, TIME ON;` + actual execution plan | Read manually |
| SQLite | `EXPLAIN QUERY PLAN <query>;` | Look for `SCAN table` vs `SEARCH table USING INDEX` |

Run it on production-like data. A plan on a 1000-row dev table tells you nothing about a 50-million-row table.

## What the checker looks for
| Finding | PostgreSQL sign | MySQL sign |
|---|---|---|
| SEQ_SCAN_LARGE | `Seq Scan on t` with actual rows >= 10000 | `type: ALL`, `rows` >= 10000, `key: NULL` |
| ROWS_REMOVED | `Rows Removed by Filter` >= 9x the rows kept | `filtered` <= 10% on >= 1000 rows |
| EST_MISMATCH | `rows=` estimate vs `actual ... rows=` off by 10x | (EXPLAIN ANALYZE only, read manually) |
| DISK_SORT | `Sort Method: external merge  Disk: ...` | `Using temporary; Using filesort` |
| NESTED_LOOP_MANY | inner node with `loops=` >= 1000 | many rows in the driving table + `ref` lookups |

## Reading a PostgreSQL plan
- Read from the most indented node up; each `->` feeds its parent.
- `cost=a..b` is the planner's guess (arbitrary units), `actual time=a..b` is milliseconds per loop; multiply by `loops`.
- `rows=` in the cost part is the estimate, in the actual part the real count. A 10x miss means stale or missing statistics: `ANALYZE table;`, or extended statistics for correlated columns (`CREATE STATISTICS`).
- `Index Scan` / `Index Only Scan` / `Bitmap Index Scan` = index used. `Heap Fetches` high on an index-only scan = run VACUUM.
- `Buffers: shared read=` counts 8 kB pages from disk; `hit=` from cache.
- `Sort Method: external merge` = the sort did not fit in `work_mem`. Prefer an index that returns rows in order; raise work_mem per session only for known reporting queries.

## Reading MySQL EXPLAIN
- `type` from best to worst: `const`, `eq_ref`, `ref`, `range`, `index` (full index scan), `ALL` (full table scan).
- `key` is the index chosen (`NULL` = none); `possible_keys` lists candidates.
- `rows` x `filtered`% = rows passed on to the next step.
- `Extra`: `Using index` (covering, good), `Using where` (filter after reading), `Using temporary` and `Using filesort` (extra work for GROUP BY / ORDER BY).

## Before/after measurement
1. Run the original 3 times, record median execution time and rows/buffers read.
2. Apply one change at a time (rewrite, then index).
3. Run again with the same parameters; also test a "big" customer and a "small" one, because plans change with data skew.
4. Check write cost for new indexes on hot tables (insert latency, index size with `pg_relation_size` or `information_schema.STATISTICS`).
FILE:templates/review-report.md
# SQL performance review: <query or endpoint name>

**Engine:** <PostgreSQL 16 / MySQL 8.0 / ...>   **Tables:** <orders ~1.2M rows, order_items ~4M>
**Summary:** <one sentence: the main problem and the expected effect of the fix>

## Findings
| # | Severity | Finding | Statement | Why it matters | Fix |
|---|---|---|---|---|---|
| 1 | HIGH | <AND_OR_PRECEDENCE> | <stmt 1> | <returns all shipped orders, full scan> | <parenthesize / IN (...)> |
| 2 | <MEDIUM> | <...> | <...> | <...> | <...> |

## Rewritten queries
### Statement <n>: <name>
Before:
```sql
<original>
```
After:
```sql
<rewrite>
```
Why: <one or two sentences>

## Index changes (only if needed)
```sql
<CREATE INDEX CONCURRENTLY ix_orders_customer_created ON orders (customer_id, created_at DESC, id);>
```
Write cost: <table write rate, expected index size>

## Measure
| Query | Before (ms, rows read) | After (ms, rows read) |
|---|---|---|
| <stmt 1> | <912 ms, 1.2M rows> | <expected: < 5 ms, ~20 rows> |

Run `EXPLAIN (ANALYZE, BUFFERS)` again after each change, with a large and a small customer.

## Not changed
<findings deliberately left, e.g. SELECT * in an admin export, with the reason>
FILE:examples/order-history.sql
-- Order history page of a small online shop (PostgreSQL), captured from the app log.
-- 1. Order list for the account page
SELECT * FROM orders o
WHERE LOWER(o.customer_email) = 'mina.sato@example.com'
  AND o.status = 'paid' OR o.status = 'shipped'
ORDER BY o.created_at DESC
LIMIT 20 OFFSET 4000;

-- 2-4. Line items, loaded one order at a time by the ORM
SELECT product_id, qty, unit_price FROM order_items WHERE order_id = 98211;
SELECT product_id, qty, unit_price FROM order_items WHERE order_id = 98340;
SELECT product_id, qty, unit_price FROM order_items WHERE order_id = 98502;

-- 5. Search box
SELECT p.id, p.name FROM products p
WHERE p.name LIKE '%wireless%' AND p.sku = 40017;

-- 6. Customers who never ordered
SELECT c.id, c.email FROM customers c
WHERE c.id NOT IN (SELECT customer_id FROM orders)
  AND DATE(c.created_at) = '2026-10-01';

-- 7. Order lookup by id from a URL parameter
SELECT o.id, o.total FROM orders o WHERE o.customer_id = '1042';
FILE:examples/order-history-explain.txt
Limit  (cost=48211.30..48211.35 rows=20 width=212) (actual time=912.4..912.5 rows=20 loops=1)
  ->  Sort  (cost=48201.30..48251.12 rows=19930 width=212) (actual time=905.1..911.8 rows=4020 loops=1)
        Sort Key: created_at DESC
        Sort Method: external merge  Disk: 18432kB
        ->  Seq Scan on orders o  (cost=0.00..41870.00 rows=19930 width=212) (actual time=0.03..801.7 rows=412560 loops=1)
              Filter: (((lower(customer_email) = 'mina.sato@example.com'::text) AND (status = 'paid'::text)) OR (status = 'shipped'::text))
              Rows Removed by Filter: 787440
Planning Time: 0.4 ms
Execution Time: 914.9 ms
FILE:examples/indexes.txt
orders(customer_id, created_at)
orders(status)
order_items(order_id)
customers(id)
products(id)
FILE:scripts/sql_review.py
#!/usr/bin/env python3
"""SQL query performance reviewer (Python 3 standard library only).

Reads a .sql file (one or more statements separated by ;) and, optionally, EXPLAIN output,
and flags common performance problems with a heuristic, regex-based scan. It does not connect
to a database and never runs the SQL. Treat findings as review prompts, not proof.

Query findings (severity):
  HIGH   FUNC_ON_COLUMN     function or arithmetic wraps a column in WHERE/ON (index cannot be used)
  HIGH   LEADING_WILDCARD   LIKE/ILIKE pattern starts with % or _ (full scan)
  HIGH   N_PLUS_ONE         the same statement shape repeats >= --repeat times with different literals
  HIGH   ORDER_BY_RANDOM    ORDER BY RAND()/RANDOM() sorts the whole table
  HIGH   NO_WHERE_WRITE     UPDATE or DELETE without WHERE
  HIGH   AND_OR_PRECEDENCE  AND and OR mixed without parentheses: AND binds first, so the OR branch
                            matches rows from the whole table (wrong results and a full scan)
  MEDIUM SELECT_STAR        SELECT * or alias.* (wide rows, no covering index, breaks on schema change)
  MEDIUM OR_ACROSS_COLUMNS  OR between different columns in WHERE (consider UNION ALL)
  MEDIUM IMPLICIT_CAST      code/phone/sku-like (text) column compared with a bare number: MySQL
                            casts every row and skips the index, PostgreSQL raises a type error
  MEDIUM NOT_IN_SUBQUERY    NOT IN (SELECT ...): NULL trap and poor anti-join plans; use NOT EXISTS
  MEDIUM DEEP_OFFSET        OFFSET (or LIMIT a,b) >= 1000: keyset pagination instead
  MEDIUM NO_INDEX_FOR_FILTER  (needs --indexes) filtered/joined column not leading any known index
  LOW    QUOTED_NUMBER      id/number column compared with a quoted number: usually fine for a literal,
                            but a sign the app binds ids as text (typed text parameters can force a cast)
  LOW    OR_SAME_COLUMN     col = a OR col = b: write col IN (a, b)
  LOW    COUNT_FOR_EXISTS   COUNT(*) > 0 used as an existence check; use EXISTS
  LOW    DISTINCT_JOIN      SELECT DISTINCT with JOIN may hide a join fan-out
EXPLAIN findings (PostgreSQL text format and MySQL EXPLAIN \\G vertical format):
  HIGH   SEQ_SCAN_LARGE     Seq Scan / type ALL over >= --large-rows rows
  MEDIUM ROWS_REMOVED       a filter throws away >= 90% of the rows it reads
  MEDIUM EST_MISMATCH       estimated vs actual rows differ by >= 10x (stale statistics)
  MEDIUM DISK_SORT          external merge sort / Using filesort with Using temporary
  LOW    NESTED_LOOP_MANY   inner side of a nested loop runs >= 1000 loops
Usage:
  python3 sql_review.py query.sql
  python3 sql_review.py query.sql --explain plan.txt --indexes indexes.txt --json
  python3 sql_review.py --self-test
--indexes file: one index per line, e.g. "orders(customer_id, created_at)".
Exit code: 0 no HIGH findings, 1 HIGH findings, 2 bad input.
"""
import argparse
import json
import re
import sys
from collections import defaultdict

SEV_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
FUNCS = ("lower", "upper", "trim", "ltrim", "rtrim", "date", "year", "month", "day", "cast",
         "coalesce", "ifnull", "nvl", "substring", "substr", "left", "right", "to_char",
         "date_trunc", "extract", "date_format", "convert", "abs", "round", "concat", "length")
ID_COL = re.compile(r"(^|_)(id|number|count|qty|quantity|amount)$")
TEXT_COL = re.compile(r"(phone|zip|postal_code|postcode|sku|isbn|iban|code|_no)$")
KEYWORDS = {"where", "on", "and", "or", "not", "join", "left", "right", "inner", "outer", "full",
            "cross", "group", "order", "by", "limit", "offset", "having", "union", "select", "from",
            "as", "using", "set", "values", "into", "lateral", "natural", "straight_join", "with"}


class DataError(ValueError):
    pass


def mask(sql):
    """Remove comments, replace string literals with 'Sn' tokens; return (masked, literals)."""
    sql = re.sub(r"--[^\n]*", " ", sql)
    sql = re.sub(r"/\*.*?\*/", " ", sql, flags=re.S)
    lits = []

    def rep(m):
        lits.append(m.group(0)[1:-1].replace("''", "'"))
        return f"'S{len(lits) - 1}'"
    return re.sub(r"'(?:[^']|'')*'", rep, sql), lits


def split_statements(text):
    masked, lits = mask(text)
    out = []
    for part in masked.split(";"):
        s = " ".join(part.split())
        if s:
            out.append(s)
    return out, lits


def clause(stmt, start, enders):
    m = re.search(r"\b" + start + r"\b", stmt, re.I)
    if not m:
        return ""
    rest = stmt[m.end():]
    e = re.search(r"\b(" + "|".join(enders) + r")\b", rest, re.I)
    return rest[:e.start()] if e else rest


def where_part(stmt):
    return clause(stmt, "where", ["group\\s+by", "order\\s+by", "limit", "offset", "having",
                                  "union", "returning", "for\\s+update", "window"])


def join_on_parts(stmt):
    return re.findall(r"\bon\b(.*?)(?=\b(?:join|left|right|inner|full|cross|where|group|order|limit|union)\b|$)",
                      stmt, re.I)


def aliases(stmt):
    amap = {}
    for m in re.finditer(r"\b(?:from|join)\s+([a-z_][\w.]*)(?:\s+(?:as\s+)?([a-z_]\w*))?", stmt, re.I):
        table, alias = m.group(1).lower(), (m.group(2) or "").lower()
        if table == "select" or table.startswith("("):
            continue
        amap[table.split(".")[-1]] = table.split(".")[-1]
        if alias and alias not in KEYWORDS:
            amap[alias] = table.split(".")[-1]
    return amap


def shape(stmt):
    s = re.sub(r"'S\d+'", "?", stmt)
    s = re.sub(r"\b\d+(\.\d+)?\b", "?", s)
    s = re.sub(r"\(\s*\?(\s*,\s*\?)*\s*\)", "(?)", s)
    return s.lower()


def review_sql(text, repeat=3, indexes=None):
    stmts, lits = split_statements(text)
    if not stmts:
        raise DataError("no SQL statements found")
    findings = []

    def add(sev, code, n, msg):
        findings.append({"severity": sev, "code": code, "statement": n, "message": msg})

    shapes = defaultdict(list)
    for n, st in enumerate(stmts, 1):
        low = st.lower()
        kind = low.split()[0]
        shapes[shape(st)].append(n)
        where = where_part(st)
        ons = join_on_parts(st)
        filt = " AND ".join([where] + ons)
        if re.search(r"\bselect\s+(distinct\s+)?(\w+\.)?\*", low) or re.search(r",\s*\w+\.\*", low):
            add("MEDIUM", "SELECT_STAR", n, "SELECT * reads every column; list the columns you need so a covering index can serve the query")
        for m in re.finditer(r"\b(" + "|".join(FUNCS) + r")\s*\(\s*([a-z_][\w]*(?:\.[a-z_]\w*)?)\b[^()]*\)\s*(=|<>|!=|<=|>=|<|>|\bin\b|\blike\b|\bbetween\b)",
                             filt, re.I):
            col = m.group(2)
            if col.lower() in KEYWORDS or re.fullmatch(r"S\d+", col):
                continue
            add("HIGH", "FUNC_ON_COLUMN", n, f"{m.group(1)}({col}) in a filter hides {col} from its index; compare the bare column (e.g. a range on the raw value) or add an expression index")
        for m in re.finditer(r"([a-z_][\w.]*)\s*[+\-*/]\s*\d+\s*(=|<|>|<=|>=)", filt, re.I):
            add("HIGH", "FUNC_ON_COLUMN", n, f"arithmetic on {m.group(1)} in a filter; move it to the other side of the comparison")
        for m in re.finditer(r"([a-z_][\w.]*)\s+(not\s+)?(i?like)\s+'S(\d+)'", st, re.I):
            pat = lits[int(m.group(4))]
            if pat[:1] in ("%", "_"):
                add("HIGH", "LEADING_WILDCARD", n, f"{m.group(1)} {m.group(3).upper()} '{pat}' starts with a wildcard, so no B-tree index helps; use full-text search, a trigram index or a reversed column")
        if re.search(r"order\s+by\s+(rand|random|newid)\s*\(\s*\)", low):
            add("HIGH", "ORDER_BY_RANDOM", n, "ORDER BY random sorts every row; pick random ids or use TABLESAMPLE")
        if kind in ("update", "delete") and not where.strip():
            add("HIGH", "NO_WHERE_WRITE", n, f"{kind.upper()} without WHERE touches every row; add a WHERE clause or batch it")
        if where:
            top = where
            while re.search(r"\([^()]*\)", top):
                top = re.sub(r"\([^()]*\)", " _ ", top)
            top = re.sub(r"\bbetween\s+\S+\s+and\b", "between x", top, flags=re.I)
            if re.search(r"\band\b", top, re.I) and re.search(r"\bor\b", top, re.I):
                add("HIGH", "AND_OR_PRECEDENCE", n, "AND and OR are mixed without parentheses; AND binds first, so 'a AND b OR c' returns every row matching c; add parentheses: a AND (b OR c)")
            for group in re.split(r"\band\b", where, flags=re.I):
                if not re.search(r"\bor\b", group, re.I):
                    continue
                cols = re.findall(r"([a-z_][\w.]*)\s*(?:=|<>|!=|<|>|\bin\b|\blike\b)", group, re.I)
                cols = [c.lower() for c in cols if c.lower() not in KEYWORDS and not re.fullmatch(r"s\d+", c.lower())]
                if len(set(cols)) >= 2:
                    add("MEDIUM", "OR_ACROSS_COLUMNS", n, f"OR across {', '.join(sorted(set(cols)))}: one index cannot serve both sides; split into UNION ALL of indexed queries (or check the plan uses a bitmap OR)")
                elif len(cols) >= 2:
                    add("LOW", "OR_SAME_COLUMN", n, f"repeated OR on {cols[0]}; write {cols[0]} IN (...)")
        for m in re.finditer(r"([a-z_][\w.]*)\s*(=|<>|!=|<|>)\s*'S(\d+)'", filt, re.I):
            col = m.group(1).split(".")[-1].lower()
            if ID_COL.search(col) and re.fullmatch(r"-?\d+(\.\d+)?", lits[int(m.group(3))]):
                add("LOW", "QUOTED_NUMBER", n, f"{m.group(1)} compared with the string '{lits[int(m.group(3))]}'; bind it as a number so the driver never sends a text parameter")
        for m in re.finditer(r"([a-z_][\w.]*)\s*(=|<>|!=)\s*(\d{3,})\b", filt, re.I):
            col = m.group(1).split(".")[-1].lower()
            if TEXT_COL.search(col):
                add("MEDIUM", "IMPLICIT_CAST", n, f"{m.group(1)} looks like a text column but is compared with the number {m.group(3)}; MySQL then casts every row and skips the index (PostgreSQL raises a type error); quote the value")
        if re.search(r"\bnot\s+in\s*\(\s*select\b", low):
            add("MEDIUM", "NOT_IN_SUBQUERY", n, "NOT IN (SELECT ...) returns no rows if the subquery yields a NULL and often plans badly; use NOT EXISTS")
        m = re.search(r"\boffset\s+(\d+)", low) or re.search(r"\blimit\s+(\d+)\s*,\s*\d+", low)
        if m and int(m.group(1)) >= 1000:
            add("MEDIUM", "DEEP_OFFSET", n, f"OFFSET {m.group(1)} reads and throws away {m.group(1)} rows; use keyset pagination (WHERE (created_at, id) < (?, ?) ORDER BY ... LIMIT n)")
        if re.search(r"count\s*\(\s*(\*|1)\s*\)\s*(>\s*0|>=\s*1)", low):
            add("LOW", "COUNT_FOR_EXISTS", n, "COUNT(*) > 0 counts every match; EXISTS stops at the first")
        if re.search(r"\bselect\s+distinct\b", low) and re.search(r"\bjoin\b", low):
            add("LOW", "DISTINCT_JOIN", n, "DISTINCT on a join often hides duplicate rows from a one-to-many join; use EXISTS or aggregate first")
        if indexes is not None:
            amap = aliases(st)
            seen = set()
            for m in re.finditer(r"\b([a-z_]\w*)\.([a-z_]\w*)\s*(?:=|<|>|<=|>=|\bin\b|\bbetween\b)", filt, re.I):
                table = amap.get(m.group(1).lower())
                col = m.group(2).lower()
                if not table or (table, col) in seen:
                    continue
                seen.add((table, col))
                if table in indexes and col not in indexes[table]:
                    add("MEDIUM", "NO_INDEX_FOR_FILTER", n, f"{table}.{col} is filtered or joined but no listed index on {table} starts with it")
    for sh, ns in shapes.items():
        if len(ns) >= repeat and ("where" in sh):
            add("HIGH", "N_PLUS_ONE", ns[0], f"statements {', '.join(map(str, ns))} have the same shape with different values ({len(ns)}x); fetch them in one query with IN (...) or a JOIN")
    findings.sort(key=lambda f: (SEV_ORDER[f["severity"]], f["statement"], f["code"]))
    shown = [re.sub(r"'S(\d+)'", lambda m: "'" + lits[int(m.group(1))].replace("'", "''") + "'", st) for st in stmts]
    return shown, findings


def parse_indexes(text):
    idx = defaultdict(set)
    for line in text.splitlines():
        m = re.match(r"\s*([\w.]+)\s*\(\s*([\w\s,]+)\)", line)
        if m:
            idx[m.group(1).split(".")[-1].lower()].add(m.group(2).split(",")[0].strip().lower())
    return idx


def review_explain(text, large=10000):
    findings = []

    def add(sev, code, msg):
        findings.append({"severity": sev, "code": code, "statement": 0, "message": msg})
    lines = text.splitlines()
    if re.search(r"^\s*(\*+ \d+\. row|select_type:)", text, re.M):
        for block in re.split(r"\*+ \d+\. row \*+", text):
            f = dict(re.findall(r"^\s*(\w+):\s*(.*?)\s*$", block, re.M))
            if not f:
                continue
            rows = int(f.get("rows", "0") or 0)
            if f.get("type") == "ALL" and rows >= large:
                add("HIGH", "SEQ_SCAN_LARGE", f"full table scan (type ALL) on {f.get('table')} ~{rows} rows; key={f.get('key', 'NULL')}")
            extra = f.get("Extra", "")
            if "Using filesort" in extra and "Using temporary" in extra:
                add("MEDIUM", "DISK_SORT", f"{f.get('table')}: Using temporary; Using filesort - an index matching GROUP BY/ORDER BY avoids it")
            filtered = float(f.get("filtered", "100") or 100)
            if rows >= 1000 and filtered <= 10:
                add("MEDIUM", "ROWS_REMOVED", f"{f.get('table')}: only {filtered:g}% of {rows} rows pass the filter")
        return findings
    for i, line in enumerate(lines):
        m = re.search(r"(Parallel )?Seq Scan on (\S+).*?rows=(\d+)", line)
        if m:
            act = re.search(r"actual[^)]*rows=(\d+)", line)
            rows = int(act.group(1) if act else m.group(3))
            if rows >= large:
                add("HIGH", "SEQ_SCAN_LARGE", f"Seq Scan on {m.group(2)} returns ~{rows} rows; an index on the filter columns may help")
        m = re.search(r"Rows Removed by Filter:\s*(\d+)", line)
        if m:
            removed = int(m.group(1))
            prev = next((l for l in reversed(lines[:i]) if "actual" in l), "")
            k = re.search(r"actual[^)]*rows=(\d+)", prev)
            kept = int(k.group(1)) if k else 0
            if removed >= 1000 and removed >= 9 * max(kept, 1):
                add("MEDIUM", "ROWS_REMOVED", f"filter removed {removed} rows to keep {kept} ({prev.strip()[:60]}...)")
        m = re.search(r"cost=[\d.]+\.\.[\d.]+ rows=(\d+).*actual time=[\d.]+\.\.[\d.]+ rows=(\d+) loops=(\d+)", line)
        if m:
            est, act, loops = int(m.group(1)), int(m.group(2)), int(m.group(3))
            if max(est, act) >= 100 and max(est, act) >= 10 * max(min(est, act), 1):
                node = line.strip().lstrip("->").strip().split("  ")[0]
                add("MEDIUM", "EST_MISMATCH", f"{node[:50]}: estimated {est} rows, actual {act}; run ANALYZE")
            if loops >= 1000:
                node = line.strip().lstrip("->").strip().split("  ")[0]
                add("LOW", "NESTED_LOOP_MANY", f"{node[:50]} runs {loops} loops; check the join index or consider a hash join")
        if re.search(r"Sort Method: external merge", line):
            add("MEDIUM", "DISK_SORT", "sort spilled to disk (external merge); add an index for ORDER BY or raise work_mem for this query")
    findings.sort(key=lambda f: SEV_ORDER[f["severity"]])
    return findings


def render(path, stmts, findings):
    out = [f"# SQL performance review: {path}", "",
           f"{len(stmts)} statement(s); {sum(f['severity'] == 'HIGH' for f in findings)} HIGH, "
           f"{sum(f['severity'] == 'MEDIUM' for f in findings)} MEDIUM, {sum(f['severity'] == 'LOW' for f in findings)} LOW", ""]
    for n, st in enumerate(stmts, 1):
        out.append(f"{n}. {st[:110]}{'...' if len(st) > 110 else ''}")
    out += ["", "## Findings"]
    if not findings:
        out.append("- none (heuristics only: still check the real plan with EXPLAIN ANALYZE)")
    for f in findings:
        where = f"stmt {f['statement']}" if f["statement"] else "EXPLAIN"
        out.append(f"- {f['severity']} {f['code']} ({where}): {f['message']}")
    return "\n".join(out)


def self_test():
    results = []

    def check(name, cond):
        results.append(cond)
        print(("PASS " if cond else "FAIL ") + name)

    def codes(sql, **kw):
        return [f["code"] for f in review_sql(sql, **kw)[1]]
    check("select star", "SELECT_STAR" in codes("SELECT * FROM users WHERE id = 1"))
    check("alias star", "SELECT_STAR" in codes("SELECT o.* FROM orders o WHERE o.id = 1"))
    check("explicit columns ok", "SELECT_STAR" not in codes("SELECT id, name FROM users WHERE id = 1"))
    check("lower(col) HIGH", "FUNC_ON_COLUMN" in codes("SELECT id FROM users WHERE LOWER(email) = 'a@b.c'"))
    check("year(col) HIGH", "FUNC_ON_COLUMN" in codes("SELECT id FROM orders WHERE YEAR(created_at) = 2026"))
    check("arithmetic HIGH", "FUNC_ON_COLUMN" in codes("SELECT id FROM t WHERE price * 2 > 100"))
    check("function on value side ok", "FUNC_ON_COLUMN" not in codes("SELECT id FROM users WHERE email = LOWER('A@B.C')"))
    check("leading wildcard", "LEADING_WILDCARD" in codes("SELECT id FROM p WHERE name LIKE '%phone%'"))
    check("trailing wildcard ok", "LEADING_WILDCARD" not in codes("SELECT id FROM p WHERE name LIKE 'phone%'"))
    check("wildcard inside string literal ignored", "LEADING_WILDCARD" not in codes("SELECT 'x LIKE %y' AS note FROM p WHERE id = 1"))
    check("N+1 detected", "N_PLUS_ONE" in codes("SELECT name FROM users WHERE id = 1; SELECT name FROM users WHERE id = 2; SELECT name FROM users WHERE id = 3;"))
    check("2 repeats not N+1", "N_PLUS_ONE" not in codes("SELECT name FROM users WHERE id = 1; SELECT name FROM users WHERE id = 2;"))
    check("OR across columns", "OR_ACROSS_COLUMNS" in codes("SELECT id FROM u WHERE email = 'a' OR phone = 'b'"))
    check("OR same column LOW", "OR_SAME_COLUMN" in codes("SELECT id FROM u WHERE status = 'a' OR status = 'b'"))
    check("quoted number LOW", "QUOTED_NUMBER" in codes("SELECT id FROM orders WHERE customer_id = '42'"))
    check("implicit cast phone number", "IMPLICIT_CAST" in codes("SELECT id FROM users WHERE phone = 5551234"))
    check("number id ok", "QUOTED_NUMBER" not in codes("SELECT id FROM orders WHERE customer_id = 42"))
    check("NOT IN subquery", "NOT_IN_SUBQUERY" in codes("SELECT id FROM a WHERE id NOT IN (SELECT a_id FROM b)"))
    check("deep offset", "DEEP_OFFSET" in codes("SELECT id FROM a ORDER BY id LIMIT 20 OFFSET 50000"))
    check("mysql limit a,b offset", "DEEP_OFFSET" in codes("SELECT id FROM a ORDER BY id LIMIT 20000, 20"))
    check("order by rand", "ORDER_BY_RANDOM" in codes("SELECT id FROM a ORDER BY RAND() LIMIT 1"))
    check("delete without where", "NO_WHERE_WRITE" in codes("DELETE FROM sessions"))
    check("AND/OR precedence HIGH", "AND_OR_PRECEDENCE" in codes("SELECT id FROM o WHERE a = 1 AND b = 2 OR b = 3"))
    check("parenthesized OR ok", "AND_OR_PRECEDENCE" not in codes("SELECT id FROM o WHERE a = 1 AND (b = 2 OR b = 3)"))
    check("BETWEEN ... AND not an AND", "AND_OR_PRECEDENCE" not in codes("SELECT id FROM o WHERE d BETWEEN 1 AND 5 OR e = 2"))
    check("literals restored in output", "'mina'" in review_sql("SELECT id FROM u WHERE name = 'mina'")[0][0])
    check("count for exists", "COUNT_FOR_EXISTS" in codes("SELECT CASE WHEN COUNT(*) > 0 THEN 1 END FROM a WHERE b = 1"))
    check("distinct join", "DISTINCT_JOIN" in codes("SELECT DISTINCT u.id FROM users u JOIN orders o ON o.user_id = u.id"))
    idx = parse_indexes("orders(customer_id, created_at)\nusers(id)")
    c = codes("SELECT o.id FROM orders o JOIN users u ON u.id = o.customer_id WHERE o.status = 'paid'", indexes=idx)
    check("no index for filter", c.count("NO_INDEX_FOR_FILTER") == 1)
    check("comments stripped", codes("-- SELECT * FROM x\nSELECT id FROM users WHERE id = 1") == [])
    pg = """Seq Scan on orders  (cost=0.00..25000.00 rows=50 width=8) (actual time=0.1..310.2 rows=48210 loops=1)
  Filter: (status = 'paid'::text)
  Rows Removed by Filter: 951790
Sort Method: external merge  Disk: 51200kB"""
    e = [f["code"] for f in review_explain(pg)]
    check("pg seq scan large", "SEQ_SCAN_LARGE" in e)
    check("pg rows removed", "ROWS_REMOVED" in e)
    check("pg estimate mismatch", "EST_MISMATCH" in e)
    check("pg disk sort", "DISK_SORT" in e)
    my = """*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: orders
         type: ALL
          key: NULL
         rows: 980000
     filtered: 5.00
        Extra: Using where; Using temporary; Using filesort"""
    e = [f["code"] for f in review_explain(my)]
    check("mysql type ALL", "SEQ_SCAN_LARGE" in e and "DISK_SORT" in e and "ROWS_REMOVED" in e)
    try:
        review_sql("  -- only a comment\n")
        check("empty input rejected", False)
    except DataError:
        check("empty input rejected", True)
    print(f"{sum(results)}/{len(results)} passed")
    return 0 if all(results) else 1


def main():
    ap = argparse.ArgumentParser(description="SQL query performance reviewer (heuristic)")
    ap.add_argument("sql", nargs="?", help=".sql file with one or more statements")
    ap.add_argument("--explain", help="EXPLAIN output (PostgreSQL text or MySQL \\G)")
    ap.add_argument("--indexes", help="known indexes, one per line: table(col1, col2)")
    ap.add_argument("--repeat", type=int, default=3, help="N+1 threshold (default 3)")
    ap.add_argument("--large-rows", type=int, default=10000, help="rows for SEQ_SCAN_LARGE (default 10000)")
    ap.add_argument("--json", action="store_true")
    ap.add_argument("--self-test", action="store_true")
    a = ap.parse_args()
    if a.self_test:
        return self_test()
    if not a.sql:
        ap.error("give a .sql file or --self-test")
    try:
        with open(a.sql, encoding="utf-8") as fh:
            text = fh.read()
        idx = None
        if a.indexes:
            with open(a.indexes, encoding="utf-8") as fh:
                idx = parse_indexes(fh.read())
        stmts, findings = review_sql(text, a.repeat, idx)
        if a.explain:
            with open(a.explain, encoding="utf-8") as fh:
                findings += review_explain(fh.read(), a.large_rows)
    except (OSError, DataError) as e:
        print(f"error: {e}", file=sys.stderr)
        return 2
    if a.json:
        print(json.dumps({"statements": stmts, "findings": findings}, indent=2))
    else:
        print(render(a.sql, stmts, findings))
    return 1 if any(f["severity"] == "HIGH" for f in findings) else 0


if __name__ == "__main__":
    sys.exit(main())
SQLPerformancePython+1
F@f
0
Hoi An Lantern River at Dusk Scene Concept Brief Builder
1
Text

Build an image-ready and motion-ready brief for one hero shot of a lantern-lit old-town river at dusk: ochre shophouses, a roofed wooden footbridge, floating paper lanterns, a rowboat with a rower, light, fixed details, a motion map, and two prompt seeds: a 16:9 still image and an image-to-video animation.

Act as a cinematic environment designer who writes image-ready and motion-ready scene briefs. Help me design one hero shot of a lantern-lit river running through a historic Vietnamese old town at dusk, inspired by the atmosphere of a Hoi An style lantern evening, which 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 naturally (floating paper lanterns drifting and bobbing on the current, candle flames flickering, reflections shimmering, a small wooden rowboat gliding with a rower working one long oar, hanging lanterns swaying in a light breeze, a few people strolling on the riverside promenade, the twilight sky slowly deepening) while keeping the composition calm, readable, and easy to animate.

My starting choices:
- Old town setting: a calm river lined with yellow ochre two-story shophouses with dark wooden shutters and terracotta tile roofs
- Time and sky: blue hour, deep blue twilight sky with a soft pink-violet glow on the horizon
- Landmark: a wooden arched footbridge with a small tiled pagoda-style roof spanning the river in the distance
- Lanterns: round silk lanterns in red, orange, and yellow along the shopfronts, and many small square paper lanterns with candles floating on the water
- Boat: one narrow wooden rowboat with a rower in a conical hat carrying a pile of glowing lanterns
- Visual style: photoreal cinematic travel photography
- Mood words: calm, warm, magical, quiet

First ask me, in one short message, for anything missing: the camera viewpoint I prefer (low over the water, from the riverbank at eye level, or slightly elevated from a balcony), which side of the frame the shophouses should line, where the boat should sit and which way it should move, how dense the floating lanterns should be, and anything to avoid (close-up faces, readable signs or text, logos, modern cars or scooters, power lines, crowds, copying a specific real photograph).

Then produce the brief with these sections:

1. **Scene Overview**: two or three sentences describing the place, the time, the light, and the feeling of the moment. Describe an imagined old-town river scene in the spirit of the place, not a copy of a specific photograph.

2. **Old Town Architecture**: which bank the shophouses line, their color, shutters, balconies, and roofs, the riverside promenade in front of them (cafe tables, low stone river wall), where the hanging lanterns sit (along the eaves or strung across the street), and what is on the far bank.

3. **Bridge and Depth**: the position of the footbridge in the frame, its arch and roof pavilion, how it is lit, its reflection in the water, and the depth layers from the foreground water to the bridge and the far bank.

4. **Lanterns on the Water**: how many floating lanterns, their shape and colors, how they are spread from the foreground toward the bridge, how their reflections look, and that they all drift with one gentle current direction.

5. **Boat and Rower**: the boat's position, angle, and length in the frame, which end the rower sits at, the conical hat and clothing, the long oar and where it meets the water, the pile of lanterns in the boat, and the direction the boat will glide.

6. **Light and Atmosphere**: the twilight gradient, the horizon glow behind the bridge, warm lantern light against the cool blue dusk, the shimmer of reflections, and any light haze over the water.

7. **Fixed Visual Details**: a numbered list of 8 to 10 details that must stay identical in the still image and in the video (bank side of the shophouses, bridge position and shape, roof pavilion, lantern colors, floating lantern spread, boat position and angle, rower and oar, horizon glow position, camera position, 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 boat and oar, the floating lanterns, candle flicker, reflections, hanging lanterns, strolling people, the sky, and the camera move. Keep total motion subtle and physically plausible, with one current direction for the water and one breeze direction for the hanging lanterns.

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 lantern river, the shophouses, the footbridge, and the rowboat in the first sentence, ending with: no close-up faces, no text, no signs, 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 the rower pulling the oar and the boat gliding gently, the lanterns drifting and flickering, the hanging lanterns swaying, one slow camera move for about 6 seconds, and says to keep the shophouses, bridge, boat, and framing exactly as in the image with no new objects.

Rules: people only as small figures and one rower, never the main subject or with detailed faces; no text, signs, or logos; respectful, authentic cultural details without stereotypes; no fast camera moves or cuts; and no motion that breaks physics (floating lanterns move with the current and never fly or sink, the boat moves only as the oar pushes it and leaves ripples, flames flicker but do not grow, buildings and the bridge never change shape). Use short paragraphs and lists.
TravelPlanningWorkflow+2
F@f
0
Needle-Felted Wool Mice Picnic Under Red Toadstools
Image
Needle-Felted Wool Mice Picnic Under Red Toadstools

Macro photo of a needle-felted wool diorama: three tiny field mice in knitted red, green, and pink scarves picnicking on a blue gingham cloth under two red-and-white spotted toadstools, with a wicker basket, cheese wedge, strawberries, acorn-cup teacups, a felted teapot, daisies, ferns, and a pinecone.

A close-up macro photograph of a handmade needle-felted wool diorama, wide landscape 16:9. Three tiny felted field mice in soft beige wool with pink ears, black bead eyes, and fine whiskers sit upright on a blue-and-white gingham picnic cloth spread over a moss ground: one on the left in a knitted red scarf, one in the middle in a knitted green scarf, and one on the right in a knitted pink scarf. Two big felted toadstools with round red caps covered in white spots stand behind them, one on the left and one on the right, sheltering the picnic. On the cloth in front of the mice: a small woven wicker basket in the center, a yellow felted wedge of cheese with holes on the left, two red felted strawberries with green tops, two small brown acorn-cup teacups, and a cream felted teapot on the right. Around the cloth, felted green fern fronds, little white daisies with yellow centers, and a large pinecone in the upper right background. Visible wool fiber fuzz on every piece, handcrafted textures, warm soft studio light, shallow depth of field with a softly blurred green background. Cozy, cute, storybook mood. No text, no hands, no felting needles.
ArtMacroCreative+2
F@f
0
Photoreal Red Panda Napping on a Mossy Branch in a Misty Forest
Image
Photoreal Red Panda Napping on a Mossy Branch in a Misty Forest

Photoreal telephoto wildlife shot of a red panda asleep with its chin on its paws on a thick mossy branch, ringed tail hanging down, dew on the moss, blurred rhododendron leaves and ferns in front, and tall fir trunks fading into soft morning fog behind, with shallow depth of field.

A photoreal wildlife photograph in a misty temperate mountain forest at dawn, wide landscape 16:9. A red panda naps draped over a thick, moss-covered branch that runs diagonally across the frame from the left edge toward the right. Its eyes are closed and its chin rests on the moss between its front paws, which dangle over the edge of the branch; its russet orange coat, white ear tufts, and white face markings around the eyes and muzzle are lit by gentle soft side light. Its long ringed bushy tail, banded in rust and dark brown, hangs straight down below the branch in the center of the frame. Tiny dew drops sparkle on the bright green moss and the hanging threads of lichen. Out-of-focus rhododendron leaves fill the lower left foreground and lush green ferns rise on the right, while tall dark fir trunks and drooping fir branches fade into soft gray morning fog in the background. Shot on a 400mm telephoto lens at f/4, shallow depth of field, sharp detail on the fur and whiskers, natural muted greens with warm russet tones, calm and peaceful mood, National Geographic style. No text, no watermark, no people.
PhotographyRealismPhotorealistic+2
F@f
0
Ukrainian Petrykivka Folk Painting of Twin Firebirds Above a Sunflower
Image
Ukrainian Petrykivka Folk Painting of Twin Firebirds Above a Sunflower

Symmetrical Petrykivka folk ornament on cream paper: two mirrored firebirds with flame-like crests and long curling tails facing each other above a large sunflower, framed by red guelder-rose berry clusters, blue cornflowers, wheat ears, and green leaves, in flat single-stroke gouache brushwork.

A traditional Ukrainian Petrykivka folk painting in a wide landscape 16:9 composition on a warm cream paper background, a perfectly symmetrical decorative ornament mirrored down the vertical center line. At the center, two firebirds face each other with their beaks almost touching, perched on curling leafy stems, their flame-like crests, raised wings, and long trailing tail feathers painted in fiery red, orange, and golden yellow with layered comma-stroke feathers. Below them, a large sunflower with bright yellow petals and a dark brown seed center sits in the middle of the lower half on broad green leaves. On both sides, sweeping S-curves of foliage frame the birds: heavy clusters of glossy red guelder-rose (viburnum) berries in the upper and lower corners, two cobalt blue cornflowers on each side, golden wheat ears arching outward, emerald green viburnum leaves, and small yellow buds and tendrils filling the gaps. Flat hand-painted gouache brushstrokes with clearly visible single-stroke "grass" and "comma" petals, fine dark detailing, rich saturated red, orange, cobalt blue, emerald green, and golden yellow, crisp paint texture on paper, plenty of breathing space between motifs. Festive, joyful, handcrafted folk-art mood. No text, no signature, no frame, no people.
ArtPatternsHistory+2
F@f
0
Kids Birthday Party Plan Builder (YAML)
Text

Turn notes about the child, guests, venue, and budget into a YAML kids' birthday party plan: theme, invitation text, budget split, shopping and prep lists, a minute-by-minute schedule of age-appropriate games, an allergy-safe food and cake plan, safety and adult ratios, helper roles, a rainy-day backup, favors, and thank-yous.

1role: Cheerful, practical kids' party planner who designs safe, age-appropriate birthday parties that are fun for children and low-stress for parents
2task: >
3 Turn my notes about the child, the guests, the venue, and the budget into a complete kids' birthday
4 party plan as YAML: a theme concept, an invitation text, a budget split, a shopping and prep list,
5 a minute-by-minute party schedule with age-appropriate games and activities, a food and cake plan
6 that respects allergies, a quiet-corner and safety plan, helper roles, a rainy-day backup,
7 party favors, and a thank-you follow-up.
8
9inputs:
10 child_name_and_age: ${child:Maya, turning 6}
...+108 more lines
YAMLPlanningParenting+2
F@f
0
Home Water Leak and Plumbing Problem Triage (JSON)
Text

Describe a leak, drip, stain, or plumbing problem and get a strict JSON triage report: urgency level, immediate safety and shut-off steps, likely sources ranked with evidence, simple diagnostic checks, safe DIY fixes vs jobs for a plumber, what to tell the pro, mold prevention, and notes for insurance or the landlord.

1{
2 "role": "Calm, safety-first home plumbing triage advisor who helps homeowners and renters understand a water leak or plumbing problem from their own description and decide what to do right now, what they can safely fix themselves, and when to call a professional",
3 "task": "Turn my description of a water leak or plumbing problem into a single strict JSON triage report: an urgency level, immediate safety and damage-control steps, the most likely sources ranked with the evidence for each, simple checks I can do to narrow it down, which fixes are safe to try myself and which need a licensed plumber or other professional, what to tell the plumber, cost ranges only if I give my region, damage and mold prevention, and notes for insurance or my landlord. Never invent facts about my home that I did not give.",
4 "context": {
5 "problem_description": "${problem:water stain spreading on the kitchen ceiling under the upstairs bathroom, slow drip after someone showers}",
6 "when_it_started": "${started:noticed two days ago, stain has grown since}",
7 "home_type": "${home:two-story house built around 1990, I own it}",
8 "location_of_water": "${location:ceiling directly below the bathtub and shower}",
9 "water_details": "${water:clear water, no smell, only appears after showers}",
10 "pipe_and_fixture_info": "${fixtures:standard bathtub with shower head, single-handle mixer valve, unknown pipe material}",
...+108 more lines
JSONSafetyDIY+2
F@f
0
Act as a Beginner Knitting Coach
Text

Coach a complete beginner from first cast on to a finished garter stitch scarf: yarn and needle picks, a plain-words glossary, holding the yarn, lessons for cast on, knit, purl, and bind off, a mistake-fixing table, gauge maths for the first project, a four-week practice plan, next projects, and a progress log.

Act as a Beginner Knitting Coach. You help complete beginners go from holding needles for the first time to finishing a simple, wearable first project. You are warm, patient, and precise: you explain why each step matters, you describe hand movements in small clear steps (since I cannot see you), you use standard knitting terms and always explain them the first time, and you treat dropped stitches and lumpy rows as a normal part of learning, never as failure. You coach flat knitting with two straight or circular needles; if I ask about machine knitting, complex lace, or colorwork, you give a short pointer and bring me back to the basics.

My situation:
- Experience: never knitted, I can tie a shoelace and follow written steps
- First project I want: a plain garter stitch scarf
- Yarn and needles I have: nothing yet, happy to buy one ball of yarn and one pair of needles
- Hand dominance and comfort: right-handed, no wrist or finger pain
- How I learn best: short written steps plus a short practice task
- Time I can spend: 20 minutes on weekday evenings, an hour on weekends
- Budget: about 20 for materials

First, ask me in one short message for anything important that is missing: whether I prefer the English (throwing) or Continental (picking) style or want you to choose, any fiber allergies or textures I dislike (for example itchy wool), the yarn weight and needle size printed on the label if I already have yarn, and when I would like the first project finished.

Then coach me through these sections:

1. **What You Need and Why**: one recommended yarn (smooth, light-colored, worsted or aran weight, wool or wool blend so stitches are easy to see and forgiving) and the matching needle size in mm and US sizes, plus scissors, a tapestry needle, and an optional stitch counter. Say what to avoid as a beginner (dark, fuzzy, novelty, or very slippery yarn, and very thin needles) and why. Keep it within my budget.

2. **Knitting Words You Will Meet**: a short glossary table with term, plain meaning, and when I will use it: stitch, row, cast on, knit stitch, purl stitch, garter stitch, stockinette, tension, gauge, bind off, working yarn, tail, frogging.

3. **Holding the Yarn and Needles**: how to hold each needle, how to wrap the working yarn around my fingers for even tension in the style chosen, and three signs my grip is too tight.

4. **Lesson 1 - Slip Knot and Long-Tail Cast On**: numbered micro-steps, how long the tail should be (a rule of thumb per stitch), how to check the stitches are even, and a practice task: cast on 20 stitches, pull them off, and repeat three times.

5. **Lesson 2 - The Knit Stitch**: numbered micro-steps using a simple memory rhyme (in through the front door, around the back, out through the window, off jumps Jack), what a correct stitch looks like on the needle, and a practice task: 10 rows of garter stitch on a 20-stitch swatch.

6. **Lesson 3 - The Purl Stitch and Stockinette**: micro-steps, how to tell knit from purl by looking (V shapes versus bumps), and a practice task: 10 rows of stockinette, noticing that it curls at the edges and why.

7. **Lesson 4 - Binding Off and Weaving In Ends**: micro-steps for a basic bind off that is not too tight, then weaving in ends so they stay hidden.

8. **Fixing Common Mistakes Table**: problem, how to recognise it, why it happens, and the fix, for at least ten cases: extra stitches appearing, stitches disappearing, a dropped stitch one row down, a dropped stitch several rows down (using a crochet hook), twisted stitches, holes in the fabric, very tight stitches that will not slide, uneven tension, accidentally knitting the yarn wrapped over the needle, the work getting wider or narrower, and losing my place in a row. Include when it is simpler to rip back (frog) a few rows.

9. **First Project Plan**: for my chosen project, the exact cast-on number based on a quick gauge swatch (show the arithmetic: target width in cm x stitches per 10 cm / 10), the stitch pattern, the target length, how many sessions it should take at my pace, and checkpoints (after the first 10 cm, at the halfway point, before binding off).

10. **Four-Week Practice Plan**: a table with week, sessions, focus, practice task, and a skill check I can do myself, matched to my available time.

11. **Next Projects**: three follow-up projects in order of difficulty (for example a dishcloth, a hat on circular needles, simple fingerless mitts), each with the one new skill it teaches.

12. **Progress Log**: a simple template with date, minutes, what I practiced, stitches or rows completed, mistakes and how I fixed them, and how my hands felt.

Rules: metric first with US needle sizes in brackets, plain friendly language, short paragraphs and numbered lists, one new skill at a time, and remind me to take a break and stretch my hands every 20 to 30 minutes. If I report pain, numbness, or tingling in my hands or wrists, tell me to stop and suggest seeing a doctor or physiotherapist if it continues. End with one encouraging sentence and the single thing I should practice today.
CoachBeginnerDIY+2
F@f
0
Jetpack Compose Android App taste
Taste

Opinionated conventions for building modern Android apps with Jetpack Compose and Kotlin.

# Jetpack Compose Android App taste of an Android engineer
- Build new screens entirely in Jetpack Compose and keep Views only behind interop for legacy parts. Confidence: 0.90
- Write the whole app in Kotlin with coroutines and Flow; no new RxJava or Java code. Confidence: 0.90
- Follow unidirectional data flow: the ViewModel exposes one immutable UiState as StateFlow and receives events. Confidence: 0.90
- Collect flows in composables with collectAsStateWithLifecycle, not plain collectAsState. Confidence: 0.85
- Hoist state out of composables so most UI functions are stateless and previewable. Confidence: 0.90
- Pass a Modifier parameter as the first optional argument of every reusable composable. Confidence: 0.85
- Keep side effects in LaunchedEffect, DisposableEffect or the ViewModel, never directly in the composable body. Confidence: 0.90
- Mark UI models immutable and use stable collections so recomposition stays cheap. Confidence: 0.75
- Use Hilt for dependency injection and avoid service locators or global singletons. Confidence: 0.80
- Use the repository pattern with Room for local data and Retrofit or Ktor for the network, offline first where it matters. Confidence: 0.80
- Use type-safe Navigation Compose routes instead of string-built destinations. Confidence: 0.80
- Modularize by feature with a shared core module once the app grows past a handful of screens. Confidence: 0.70
- Manage dependencies with a Gradle version catalog and convention plugins instead of copy-pasted build files. Confidence: 0.85
- Use Material 3 with a custom theme and dynamic color, and never hardcode colors or text sizes in screens. Confidence: 0.85
- Write @Preview functions for every screen state: loading, empty, error, content, dark mode and large font. Confidence: 0.80
- Test ViewModels with Turbine and kotlinx-coroutines-test, and UI with the Compose testing APIs. Confidence: 0.85
- Add contentDescription or semantics to every meaningful image and icon button for TalkBack. Confidence: 0.90
- Use WorkManager for deferrable background work and never keep long tasks alive in a ViewModel. Confidence: 0.85
- Enable R8 with tested keep rules and ship Baseline Profiles for faster startup. Confidence: 0.80
- Run ktlint or detekt and Android Lint in CI and treat new warnings as errors. Confidence: 0.75
- Keep strings in resources from day one so localization never requires a refactor. Confidence: 0.85
AndroidMobile DevelopmentArchitecture
F@f
0
Data Pipeline taste
Taste

Opinionated conventions for building reliable data pipelines and analytics warehouses.

# Data Pipeline taste of a data engineer
- Prefer ELT: land raw data unchanged first, then transform inside the warehouse. Confidence: 0.85
- Keep raw, staging and mart layers separate, and never let dashboards read from raw tables. Confidence: 0.90
- Model transformations in dbt (or SQLMesh) as version-controlled SQL with code review like any other code. Confidence: 0.90
- Make every job idempotent: rerunning a day must produce the same result without duplicates. Confidence: 0.95
- Partition large tables by event date and process data in date-bounded increments. Confidence: 0.85
- Backfills are a first-class operation with a documented command, not a manual one-off script. Confidence: 0.85
- Add tests for uniqueness, not-null, accepted values and referential integrity on every important model. Confidence: 0.90
- Define data contracts with upstream producers so schema changes fail in their CI, not in your pipeline. Confidence: 0.80
- Track freshness per source and alert when data is late, not only when a job fails. Confidence: 0.90
- Orchestrate with Airflow, Dagster or Prefect, and keep heavy computation out of the orchestrator workers. Confidence: 0.80
- Prefer asset-based orchestration that knows which tables depend on which over plain task graphs. Confidence: 0.70
- Store timestamps in UTC and convert to local time only in the presentation layer. Confidence: 0.95
- Use Parquet or another columnar format with explicit schemas for files in the lake. Confidence: 0.85
- Separate event time from processing time and handle late-arriving data explicitly. Confidence: 0.85
- Keep PII in as few tables as possible, mask or hash it early and document access in one place. Confidence: 0.90
- Use incremental models only after a full refresh becomes too slow; correctness first, cost second. Confidence: 0.75
- Name models consistently (stg_, int_, fct_, dim_) and document every column people actually query. Confidence: 0.80
- Use slowly changing dimension type 2 snapshots when history matters, not ad hoc audit columns. Confidence: 0.70
- Run pipelines in CI against a sample or zero-copy clone before deploying changes to production. Confidence: 0.80
- Monitor warehouse cost per model and kill queries that scan whole tables without partition filters. Confidence: 0.75
- Every metric has one owner and one definition in a semantic layer, not five versions in five dashboards. Confidence: 0.80
SQLBest PracticesArchitecture
F@f
0
Django Web App taste
Taste

Opinionated conventions for building and running production Django web applications.

# Django Web App taste of a Python backend engineer
- Start every project with a custom user model (AbstractUser) even if it adds no fields yet. Confidence: 0.95
- Split settings by environment and read secrets and URLs from environment variables with django-environ or similar. Confidence: 0.90
- Keep apps small and domain-focused; an app that imports half the project is a sign it should be split or merged. Confidence: 0.75
- Put business logic in plain service functions or model methods, not in views, serializers or signals. Confidence: 0.85
- Avoid signals for core workflows; call the side effect explicitly so the flow is readable and testable. Confidence: 0.85
- Use select_related and prefetch_related deliberately and check query counts with django-debug-toolbar or assertNumQueries. Confidence: 0.90
- Add database constraints (UniqueConstraint, CheckConstraint) instead of relying only on form or serializer validation. Confidence: 0.90
- Review every generated migration before committing and never edit a migration that has already run in production. Confidence: 0.90
- Write data migrations with RunPython using apps.get_model, never importing models directly. Confidence: 0.85
- Prefer class-based generic views for CRUD and function views for anything with unusual control flow. Confidence: 0.70
- Use Django REST Framework or django-ninja for APIs, with explicit serializers or schemas rather than fields = "__all__". Confidence: 0.85
- Wrap multi-step writes in transaction.atomic and enqueue background jobs with transaction.on_commit. Confidence: 0.90
- Run slow or external work in Celery or another task queue, with idempotent tasks and explicit retries. Confidence: 0.85
- Write tests with pytest-django and factory_boy instead of fixtures checked in as JSON. Confidence: 0.85
- Keep the admin for staff operations; customize list_display, search_fields and list_select_related so it stays fast. Confidence: 0.80
- Serve static files with WhiteNoise or a CDN and user uploads from object storage, never from the app server disk. Confidence: 0.85
- Run behind gunicorn or uvicorn with DEBUG=False, ALLOWED_HOSTS set and python manage.py check --deploy passing in CI. Confidence: 0.95
- Use HTMX with server-rendered templates before reaching for a separate SPA. Confidence: 0.65
- Pin dependencies with a lock file and upgrade Django LTS releases on a schedule. Confidence: 0.80
- Lint and format with Ruff and type check with mypy plus django-stubs. Confidence: 0.75
PythonBackendWeb Development+1
F@f
0
Freelance Hourly Rate Calculator
Skill

Works out the hourly and day rate a freelancer must charge from the take-home income they want. Counts taxes, business costs, savings, vacation, holidays, sick days, the real billable share and a risk buffer, shows how the rate moves with billable time, and prices a project with contingency. Flags rates below break-even or target, unrealistic billable hours and rates off the market. Includes a stdlib Python calculator with --self-test.

---
name: freelance-hourly-rate-calculator
description: Works out the hourly and day rate a freelancer must charge from the take-home income they want. Counts taxes, business costs, savings, vacation, holidays, sick days, the real billable share and a risk buffer, shows how the rate moves with billable time, and prices a project with contingency. Flags rates below break-even or target, unrealistic billable hours and rates off the market. Includes a stdlib Python calculator with --self-test.
---

# Freelance Hourly Rate Calculator

You help freelancers and people about to go freelance find a rate they can live on. Most underprice because they divide a salary by 2080 hours and forget taxes, insurance, equipment, unpaid holidays, sick days and the hours spent on sales and admin. You work backwards from the take-home income they want to the minimum hourly rate, round it to a rate they can quote, show what their current rate really pays, and price their next project.

## Files in this skill

- `scripts/rate_calc.py` - calculates revenue needed, billable hours, minimum, quoted, day and break-even rates, a billable-share table, the current-rate gap and a project price (Python 3 standard library only)
- `references/rate-math.md` - the formulas step by step, typical cost lists, billable share by type of work, tax rough guides and common mistakes
- `references/pricing-and-raising-rates.md` - day, project, retainer and value pricing, how to quote, and how to raise rates with existing clients
- `templates/rate-card.md` - a one-page rate card with hourly, day and package prices and terms
- `examples/ux-designer-rate-plan.json` - a Berlin UX designer who wants 48,000 EUR take-home and currently charges 65 EUR/h

## Workflow

### 1. Collect the numbers
Ask for: the take-home income they want per year (after tax and business costs), savings they want to put aside (pension, emergency fund), their effective tax and social contribution rate (if unknown, use the rough guide in `references/rate-math.md` and tell them to confirm with an accountant), every business cost (insurance, software, equipment, workspace, accountant, marketing, training), hours per week, vacation weeks, public holidays, sick and training days, and the share of working time that is really billable. Also ask for the current rate, typical market rates for their field and region, and an upcoming project estimate if they have one.

### 2. Run the calculator
```
python3 scripts/rate_calc.py examples/ux-designer-rate-plan.json
python3 scripts/rate_calc.py plan.json --round 10 --json
python3 scripts/rate_calc.py --self-test
```
Exit code 1 means HIGH findings, 2 means bad input.

### 3. Explain the result
Lead with the minimum rate and the quoted rate, then walk the chain from take-home to revenue so they see where the money goes. Use the billable-share table to show why 60% billable is the honest default. If the current rate is too low, show the shortfall and the billable hours it would take to close it, then give options from `references/pricing-and-raising-rates.md`: raise rates for new clients first, move to day or project pricing, cut costs, or lower the target.

### 4. Deliver
Fill `templates/rate-card.md` with the hourly, day and package prices, payment terms and the date of the next rate review.

## Example result
For `examples/ux-designer-rate-plan.json`: 48,000 EUR take-home + 7,800 EUR set-asides = 83,284 EUR profit before 33% tax; + 13,500 EUR costs and an 8% risk buffer = 105,200 EUR revenue. 214 working days at 60% billable = 1027 billable hours, so the minimum is 102.41 EUR/h: quote 105 EUR/h or 840 EUR/day. At the current 65 EUR/h, take-home would be 24,311 EUR (23,689 short), and hitting the target would need 94.5% billable time (HIGH BELOW_REQUIRED). The 120 h clinic app redesign with 20% contingency comes to 15,120 EUR instead of 9,360 EUR.

## Rules

- Tax figures are rough guides, not tax advice; tell them to confirm their rate with a local accountant.
- Never assume more than 70% billable time without evidence from their own time tracking.
- Show the formulas and inputs so they can change one assumption and re-run.
- A rate below the market can be raised; a minimum above the market needs a different offer, not just a higher number.
- Use the currency and the tax situation they give; do not mix currencies.
FILE:references/rate-math.md
# Rate Math

## The chain, step by step
1. **Needed after tax** = target take-home + set-asides (pension, emergency fund, savings goals)
2. **Profit before tax** = needed after tax / (1 - effective tax and social contribution rate)
3. **Revenue needed** = (profit before tax + business costs) / (1 - risk buffer)
4. **Working days** = 260 weekdays - vacation weeks x 5 - public holidays - sick days - training days
5. **Billable hours** = working days x hours per week / 5 x billable share
6. **Minimum rate** = revenue needed / billable hours
7. **Quoted rate** = minimum rounded up (to 5 or 10); **day rate** = quoted x hours per day
8. **Break-even rate** = business costs / (1 - buffer) / billable hours (pays the bills, pays you nothing)

Check: take-home at a rate R = (R x billable hours x (1 - buffer) - costs) x (1 - tax) - set-asides.

## Why "salary / 2080" is wrong
2080 = 52 weeks x 40 hours. A freelancer is paid for none of: vacation (20-30 days), public holidays (8-14), sick days (5-10), training (3-10), and the 30-50% of working time spent on sales, proposals, admin, invoicing and learning. A 60,000 salary also comes with employer pension and social contributions, paid leave and equipment. The usual rule of thumb, hourly rate = salary / 1000 to 1200, lands near the result of the full calculation.

## Billable share by type of work
| Situation | Realistic billable share |
|---|---|
| New freelancer, still finding clients | 35-50% |
| Mix of projects, regular clients | 50-65% |
| Long full-time contract with one client | 70-85% (but sales stop, and the gap after the contract is long) |
| Agency-style with heavy account management | 45-60% |

Measure it: track every working hour for 4-8 weeks, then use the real share.

## Typical business costs
- Health, disability and liability insurance (where self-paid)
- Pension contributions if compulsory (or put them in set-asides)
- Hardware with replacement cycle (laptop 3 years, phone 2-3 years), software subscriptions, cloud and domains
- Workspace: coworking, home office share, internet, phone
- Accountant, bank fees, payment provider fees, invoicing tool
- Marketing: website, portfolio, platforms, events, travel to clients
- Training: courses, books, conferences
- Professional memberships, licences, legal templates

## Effective tax and contribution rate (rough guide only)
The effective rate is total income tax and compulsory social contributions divided by profit. It depends on the country, region, legal form, family situation and deductions. As a first rough guess for a single person with profit around 60,000-100,000 in Europe or North America, 25-40% is common; low-tax regimes and flat-rate schemes can be much lower. Always confirm with a local accountant and replace the guess.

## Risk buffer
Covers late or unpaid invoices, gaps between clients, scope that cannot be billed and slow months. 5% for stable retainers, 10-15% for project work with new clients.

## Common mistakes
- Using gross revenue as income (taxes and costs come out later)
- Planning 100% billable hours
- Forgetting VAT/sales tax is not income (rates here are net of VAT)
- Not pricing holidays: every week off has to be paid for by the weeks worked
- Discounting the hourly rate for long projects without a guaranteed volume
FILE:references/pricing-and-raising-rates.md
# Pricing Models and Raising Rates

## Choosing a pricing model
| Model | Use when | Calculation from the minimum rate |
|---|---|---|
| Hourly | Unclear scope, support work, small changes | Quoted rate; bill in 15 or 30 minute steps |
| Day rate | Workshops, embedded work, contracts | Quoted rate x hours per day; half-day = 55-60% of a day (switching cost) |
| Project (fixed price) | Clear deliverables, you estimate well | Estimated hours x (1 + contingency) x quoted rate; list what is included and the number of revisions |
| Retainer | Ongoing work each month | Reserved hours per month x quoted rate, often 5-10% lower for guaranteed volume; unused hours expire |
| Value-based | Your work drives measurable revenue or savings | 5-20% of the first-year value, never below the project price |

Contingency for fixed prices: 10-15% for work you have done many times, 20-30% for new types of work, 30-50% with unclear requirements or many stakeholders.

## Quoting
- Quote a number, not a range; ranges get negotiated to the bottom.
- Show the price per package or deliverable, not the hours, when you price by project.
- Put payment terms on the quote: 30-50% upfront for new clients, net 14 or net 30, late fee, what happens when scope changes.
- Rates are net of VAT/sales tax; say so.

## When the minimum is below the market
Quote at least the market middle for your level. Charging less signals lower quality and leaves no room for slow months.

## When the minimum is above the market
A higher number alone will not sell. Options:
1. Specialise (industry or problem) so you compete with fewer people and can price above the general market.
2. Package outcomes and use project or value pricing.
3. Cut costs that do not bring work.
4. Raise the billable share with retainers and fewer, larger clients.
5. Lower the target for the first year and plan rate increases.

## Raising rates with existing clients
- New clients get the new rate immediately.
- Existing clients: 30-60 days notice, a date, a short reason (more experience, scope, market), no apology.
- Raise by 10-20% at a time, once a year or when you are fully booked for 3+ months.
- Offer the old rate for a prepaid block of hours or a longer retainer if you want to keep a key client.

Sample notice:
> From 1 January, my rate for new work will be 105 EUR per hour (840 EUR per day). Work already agreed stays at the current rate until it is finished. Thank you for the great collaboration this year; I look forward to the next projects.

## Signals that the rate is too low
- You are fully booked for months and nobody pushes back on price.
- You win almost every proposal.
- You cannot take time off or pay for training.
FILE:templates/rate-card.md
# Rate Card - <name>, <role>

**Valid from:** <date>  **Next review:** <date + 12 months>  **Currency:** <EUR>, prices net of VAT

## Rates
| Service | Price |
|---|---|
| Hourly rate | <105> per hour |
| Day rate (8 h) | <840> per day |
| Half day (4 h) | <480> |
| Monthly retainer (<20> h, unused hours expire) | <2,000> per month |

## Packages
| Package | Includes | Price |
|---|---|---|
| <UX audit> | <expert review of 10 key screens, prioritised report, 1 h call> | <2,400> |
| <App redesign> | <research, wireframes, UI for <n> screens, 2 revision rounds, design system> | <15,120> |

## Terms
- Payment: <40% upfront, rest on delivery>, invoices due in <14> days; late fee <x%> per month
- Scope changes: quoted separately before work starts
- Revisions: <2> rounds included, then hourly
- Cancellation: <notice period / fee>
- Travel: <outside city billed at cost + travel time at 50%>

## Behind the numbers (private, do not send)
- Minimum rate: <102.41>/h at <60>% billable (<1027> h/year)
- Break-even rate: <14.29>/h
- Target take-home: <48,000>, revenue needed: <105,200>
FILE:examples/ux-designer-rate-plan.json
{
  "name": "Deniz, freelance UX/UI designer (Berlin)",
  "currency": "EUR",
  "target_net_income": 48000,
  "set_asides": [
    {"name": "retirement", "annual": 6000},
    {"name": "emergency fund top-up", "monthly": 150}
  ],
  "tax": {"effective_rate_pct": 33},
  "business_costs": [
    {"name": "health and care insurance (self-paid)", "monthly": 520},
    {"name": "coworking desk", "monthly": 190},
    {"name": "software (Figma, Adobe, Notion)", "monthly": 95},
    {"name": "laptop and display (3-year write-off)", "annual": 1100},
    {"name": "accountant", "monthly": 110},
    {"name": "liability insurance", "annual": 280},
    {"name": "website, portfolio and domain", "annual": 240},
    {"name": "courses and conferences", "annual": 900}
  ],
  "time": {
    "hours_per_week": 40,
    "vacation_weeks": 5,
    "public_holidays_days": 10,
    "sick_days": 6,
    "training_days": 5,
    "billable_pct": 60,
    "hours_per_day": 8
  },
  "risk_buffer_pct": 8,
  "current_rate": 65,
  "market_rate": {"low": 70, "high": 110},
  "project": {"name": "Clinic booking app redesign", "estimated_hours": 120, "contingency_pct": 20}
}
FILE:scripts/rate_calc.py
#!/usr/bin/env python3
"""Freelance hourly rate calculator (Python 3 standard library only).

Works backwards from the take-home income you want to the hourly and day rate you must charge,
counting taxes, business costs, unpaid time and a risk buffer. Compares the result with your
current rate and the market, and prices a project.

Input JSON (amounts per year unless "monthly" is given):
  {"name": "...", "currency": "EUR",
   "target_net_income": 48000,                      # take-home after tax and costs
   "set_asides": [{"name": "retirement", "annual": 6000}],          (optional, after tax)
   "tax": {"effective_rate_pct": 32},               # income tax + social contributions on profit
   "business_costs": [{"name": "coworking", "monthly": 180}, {"name": "laptop", "annual": 2400}],
   "time": {"hours_per_week": 40, "vacation_weeks": 5, "public_holidays_days": 10,
            "sick_days": 6, "training_days": 5, "billable_pct": 60, "hours_per_day": 8},
   "risk_buffer_pct": 10,                           # unpaid invoices, gaps between clients
   "current_rate": 55, "market_rate": {"low": 60, "high": 95},       (optional)
   "project": {"name": "...", "estimated_hours": 120, "contingency_pct": 20}}  (optional)
Formulas:
  needed after tax   = target net + set-asides
  profit before tax  = needed after tax / (1 - tax rate)
  revenue needed     = (profit before tax + business costs) / (1 - risk buffer)
  working days       = 260 - vacation weeks x 5 - holidays - sick days - training days
  billable hours     = working days x hours per week / 5 x billable %
  minimum rate       = revenue needed / billable hours; quoted rate = minimum rounded up to
                       the next --round (default 5); project price rounded up to 10; day rate = quoted x hours per day
  break-even rate    = business costs / (1 - buffer) / billable hours (no income at all)
  net at a rate      = (rate x billable hours x (1 - buffer) - costs) x (1 - tax) - set-asides
Findings (severity):
  HIGH   NO_TAX            tax rate missing or 0
  HIGH   BELOW_BREAK_EVEN  current rate does not even cover business costs
  HIGH   BELOW_REQUIRED    current rate below the minimum rate (yearly shortfall shown)
  MEDIUM ABOVE_MARKET      minimum rate above the market high: niche, value pricing or lower target
  MEDIUM BILLABLE_HIGH     billable share above 75% of working time
  MEDIUM LONG_HOURS        more than 50 hours per week
  MEDIUM SHORT_VACATION    under 3 weeks of vacation
  LOW    UNDER_MARKET      quoted rate below the market low: you can likely charge more
  LOW    NO_BUFFER         risk buffer 0
  LOW    NO_SICK_DAYS      no sick days planned
  LOW    NO_CONTINGENCY    project quote without contingency
Usage:
  python3 rate_calc.py plan.json
  python3 rate_calc.py plan.json --round 10 --json
  python3 rate_calc.py --self-test
Exit code: 0 no HIGH findings, 1 HIGH findings, 2 bad input.
"""
import argparse
import json
import math
import sys

SEV_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}


class DataError(ValueError):
    pass


def annual(item):
    if "annual" in item:
        return float(item["annual"])
    if "monthly" in item:
        return float(item["monthly"]) * 12
    raise DataError(f"cost '{item.get('name', '?')}' needs 'annual' or 'monthly'")


def round_up(x, step):
    return math.ceil(x / step - 1e-9) * step


def calc(cfg, step=5):
    t = cfg.get("time") or {}
    hpw = float(t.get("hours_per_week", 40))
    hpd = float(t.get("hours_per_day", hpw / 5))
    billable = float(t.get("billable_pct", 60)) / 100
    vac, hol = float(t.get("vacation_weeks", 4)), float(t.get("public_holidays_days", 10))
    sick, train = float(t.get("sick_days", 5)), float(t.get("training_days", 0))
    tax = float((cfg.get("tax") or {}).get("effective_rate_pct", 0)) / 100
    buffer = float(cfg.get("risk_buffer_pct", 0)) / 100
    if "target_net_income" not in cfg:
        raise DataError("target_net_income is required")
    net = float(cfg["target_net_income"])
    if not (0 <= tax < 0.9) or not (0 <= buffer < 0.5):
        raise DataError("tax must be 0-90% and risk buffer 0-50%")
    if not (0 < billable <= 1) or hpw <= 0:
        raise DataError("billable_pct must be 1-100 and hours_per_week > 0")
    costs = [{"name": c.get("name", "cost"), "annual": annual(c)} for c in cfg.get("business_costs", [])]
    sets = [{"name": s.get("name", "set-aside"), "annual": annual(s)} for s in cfg.get("set_asides", [])]
    cost_total = sum(c["annual"] for c in costs)
    set_total = sum(s["annual"] for s in sets)
    working_days = 260 - vac * 5 - hol - sick - train
    if working_days <= 0:
        raise DataError("no working days left after vacation, holidays, sick and training days")
    avail = working_days * hpw / 5
    bill_h = avail * billable
    after_tax = net + set_total
    pre_tax = after_tax / (1 - tax)
    revenue = (pre_tax + cost_total) / (1 - buffer)
    min_rate = revenue / bill_h
    rate = round_up(min_rate, step)
    breakeven = cost_total / (1 - buffer) / bill_h

    def net_at(r, hours=bill_h):
        return (r * hours * (1 - buffer) - cost_total) * (1 - tax) - set_total

    sens = []
    for pct in (40, 50, 60, 70, 80):
        h = avail * pct / 100
        sens.append({"billable_pct": pct, "billable_hours": round(h), "min_rate": round(revenue / h, 2)})
    res = {"name": cfg.get("name", "freelancer"), "currency": cfg.get("currency", ""),
           "costs": costs, "set_asides": sets, "business_costs": round(cost_total, 2), "set_aside_total": round(set_total, 2),
           "target_net_income": net, "profit_before_tax": round(pre_tax, 2), "revenue_needed": round(revenue, 2),
           "working_days": working_days, "available_hours": round(avail, 1), "billable_hours": round(bill_h, 1),
           "billable_pct": billable * 100, "min_rate": round(min_rate, 2), "quoted_rate": rate,
           "day_rate": round_up(rate * hpd, step), "hours_per_day": hpd, "break_even_rate": round(breakeven, 2),
           "net_at_quoted": round(net_at(rate), 2), "sensitivity": sens, "findings": []}
    findings = res["findings"]

    def add(sev, code, msg):
        findings.append({"severity": sev, "code": code, "message": msg})

    cur = cfg.get("current_rate")
    cur_info = None
    if cur:
        cur = float(cur)
        hours_needed = revenue / cur
        cur_info = {"rate": cur, "net_income": round(net_at(cur), 2), "shortfall": round(net - net_at(cur), 2),
                    "billable_hours_needed": round(hours_needed), "billable_pct_needed": round(hours_needed / avail * 100, 1)}
        res["current"] = cur_info
    if tax == 0:
        add("HIGH", "NO_TAX", "no tax rate given; income tax and social contributions usually take 25-45% of profit")
    if cur_info and cur < breakeven:
        add("HIGH", "BELOW_BREAK_EVEN", f"current rate {cur:g} is below the break-even rate {breakeven:.2f}; every billed hour loses money")
    elif cur_info and cur < min_rate:
        add("HIGH", "BELOW_REQUIRED", f"current rate {cur:g} is {min_rate - cur:.2f} below the minimum {min_rate:.2f}; take-home falls {cur_info['shortfall']:,.0f} short "
            f"(you would need {cur_info['billable_hours_needed']} billable hours, {cur_info['billable_pct_needed']}% of working time)")
    m = cfg.get("market_rate") or {}
    if m.get("high") and min_rate > float(m["high"]):
        add("MEDIUM", "ABOVE_MARKET", f"minimum rate {min_rate:.2f} is above the market high {m['high']}; specialise, price by value, cut costs or lower the target")
    elif m.get("low") and rate < float(m["low"]):
        add("LOW", "UNDER_MARKET", f"quoted rate {rate:g} is below the market low {m['low']}; quote at least {m['low']}")
    if billable > 0.75:
        add("MEDIUM", "BILLABLE_HIGH", f"{billable * 100:.0f}% billable leaves too little time for sales, admin and learning; 50-70% is realistic")
    if hpw > 50:
        add("MEDIUM", "LONG_HOURS", f"{hpw:g} hours per week is not sustainable for a year")
    if vac < 3:
        add("MEDIUM", "SHORT_VACATION", f"only {vac:g} weeks of vacation planned; nobody pays you for holidays, so price them in")
    if buffer == 0:
        add("LOW", "NO_BUFFER", "no risk buffer for unpaid invoices and gaps between clients; 5-15% is common")
    if sick == 0:
        add("LOW", "NO_SICK_DAYS", "no sick days planned; budget at least 5")
    p = cfg.get("project")
    if p:
        hrs = float(p.get("estimated_hours", 0))
        cont = float(p.get("contingency_pct", 0)) / 100
        res["project"] = {"name": p.get("name", "project"), "estimated_hours": hrs, "contingency_pct": cont * 100,
                          "quoted_hours": round(hrs * (1 + cont), 1), "price": round_up(hrs * (1 + cont) * rate, 10),
                          "price_at_current": round_up(hrs * (1 + cont) * cur, 10) if cur else None}
        if cont == 0:
            add("LOW", "NO_CONTINGENCY", "project estimate has no contingency; add 15-30% for unknowns and revisions")
    findings.sort(key=lambda f: SEV_ORDER[f["severity"]])
    return res


def money(x, cur=""):
    return f"{x:,.0f} {cur}".strip() if abs(x) >= 100 else f"{x:,.2f} {cur}".strip()


def rate_fmt(x, cur=""):
    return f"{x:,.2f} {cur}".strip()


def render(r):
    c = r["currency"]
    out = [f"# Freelance rate plan: {r['name']}", "",
           f"Minimum rate {rate_fmt(r['min_rate'], c)}/h -> quote **{rate_fmt(r['quoted_rate'], c)}/h**, day rate {money(r['day_rate'], c)} ({r['hours_per_day']:g} h)",
           f"Break-even (covers costs, no income): {rate_fmt(r['break_even_rate'], c)}/h | take-home at the quoted rate: {money(r['net_at_quoted'], c)}/year", "",
           "## From take-home to revenue", "| Step | Amount |", "|---|---|",
           f"| Target take-home | {money(r['target_net_income'], c)} |",
           f"| + set-asides ({', '.join(s['name'] for s in r['set_asides']) or 'none'}) | {money(r['set_aside_total'], c)} |",
           f"| = profit before tax | {money(r['profit_before_tax'], c)} |",
           f"| + business costs | {money(r['business_costs'], c)} |",
           f"| = revenue needed (incl. risk buffer) | {money(r['revenue_needed'], c)} |", "",
           "## Time", f"{r['working_days']:g} working days, {r['available_hours']:g} working hours, {r['billable_pct']:g}% billable = **{r['billable_hours']:g} billable hours**", "",
           "| Billable share | Billable hours | Minimum rate |", "|---|---|---|"]
    for s in r["sensitivity"]:
        out.append(f"| {s['billable_pct']}% | {s['billable_hours']} | {rate_fmt(s['min_rate'], c)} |")
    out += ["", "## Business costs", "| Cost | Per year |", "|---|---|"]
    for k in r["costs"]:
        out.append(f"| {k['name']} | {money(k['annual'], c)} |")
    if r.get("current"):
        cu = r["current"]
        out += ["", "## Current rate", f"At {rate_fmt(cu['rate'], c)}/h: take-home {money(cu['net_income'], c)}/year "
                f"({'short by ' + money(cu['shortfall'], c) if cu['shortfall'] > 0 else 'target met'}); target needs {cu['billable_hours_needed']} billable hours ({cu['billable_pct_needed']}% of working time)"]
    if r.get("project"):
        p = r["project"]
        out += ["", f"## Project quote: {p['name']}", f"{p['estimated_hours']:g} h + {p['contingency_pct']:g}% contingency = {p['quoted_hours']:g} h x {rate_fmt(r['quoted_rate'], c)} -> **{money(p['price'], c)}**"
                + (f" (at the current rate: {money(p['price_at_current'], c)})" if p.get("price_at_current") else "")]
    out += ["", "## Findings"]
    if not r["findings"]:
        out.append("- none")
    for f in r["findings"]:
        out.append(f"- {f['severity']} {f['code']}: {f['message']}")
    return "\n".join(out) + "\n"


def self_test():
    tests, ok = [], 0

    def check(name, cond):
        nonlocal ok
        tests.append(name)
        print(("PASS " if cond else "FAIL ") + name)
        ok += bool(cond)

    base = {"target_net_income": 50000, "tax": {"effective_rate_pct": 30}, "business_costs": [{"name": "x", "annual": 10000}],
            "time": {"hours_per_week": 40, "vacation_weeks": 4, "public_holidays_days": 10, "sick_days": 5, "training_days": 5, "billable_pct": 60},
            "risk_buffer_pct": 10}
    r = calc(base)
    check("working days 260-20-10-5-5 = 220", r["working_days"] == 220)
    check("billable hours 220 x 8 x 60% = 1056", r["billable_hours"] == 1056)
    check("profit before tax 50000/0.7", abs(r["profit_before_tax"] - 71428.57) < 0.01)
    check("revenue (71428.57+10000)/0.9", abs(r["revenue_needed"] - 90476.19) < 0.01)
    check("minimum rate 90476.19/1056 = 85.68", abs(r["min_rate"] - 85.68) < 0.01)
    check("quoted rate rounded up to 90", r["quoted_rate"] == 90)
    check("day rate 8 x 90 = 720", r["day_rate"] == 720)
    check("break-even 10000/0.9/1056 = 10.52", abs(r["break_even_rate"] - 10.52) < 0.01)
    check("net at the minimum rate equals target", abs(((r["min_rate"] * 1056 * 0.9 - 10000) * 0.7) - 50000) < 10)
    check("clean plan has no findings", r["findings"] == [])
    check("monthly cost x 12", annual({"monthly": 150}) == 1800)
    check("set-asides raise the rate", calc({**base, "set_asides": [{"name": "pension", "annual": 7000}]})["min_rate"] > r["min_rate"])
    check("sensitivity 50% rate higher than 70%", r["sensitivity"][1]["min_rate"] > r["sensitivity"][3]["min_rate"])

    def codes(**over):
        c = json.loads(json.dumps(base))
        for k, v in over.items():
            if isinstance(v, dict) and isinstance(c.get(k), dict):
                c[k].update(v)
            else:
                c[k] = v
        return {f["code"] for f in calc(c)["findings"]}

    check("no tax HIGH", "NO_TAX" in codes(tax={"effective_rate_pct": 0}))
    check("below required HIGH", "BELOW_REQUIRED" in codes(current_rate=60))
    check("below break-even HIGH", "BELOW_BREAK_EVEN" in codes(current_rate=8))
    check("rate above market MEDIUM", "ABOVE_MARKET" in codes(market_rate={"low": 40, "high": 70}))
    check("rate under market LOW", "UNDER_MARKET" in codes(market_rate={"low": 120, "high": 160}))
    check("billable 85% MEDIUM", "BILLABLE_HIGH" in codes(time={"billable_pct": 85}))
    check("55 h/week MEDIUM", "LONG_HOURS" in codes(time={"hours_per_week": 55}))
    check("2 weeks vacation MEDIUM", "SHORT_VACATION" in codes(time={"vacation_weeks": 2}))
    check("no buffer LOW", "NO_BUFFER" in codes(risk_buffer_pct=0))
    check("no sick days LOW", "NO_SICK_DAYS" in codes(time={"sick_days": 0}))
    check("no contingency LOW", "NO_CONTINGENCY" in codes(project={"estimated_hours": 40, "contingency_pct": 0}))
    p = calc({**base, "project": {"estimated_hours": 100, "contingency_pct": 20}})["project"]
    check("project 120 h x 90 = 10800", p["quoted_hours"] == 120 and p["price"] == 10800)
    for bad, label in (({"tax": {"effective_rate_pct": 95}}, "tax 95% rejected"), ({"time": {"billable_pct": 0}}, "billable 0 rejected"),
                       ({"time": {"vacation_weeks": 60}}, "no working days rejected")):
        try:
            c = json.loads(json.dumps(base))
            for k, v in bad.items():
                c[k].update(v)
            calc(c)
            check(label, False)
        except DataError:
            check(label, True)
    print(f"{ok}/{len(tests)} passed")
    return 0 if ok == len(tests) else 1


def main():
    ap = argparse.ArgumentParser(description="Freelance hourly rate calculator")
    ap.add_argument("plan", nargs="?", help="plan JSON file")
    ap.add_argument("--round", type=float, default=5, help="round the quoted rate up to this step (default 5)")
    ap.add_argument("--json", action="store_true")
    ap.add_argument("--self-test", action="store_true")
    a = ap.parse_args()
    if a.self_test:
        return self_test()
    if not a.plan:
        ap.error("plan file required")
    try:
        with open(a.plan, encoding="utf-8") as fh:
            r = calc(json.load(fh), a.round)
    except (OSError, json.JSONDecodeError, DataError, ValueError, TypeError) as e:
        print(f"error: {e}", file=sys.stderr)
        return 2
    print(json.dumps(r, indent=2) if a.json else render(r), end="\n" if a.json else "")
    return 1 if any(f["severity"] == "HIGH" for f in r["findings"]) else 0


if __name__ == "__main__":
    sys.exit(main())
Personal FinancePricingCareer+2
F@f
0
Stale Git Branch Cleanup Auditor
Skill

Audits a repository's remote branches and plans a safe cleanup. Sorts each branch into delete (merged, squash-merged or empty), archive then delete (idle unmerged work), ask the owner, open PR, active or protected, and groups the asks by author. Flags case-colliding names, stale PRs, branches far behind main, long-lived branches and bad names, and prints a reviewable tag-then-delete script. Includes a stdlib Python auditor with --self-test.

---
name: stale-git-branch-cleanup-auditor
description: Audits a repository's remote branches and plans a safe cleanup. Sorts each branch into delete (merged, squash-merged or empty), archive then delete (idle unmerged work), ask the owner, open PR, active or protected, and groups the asks by author. Flags case-colliding names, stale PRs, branches far behind main, long-lived branches and bad names, and prints a reviewable tag-then-delete script. Includes a stdlib Python auditor with --self-test.
---

# Stale Git Branch Cleanup Auditor

You help teams clean up the dozens or hundreds of forgotten branches on a shared remote without losing anyone's work. You decide, branch by branch, what is safe to delete, what should be archived as a tag first, and who has to be asked, then produce a cleanup script that people review before anything is removed.

## Files in this skill

- `scripts/branch_audit.py` - classifies remote branches from a TSV or CSV export or straight from a local clone (`--repo`), prints findings, owner lists and a cleanup script (Python 3 standard library only; never deletes anything itself)
- `references/cleanup-rules.md` - the decision rules, squash-merge detection, archive tags, protected branches, naming conventions and how to restore a deleted branch
- `references/exporting-branch-data.md` - git and GitHub/GitLab commands to produce the input, including PR states
- `templates/cleanup-announcement.md` - a team message with the plan, the owner asks and the deadline
- `examples/webshop-branches.csv` - 16 branches of an online shop repository with merged, squash-merged, stale, colliding and protected branches

## Workflow

### 1. Get the branch data
Preferred: a fresh local clone (`git fetch --prune origin`) and run the script with `--repo`. Ask for the base branch (main, master or develop), the PR states from `gh pr list` or the GitLab API (squash merges cannot be seen in git history), the protected branch patterns and the team's naming convention. If they paste a list instead, convert it to the CSV columns in `references/exporting-branch-data.md`.

### 2. Run the auditor
```
python3 scripts/branch_audit.py examples/webshop-branches.csv --today 2026-10-24
python3 scripts/branch_audit.py --repo . --base main --prs prs.tsv --stale-days 90 --ask-days 30
python3 scripts/branch_audit.py --self-test
```
Exit code 1 means HIGH findings, 2 means bad input.

### 3. Review with the user
Walk through the actions in this order: DELETE_MERGED (safe, the work is in the base branch), ARCHIVE_AND_DELETE (recoverable from the archive tag), ASK_OWNER (needs an answer), then the findings. Fix CASE_COLLISION first because it breaks clones on macOS and Windows. Adjust thresholds to the team's pace (a research repo may want 180 stale days). Use `references/cleanup-rules.md` to explain each decision.

### 4. Deliver
Fill `templates/cleanup-announcement.md` with the counts, the per-owner list and a deadline (usually 7 days). Hand over the generated script with the advice to run the DELETE_MERGED lines first and the archive lines after the deadline.

## Example result
For `examples/webshop-branches.csv` (as of 2026-10-24, stale 90 days, ask 30 days): 16 branches -> 3 delete (merged, squash-merged PR, no commits ahead), 3 archive and delete (spike/graphql-gateway, feature/gift-cards, johns-test), 2 ask owner, 3 open PRs, 1 active, 4 protected. Findings: HIGH CASE_COLLISION (fix/search-timeout vs fix/Search-Timeout), MEDIUM PR_STALE (feature/checkout-redesign, idle 205 days), 3 MEDIUM FAR_BEHIND, 2 LOW BAD_NAME, 2 LOW LONG_LIVED, LOW OLD_PROTECTED (release/2.3).

## Rules

- Never delete branches yourself and never run the script for the user; print it for review.
- Unmerged work is archived as a tag (`archive/<branch>`) before the branch is deleted, so it can be restored.
- Squash and rebase merges leave the branch "unmerged" in git; trust the PR state, not only `--merged`.
- Protected, release and base branches are never in the delete list, even if they look idle.
- Ask owners before touching unmerged work and give a clear deadline.
FILE:references/cleanup-rules.md
# Cleanup Rules

## Decision order (first match wins)
| Action | Condition | Why it is safe or not |
|---|---|---|
| KEEP_PROTECTED | matches `--protect` (main, master, develop, trunk, gh-pages, release/*, hotfix/*) | Deployments, release maintenance and branch protection rules depend on them |
| DELETE_MERGED | reachable from the base branch, 0 commits ahead, or its PR was merged | Every commit (or its squashed equivalent) is already in the base branch |
| KEEP_OPEN_PR | open pull/merge request | Deleting the branch closes the PR; ask instead (see PR_STALE) |
| ARCHIVE_AND_DELETE | unmerged and idle >= stale days (default 90) | Unique commits: tag first so the work is recoverable |
| ASK_OWNER | unmerged and idle >= ask days (default 30) | Might still be in use; the owner decides |
| KEEP_ACTIVE | unmerged, recent commits | Work in progress |

"Idle" is the committer date of the branch tip. A rebase updates it, so a branch rebased yesterday counts as active even if nobody worked on it for months.

## Squash and rebase merges
`git branch -r --merged main` only finds branches whose tip commit is in main. When a PR is squash-merged or rebase-merged, main gets new commits and the branch still looks unmerged with N commits ahead. Use the PR state (`pr = merged`) to catch these. Without PR data, check by hand:
```
git diff --stat origin/main...origin/<branch>     # empty or tiny = changes already in main
git log --oneline origin/main --grep "<PR title or number>"
```
A closed (not merged) PR with old commits is abandoned work: archive it.

## Archive tags
```
git tag archive/<branch> origin/<branch>
git push origin refs/tags/archive/<branch>
git push origin --delete <branch>
```
Restore later with `git push origin archive/<branch>:refs/heads/<branch>`. Tags keep the commits alive forever (no garbage collection). If the archive tags clutter the tag list, push them to a separate archive remote instead.

Deleted without a tag? On GitHub, a branch deleted from a PR page can be restored from the PR for a while; otherwise someone's local clone or `git reflog` on the server-side mirror is the only way back. That is why the script tags first.

## Findings
| Code | Severity | Meaning | Action |
|---|---|---|---|
| CASE_COLLISION | HIGH | `fix/Login` and `fix/login` exist; case-insensitive file systems store refs as files, so fetch fails or one ref hides the other | Delete or rename one today |
| PR_STALE | MEDIUM | Open PR with no commit for >= stale days | Owner finishes, splits or closes it |
| FAR_BEHIND | MEDIUM | Unmerged branch > 300 commits behind base | Rebase/merge base soon or archive; conflicts grow every day |
| LONG_LIVED | LOW | > 50 unmerged commits and idle >= ask days | Split into smaller PRs; hide unfinished work behind a feature flag |
| OLD_PROTECTED | LOW | release/* or hotfix/* idle >= stale days | Tag the release (`v2.3.x`), remove protection, delete |
| BAD_NAME | LOW | No allowed `<type>/` prefix, or uppercase/spaces | Rename to `feature/...`, `fix/...`, `chore/...` |

## Naming convention
`<type>/<short-kebab-description>` with optional ticket: `feature/cart-coupons`, `fix/1423-vat-rounding`, `chore/bump-node-22`. Default allowed types: feature, feat, fix, bugfix, hotfix, release, chore, docs, refactor, test, ci, perf, build, deps, dependabot, renovate, experiment, spike. Override with `--prefixes`.

## Keeping it clean afterwards
- Enable "Automatically delete head branches" (GitHub) or "Delete source branch" (GitLab) on merge.
- Protect only branches that need it, with patterns.
- Run the audit monthly; most teams settle at 90 stale days and 30 ask days.
- Bots (dependabot, renovate) recreate their branches; keep their PRs moving instead of deleting branches.
FILE:references/exporting-branch-data.md
# Exporting Branch Data

## Option A: let the script read the clone
```
git fetch --prune origin
python3 scripts/branch_audit.py --repo . --remote origin --base main
```
The script runs `git for-each-ref`, `git branch -r --merged origin/main` and `git rev-list --left-right --count origin/main...origin/<branch>` for every branch. Large repositories with thousands of branches take a minute.

## Option B: build the TSV (or CSV) yourself
Columns (tab or comma separated, header required; the delimiter is detected from the header): `branch last_commit author ahead behind merged pr`

```
base=origin/main
printf 'branch\tlast_commit\tauthor\tahead\tbehind\tmerged\tpr\n' > branches.tsv
git branch -r --merged "$base" | sed 's/^ *//' > /tmp/merged.txt
git for-each-ref --format='%(refname:short)%09%(committerdate:short)%09%(authorname)' refs/remotes/origin |
while IFS="$(printf '\t')" read -r ref date author; do
  case "$ref" in origin|origin/HEAD|"$base") continue;; esac
  set -- $(git rev-list --left-right --count "$base...$ref")
  merged=no; grep -qx "$ref" /tmp/merged.txt && merged=yes
  printf '%s\t%s\t%s\t%s\t%s\t%s\tnone\n' "ref#origin/" "$date" "$author" "$2" "$1" "$merged" >> branches.tsv
done
```
`$1` is "behind" (commits only in base) and `$2` is "ahead" (commits only in the branch).

## PR states
GitHub CLI (`gh pr list` returns newest PRs first; awk keeps the first, i.e. newest, PR per branch):
```
gh pr list --state all --limit 1000 --json headRefName,state \
  -q '.[] | [.headRefName, (.state|ascii_downcase)] | @tsv' | awk -F'\t' '!seen[$1]++' > prs.tsv
python3 scripts/branch_audit.py --repo . --prs prs.tsv
```
GitHub states map to open, merged, closed. GitLab: `glab mr list --all` or the API `GET /projects/:id/merge_requests?state=all` (`source_branch`, `state`: opened -> open, merged, closed).

For a TSV or CSV, put the state in the `pr` column (open, merged, closed or none).

## Thresholds
| Option | Default | Notes |
|---|---|---|
| `--stale-days` | 90 | Idle unmerged branches beyond this are archived |
| `--ask-days` | 30 | Idle unmerged branches beyond this need an owner answer |
| `--behind-limit` | 300 | FAR_BEHIND threshold in commits |
| `--protect` | main,master,develop,trunk,gh-pages,release/*,hotfix/* | fnmatch patterns, case-sensitive |
| `--prefixes` | feature,fix,chore,... | Allowed name prefixes for BAD_NAME |
| `--today` | today | Fix the date for repeatable reports |

## Privacy
Author names come from commits. Share the per-owner list only inside the team.
FILE:templates/cleanup-announcement.md
# Branch cleanup on <repository> - please check by <deadline date>

Hi team,

We have <N> branches on `<remote>`. On <cleanup date> we will clean up:

- **<n> merged branches** will be deleted (their changes are already in `<base>`).
- **<n> idle unmerged branches** (no commits for <stale days>+ days) will be saved as `archive/<branch>` tags and then deleted. You can restore any of them with `git push origin archive/<branch>:refs/heads/<branch>`.
- **<n> branches need your answer** (no commits for <ask days>+ days): keep, merge, or archive?

## Your branches
| Owner | Branches | Needed from you |
|---|---|---|
| <name> | <branch, branch> | <keep / archive / merge by deadline> |

## Please fix
- <CASE_COLLISION: fix/search-timeout and fix/Search-Timeout - Lena, delete one today>
- <PR_STALE: feature/checkout-redesign - Marco, finish, split or close>

## From now on
- Branch names: `<type>/<short-description>` (feature/, fix/, chore/, ...)
- Head branches are deleted automatically after merge.
- This audit runs on the first Monday of each month.

Reply in <channel/thread> if a branch must stay. Thanks!
FILE:examples/webshop-branches.csv
branch,last_commit,author,ahead,behind,merged,pr
main,2026-10-23,Selin Kaya,0,0,yes,none
develop,2026-10-22,Selin Kaya,4,0,no,none
release/2.3,2025-11-12,Selin Kaya,2,611,no,none
release/2.8,2026-09-30,Selin Kaya,1,48,no,none
feature/cart-coupons,2026-10-20,Marco Rossi,12,6,no,open
feature/checkout-redesign,2026-04-02,Marco Rossi,134,742,no,open
feature/wishlist-sharing,2026-09-08,Aiko Tanaka,7,95,no,none
feature/gift-cards,2026-05-19,Aiko Tanaka,23,401,no,closed
fix/vat-rounding,2026-10-02,Lena Fischer,3,22,yes,merged
fix/search-timeout,2026-08-14,Lena Fischer,5,130,no,merged
fix/Search-Timeout,2026-08-13,Lena Fischer,1,131,no,none
chore/bump-node-22,2026-07-01,Omar Haddad,0,210,no,none
spike/graphql-gateway,2026-02-10,Omar Haddad,58,988,no,none
johns-test,2026-06-21,John Miller,2,260,no,none
dependabot/npm_and_yarn/axios-1.7.4,2026-10-18,dependabot[bot],1,9,no,open
experiment/ab-banner,2026-10-12,Aiko Tanaka,4,14,no,none
FILE:scripts/branch_audit.py
#!/usr/bin/env python3
"""Stale Git branch cleanup auditor (Python 3 standard library only).

Sorts every remote branch into an action, flags risky branches and prints a reviewable
cleanup script. Nothing is deleted: the script only prints commands.

Input, either:
  1. a TSV or CSV export (header required, delimiter detected from the header, extra columns ignored):
       branch  last_commit  author  ahead  behind  merged  pr
     last_commit = YYYY-MM-DD, ahead/behind = commits vs the base branch,
     merged = yes/no (reachable from base), pr = open | merged | closed | none
  2. --repo PATH: reads the remote branches straight from a local clone with git
     (run `git fetch --prune` first). PR state comes from --prs FILE (lines "branch<TAB>state",
     e.g. from `gh pr list --state all --json headRefName,state`), otherwise "none".
Actions (first match wins):
  KEEP_PROTECTED      name matches --protect patterns (main, master, develop, release/*, ...)
  DELETE_MERGED       merged into base, 0 commits ahead, or its PR was merged (squash/rebase)
  KEEP_OPEN_PR        has an open pull request
  ARCHIVE_AND_DELETE  unmerged and no commit for >= --stale-days (tag archive/<name>, then delete)
  ASK_OWNER           unmerged and no commit for >= --ask-days
  KEEP_ACTIVE         recent unmerged work
Findings (severity):
  HIGH   CASE_COLLISION  branch names that differ only by case (break on macOS/Windows clones)
  MEDIUM PR_STALE        open PR whose branch has had no commit for >= --stale-days
  MEDIUM FAR_BEHIND      unmerged branch more than --behind-limit commits behind base
  LOW    LONG_LIVED      unmerged branch more than 50 commits ahead and older than --ask-days
  LOW    OLD_PROTECTED   protected release/hotfix branch older than --stale-days (tag it, unprotect, delete)
  LOW    BAD_NAME        name without an allowed prefix, or with uppercase/space characters
Usage:
  python3 branch_audit.py branches.csv --today 2026-10-24
  python3 branch_audit.py --repo . --base main --prs prs.tsv
  python3 branch_audit.py branches.tsv --json
  python3 branch_audit.py --self-test
Exit code: 0 no HIGH findings, 1 HIGH findings, 2 bad input.
"""
import argparse
import csv
import datetime as dt
import fnmatch
import json
import shlex
import subprocess
import sys

DEFAULT_PROTECT = "main,master,develop,trunk,gh-pages,release/*,hotfix/*"
DEFAULT_PREFIXES = "feature,feat,fix,bugfix,hotfix,release,chore,docs,refactor,test,ci,perf,build,deps,dependabot,renovate,experiment,spike"
ACTION_ORDER = ["DELETE_MERGED", "ARCHIVE_AND_DELETE", "ASK_OWNER", "KEEP_OPEN_PR", "KEEP_ACTIVE", "KEEP_PROTECTED"]
SEV_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}


class DataError(ValueError):
    pass


def yes(v):
    return str(v).strip().lower() in ("yes", "y", "true", "1")


def parse_tsv(text):
    lines = text.splitlines()
    delim = "\t" if lines and "\t" in lines[0] else ","
    rows = list(csv.DictReader(lines, delimiter=delim))
    need = {"branch", "last_commit", "author", "ahead", "behind", "merged"}
    if not rows:
        raise DataError("no branches in input")
    missing = need - set(rows[0].keys())
    if missing:
        raise DataError("missing columns: " + ", ".join(sorted(missing)))
    out = []
    for i, r in enumerate(rows, 2):
        try:
            out.append({"branch": r["branch"].strip(), "last_commit": dt.date.fromisoformat(r["last_commit"].strip()[:10]),
                        "author": r["author"].strip() or "unknown", "ahead": int(r["ahead"]), "behind": int(r["behind"]),
                        "merged": yes(r["merged"]), "pr": (r.get("pr") or "none").strip().lower() or "none"})
        except (ValueError, TypeError) as e:
            raise DataError(f"line {i}: {e}")
    return out


def git(repo, *args):
    r = subprocess.run(["git", "-C", repo, *args], capture_output=True, text=True)
    if r.returncode != 0:
        raise DataError(f"git {' '.join(args)}: {r.stderr.strip()}")
    return r.stdout


def read_repo(repo, remote, base, prs):
    base_ref = f"{remote}/{base}"
    git(repo, "rev-parse", "--verify", base_ref)
    merged = {l.strip() for l in git(repo, "branch", "-r", "--merged", base_ref).splitlines()}
    out = []
    fmt = "%(refname:short)%09%(committerdate:short)%09%(authorname)"
    for line in git(repo, "for-each-ref", f"--format={fmt}", f"refs/remotes/{remote}").splitlines():
        ref, date, author = line.split("\t")
        if ref in (remote, f"{remote}/HEAD") or ref == base_ref:
            continue
        behind, ahead = git(repo, "rev-list", "--left-right", "--count", f"{base_ref}...{ref}").split()
        name = ref[len(remote) + 1:]
        out.append({"branch": name, "last_commit": dt.date.fromisoformat(date), "author": author,
                    "ahead": int(ahead), "behind": int(behind), "merged": ref in merged, "pr": prs.get(name, "none")})
    return out


def audit(branches, today, base="main", remote="origin", stale_days=90, ask_days=30, behind_limit=300,
          protect=DEFAULT_PROTECT, prefixes=DEFAULT_PREFIXES):
    protect_pats = [p.strip() for p in protect.split(",") if p.strip()]
    allowed = {p.strip() for p in prefixes.split(",") if p.strip()}
    findings, rows = [], []

    def add(sev, code, branch, msg):
        findings.append({"severity": sev, "code": code, "branch": branch, "message": msg})

    for b in branches:
        name, age = b["branch"], (today - b["last_commit"]).days
        prot = name == base or any(fnmatch.fnmatchcase(name, p) for p in protect_pats)
        if prot:
            action = "KEEP_PROTECTED"
        elif b["merged"] or b["ahead"] == 0 or b["pr"] == "merged":
            action = "DELETE_MERGED"
        elif b["pr"] == "open":
            action = "KEEP_OPEN_PR"
        elif age >= stale_days:
            action = "ARCHIVE_AND_DELETE"
        elif age >= ask_days:
            action = "ASK_OWNER"
        else:
            action = "KEEP_ACTIVE"
        why = {"KEEP_PROTECTED": "protected pattern",
               "DELETE_MERGED": "merged into " + base if b["merged"] else ("no commits ahead" if b["ahead"] == 0 else "PR merged (squash/rebase)"),
               "KEEP_OPEN_PR": "open pull request", "ARCHIVE_AND_DELETE": f"unmerged, idle {age} days",
               "ASK_OWNER": f"unmerged, idle {age} days", "KEEP_ACTIVE": f"active {age} days ago"}[action]
        rows.append({**b, "last_commit": b["last_commit"].isoformat(), "age_days": age, "action": action, "reason": why})
        unmerged = action in ("ARCHIVE_AND_DELETE", "ASK_OWNER", "KEEP_OPEN_PR", "KEEP_ACTIVE")
        if action == "KEEP_OPEN_PR" and age >= stale_days:
            add("MEDIUM", "PR_STALE", name, f"open PR but no commit for {age} days; ask {b['author']} to finish or close it")
        if unmerged and b["behind"] > behind_limit:
            add("MEDIUM", "FAR_BEHIND", name, f"{b['behind']} commits behind {base}; rebase soon or expect painful conflicts")
        if unmerged and b["ahead"] > 50 and age >= ask_days:
            add("LOW", "LONG_LIVED", name, f"{b['ahead']} unmerged commits; split into smaller PRs or put it behind a feature flag")
        if prot and name != base and age >= stale_days and (name.startswith("release/") or name.startswith("hotfix/")):
            add("LOW", "OLD_PROTECTED", name, f"protected branch idle {age} days; tag it, remove protection and delete")
        if not prot:
            prefix = name.split("/", 1)[0] if "/" in name else ""
            if prefix not in allowed or name != name.lower() or " " in name:
                add("LOW", "BAD_NAME", name, "use lowercase <type>/<short-description> (e.g. feature/cart-coupons)")
    seen = {}
    for b in branches:
        seen.setdefault(b["branch"].lower(), []).append(b["branch"])
    for names in seen.values():
        if len(names) > 1:
            add("HIGH", "CASE_COLLISION", " / ".join(names), "names differ only by case; clones on macOS/Windows see one ref and fetches fail; delete or rename one")
    findings.sort(key=lambda f: (SEV_ORDER[f["severity"]], f["code"], f["branch"]))
    rows.sort(key=lambda r: (ACTION_ORDER.index(r["action"]), -r["age_days"]))
    counts = {a: sum(r["action"] == a for r in rows) for a in ACTION_ORDER}
    owners = {}
    for r in rows:
        if r["action"] in ("ASK_OWNER", "ARCHIVE_AND_DELETE"):
            owners.setdefault(r["author"], []).append(r["branch"])
    script = ["#!/bin/sh", "# Review before running. Generated by branch_audit.py; nothing has been deleted.", "set -e"]
    for r in rows:
        q = shlex.quote(r["branch"])
        if r["action"] == "DELETE_MERGED":
            script.append(f"git push {remote} --delete {q}   # {r['reason']}")
    for r in rows:
        if r["action"] == "ARCHIVE_AND_DELETE":
            q = shlex.quote(r["branch"])
            tag = shlex.quote("archive/" + r["branch"])
            script.append(f"git tag {tag} {shlex.quote(remote + '/' + r['branch'])} && git push {remote} {shlex.quote('refs/tags/archive/' + r['branch'])} && git push {remote} --delete {q}")
    return {"today": today.isoformat(), "base": base, "remote": remote, "stale_days": stale_days, "ask_days": ask_days,
            "branches": len(rows), "counts": counts, "rows": rows, "owners": owners, "findings": findings,
            "cleanup_script": "\n".join(script) + "\n"}


def render(r):
    c = r["counts"]
    out = [f"# Branch cleanup audit ({r['remote']}, base {r['base']}, as of {r['today']})", "",
           f"{r['branches']} branches: {c['DELETE_MERGED']} delete (merged), {c['ARCHIVE_AND_DELETE']} archive and delete, "
           f"{c['ASK_OWNER']} ask owner, {c['KEEP_OPEN_PR']} open PR, {c['KEEP_ACTIVE']} active, {c['KEEP_PROTECTED']} protected",
           f"Rules: stale >= {r['stale_days']} days idle, ask owner >= {r['ask_days']} days", "",
           "| Action | Branch | Last commit | Author | Ahead/behind | Reason |", "|---|---|---|---|---|---|"]
    for row in r["rows"]:
        out.append(f"| {row['action']} | {row['branch']} | {row['last_commit']} | {row['author']} | {row['ahead']}/{row['behind']} | {row['reason']} |")
    out += ["", "## Ask the owners"]
    if not r["owners"]:
        out.append("- nobody")
    for who, names in sorted(r["owners"].items()):
        out.append(f"- {who}: {', '.join(names)}")
    out += ["", "## Findings"]
    if not r["findings"]:
        out.append("- none")
    for f in r["findings"]:
        out.append(f"- {f['severity']} {f['code']} {f['branch']}: {f['message']}")
    out += ["", "## Cleanup script (review first, run after owners agree)", "```sh", r["cleanup_script"].rstrip(), "```"]
    return "\n".join(out) + "\n"


def self_test():
    tests, ok = [], 0

    def check(name, cond):
        nonlocal ok
        tests.append(name)
        print(("PASS " if cond else "FAIL ") + name)
        ok += bool(cond)

    today = dt.date(2026, 10, 24)

    def b(name, days, ahead=3, behind=10, merged=False, pr="none", author="ana"):
        return {"branch": name, "last_commit": today - dt.timedelta(days=days), "author": author,
                "ahead": ahead, "behind": behind, "merged": merged, "pr": pr}

    def act(**kw):
        name = kw.pop("name", "feature/x")
        days = kw.pop("days", 10)
        return audit([b(name, days, **kw)], today)["rows"][0]["action"]

    check("main protected", act(name="main", days=500) == "KEEP_PROTECTED")
    check("release/* protected", act(name="release/2.4", days=200) == "KEEP_PROTECTED")
    check("merged -> DELETE_MERGED", act(merged=True, days=5) == "DELETE_MERGED")
    check("0 ahead -> DELETE_MERGED", act(ahead=0) == "DELETE_MERGED")
    check("squash-merged PR -> DELETE_MERGED", act(pr="merged", ahead=4) == "DELETE_MERGED")
    check("open PR kept", act(pr="open", days=200) == "KEEP_OPEN_PR")
    check("idle 120 d unmerged -> ARCHIVE", act(days=120) == "ARCHIVE_AND_DELETE")
    check("idle 45 d -> ASK_OWNER", act(days=45) == "ASK_OWNER")
    check("idle 5 d -> KEEP_ACTIVE", act(days=5) == "KEEP_ACTIVE")
    check("closed PR idle 100 d -> ARCHIVE", act(pr="closed", days=100) == "ARCHIVE_AND_DELETE")

    def codes(rows, **kw):
        return {f["code"] for f in audit(rows, today, **kw)["findings"]}

    check("case collision HIGH", "CASE_COLLISION" in codes([b("feature/Login", 3), b("feature/login", 3)]))
    check("stale PR MEDIUM", "PR_STALE" in codes([b("feature/a", 100, pr="open")]))
    check("far behind MEDIUM", "FAR_BEHIND" in codes([b("feature/a", 10, behind=900)]))
    check("merged far behind not flagged", "FAR_BEHIND" not in codes([b("feature/a", 10, behind=900, merged=True)]))
    check("long lived LOW", "LONG_LIVED" in codes([b("feature/a", 40, ahead=80)]))
    check("old release LOW", "OLD_PROTECTED" in codes([b("release/1.0", 400)]))
    check("bad name LOW", "BAD_NAME" in codes([b("johns-test", 3)]) and "BAD_NAME" in codes([b("feature/NewThing", 3)]))
    check("good name not flagged", "BAD_NAME" not in codes([b("fix/cart-total", 3)]))
    r = audit([b("feature/old", 200), b("fix/done", 3, merged=True)], today)
    check("script deletes merged branch", "git push origin --delete fix/done" in r["cleanup_script"])
    check("script tags before deleting stale", "git tag archive/feature/old origin/feature/old && git push origin refs/tags/archive/feature/old && git push origin --delete feature/old" in r["cleanup_script"])
    r = audit([b("main", 1, ahead=0, behind=0), b("feature/old", 200), b("develop", 300)], today)
    check("script never deletes protected branches", "--delete main" not in r["cleanup_script"] and "--delete develop" not in r["cleanup_script"]
          and r["cleanup_script"].rstrip().endswith("git push origin --delete feature/old"))
    r = audit([b("feature/it's", 200)], today)
    check("odd names are shell-quoted", "'feature/it'\"'\"'s'" in r["cleanup_script"])
    r = audit([b("feature/a", 200, author="ana"), b("feature/b", 40, author="ana"), b("feature/c", 2, author="bo")], today)
    check("owners grouped", r["owners"] == {"ana": ["feature/a", "feature/b"]})
    rows = parse_tsv("branch\tlast_commit\tauthor\tahead\tbehind\tmerged\tpr\nfeature/x\t2026-10-01\tAna\t2\t5\tno\topen\n")
    check("TSV parsed", rows[0]["ahead"] == 2 and rows[0]["pr"] == "open" and not rows[0]["merged"])
    rows = parse_tsv("branch,last_commit,author,ahead,behind,merged,pr\nfix/y,2026-09-01,Bo,0,3,yes,none\n")
    check("CSV parsed", rows[0]["branch"] == "fix/y" and rows[0]["merged"] and rows[0]["behind"] == 3)
    try:
        parse_tsv("branch\tauthor\nx\ty\n")
        check("missing columns rejected", False)
    except DataError:
        check("missing columns rejected", True)
    # real git repository round trip (skipped if git is missing)
    import os, shutil, tempfile
    if shutil.which("git"):
        d = tempfile.mkdtemp()
        try:
            env = {**os.environ, "GIT_AUTHOR_NAME": "Ana", "GIT_AUTHOR_EMAIL": "a@x", "GIT_COMMITTER_NAME": "Ana", "GIT_COMMITTER_EMAIL": "a@x"}
            run = lambda *a, cwd=d: subprocess.run(["git", *a], cwd=cwd, env=env, capture_output=True, check=True)
            os.makedirs(f"{d}/remote.git"); run("init", "-q", "--bare", "-b", "main", cwd=f"{d}/remote.git")
            run("clone", "-q", f"{d}/remote.git", "w"); w = f"{d}/w"
            run("checkout", "-q", "-b", "main", cwd=w); run("commit", "-q", "--allow-empty", "-m", "init", cwd=w)
            run("checkout", "-q", "-b", "fix/done", cwd=w); run("commit", "-q", "--allow-empty", "-m", "fix", cwd=w)
            run("checkout", "-q", "main", cwd=w); run("merge", "-q", "--ff-only", "fix/done", cwd=w)
            run("checkout", "-q", "-b", "feature/wip", cwd=w); run("commit", "-q", "--allow-empty", "-m", "wip", cwd=w)
            run("push", "-q", "origin", "main", "fix/done", "feature/wip", cwd=w)
            r = audit(read_repo(w, "origin", "main", {}), today)
            acts = {x["branch"]: x["action"] for x in r["rows"]}
            check("git repo: merged and active branches read", acts == {"fix/done": "DELETE_MERGED", "feature/wip": "KEEP_ACTIVE"})
        finally:
            shutil.rmtree(d, ignore_errors=True)
    print(f"{ok}/{len(tests)} passed")
    return 0 if ok == len(tests) else 1


def main():
    ap = argparse.ArgumentParser(description="Stale Git branch cleanup auditor")
    ap.add_argument("tsv", nargs="?", help="branch TSV or CSV export")
    ap.add_argument("--repo", help="read remote branches from this local clone instead of a TSV")
    ap.add_argument("--prs", help="TSV 'branch<TAB>state' with PR states (open/merged/closed), used with --repo")
    ap.add_argument("--remote", default="origin")
    ap.add_argument("--base", default="main")
    ap.add_argument("--today", help="YYYY-MM-DD (default: today)")
    ap.add_argument("--stale-days", type=int, default=90)
    ap.add_argument("--ask-days", type=int, default=30)
    ap.add_argument("--behind-limit", type=int, default=300)
    ap.add_argument("--protect", default=DEFAULT_PROTECT)
    ap.add_argument("--prefixes", default=DEFAULT_PREFIXES)
    ap.add_argument("--json", action="store_true")
    ap.add_argument("--self-test", action="store_true")
    a = ap.parse_args()
    if a.self_test:
        return self_test()
    try:
        today = dt.date.fromisoformat(a.today) if a.today else dt.date.today()
        if a.repo:
            prs = {}
            if a.prs:
                with open(a.prs, encoding="utf-8") as fh:
                    for line in fh:
                        if "\t" in line:
                            k, v = line.rstrip("\n").split("\t", 1)
                            prs[k] = v.strip().lower()
            branches = read_repo(a.repo, a.remote, a.base, prs)
        elif a.tsv:
            with open(a.tsv, encoding="utf-8") as fh:
                branches = parse_tsv(fh.read())
        else:
            ap.error("give a TSV/CSV file or --repo PATH")
        r = audit(branches, today, a.base, a.remote, a.stale_days, a.ask_days, a.behind_limit, a.protect, a.prefixes)
    except (OSError, DataError, ValueError) as e:
        print(f"error: {e}", file=sys.stderr)
        return 2
    print(json.dumps(r, indent=2) if a.json else render(r), end="\n" if a.json else "")
    return 1 if any(f["severity"] == "HIGH" for f in r["findings"]) else 0


if __name__ == "__main__":
    sys.exit(main())
GitGitHubDevOps+2
F@f
0
Backup Retention Policy Planner
Skill

Plans and audits backup retention (grandfather-father-son) policies. Simulates hourly, daily, weekly, monthly and yearly keep rules like restic or borg, lists the restore points you really keep and the gaps between them, and estimates storage and cost now and in 12 months. Flags missed RPO, short history, legal retention gaps, no offsite or immutable copy, stale restore tests and job overlap. Includes a stdlib Python planner with --self-test.

---
name: backup-retention-policy-planner
description: Plans and audits backup retention (grandfather-father-son) policies. Simulates hourly, daily, weekly, monthly and yearly keep rules like restic or borg, lists the restore points you really keep and the gaps between them, and estimates storage and cost now and in 12 months. Flags missed RPO, short history, legal retention gaps, no offsite or immutable copy, stale restore tests and job overlap. Includes a stdlib Python planner with --self-test.
---

# Backup Retention Policy Planner

You help engineers and small IT teams design a backup retention policy they can defend: enough restore points to recover from yesterday's mistake and last quarter's silent corruption, a copy that survives a ransomware attack or a lost account, a storage bill that fits the budget, and a restore that has actually been tested. You turn "keep 7 daily, 4 weekly, 12 monthly" into the concrete list of restore points it produces and check it against the requirements.

## Files in this skill

- `scripts/retention_plan.py` - simulates a GFS policy, lists kept restore points, gaps, storage and cost, and prints findings (Python 3 standard library only)
- `references/retention-design.md` - how keep rules select points, choosing tiers from RPO and history needs, legal retention, storage models and cost
- `references/resilience-checklist.md` - 3-2-1-1-0, immutability, encryption and keys, restore testing, monitoring and what each finding means
- `templates/backup-policy.md` - a one-page backup policy to fill in and share with the team
- `examples/clinic-postgres-policy.json` - a PostgreSQL database backed up nightly to S3 with a 4 h RPO and 3-year legal retention

## Workflow

### 1. Collect the facts
Ask for: what is backed up and how (tool, full or incremental), backup frequency and how long a run takes, the current keep rules, data size, daily change rate and growth, where copies live (onsite, offsite, immutable/object lock, encrypted), the storage price, the date of the last successful restore test, and the requirements: RPO (maximum data loss), how far back they must be able to restore, any legal or contractual retention, and a storage budget. If they do not know the change rate, start with 2-5% per day for databases and 1% for file shares, and say so.

### 2. Run the planner
```
python3 scripts/retention_plan.py examples/clinic-postgres-policy.json
python3 scripts/retention_plan.py policy.json --json
python3 scripts/retention_plan.py --self-test
```
Exit code 1 means HIGH findings, 2 means bad input.

### 3. Explain the result
Start with the restore points they really have (count, oldest, biggest gaps) because most people overestimate them. Then go through the findings by severity using `references/retention-design.md` and `references/resilience-checklist.md`, and give one concrete change for each: a new keep rule, WAL or log shipping for a tight RPO, object lock, a restore drill date. Re-run the planner with the proposed policy and show the before and after storage and cost.

### 4. Deliver
Fill `templates/backup-policy.md` with the final keep rules, copies, requirements, restore test plan and owners.

## Example result
For `examples/clinic-postgres-policy.json` (nightly backups, keep 7 daily, 4 weekly, 6 monthly): 14 restore points, oldest 4.8 months, gaps of up to 31 days after the first month, 968 GB now and 1550 GB in 12 months (22.27 to 35.66 USD/month). Findings: HIGH RPO_MISSED (24 h backups against a 4 h RPO; add WAL archiving or 4-hourly incrementals), HIGH LEGAL_RETENTION (3 years needed; set yearly to 5), MEDIUM RESTORE_TEST_OLD (193 days), MEDIUM NO_IMMUTABLE, MEDIUM STORAGE_OVER (1550 GB against a 1500 GB budget in 12 months).

## Rules

- A backup that has never been restored is not proven. Always ask for the last restore test and recommend a timed drill.
- Keep rules count buckets, not days: "monthly 12" keeps the newest backup of each of the last 12 months that have one, including the current month.
- Say which storage model you assumed; incremental estimates are an upper bound because deduplication and repeated block changes make the real size smaller.
- RPO is set by backup frequency (or log shipping), not by retention; history depth is set by retention, not by frequency.
- Never suggest deleting existing backups until the new policy has run and a restore from it has been tested.
FILE:references/retention-design.md
# Retention Design

## How keep rules select restore points
Tools such as restic (`forget --keep-daily 7 --keep-weekly 4`), borg (`prune --keep-daily 7`), pgBackRest and most backup appliances use the same idea. For each tier, walk the backups from newest to oldest and keep a backup when its bucket differs from the last one kept by that tier, until the tier's count is used:

| Tier | Bucket | "keep N" means |
|---|---|---|
| hourly | calendar hour | newest backup of each of the last N hours that have a backup |
| daily | calendar day | newest backup of each of the last N days |
| weekly | ISO week (Monday to Sunday) | newest backup of each of the last N weeks |
| monthly | calendar month | newest backup of each of the last N months |
| yearly | calendar year | newest backup of each of the last N years |

Consequences people miss:
- Every tier starts with the newest backup, so the tiers overlap. 7 daily + 4 weekly + 12 monthly is 20 points, not 23.
- The current week, month and year count as one bucket each. "monthly 12" reaches back about 11 months, and "yearly 3" only reaches the end of the year before last (about 1.8 years in late October).
- Monthly points are month-end backups (the newest in each month), weekly points are Sunday backups for ISO weeks.
- Days without a backup do not use up a daily slot, so a failed week stretches daily history further back but leaves a hole.

`scripts/retention_plan.py` reproduces this rule, prints every kept point with its tiers (D/W/M/Y/H) and lists the largest gaps.

## Choosing tiers from requirements
| Requirement | What sets it | Typical answer |
|---|---|---|
| RPO (max data loss) | backup frequency or log shipping | 24 h: nightly; 1-4 h: hourly snapshots or incrementals; minutes: database WAL/binlog archiving with point-in-time recovery |
| Recover from a mistake noticed late | daily and weekly depth | at least 14-35 days: the time it takes someone to notice a bad deploy, a deleted folder or a bad import |
| Silent corruption, audits | monthly depth | 6-13 months |
| Legal or contractual retention | yearly tier or a separate archive | N years needs yearly N+2 to have an N-year-old point all year round; often better as a separate archive job with its own lock |
| Budget | storage model and change rate | see below |

A common, defensible starting point for business data: hourly 24 (if RPO < 24 h), daily 14, weekly 8, monthly 12, yearly as required by law.

Gaps: when a coarse tier starts long after the fine tier ends (daily 7 then monthly), restores between day 8 and the month end land on a point up to 30 days older than the one you wanted. Add weekly points or more daily points (`TIER_GAP`).

## Storage models
- **Full**: every kept point is a complete copy (tape, simple dump files, some snapshot exports). Storage = points x full size.
- **Incremental / deduplicated** (restic, borg, pgBackRest incremental, ZFS/EBS snapshots): one full plus the data changed between kept points. The planner charges `daily_change_pct x days` between neighbouring points, capped at 100% of the full size. This is an upper bound: when the same blocks change every day, a monthly gap costs much less than 30 days of changes. Measure real numbers with `restic stats --mode raw-data`, `borg info` or the storage bill and adjust `daily_change_pct`.
- Growth: `growth_pct_per_month` compounds for 12 months to show next year's storage and cost.

Cost levers, from cheapest to most impact on recovery: compress and deduplicate; move monthly and yearly points to a colder storage class (mind minimum storage durations and retrieval fees); drop hourly points after 2-3 days; reduce monthly points after the legal need is met by a yearly archive.

## Worked example
Nightly backups at 02:00, keep daily 7, weekly 4, monthly 6, as of 2026-10-24:
- daily: 10-24 back to 10-18
- weekly: 10-24 (current ISO week), 10-18, 10-11, 10-04
- monthly: 10-24, 09-30, 08-31, 07-31, 06-30, 05-31
- 14 distinct points; oldest 2026-05-31 (about 4.8 months); the gaps after the first month are 30-31 days.
FILE:references/resilience-checklist.md
# Resilience Checklist

## 3-2-1-1-0
- **3** copies of the data (production plus two backups)
- **2** different storage types or systems
- **1** copy offsite (another region, provider or building)
- **1** copy immutable or offline (object lock in compliance mode, WORM storage, tape out of the library, a pull-based backup the source cannot delete)
- **0** errors in the last verified restore

## What each finding means and how to fix it
| Code | Severity | Why it matters | Typical fix |
|---|---|---|---|
| RPO_MISSED | HIGH | A failure just before the next backup loses more data than the business accepted | Shorter interval, hourly incrementals, or continuous log shipping (PostgreSQL WAL archiving with pgBackRest/WAL-G, MySQL binlog, SQL Server log backups) |
| HISTORY_SHORT | HIGH | Problems noticed late (bad import, deleted folder, slow corruption) cannot be undone | More daily/weekly points; at least the time it takes to notice a problem plus a margin |
| LEGAL_RETENTION | HIGH | Contract, tax or health-record rules can require years of history | Yearly tier of N+2 or a separate yearly archive job with object lock retention set to the legal period |
| NO_OFFSITE | HIGH | Fire, region outage or a compromised account removes production and backups together | Replicate to another region/provider with separate credentials |
| NEVER_RESTORED | HIGH | Backups fail silently: missing files, wrong permissions, lost encryption keys, untested runbooks | Restore drill now; record the time it took (actual RTO) |
| OVERLAP | HIGH | Jobs collide or get skipped, so the real interval is longer than planned | Incremental backups, faster storage, or a longer interval with log shipping |
| RESTORE_TEST_OLD | MEDIUM | Systems change; last spring's test says little about today | Quarterly drills; monthly automated restore-and-checksum for critical data |
| NO_IMMUTABLE | MEDIUM | Ransomware and attackers delete backups first | S3 Object Lock / Azure immutable blob / GCS bucket lock, or an offline copy |
| NOT_ENCRYPTED | MEDIUM | An offsite copy leaks all data if the bucket or tape is exposed | Client-side encryption; keys stored outside the backup system and escrowed |
| STORAGE_OVER | MEDIUM | The budget forces someone to cut retention in a hurry later | Colder storage for old tiers, fewer monthly points, measure the real change rate |
| TIER_GAP | LOW | Restores in the gap land on a much older point than wanted | Add weekly points or extend daily points |
| TIGHT_WINDOW | LOW | Data growth will push the job into overlap | Plan incremental or parallel backups before it happens |

## Restore drill (one page)
1. Pick a random restore point, not the newest.
2. Restore into an isolated environment using only the runbook and the escrowed keys.
3. Check: row counts or file counts, application starts, a known record from that date exists.
4. Record start and end time (actual RTO), problems and runbook fixes.
5. Delete the restored copy and log the drill date; the planner's `last_restore_test` is this date.

## Monitoring
- Alert when the newest backup is older than 1.5 x the interval.
- Alert on failed or partial jobs, not only on missing ones.
- Track backup size per run; a sudden drop often means a broken include path, a sudden jump can mean ransomware encryption.
- Check that prune/forget jobs run; a policy that is never applied silently fills the storage.

## Keys and access
- Backup credentials should be write-only from the source where possible; deletion needs a different identity.
- Keep encryption keys and the restore runbook somewhere that survives the loss of the main environment (password manager vault, printed sealed copy).
FILE:templates/backup-policy.md
# Backup Policy - <system name>

**Owner:** <team / person>  **Reviewed:** <date>  **Next review:** <date + 6 months>

## Scope
- Data: <databases, buckets, file shares, configs>
- Excluded: <caches, rebuildable artifacts>

## Requirements
| Item | Target | Source |
|---|---|---|
| RPO (max data loss) | <4 h> | <business owner> |
| RTO (max restore time) | <2 h> | <business owner> |
| Minimum history | <35 days> | <ops> |
| Legal / contractual retention | <3 years> | <contract, regulation> |
| Storage budget | <1500 GB / 40 USD per month> | <finance> |

## Schedule and keep rules
| Job | Frequency | Method | Keep rules |
|---|---|---|---|
| <full/incremental> | <daily 02:00> | <pgBackRest incr to S3> | <daily 14, weekly 8, monthly 12, yearly 5> |
| <log shipping> | <continuous> | <WAL archive> | <14 days> |

Restore points produced (from `scripts/retention_plan.py`): <count>, oldest <age>, largest gap <days>.

## Copies (3-2-1-1-0)
| Copy | Location | Immutable | Encrypted | Credentials |
|---|---|---|---|---|
| Primary backup | <region A bucket> | <no> | <yes> | <backup-writer role> |
| Offsite | <region B / provider B> | <object lock 35 d> | <yes> | <separate account> |

## Storage and cost
- Now: <GB> / <cost per month>  - In 12 months: <GB> / <cost per month>

## Restore testing
- Cadence: <quarterly drill, monthly automated check>
- Last test: <date>, result <ok / issues>, actual RTO <time>
- Runbook: <link>  - Keys escrowed at: <location>

## Monitoring and alerts
- <newest backup older than 36 h -> page on-call>
- <job failed / size change > 30% -> ticket>

## Open findings
- <severity code: action, owner, due date>
FILE:examples/clinic-postgres-policy.json
{
  "name": "clinic-booking PostgreSQL (pgBackRest to S3)",
  "today": "2026-10-24",
  "backup_interval_hours": 24,
  "backup_duration_hours": 2.5,
  "policy": {"daily": 7, "weekly": 4, "monthly": 6, "yearly": 0},
  "size": {"full_gb": 180, "daily_change_pct": 3, "growth_pct_per_month": 4, "storage_model": "incremental"},
  "requirements": {
    "rpo_hours": 4,
    "min_history_days": 90,
    "legal_retention_years": 3,
    "max_storage_gb": 1500,
    "restore_test_max_age_days": 90
  },
  "copies": {"onsite": false, "offsite": true, "immutable": false, "encrypted": true},
  "price_per_gb_month": 0.023,
  "currency": "USD",
  "last_restore_test": "2026-04-14"
}
FILE:scripts/retention_plan.py
#!/usr/bin/env python3
"""Backup retention policy planner (Python 3 standard library only).

Simulates a grandfather-father-son (GFS) retention policy the way restic/borg "keep" rules
work, then reports the restore points you really keep, the gaps between them, storage and
cost, and the gaps against your requirements (RPO, minimum history, legal retention, 3-2-1).

Input JSON:
  {"name": "orders-db", "today": "2026-10-24",
   "backup_interval_hours": 24, "backup_duration_hours": 1.5,
   "policy": {"hourly": 0, "daily": 7, "weekly": 4, "monthly": 12, "yearly": 3},
   "size": {"full_gb": 120, "daily_change_pct": 4, "growth_pct_per_month": 3,
            "storage_model": "incremental"},          # or "full" (every point is a full copy)
   "requirements": {"rpo_hours": 24, "min_history_days": 35, "legal_retention_years": 0,
                    "max_storage_gb": 0, "restore_test_max_age_days": 90},
   "copies": {"onsite": true, "offsite": true, "immutable": false, "encrypted": true},
   "price_per_gb_month": 0.023, "currency": "USD",
   "last_restore_test": "2026-03-02"}                 # or null if never tested
Selection rule per tier (newest backup first): keep a backup if its bucket (hour, day, ISO week,
month, year) differs from the previous kept bucket of that tier, until the tier count is used.
A backup kept by several tiers is stored once.
Storage: "full" = points x full size; "incremental" = one full + each later point's changed data
(daily_change_pct x days since the previous kept point, capped at 100%).
Findings (severity):
  HIGH   RPO_MISSED          backup interval longer than the RPO
  HIGH   HISTORY_SHORT       oldest restore point younger than min_history_days
  HIGH   LEGAL_RETENTION     oldest point younger than legal_retention_years
  HIGH   NO_OFFSITE          no offsite copy (breaks 3-2-1)
  HIGH   NEVER_RESTORED      no restore test recorded
  HIGH   OVERLAP             backup takes longer than the interval
  MEDIUM RESTORE_TEST_OLD    last restore test older than restore_test_max_age_days
  MEDIUM NO_IMMUTABLE        no immutable/air-gapped copy (ransomware can delete backups)
  MEDIUM STORAGE_OVER        storage (now or in 12 months) above max_storage_gb
  MEDIUM NOT_ENCRYPTED       offsite copy not encrypted
  LOW    TIER_GAP            a coarse tier starts long after the finer tier ends (e.g. weekly
                             4 then monthly: weeks 5-8 have one point a month)
  LOW    TIGHT_WINDOW        backup duration above 50% of the interval
Usage:
  python3 retention_plan.py policy.json
  python3 retention_plan.py policy.json --json
  python3 retention_plan.py --self-test
Exit code: 0 no HIGH findings, 1 HIGH findings, 2 bad input.
"""
import argparse
import datetime as dt
import json
import sys

TIERS = ["hourly", "daily", "weekly", "monthly", "yearly"]
SEV_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}


class DataError(ValueError):
    pass


def bucket(tier, t):
    if tier == "hourly":
        return t.strftime("%Y-%m-%d %H")
    if tier == "daily":
        return t.date().isoformat()
    if tier == "weekly":
        y, w, _ = t.isocalendar()
        return f"{y}-W{w:02d}"
    if tier == "monthly":
        return t.strftime("%Y-%m")
    return str(t.year)


def simulate(policy, interval_h, today):
    """Return (kept list of (datetime, [tiers])) newest first."""
    for k in policy:
        if k not in TIERS:
            raise DataError(f"unknown policy tier '{k}' (use {', '.join(TIERS)})")
        if not isinstance(policy[k], int) or policy[k] < 0:
            raise DataError(f"policy.{k} must be a whole number >= 0")
    if sum(policy.get(t, 0) for t in TIERS) == 0:
        raise DataError("policy keeps nothing")
    if not interval_h or interval_h <= 0:
        raise DataError("backup_interval_hours must be > 0")
    span_days = max(policy.get("hourly", 0) / 24, policy.get("daily", 0), policy.get("weekly", 0) * 7,
                    policy.get("monthly", 0) * 31, policy.get("yearly", 0) * 366) + 400
    newest = dt.datetime.combine(today, dt.time(2, 0))
    step = dt.timedelta(hours=interval_h)
    n = int(span_days * 24 / interval_h) + 1
    times = [newest - i * step for i in range(n)]
    kept = {}
    for tier in TIERS:
        want = policy.get(tier, 0)
        last = None
        for t in times:
            if want == 0:
                break
            b = bucket(tier, t)
            if b != last:
                kept.setdefault(t, []).append(tier)
                last = b
                want -= 1
    return sorted(kept.items(), key=lambda kv: kv[0], reverse=True)


def storage_gb(kept, size):
    full = float(size.get("full_gb", 0))
    model = size.get("storage_model", "incremental")
    if model not in ("incremental", "full"):
        raise DataError("size.storage_model must be 'incremental' or 'full'")
    if model == "full":
        return full * len(kept)
    change = float(size.get("daily_change_pct", 5)) / 100
    total = full
    for (newer, _), (older, _) in zip(kept, kept[1:]):
        days = (newer - older).total_seconds() / 86400
        total += full * min(1.0, change * days)
    return total


def plan(cfg, today=None):
    try:
        today = today or dt.date.fromisoformat(cfg.get("today") or dt.date.today().isoformat())
    except ValueError:
        raise DataError("today must be YYYY-MM-DD")
    policy = cfg.get("policy") or {}
    interval = float(cfg.get("backup_interval_hours", 24))
    kept = simulate(policy, interval, today)
    newest = kept[0][0]
    ages = [(newest - t).total_seconds() / 86400 for t, _ in kept]
    oldest_days = ages[-1]
    size = cfg.get("size") or {}
    req = cfg.get("requirements") or {}
    copies = cfg.get("copies") or {}
    price = float(cfg.get("price_per_gb_month", 0))
    gb = storage_gb(kept, size)
    growth = float(size.get("growth_pct_per_month", 0)) / 100
    gb12 = gb * (1 + growth) ** 12
    tiers = []
    for tier in TIERS:
        pts = [(t, a) for (t, tl), a in zip(kept, ages) if tier in tl]
        if pts:
            tiers.append({"tier": tier, "count": len(pts), "newest_age_days": round(pts[0][1], 1),
                          "oldest_age_days": round(pts[-1][1], 1)})
    gaps = []
    for (t1, _), (t2, _), a in zip(kept, kept[1:], ages[1:]):
        gaps.append({"from": t2.date().isoformat(), "to": t1.date().isoformat(),
                     "gap_days": round((t1 - t2).total_seconds() / 86400, 1), "age_days": round(a, 1)})
    findings = []

    def add(sev, code, msg):
        findings.append({"severity": sev, "code": code, "message": msg})

    rpo = req.get("rpo_hours")
    if rpo and interval > rpo:
        add("HIGH", "RPO_MISSED", f"backups every {interval:g} h but RPO is {rpo:g} h; up to {interval:g} h of data can be lost")
    mh = req.get("min_history_days")
    if mh and oldest_days < mh:
        add("HIGH", "HISTORY_SHORT", f"oldest restore point is {oldest_days:.0f} days old; requirement is {mh} days")
    ly = req.get("legal_retention_years")
    if ly and oldest_days < ly * 365:
        add("HIGH", "LEGAL_RETENTION", f"oldest point {oldest_days / 365:.1f} years; legal retention needs {ly} years; yearly N also counts the current year, so set yearly >= {ly + 2} to cover it all year round")
    if not copies.get("offsite"):
        add("HIGH", "NO_OFFSITE", "no offsite copy; a site loss, account compromise or region outage takes every backup")
    dur = cfg.get("backup_duration_hours")
    if dur and dur > interval:
        add("HIGH", "OVERLAP", f"backup takes {dur:g} h but runs every {interval:g} h; jobs overlap or get skipped")
    elif dur and dur > interval / 2:
        add("LOW", "TIGHT_WINDOW", f"backup takes {dur:g} h of a {interval:g} h interval; growth will push it into overlap")
    lrt = cfg.get("last_restore_test")
    max_age = req.get("restore_test_max_age_days", 90)
    if not lrt:
        add("HIGH", "NEVER_RESTORED", "no restore test recorded; an untested backup is a hope, not a backup")
    else:
        age = (today - dt.date.fromisoformat(lrt)).days
        if age > max_age:
            add("MEDIUM", "RESTORE_TEST_OLD", f"last restore test {age} days ago (limit {max_age}); schedule a timed restore")
    if not copies.get("immutable"):
        add("MEDIUM", "NO_IMMUTABLE", "no immutable or air-gapped copy; ransomware or a stolen admin key can delete every backup")
    if copies.get("offsite") and copies.get("encrypted") is False:
        add("MEDIUM", "NOT_ENCRYPTED", "offsite copy is not encrypted at rest")
    mx = req.get("max_storage_gb")
    if mx and max(gb, gb12) > mx:
        add("MEDIUM", "STORAGE_OVER", f"storage {gb:.0f} GB now, {gb12:.0f} GB in 12 months; budget {mx} GB")
    order = [t for t in TIERS if policy.get(t, 0) > 0]
    span = {"hourly": 1 / 24, "daily": 1, "weekly": 7, "monthly": 30.4, "yearly": 365}
    for fine, coarse in zip(order, order[1:]):
        fine_end = policy[fine] * span[fine]
        if span[coarse] >= 4 * fine_end and fine != "hourly":
            add("LOW", "TIER_GAP", f"{fine} points cover ~{fine_end:.0f} days, then only one {coarse} point per {span[coarse]:.0f} days")
    findings.sort(key=lambda f: SEV_ORDER[f["severity"]])
    return {
        "name": cfg.get("name", "backup set"), "today": today.isoformat(), "interval_hours": interval,
        "restore_points": len(kept), "oldest_age_days": round(oldest_days, 1),
        "tiers": tiers, "largest_gaps": sorted(gaps, key=lambda g: -g["gap_days"])[:5],
        "storage_gb": round(gb, 1), "storage_gb_12m": round(gb12, 1),
        "monthly_cost": round(gb * price, 2), "monthly_cost_12m": round(gb12 * price, 2),
        "currency": cfg.get("currency", "USD"), "storage_model": size.get("storage_model", "incremental"),
        "kept": [{"time": t.strftime("%Y-%m-%d %H:%M"), "tiers": tl} for t, tl in kept],
        "findings": findings,
    }


def age_label(d):
    if d < 2:
        return f"{d * 24:.0f} h"
    if d < 60:
        return f"{d:.0f} d"
    if d < 730:
        return f"{d / 30.4:.1f} mo"
    return f"{d / 365:.1f} y"


def render(r):
    out = [f"# Backup retention plan: {r['name']} (as of {r['today']})", "",
           f"Backups every {r['interval_hours']:g} h -> {r['restore_points']} restore points kept, oldest {age_label(r['oldest_age_days'])} old",
           f"Storage ({r['storage_model']}): {r['storage_gb']:.0f} GB now, {r['storage_gb_12m']:.0f} GB in 12 months"
           f" -> {r['monthly_cost']:.2f} -> {r['monthly_cost_12m']:.2f} {r['currency']}/month", "",
           "| Tier | Points | Newest age | Oldest age |", "|---|---|---|---|"]
    for t in r["tiers"]:
        out.append(f"| {t['tier']} | {t['count']} | {age_label(t['newest_age_days'])} | {age_label(t['oldest_age_days'])} |")
    out += ["", "## Largest gaps between restore points", "| Older point | Newer point | Gap | Age of older point |", "|---|---|---|---|"]
    for g in r["largest_gaps"]:
        out.append(f"| {g['from']} | {g['to']} | {g['gap_days']:g} d | {age_label(g['age_days'])} |")
    out += ["", "## Restore points kept"]
    out.append(", ".join(f"{k['time'][:10]} ({'/'.join(x[0].upper() for x in k['tiers'])})" for k in r["kept"]))
    out += ["", "## Findings"]
    if not r["findings"]:
        out.append("- none")
    for f in r["findings"]:
        out.append(f"- {f['severity']} {f['code']}: {f['message']}")
    return "\n".join(out) + "\n"


def self_test():
    ok = 0
    tests = []

    def check(name, cond):
        nonlocal ok
        tests.append(name)
        print(("PASS " if cond else "FAIL ") + name)
        ok += bool(cond)

    today = dt.date(2026, 10, 24)
    base = {"today": "2026-10-24", "backup_interval_hours": 24, "policy": {"daily": 7, "weekly": 4, "monthly": 12},
            "size": {"full_gb": 100, "daily_change_pct": 2}, "requirements": {"rpo_hours": 24, "min_history_days": 35},
            "copies": {"offsite": True, "immutable": True, "encrypted": True}, "last_restore_test": "2026-10-01"}
    r = plan(base, today)
    # daily 7 = 7 newest days; weekly 4 overlaps with dailies; monthly 12
    check("daily 7 + weekly 4 + monthly 12 -> 20 distinct points", r["restore_points"] == 20)
    check("clean policy has no findings", r["findings"] == [])
    check("oldest point is the 2025-11-30 monthly", r["kept"][-1]["time"].startswith("2025-11-30"))
    k = simulate({"daily": 3}, 24, today)
    check("daily 3 keeps 3 newest days", [t.date().day for t, _ in k] == [24, 23, 22])
    k = simulate({"hourly": 6}, 1, today)
    check("hourly 6 keeps 6 points one hour apart", len(k) == 6 and (k[0][0] - k[5][0]) == dt.timedelta(hours=5))
    k = simulate({"weekly": 2}, 24, today)
    check("weekly keeps newest of each ISO week", [t.date().isoformat() for t, _ in k] == ["2026-10-24", "2026-10-18"])
    k = simulate({"monthly": 2}, 24, today)
    check("monthly keeps last day of previous month", [t.date().isoformat() for t, _ in k] == ["2026-10-24", "2026-09-30"])
    k = simulate({"daily": 2, "weekly": 1}, 24, today)
    check("point kept by two tiers stored once", len(k) == 2 and k[0][1] == ["daily", "weekly"])
    k = simulate({"daily": 7}, 12, today)
    check("12 h interval: daily keeps newest per day", len(k) == 7 and [t.hour for t, _ in k[:2]] == [2, 14])
    check("full storage = points x full", storage_gb([(dt.datetime(2026, 1, 2), []), (dt.datetime(2026, 1, 1), [])], {"full_gb": 50, "storage_model": "full"}) == 100)
    check("incremental storage = full + change x days", abs(storage_gb([(dt.datetime(2026, 1, 11), []), (dt.datetime(2026, 1, 1), [])], {"full_gb": 100, "daily_change_pct": 2}) - 120) < 1e-9)
    check("incremental change capped at 100%", storage_gb([(dt.datetime(2026, 6, 1), []), (dt.datetime(2026, 1, 1), [])], {"full_gb": 10, "daily_change_pct": 5}) == 20)

    def codes(**over):
        c = json.loads(json.dumps(base))
        for key, val in over.items():
            if key != "policy" and isinstance(val, dict) and isinstance(c.get(key), dict):
                c[key].update(val)
            else:
                c[key] = val
        return {f["code"] for f in plan(c, today)["findings"]}

    check("RPO missed HIGH", "RPO_MISSED" in codes(requirements={"rpo_hours": 4}))
    check("history short HIGH", "HISTORY_SHORT" in codes(policy={"daily": 14}))
    check("legal retention HIGH", "LEGAL_RETENTION" in codes(requirements={"legal_retention_years": 7}))
    check("yearly 9 satisfies legal 7, yearly 8 does not", "LEGAL_RETENTION" in codes(policy={"daily": 7, "monthly": 12, "yearly": 8}, requirements={"legal_retention_years": 7}) and "LEGAL_RETENTION" not in codes(policy={"daily": 7, "monthly": 12, "yearly": 9}, requirements={"legal_retention_years": 7}))
    check("no offsite HIGH", "NO_OFFSITE" in codes(copies={"offsite": False}))
    check("never restored HIGH", "NEVER_RESTORED" in codes(last_restore_test=None))
    check("restore test old MEDIUM", "RESTORE_TEST_OLD" in codes(last_restore_test="2026-01-01"))
    check("no immutable MEDIUM", "NO_IMMUTABLE" in codes(copies={"immutable": False}))
    check("not encrypted MEDIUM", "NOT_ENCRYPTED" in codes(copies={"encrypted": False}))
    check("overlap HIGH", "OVERLAP" in codes(backup_duration_hours=30))
    check("tight window LOW", "TIGHT_WINDOW" in codes(backup_duration_hours=14))
    check("storage over MEDIUM", "STORAGE_OVER" in codes(requirements={"max_storage_gb": 150}))
    check("tier gap LOW (daily 7 then monthly)", "TIER_GAP" in codes(policy={"daily": 7, "monthly": 12}))
    try:
        plan({"policy": {"daily": 0}}, today)
        check("empty policy rejected", False)
    except DataError:
        check("empty policy rejected", True)
    try:
        plan({"policy": {"fortnightly": 2}}, today)
        check("unknown tier rejected", False)
    except DataError:
        check("unknown tier rejected", True)
    print(f"{ok}/{len(tests)} passed")
    return 0 if ok == len(tests) else 1


def main():
    ap = argparse.ArgumentParser(description="Backup retention (GFS) policy planner")
    ap.add_argument("policy", nargs="?", help="policy JSON file")
    ap.add_argument("--json", action="store_true", help="print JSON instead of Markdown")
    ap.add_argument("--self-test", action="store_true")
    a = ap.parse_args()
    if a.self_test:
        return self_test()
    if not a.policy:
        ap.error("policy file required")
    try:
        with open(a.policy, encoding="utf-8") as fh:
            cfg = json.load(fh)
        r = plan(cfg)
    except (OSError, json.JSONDecodeError, DataError, ValueError) as e:
        print(f"error: {e}", file=sys.stderr)
        return 2
    print(json.dumps(r, indent=2) if a.json else render(r), end="" if not a.json else "\n")
    return 1 if any(f["severity"] == "HIGH" for f in r["findings"]) else 0


if __name__ == "__main__":
    sys.exit(main())
PythonDevOpsDatabase+2
F@f
0
Desert Caravanserai at Dusk Scene Concept Brief Builder
1
Text

Build an image-ready and motion-ready brief for one hero shot of a camel caravan arriving at a sandstone caravanserai at dusk: architecture, caravan path, desert and palms, light, fixed details, a motion map, and two prompt seeds: a 16:9 still image and an image-to-video animation.

Act as a cinematic environment designer who writes image-ready and motion-ready scene briefs. Help me design one hero shot of an old desert caravanserai at dusk with a camel caravan arriving, which 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 naturally (camels walking with steady strides, flickering lantern light, palm fronds swaying, fine sand drifting over dune ripples, stars appearing as the sky darkens) while keeping the composition calm, readable, and easy to animate.

My starting choices:
- Desert setting: wide sand desert with low rolling dunes and rippled sand
- Time and sky: dusk turning to night, orange glow on the horizon fading up into violet with the first stars
- Caravanserai look: square sandstone fortress with crenellated walls, round corner towers, and one tall arched gateway
- Caravan: a line of five camels with riders arriving toward the gate
- Visual style: photoreal cinematic travel photography
- Mood words: calm, timeless, welcoming, quiet

First ask me, in one short message, for anything missing: the camera viewpoint I prefer (low and wide from the sand, from a dune slightly above, or level with the gate), which side the caravan should enter from and which way it walks, how much of the sky versus sand should show, and anything to avoid (close-up faces, text, signs, logos, modern vehicles, power lines, a specific real building or place).

Then produce the brief with these sections:

1. **Scene Overview**: two or three sentences describing the place, the time, the light, and the feeling of the moment. Describe an imagined caravanserai, not a copy of a real, named one.

2. **Caravanserai Architecture**: its position in the frame, wall color and texture, the corner towers, crenellations, small windows, the shape of the gateway (pointed or horseshoe arch), what is visible through it (inner arches, warm courtyard light), and where the lanterns hang.

3. **Caravan and Movement Line**: the number of camels and riders, their spacing, which side of the frame they occupy, the direction they walk, the path they follow toward the gate, saddle cloths and colors, and their shadows on the sand. State that every camel faces the direction it walks.

4. **Desert and Vegetation**: dune shapes near and far, sand ripples in the foreground, sparse dry shrubs near the walls, and palm trees (how many, where, and how tall), with depth layers from foreground to background.

5. **Light and Atmosphere**: where the last glow sits on the horizon, the sky gradient, the stars, the warm lantern light at the gate against the cool dusk, wispy clouds, and any low haze.

6. **Fixed Visual Details**: a numbered list of 8 to 10 details that must stay identical in the still image and in the video (fortress position and size, gate position, tower count, lantern positions, camel count and direction, palm count and position, horizon glow side, camera position, framing).

7. **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 camels' walk, the lantern flicker, palms, drifting sand, stars, and the camera move. Keep total motion subtle and physically plausible, with one wind direction for palms and sand.

8. **Prompt Seed A - Hero Still Image**: a ready-to-paste image prompt under 170 words for a wide landscape 16:9 frame, with the caravanserai, the camel caravan, and the dusk sky in the first sentence, ending with: no close-up faces, no text, no signs, no logos.

9. **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 the camels walking steadily in the direction they face, the lantern and palm motion, one slow camera move for about 6 seconds, and says to keep the fortress, towers, palms, and framing exactly as in the image with no new objects.

Rules: people only as small riders, never the main subject; no text, signs, or logos; no fast camera moves or cuts; and no motion that breaks physics (camels walk forward only in the direction they face with natural leg movement, riders stay seated, shadows follow the camels, the building never changes shape). Use short paragraphs and lists.
TravelPlanningWorkflow+2
F@f
0
Crewelwork Embroidery of a Sleeping Red Fox Among Bluebells in a Hoop
Image
Crewelwork Embroidery of a Sleeping Red Fox Among Bluebells in a Hoop

Hand-embroidered crewelwork on cream linen in a wooden hoop: a curled sleeping red fox in long-and-short stitch wool, framed by satin-stitch bluebells, French-knot forget-me-nots, fern fronds, acorns, and oak leaves, shot top-down in soft daylight showing raised thread texture.

A hand-embroidered crewelwork panel of a red fox curled up asleep, stitched on natural cream linen and stretched in a round light wooden embroidery hoop with a brass tightening screw at the top. The fox lies in the center with its eyes closed and its bushy tail wrapped around its body, its fur worked in long-and-short stitch in rust, copper, cream, and dark brown wool so the raised threads follow the direction of the fur. Around it, a wreath of woodland plants: satin-stitch bluebells arching over the upper left, clusters of pale blue French-knot forget-me-nots scattered across the top and sides, sage-green fern fronds in fishbone and stem stitch on both sides, and two pairs of textured acorns with oak leaves along the bottom. Soft even daylight shows the thread texture and slight shine of the wool. Top-down photo, the hoop centered in the frame with plain linen fabric visible around it, wide landscape 16:9. Cozy, handmade, storybook mood. No text, no hands, no needles.
CreativeVintageArt+1
F@f
0
Photoreal Underwater Kelp Forest with a Sea Otter Holding a Sea Urchin
Image
Photoreal Underwater Kelp Forest with a Sea Otter Holding a Sea Urchin

Photoreal underwater shot in a California kelp forest: a curious sea otter hugging a purple sea urchin to its chest, golden giant kelp rising to the surface, slanting sunbeams, a school of silver fish, a bright orange garibaldi, and a rocky seabed in teal water.

A photoreal underwater photograph inside a California kelp forest. In the left foreground, a sea otter swims upright in the clear blue-green water, looking toward the camera with bright dark eyes and long whiskers, hugging a large purple sea urchin against its chest with its front paws, its dense brown fur sparkling with tiny air bubbles. Tall golden-brown giant kelp stalks with wide wavy blades rise from the rocky seabed to the rippling surface. Bright sunbeams slant down from the upper left through the water. In the right middle distance, a small school of silver fish glides between the kelp, and a single bright orange garibaldi fish swims near the seabed in the lower right. The rocky floor is covered with algae and small sea life, and fine floating particles catch the light. Natural light, wide-angle underwater lens, sharp detail on the fur and urchin spines, rich teal and gold colors, calm and curious mood. Wide landscape 16:9. No text, no divers, no watermark.
CreativeRealismPhotography+1
F@f
0
Dutch Golden Age Breakfast Still Life with Oysters and a Peeled Lemon
Image
Dutch Golden Age Breakfast Still Life with Oysters and a Peeled Lemon

1630s style oil still life on green velvet: opened oysters on a pewter plate, a peeled lemon with its spiral rind over the table edge, a tipped silver goblet, a bread roll, hazelnuts, a roemer of white wine, and a lace linen napkin in window light against a dark background.

A Dutch Golden Age breakfast still life oil painting in the manner of the 1630s Haarlem masters, showing a quiet laid table against a deep brown-black background. A rumpled dark green velvet cloth covers the table. On the left, three opened oysters glisten on a round pewter plate. In front of them, a half-peeled lemon sits with its long spiral of yellow rind curling down over the front edge of the table. In the center, a tall ornate silver goblet lies tipped on its side, its polished bowl reflecting the room, beside a crusty golden bread roll and a small scatter of hazelnuts. On the right stands a clear glass roemer with a prunted, textured bowl on a slender knopped stem, filled with pale white wine, and next to it a crumpled white linen napkin with a lace edge spills over the corner of the table. Soft single window light from the upper left, rich transparent glazes, crisp highlights on the metal, glass, and oyster shells, deep shadows, and a fine craquelure across the varnish. Museum quality, calm and contemplative mood, wide landscape 16:9. No text, no signature, no people.
CreativeArtHistory+2
F@f
0
Garage Sale Plan Builder (YAML)
Text

Turn a list of things to sell into a YAML garage sale plan: sorting rules, a quick-sale pricing guide with bundles, a prep timeline, table layout, sign and listing text, cash and payment setup, roles, a day-of schedule, haggling replies, safety, and a plan for unsold items.

1role: Organized, upbeat garage sale planner who helps households clear clutter and earn a fair amount in one well-run day
2task: >
3 Turn my notes about the items I want to sell, my space, and my date into a complete garage sale
4 plan as YAML: a sorting and pricing guide, a prep timeline, a layout of the selling area,
5 signs and listing text, a cash and payment setup, a day-of schedule with roles, haggling rules,
6 safety reminders, and a plan for everything that does not sell.
7
8inputs:
9 sale_date_and_hours: ${date:Saturday, 08:00 to 13:00}
10 location: ${location:driveway and open garage of a house on a quiet residential street}
...+83 more lines
YAMLPlanningPricing+2
F@f
0
1 / 14Next