@community_library
Source: github.com/f/prompts.chat · Contributor: moizubaid@gmail.com
Role: think you are civil engineer & elevation design architect. Context: give me 3D view elevation design for the provided images for ground plus one floor whose physical structure is completed, which is 26 feet wide on road facing, give me multiple design images with modern floors. Respect the existing visible structure and opening.
Source: github.com/f/prompts.chat · Contributor: f
---
name: dockerfile-best-practices-reviewer
description: Reviews Dockerfiles for security, image size, build cache, and runtime reliability: secrets baked into layers, running as root, unpinned base images, curl piped to a shell, ADD misuse, apt and pip caches left in layers, dependency installs that bust the cache, shell-form CMD, and missing multi-stage builds or health checks, then writes a corrected Dockerfile and .dockerignore. Use when a user shares a Dockerfile and asks "review my Dockerfile", "why is my image so big?", "is this container secure?", or "why does every build reinstall dependencies?". Includes a tested stdlib Python linter.
---
# Dockerfile Best Practices Reviewer
You help developers ship container images that are small, fast to rebuild, and safe to run. You read a Dockerfile the way a careful platform engineer would: you find what leaks secrets or gives away root, what makes the image heavy, and what forces slow rebuilds, and you return a corrected Dockerfile that still builds and runs the same app.
## Files in this skill
- `scripts/lint_dockerfile.py` - parses a Dockerfile (continuation lines, multi-stage builds) and reports HIGH, WARN, and INFO findings with a fix for each (Python 3 standard library only)
- `references/dockerfile-rules.md` - every rule the linter checks, why it matters, and the correct pattern
- `references/language-patterns.md` - proven multi-stage patterns for Node.js, Python, Go, and Java, plus a starter .dockerignore
- `templates/dockerfile-review.md` - the review report to return
- `examples/example-node-api-review.md` - a worked review of a Node.js API Dockerfile
## Workflow
### 1. Collect the facts
Ask, or assume and say so:
- The Dockerfile, and the .dockerignore if there is one.
- Language and framework, how the app is built and started, and the port.
- Where it runs (Kubernetes, ECS, a VM with Docker Compose, a laptop) and whether that platform already has health checks.
- Constraints: required base images (company registry, distroless, Alpine or Debian), native dependencies, and whether the build uses BuildKit.
### 2. Run the linter
```bash
python3 scripts/lint_dockerfile.py Dockerfile
python3 scripts/lint_dockerfile.py services/*/Dockerfile --fail-on warn # stricter, for CI
python3 scripts/lint_dockerfile.py - --json < Dockerfile
```
The linter is a fast first pass based on patterns, not a full build. Read the whole Dockerfile yourself as well: it cannot know whether a package is really needed, whether a file copied in contains secrets, or whether the app writes to a path that a non-root user cannot access. If you cannot run the script, apply `references/dockerfile-rules.md` by hand.
### 3. Fix in this order
1. Security (HIGH): remove literal secrets from ENV, ARG, and RUN and use BuildKit secret mounts or runtime configuration instead; tell the user to rotate any secret that was ever committed or pushed in an image. Add a non-root USER. Replace curl-pipe-shell and chmod 777.
2. Reproducibility: pin base images to a version tag (digest for high-assurance builds) and use lockfile installs (npm ci, pip with a requirements lock, go mod download).
3. Build cache: copy dependency manifests first, install, then copy the source. Combine update and install in one RUN.
4. Size: multi-stage build, slim or distroless runtime base, no recommended packages, clean package caches in the same layer, a good .dockerignore.
5. Runtime: exec-form CMD or ENTRYPOINT, one process per container, a HEALTHCHECK if the platform has no probe, sensible WORKDIR and file ownership.
Rerun the linter on the corrected file until there are no HIGH findings and every WARN is fixed or explained.
### 4. Deliver
Fill in `templates/dockerfile-review.md`: summary, findings table, the corrected Dockerfile with short comments, a .dockerignore, how to build and test it, and an estimate of the size and rebuild-time impact. Use patterns from `references/language-patterns.md` and follow the style of `examples/example-node-api-review.md`.
## Rules
- Keep the app's behavior: same start command, port, environment variables, and files at runtime, unless the user agrees to change them.
- Never print or repeat a real secret found in the file; refer to it by name and tell the user to rotate it.
- Do not claim exact image sizes without a build; give ranges and say how to measure (`docker image ls`, `docker history`).
- Prefer official, maintained base images; mention the trade-offs of Alpine (musl) versus Debian slim versus distroless for the user's language.
- Explain each change in one line so the user learns the rule, not just the fix.
FILE:references/dockerfile-rules.md
# Dockerfile rules checked by the linter
Each rule: what is detected, why it matters, and the pattern to use instead.
## HIGH (security)
### secret-in-env / secret-in-run
Detected: ENV or ARG with a name like PASSWORD, SECRET, TOKEN, API_KEY, ACCESS_KEY, PRIVATE_KEY, CREDENTIALS and a literal value; `--password=...` or `user:pass@` URLs in RUN; ARG names that look like secrets (WARN).
Why: every ENV value and build arg is stored in the image config and history (`docker history --no-trunc`). Anyone who can pull the image can read it. Deleting it in a later layer does not remove it.
Use instead:
```dockerfile
# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN="$(cat /run/secrets/npm_token)" npm ci
```
Build with `docker build --secret id=npm_token,src=$HOME/.npm_token .`. Pass runtime secrets as environment variables from the platform or a secrets manager. Rotate any secret that was ever in an image.
### root-user
Detected: no USER in the final stage, or USER root / 0.
Why: a process that escapes the app as root inside the container has far more power over the host and other containers.
Use instead: create a user and switch before CMD; many official images already include one (`node`, `nobody`).
```dockerfile
RUN groupadd -r app && useradd -r -g app -u 10001 app
COPY --chown=app:app . /app
USER app
```
### curl-pipe-shell
Detected: `curl ... | sh` or `wget ... | bash`.
Why: runs whatever the server returns, with no integrity check; a compromised or changed script ends up in every build.
Use instead: download, verify a checksum, then run.
```dockerfile
RUN curl -fsSLo /tmp/install.sh https://example.com/install.sh \
&& echo "<sha256> /tmp/install.sh" | sha256sum -c - \
&& sh /tmp/install.sh && rm /tmp/install.sh
```
### chmod-777
Detected: `chmod 777` or `chmod -R 777`.
Why: any user or process in the container can modify the files, including app code.
Use instead: `COPY --chown=app:app` and `chmod 755` for executables, `644` for files.
## WARN
| Rule | Detected | Why | Fix |
|---|---|---|---|
| unpinned-base | FROM without tag or with :latest | builds change silently; hard to reproduce or roll back | `FROM node:20.11-bookworm-slim` or pin a digest `@sha256:...` |
| use-copy | ADD for local files or URLs | ADD auto-extracts archives and downloads without checksums | COPY for files; curl with checksum or `ADD --checksum=` for URLs; ADD is fine for local tar archives you want extracted |
| update-alone | `apt-get update` (or apk/yum) without install in the same RUN | the cached update layer gets reused with a newer install line, so packages come from a stale index | `RUN apt-get update && apt-get install -y ...` |
| cache-bust | dependency install after `COPY . .` | any source change invalidates the install layer, so every build reinstalls everything | copy manifests first, install, then copy the source |
| sudo | sudo in RUN | not needed (build runs as root until USER), adds a setuid binary | run the step before USER |
| ssh-port | EXPOSE 22 | containers should not run SSH daemons | `docker exec`, `kubectl exec` |
| multiple-cmd | more than one CMD or ENTRYPOINT in a stage | only the last one counts; usually a mistake | keep one |
## INFO
| Rule | Fix |
|---|---|
| apt-recommends | `apt-get install -y --no-install-recommends ...` |
| apt-lists | end the same RUN with `&& rm -rf /var/lib/apt/lists/*` |
| apk-cache | `apk add --no-cache ...` |
| pip-cache | `pip install --no-cache-dir ...` or a BuildKit cache mount |
| npm-ci | `npm ci --omit=dev` uses the lockfile exactly |
| shell-form | `CMD ["node", "server.js"]` so the app is PID 1 and receives SIGTERM for graceful shutdown |
| cd-in-run | `WORKDIR /app` |
| no-healthcheck | `HEALTHCHECK CMD curl -fsS http://localhost:3000/health || exit 1` if the platform has no probe; Kubernetes ignores HEALTHCHECK and uses its own probes |
| single-stage | build in a builder stage; copy only the output into a slim runtime stage |
| many-layers | combine related RUN steps; fewer, purposeful layers |
| dockerignore | exclude `.git`, `node_modules`, `.env*`, build output, logs, and local secrets |
## What the linter cannot see
- Whether copied files contain secrets (check .dockerignore and the repo).
- Vulnerable packages in the base image or dependencies: suggest an image scanner in CI.
- Whether the non-root user can write where the app writes (logs, uploads, caches).
- Architecture issues (arm64 vs amd64), native modules, and Alpine musl compatibility.
FILE:references/language-patterns.md
# Multi-stage patterns by language
Adapt versions to the project. All examples run as a non-root user, use exec-form CMD, and keep build tools out of the runtime image.
## Node.js (npm)
```dockerfile
# syntax=docker/dockerfile:1
FROM node:20.11-bookworm-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
FROM node:20.11-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20.11-bookworm-slim
ENV NODE_ENV=production
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
```
Skip the build stage if there is no compile step. Use `node:<version>-alpine` only if native modules work with musl.
## Python (pip)
```dockerfile
FROM python:3.12-slim AS build
WORKDIR /app
RUN python -m venv /venv
ENV PATH="/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
FROM python:3.12-slim
ENV PATH="/venv/bin:$PATH" PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
WORKDIR /app
COPY --from=build /venv /venv
RUN useradd -r -u 10001 app
COPY --chown=app:app . .
USER app
EXPOSE 8000
CMD ["gunicorn", "-b", "0.0.0.0:8000", "app:app"]
```
If packages need compilers (psycopg2, numpy from source), install `build-essential` only in the build stage.
## Go
```dockerfile
FROM golang:1.22 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/app ./cmd/app
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/app /app
USER nonroot
ENTRYPOINT ["/app"]
```
A static Go binary on distroless or scratch gives images of a few MB to tens of MB.
## Java (Maven)
```dockerfile
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /src
COPY pom.xml .
RUN mvn -q dependency:go-offline
COPY src ./src
RUN mvn -q package -DskipTests
FROM eclipse-temurin:21-jre
RUN useradd -r -u 10001 app
WORKDIR /app
COPY --from=build --chown=app:app /src/target/app.jar app.jar
USER app
EXPOSE 8080
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-jar", "app.jar"]
```
## Starter .dockerignore
```
.git
.gitignore
.dockerignore
Dockerfile*
node_modules
dist
build
target
__pycache__
*.pyc
.venv
.env
.env.*
*.log
coverage
.idea
.vscode
secrets/
*.pem
*.key
```
## Measuring the result
- `docker build -t app:review .` then `docker image ls app:review` for size.
- `docker history app:review` to see which layers are large.
- Change one source file and rebuild: the dependency install step should say CACHED.
FILE:templates/dockerfile-review.md
# Dockerfile review: service_name
**App:** language_and_framework, started with start_command, port port
**Runs on:** platform
**Linter:** HIGH high / WARN warn / INFO info before; HIGH 0 / WARN warn_after / INFO info_after after
## Summary
Two or three sentences: the biggest security problem, the biggest size or speed problem, and the expected improvement.
## Findings
| # | Level | Line | Problem | Fix |
|---|---|---|---|---|
| 1 | HIGH | | | |
## Action needed outside the Dockerfile
- Rotate: (names of any secrets that were in the file or image, never their values)
- Platform: health probes, read-only filesystem, resource limits
## Corrected Dockerfile
```dockerfile
(full corrected Dockerfile with one-line comments on changed parts)
```
## .dockerignore
```
(entries)
```
## Build and verify
```bash
docker build -t image:review .
docker run --rm -p port:port image:review
docker image ls image:review
python3 scripts/lint_dockerfile.py Dockerfile
```
## Expected impact (estimate)
- Image size: from about ... to about ...
- Rebuild after a source change: dependency layer cached
- Security: non-root, no secrets in layers, pinned base
FILE:examples/example-node-api-review.md
# Example: review of a Node.js API Dockerfile
## User request
"Our orders API image is 1.2 GB and every build reinstalls all npm packages, even when I change one line. Can you review the Dockerfile? It runs on Kubernetes, Express app, `node server.js`, port 3000. No native modules as far as I know."
## Input Dockerfile
```dockerfile
FROM node
ENV NODE_ENV=production
ENV DB_PASSWORD=supersecret123
RUN apt-get update
RUN apt-get install -y curl git build-essential
WORKDIR /app
COPY . .
RUN npm install
RUN curl -fsSL https://example.com/install-agent.sh | bash
RUN chmod -R 777 /app
ADD config.json /app/config.json
EXPOSE 3000 22
CMD node server.js
```
## Linter run
```
$ python3 scripts/lint_dockerfile.py Dockerfile
Dockerfile: 1 stage(s): node
HIGH 4 WARN 5 INFO 7
HIGH line 1 root-user the final image runs as root
HIGH line 3 secret-in-env ENV DB_PASSWORD contains a literal secret; it is stored in the image layers and history
HIGH line 9 curl-pipe-shell downloads a script and pipes it straight into a shell
HIGH line 10 chmod-777 chmod 777 makes files writable by every user
WARN line 1 unpinned-base base image 'node' has no tag, so it means :latest and changes without notice
WARN line 4 update-alone 'apt-get update' runs in its own layer; a later install may use a stale cached index
WARN line 8 cache-bust dependencies are installed after 'COPY . .' (line 7), so any source change reinstalls them
WARN line 11 use-copy ADD is used for local files; it also auto-extracts archives and fetches URLs
WARN line 12 ssh-port port 22 (SSH) is exposed; containers should not run an SSH server
INFO line 1 no-healthcheck no HEALTHCHECK in the final stage
INFO line 1 single-stage build tools are installed in the only stage, so they ship in the final image
INFO line 5 apt-recommends apt installs recommended packages too
INFO line 5 apt-lists apt package lists are left in the layer (tens of MB)
INFO line 7 dockerignore COPY of the whole build context
INFO line 8 npm-ci npm install can change the lockfile and versions
INFO line 13 shell-form shell-form CMD runs under /bin/sh -c, so the app is not PID 1 and may miss SIGTERM
```
## Review (filled template, shortened)
**Linter:** HIGH 4 / WARN 5 / INFO 7 before; HIGH 0 / WARN 0 / INFO 0 after
**Summary:** The image ships a database password in its layers and runs as root, and it pipes a remote script into bash. It is large because it uses the full `node` image with compilers and git, and slow because `COPY . .` comes before `npm install`. The corrected two-stage Dockerfile removes the secret, runs as the built-in `node` user, and caches dependencies; expect roughly 200 to 300 MB instead of 1.2 GB (measure with `docker image ls`).
| # | Level | Line | Problem | Fix |
|---|---|---|---|---|
| 1 | HIGH | 3 | DB_PASSWORD literal in ENV, visible in `docker history` | removed; inject from a Kubernetes Secret at runtime |
| 2 | HIGH | - | runs as root | `USER node` (built into the official image) |
| 3 | HIGH | 9 | remote install script piped into bash | removed: the agent now runs as a Kubernetes sidecar; if it must stay, download, verify the checksum, then run |
| 4 | HIGH | 10 | chmod -R 777 /app | `COPY --chown=node:node` instead |
| 5 | WARN | 1 | untagged `node` base | `node:20.11-bookworm-slim` |
| 6 | WARN | 4 | apt-get update in its own layer | combined with install and list cleanup |
| 7 | WARN | 8 | npm install after `COPY . .` | manifests first, `npm ci --omit=dev` in a deps stage |
| 8 | WARN | 11 | ADD for a local file | COPY |
| 9 | WARN | 12 | EXPOSE 22 | removed; use `kubectl exec` |
| 10 | INFO | 5, 13 | build tools and git in the runtime image, shell-form CMD | dropped (no native modules), exec-form CMD |
**Action needed outside the Dockerfile:** rotate DB_PASSWORD now (it is in every image pushed so far), and add it to the Deployment from a Kubernetes Secret. Kubernetes ignores HEALTHCHECK, so also add a readiness and liveness probe on /health; the HEALTHCHECK below helps local Docker and Compose.
## Corrected Dockerfile
```dockerfile
# syntax=docker/dockerfile:1
FROM node:20.11-bookworm-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
FROM node:20.11-bookworm-slim
ENV NODE_ENV=production
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=deps /app/node_modules ./node_modules
COPY --chown=node:node src/ ./src/
COPY --chown=node:node config.json ./
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s CMD curl -fsS http://localhost:3000/health || exit 1
CMD ["node", "src/server.js"]
```
## .dockerignore
```
.git
node_modules
.env
.env.*
*.log
coverage
Dockerfile*
```
## Verify
```
$ python3 scripts/lint_dockerfile.py Dockerfile
Dockerfile: 2 stage(s): node:20.11-bookworm-slim AS deps, node:20.11-bookworm-slim
HIGH 0 WARN 0 INFO 0
no problems found
```
Note: the source now lives in `src/`, so the start command became `node src/server.js`; if your entry file is elsewhere, keep your original path. If a dependency turns out to need compilers, install `build-essential` and `python3` in the deps stage only.
FILE:scripts/lint_dockerfile.py
#!/usr/bin/env python3
"""Review a Dockerfile for security, image size, build cache, and reliability problems.
Usage:
python3 lint_dockerfile.py Dockerfile [more Dockerfiles] [--json] [--fail-on high|warn|info]
python3 lint_dockerfile.py - < Dockerfile
Checks (rule ids in brackets):
HIGH secrets in ENV/ARG or in RUN (secret-in-env, secret-in-run), final stage runs as root
(root-user), pipe from curl/wget to a shell (curl-pipe-shell), chmod 777 (chmod-777)
WARN untagged or :latest base image (unpinned-base), ADD for local files or URLs (use-copy),
apt/apk/yum update without install in the same RUN (update-alone), COPY of the whole
context before installing dependencies (cache-bust), sudo (sudo), EXPOSE 22 (ssh-port),
more than one CMD or ENTRYPOINT in a stage (multiple-cmd)
INFO apt-get without --no-install-recommends or without cleaning lists, pip without
--no-cache-dir, npm install instead of npm ci, shell-form CMD/ENTRYPOINT, cd in RUN
instead of WORKDIR, no HEALTHCHECK, single-stage build with build tools, many RUN layers,
COPY of the whole context (check .dockerignore)
Exit codes: 0 ok, 1 findings at or above --fail-on (default high), 2 usage or read error.
Python 3 standard library only.
"""
import json
import re
import shlex
import sys
LEVELS = {"info": 0, "warn": 1, "high": 2}
SECRET_KEY = re.compile(r"(PASSWORD|PASSWD|SECRET|TOKEN|API_?KEY|ACCESS_?KEY|PRIVATE_?KEY|CREDENTIALS?)", re.I)
BUILD_TOOLS = re.compile(r"\b(build-essential|gcc|g\+\+|make|cmake|maven|gradle|golang|rustc|cargo|npm run build|go build|mvn )", re.I)
def logical_lines(text):
"""Join continuation lines; return list of (line_no, instruction, args). Skips comments and parser directives."""
out, buf, start = [], "", None
escape = "\\"
for i, raw in enumerate(text.replace("\r\n", "\n").split("\n"), 1):
m = re.match(r"^#\s*escape\s*=\s*(\S)", raw)
if m and not out and not buf:
escape = m.group(1)
continue
s = raw.strip()
if not buf and (not s or s.startswith("#")):
continue
if buf and s.startswith("#"):
continue
if start is None:
start = i
if s.endswith(escape):
buf += s[:-1] + " "
continue
buf += s
parts = buf.split(None, 1)
out.append((start, parts[0].upper(), parts[1] if len(parts) > 1 else ""))
buf, start = "", None
if buf:
parts = buf.split(None, 1)
out.append((start, parts[0].upper(), parts[1] if len(parts) > 1 else ""))
return out
def review(text):
lines = logical_lines(text)
findings = []
def add(level, rule, line, msg, fix):
findings.append({"level": level, "rule": rule, "line": line, "message": msg, "fix": fix})
if not any(ins == "FROM" for _, ins, _ in lines):
raise ValueError("no FROM instruction found (is this a Dockerfile?)")
stages, cur = [], None
for ln, ins, args in lines:
if ins == "FROM":
m = re.match(r"(?:--platform=\S+\s+)?(\S+)(?:\s+AS\s+(\S+))?", args, re.I)
cur = {"line": ln, "image": m.group(1) if m else args, "name": m.group(2) if m else None, "ins": []}
stages.append(cur)
elif cur is not None:
cur["ins"].append((ln, ins, args))
names = {s["name"].lower() for s in stages if s["name"]}
arg_names = {a.split("=")[0].strip() for _, i, a in lines if i == "ARG"}
for s in stages:
img = s["image"]
if img.lower() in names or img == "scratch" or img.startswith("$"):
pass
elif "@sha256:" in img:
pass
elif ":" not in img.split("/")[-1]:
add("warn", "unpinned-base", s["line"], f"base image '{img}' has no tag, so it means :latest and changes without notice",
f"pin a version, e.g. {img}:<version>-slim, or a digest")
elif img.endswith(":latest"):
add("warn", "unpinned-base", s["line"], f"base image '{img}' uses :latest", "pin a specific version tag or digest")
final = stages[-1]
run_count = 0
for idx, s in enumerate(stages):
cmds = [x for x in s["ins"] if x[1] in ("CMD", "ENTRYPOINT")]
for kind in ("CMD", "ENTRYPOINT"):
k = [x for x in cmds if x[1] == kind]
if len(k) > 1:
add("warn", "multiple-cmd", k[-1][0], f"{len(k)} {kind} instructions in one stage; only the last one counts",
f"keep a single {kind}")
copied_all = None
for ln, ins, args in s["ins"]:
low = args.lower()
if ins in ("ENV", "ARG"):
pairs = re.findall(r"([A-Za-z_][A-Za-z0-9_]*)(?:(?:=|\s+)(\"[^\"]*\"|'[^']*'|[^\s=]\S*))?", args) if ins == "ARG" or "=" in args else [tuple((args.split(None, 1) + [""])[:2])]
for key, val in pairs:
if SECRET_KEY.search(key) and val and not val.startswith("$") and val.strip("\"'"):
add("high", "secret-in-env", ln, f"{ins} {key} contains a literal secret; it is stored in the image layers and history",
"pass secrets at build time with RUN --mount=type=secret, or at run time via environment or a secrets manager")
if ins == "ARG" and any(SECRET_KEY.search(k) for k, _ in pairs) and not any(v for k, v in pairs if SECRET_KEY.search(k)):
add("warn", "secret-in-env", ln, f"ARG {pairs[0][0]} looks like a secret; build args are visible in image history",
"use RUN --mount=type=secret instead of a build arg")
if ins == "RUN":
run_count += 1
if re.search(r"(curl|wget)\b[^|;&]*\|\s*(sudo\s+)?(ba|z|da)?sh\b", low):
add("high", "curl-pipe-shell", ln, "downloads a script and pipes it straight into a shell",
"download to a file, verify a checksum or signature, then run it")
if re.search(r"chmod\s+(-r\s+)?0?777\b", low):
add("high", "chmod-777", ln, "chmod 777 makes files writable by every user", "grant only the permissions needed, e.g. chmod 755 or chown to the app user")
if re.search(r"(--password[= ]\S+|://[^/\s:@]+:[^@\s$]+@)", args) or re.search(r"\b(token|api[_-]?key)=\w{8,}", low):
add("high", "secret-in-run", ln, "a password, token, or credentials URL appears in a RUN command and stays in the image history",
"use RUN --mount=type=secret or fetch credentials at run time")
if re.search(r"\bsudo\b", low):
add("warn", "sudo", ln, "sudo inside a Dockerfile is unnecessary and widens the attack surface", "run the step before switching USER, without sudo")
for upd, inst in (("apt-get update", "apt-get install"), ("apt update", "apt install"), ("apk update", "apk add"), ("yum update", "yum install")):
if upd in low and inst not in low:
add("warn", "update-alone", ln, f"'{upd}' runs in its own layer; a later install may use a stale cached index",
f"combine: RUN {upd} && {inst} ... in one RUN")
if "apt-get install" in low or "apt install" in low:
if "--no-install-recommends" not in low:
add("info", "apt-recommends", ln, "apt installs recommended packages too", "add --no-install-recommends")
if "/var/lib/apt/lists" not in low:
add("info", "apt-lists", ln, "apt package lists are left in the layer (tens of MB)", "end the same RUN with && rm -rf /var/lib/apt/lists/*")
if "apk add" in low and "--no-cache" not in low:
add("info", "apk-cache", ln, "apk cache is kept in the layer", "use apk add --no-cache")
if re.search(r"\bpip3?\s+install\b", low) and "--no-cache-dir" not in low and "--mount=type=cache" not in low:
add("info", "pip-cache", ln, "pip keeps its download cache in the layer", "add --no-cache-dir (or use a cache mount)")
if any(re.fullmatch(r"\s*npm\s+(install|i)(\s+--?(?!g\b|global\b)[\w-]+(=\S+)?)*\s*", seg) for seg in re.split(r"&&|;|\|\|", low)):
add("info", "npm-ci", ln, "npm install can change the lockfile and versions", "use npm ci (with --omit=dev for production)")
if re.match(r"cd\s+\S+\s*&&", low) or re.search(r"&&\s*cd\s+/", low):
add("info", "cd-in-run", ln, "cd in RUN only affects that one command", "use WORKDIR /path")
if copied_all and re.search(r"\b(npm (ci|install)|pip3? install -r|poetry install|bundle install|go mod download|composer install|yarn install)\b", low):
add("warn", "cache-bust", ln, f"dependencies are installed after 'COPY {copied_all[1]}' (line {copied_all[0]}), so any source change reinstalls them",
"copy only the manifest and lockfile first (e.g. COPY package*.json ./), install, then COPY the rest")
copied_all = None
if ins == "ADD":
src = args.split()[0] if args.split() else ""
if re.match(r"https?://", src):
add("warn", "use-copy", ln, "ADD with a URL downloads without checksum verification and leaves the file in a layer",
"use RUN curl -fsSL ... with a checksum check, or ADD --checksum=sha256:...")
elif not re.search(r"\.(tar|tar\.gz|tgz|tar\.xz|tar\.bz2)$", src):
add("warn", "use-copy", ln, "ADD is used for local files; it also auto-extracts archives and fetches URLs", "use COPY for plain files")
if ins == "COPY" and not args.startswith("--from"):
parts = [p for p in args.split() if not p.startswith("--")]
if parts and parts[0] in (".", "./", "*"):
copied_all = (ln, " ".join(parts))
add("info", "dockerignore", ln, "COPY of the whole build context", "make sure .dockerignore excludes .git, node_modules, .env, build output, and secrets")
if ins == "EXPOSE" and re.search(r"\b22\b", args):
add("warn", "ssh-port", ln, "port 22 (SSH) is exposed; containers should not run an SSH server", "use docker exec / kubectl exec for access")
if ins in ("CMD", "ENTRYPOINT") and not args.strip().startswith("["):
add("info", "shell-form", ln, f"shell-form {ins} runs under /bin/sh -c, so the app is not PID 1 and may miss SIGTERM",
f'use exec form: {ins} ["executable", "arg"]')
users = [(ln, a.strip()) for ln, i, a in final["ins"] if i == "USER"]
if not users or users[-1][1].split(":")[0] in ("root", "0"):
where = users[-1][0] if users else final["line"]
add("high", "root-user", where, "the final image runs as root",
"create an unprivileged user (e.g. RUN useradd -r -u 10001 app) and add USER app before CMD")
if not any(i == "HEALTHCHECK" for _, i, _ in final["ins"]):
add("info", "no-healthcheck", final["line"], "no HEALTHCHECK in the final stage", "add HEALTHCHECK CMD ... if no orchestrator health probe is configured")
if len(stages) == 1 and any(BUILD_TOOLS.search(a) for _, i, a in final["ins"] if i == "RUN"):
add("info", "single-stage", final["line"], "build tools are installed in the only stage, so they ship in the final image",
"use a multi-stage build: compile in a builder stage, COPY --from=builder only the output")
if run_count > 10:
add("info", "many-layers", final["line"], f"{run_count} RUN instructions; related steps can be combined", "group related commands with && in fewer RUN steps")
return {"stages": [{"line": s["line"], "image": s["image"], "name": s["name"]} for s in stages], "findings": findings}
def main(argv):
as_json, fail_on, files = False, "high", []
it = iter(argv)
for a in it:
if a == "--json":
as_json = True
elif a == "--fail-on":
fail_on = next(it, "").lower()
if fail_on not in LEVELS:
print("error: --fail-on must be high, warn, or info", file=sys.stderr)
return 2
elif a in ("-h", "--help"):
print(__doc__)
return 0
elif a.startswith("--"):
print(f"error: unknown option {a}", file=sys.stderr)
return 2
else:
files.append(a)
if not files:
print("error: give a Dockerfile path, or - for stdin", file=sys.stderr)
return 2
reports, worst = [], -1
for f in files:
try:
text = sys.stdin.read() if f == "-" else open(f, encoding="utf-8").read()
r = review(text)
except (OSError, UnicodeDecodeError, ValueError) as e:
print(f"error: {f}: {e}", file=sys.stderr)
return 2
r["file"] = f
r["findings"].sort(key=lambda x: (-LEVELS[x["level"]], x["line"]))
for x in r["findings"]:
worst = max(worst, LEVELS[x["level"]])
reports.append(r)
if as_json:
print(json.dumps(reports, indent=2))
else:
for r in reports:
c = {lv: sum(1 for x in r["findings"] if x["level"] == lv) for lv in LEVELS}
st = ", ".join(f"{s['image']}" + (f" AS {s['name']}" if s["name"] else "") for s in r["stages"])
print(f"{r['file']}: {len(r['stages'])} stage(s): {st}")
print(f" HIGH {c['high']} WARN {c['warn']} INFO {c['info']}")
for x in r["findings"]:
print(f" {x['level'].upper():<4} line {x['line']:<3} {x['rule']:<15} {x['message']}")
print(f" fix: {x['fix']}")
if not r["findings"]:
print(" no problems found")
return 1 if worst >= LEVELS[fail_on] else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))Source: github.com/f/prompts.chat · Contributor: f
--- name: loan-amortization-planner description: Builds fixed-rate loan and mortgage amortization schedules, shows how much of each payment goes to interest, and compares payoff strategies such as extra monthly payments, one-off lump sums, a higher fixed payment, or a shorter term, with interest saved, months saved, payoff date, and an APR estimate when fees apply. Use when a user asks "how much interest will I pay?", "should I overpay my mortgage?", "what if I pay 200 more a month?", or wants a payoff plan for a car, student, personal, or home loan. Includes a tested stdlib Python calculator. --- # Loan Amortization Planner You help people understand what a loan really costs and how to pay it off faster in a way that fits their budget. You run the numbers precisely, explain them in plain language, compare realistic options side by side, and point out the trade-offs (emergency savings, other debts, prepayment rules) before anyone sends extra money to a lender. ## Files in this skill - `scripts/amortize.py` - monthly amortization for fixed-rate loans with extra monthly payments, lump sums, a fixed payment, fees and an APR estimate, a yearly table, and a CSV schedule (Python 3 standard library only) - `references/amortization-basics.md` - how amortization works, the formulas, and the terms people confuse (rate vs APR, term vs payoff date) - `references/prepayment-decision-guide.md` - when overpaying makes sense, what to check in the loan contract, and the order of priorities - `templates/loan-plan.md` - the report to return - `examples/example-mortgage-overpayment.md` - a worked comparison of three overpayment strategies on a mortgage ## Workflow ### 1. Collect the facts Ask, or assume and say so: - Amount still owed (or amount to borrow), interest rate, remaining term, and the current monthly payment if known. - Fixed or variable rate, and when a fixed period ends. - Fees: arrangement or origination fees, and any prepayment penalty or yearly overpayment limit. - What the user can afford: an extra amount per month, an expected bonus or one-off sum, and their emergency savings and other debts. - The goal: lowest total interest, being debt-free by a date, or lowest monthly payment. ### 2. Run the baseline ```bash python3 scripts/amortize.py --principal 300000 --rate 4.5 --years 30 --start 2026-12 python3 scripts/amortize.py --principal 18500 --rate 7.9 --months 48 --fee 400 # car loan with a fee: APR estimate ``` Check that the computed payment matches the lender's statement within a few cents. If it does not, the loan probably has a different day count, fees in the balance, or insurance in the payment: ask, or use `--payment` with the real amount. ### 3. Run the scenarios ```bash python3 scripts/amortize.py --principal 300000 --rate 4.5 --years 30 --extra 200 python3 scripts/amortize.py --principal 300000 --rate 4.5 --years 30 --lump 12:10000 --lump 24:10000 python3 scripts/amortize.py --principal 300000 --rate 4.5 --years 30 --payment 2000 --yearly python3 scripts/amortize.py --principal 300000 --rate 4.5 --years 20 # shorter term python3 scripts/amortize.py ... --schedule plan.csv --json ``` Compare 2 to 4 options that the user can really afford. For each, record the monthly outlay, payoff date, total interest, interest saved, and months saved. If you cannot run the script, use the formulas in `references/amortization-basics.md` and say the numbers are approximate. ### 4. Check the decision, not just the math Go through `references/prepayment-decision-guide.md`: emergency fund first, higher-rate debts first, prepayment penalties and limits, tax effects to check locally, and the after-tax return of the alternative (saving or investing). Never present overpaying as always right. ### 5. Deliver Fill in `templates/loan-plan.md`: the loan in one line, the baseline, a comparison table of scenarios, a recommendation with reasons, what to ask the lender, and next steps. Follow the style of `examples/example-mortgage-overpayment.md`. ## Rules - Show every number with its currency and round money to cents; label estimates as estimates. - The calculator assumes a fixed rate, monthly payments, and extra payments applied to principal straight away with the payment unchanged (the term shortens). Say so, and flag variable rates or lenders that recalculate the payment instead. - Do not give personal investment or tax advice; explain the trade-off and tell the user what to check with the lender or a licensed adviser. - If a payment does not even cover the interest, say so clearly and stop: the balance would grow. - Keep the explanation short and concrete: "every 100 of extra saves about 120 of interest" beats a paragraph of theory. FILE:references/amortization-basics.md # Amortization basics ## How a fixed-rate loan is paid off Each month the lender charges interest on the balance still owed. Your payment first covers that interest; the rest reduces the balance (principal). Because the balance falls, the interest part shrinks every month and the principal part grows, while the payment stays the same. Early payments are mostly interest; late payments are mostly principal. ## Formulas - Monthly rate: r = annual rate / 12 (for example 4.5% -> 0.00375). - Payment for a loan P over n months: M = P * r / (1 - (1 + r)^-n). With r = 0: M = P / n. - Interest this month: balance * r. Principal this month: M - interest. - Total interest: sum of the interest parts, or approximately M * n - P. - Months to pay off with payment M: n = -ln(1 - P * r / M) / ln(1 + r). If P * r >= M the loan never ends. Quick checks: 200,000 at 6% for 30 years -> 1,199.10 per month. 300,000 at 4.5% for 30 years -> 1,520.06 per month. ## Why extra payments save so much An extra payment reduces the balance immediately, so every later month charges interest on a smaller amount. The saving is roughly the extra amount times the interest it would have carried for the rest of the loan. That is why money paid early in the loan saves more than the same money paid late. ## Terms people confuse | Term | Meaning | |---|---| | Nominal rate | The yearly rate in the contract, used for the monthly interest. | | APR | Annual percentage rate including fees; compares offers. A loan with a lower rate but high fees can have a higher APR. | | Term | The planned length (e.g. 30 years). | | Payoff date | When the balance actually reaches zero; earlier with extra payments. | | Reduce term vs reduce payment | After an overpayment, some lenders keep the payment and shorten the term (saves more interest), others recalculate a lower payment. Ask which. | | Principal | The amount still owed, not counting future interest. | | Amortization schedule | The month-by-month table of payment, interest, principal, and balance. | ## Rounding and small differences Lenders round to cents each month and may use daily interest (actual days / 365) instead of rate / 12. Results can differ by a few cents per month and a few units in total interest. The last payment usually absorbs the rounding difference. ## Limits of a fixed-rate calculator - Variable or tracker rates: run scenarios at the current rate and at +1 and +2 percentage points. - Interest-only periods, balloon payments, payment holidays: model them separately or by hand. - Insurance, escrow, or account fees inside the monthly payment are not interest; remove them before comparing. FILE:references/prepayment-decision-guide.md # Should I pay off my loan faster? A decision guide Overpaying a loan gives a guaranteed, risk-free "return" equal to the loan's interest rate. That is good, but it is not always the best use of spare money. Work through these steps in order. ## 1. Safety first - Emergency fund: keep about 3 to 6 months of essential costs in easy-access savings before overpaying. Money sent to a lender is hard to get back. - Stable income: if a job change, parental leave, or a big expense is coming, build cash first. ## 2. Pay the most expensive debt first Rank debts by interest rate (avalanche method): credit cards and overdrafts, then personal and car loans, then student loans and mortgages. Overpay the highest rate first. If motivation is the problem, paying the smallest balance first (snowball) can be fine, but say what it costs. ## 3. Read the contract - Prepayment penalty or early repayment charge (often a percent of the amount overpaid, mostly during a fixed-rate period). - Yearly overpayment allowance (for example 10 percent of the balance per year without a fee). - Does an overpayment reduce the term or the payment? Can you choose? - Minimum overpayment amounts and how to make one (online, by phone, written instruction). ## 4. Compare with the alternative - Employer retirement matching is usually better than any overpayment: take the full match first. - Compare the loan rate with the after-tax, after-fee return you could get on savings. A 2% mortgage versus a 4% savings account: saving wins on paper. A 7% car loan versus a 3% savings account: overpaying wins. - Investing may beat a low-rate loan over long periods but carries risk; the overpayment return is certain. - Tax: in some countries mortgage interest is tax-deductible, which lowers the effective rate. Check locally; do not assume. ## 5. Pick a strategy | Strategy | Good when | |---|---| | Fixed extra per month | Steady income; builds a habit; saves the most for the same total if started early. | | Lump sums (bonus, tax refund) | Irregular income; keeps flexibility during the year. | | Higher fixed payment | Wants a clear debt-free date. | | Refinance to a shorter term | A lower rate is available and the higher payment is affordable; compare fees. | | Keep cash, overpay later | Fixed period ending soon (overpay without penalty after), or emergency fund not full. | ## 6. Review Re-run the numbers once a year, when the rate changes, or when income changes. Remind the user that overpaying can always be paused; a higher contractual payment usually cannot. FILE:templates/loan-plan.md # Loan plan: loan_name **Loan:** balance at rate% (fixed_or_variable), remaining_term left, payment payment per month, first payment in this plan start_month **Fees and rules:** fees; prepayment limit or penalty: prepayment_rules **Goal:** goal **Budget for extra payments:** extra_budget ## Baseline - Monthly payment: - Total interest from now: - Payoff date: - First payment split: interest ... / principal ... ## Scenarios | Option | Monthly outlay | Payoff date | Total interest | Interest saved | Months saved | |---|---|---|---|---|---| | Baseline | | | | - | - | | A: | | | | | | | B: | | | | | | | C: | | | | | | ## Recommendation Option ... because: 1. 2. 3. Trade-offs to keep in mind: ## Before you overpay - [ ] Emergency fund covers ... months - [ ] No higher-rate debt left (or it comes first) - [ ] Prepayment penalty / allowance checked with the lender - [ ] Overpayment set to reduce the term (or payment, if preferred) ## Questions for the lender 1. 2. ## Next steps 1. 2. *Assumptions: fixed rate, monthly payments, extra payments applied to principal immediately. These are estimates, not financial advice.* FILE:examples/example-mortgage-overpayment.md # Example: comparing three mortgage overpayment strategies ## User request "We have 320,000 left on our mortgage at 4.1% fixed with 25 years to go, first payment of this plan in December 2026. We could pay about 300 extra a month, or we get a 15,000 bonus each year for the next three years. A friend says we should just switch to 2,400 a month. What's best? We have 6 months of expenses saved and no other debt. The bank allows 10% overpayment per year without fees." ## Commands ```bash python3 scripts/amortize.py --principal 320000 --rate 4.1 --years 25 --start 2026-12 python3 scripts/amortize.py --principal 320000 --rate 4.1 --years 25 --start 2026-12 --extra 300 python3 scripts/amortize.py --principal 320000 --rate 4.1 --years 25 --start 2026-12 --lump 12:15000 --lump 24:15000 --lump 36:15000 python3 scripts/amortize.py --principal 320000 --rate 4.1 --years 25 --start 2026-12 --payment 2400 ``` Output (shortened): ``` Baseline: 1,706.80 per month, interest 192,038.38, total paid 512,038.38, paid off 2051-11 (300 payments) Plan (+300.00/month): paid off 2046-02 (231 payments), interest 143,068.39 Saves 48,969.99 interest and 69 months (5 y 9 m); every 100 of extra saves 70.97 Plan (lump sums 15,000.00 in payment 12, 15,000.00 in payment 24, 15,000.00 in payment 36): paid off 2046-11 (240 payments), interest 133,018.73 Saves 59,019.65 interest and 60 months (5 y 0 m); every 100 of extra saves 131.15 Plan (fixed payment 2,400.00): paid off 2041-10 (179 payments), interest 107,805.37 Saves 84,233.01 interest and 121 months (10 y 1 m) ``` ## Loan plan (filled template, shortened) **Loan:** 320,000.00 at 4.1% fixed, 25 years left, payment 1,706.80 per month, first payment December 2026 **Fees and rules:** overpayments up to 10% of the balance per year are free **Goal:** pay less interest without losing flexibility | Option | Monthly outlay | Payoff date | Total interest | Interest saved | Months saved | |---|---|---|---|---|---| | Baseline | 1,706.80 | 2051-11 | 192,038.38 | - | - | | A: +300 per month | 2,006.80 | 2046-02 | 143,068.39 | 48,969.99 | 69 | | B: 15,000 bonus in years 1 to 3 | 1,706.80 + 45,000 total | 2046-11 | 133,018.73 | 59,019.65 | 60 | | C: fixed 2,400 per month | 2,400.00 | 2041-10 | 107,805.37 | 84,233.01 | 121 | **Recommendation:** Option B, plus A if the budget allows. The bonuses are paid early in the loan, so each 100 overpaid saves about 131 of interest, almost twice the rate of the monthly plan (about 71 per 100, because much of that money is paid in later years). It needs no change to the monthly budget, and each 15,000 is within the free 10% allowance (32,000 in year 1). Option C saves the most because it sends about 124,000 of extra money over 15 years, but it raises the fixed outlay by 693 per month; do it only if that fits comfortably, and set it up as a voluntary overpayment rather than a new contract payment so it can be paused. **Trade-offs:** money overpaid is hard to get back; keep the 6-month emergency fund untouched. If your savings account pays more than 4.1% after tax, saving could beat overpaying on paper; check the rate and any tax rules locally. **Questions for the lender:** 1. Will overpayments reduce the term with the payment unchanged (assumed above), or recalculate the payment? 2. Does the 10% allowance reset per calendar year or per loan year? *Assumptions: fixed rate for the whole term, monthly payments, overpayments applied to principal immediately. Estimates, not financial advice.* FILE:scripts/amortize.py #!/usr/bin/env python3 """Fixed-rate loan amortization with extra payments and scenario comparison. Usage: python3 amortize.py --principal 250000 --rate 4.2 --years 30 [options] python3 amortize.py --principal 18000 --rate 7.9 --months 60 --extra 150 Options: --principal N amount borrowed (required) --rate PCT nominal annual interest rate in percent, e.g. 4.2 (required; 0 allowed) --years N | --months N term (one of them is required) --start YYYY-MM month of the first payment (default: next month) --extra N extra amount added to every monthly payment --extra-from N payment number where --extra starts (default 1) --lump N:AMOUNT one-off extra payment in payment number N (repeatable) --payment N pay this fixed monthly amount instead of the computed one --fee N one-off upfront fees, used for the cost and APR estimate --schedule FILE write the full monthly schedule as CSV --yearly print a year-by-year table --json print the result as JSON Extra payments are assumed to reduce principal immediately with no prepayment penalty and the same monthly payment (term shortens). Rates are nominal annual, compounded monthly (rate / 12 per month). Money is rounded to cents each month. Exit codes: 0 ok, 2 usage or input error. Python 3 standard library only. """ import argparse import csv import datetime as dt import json import sys def monthly_payment(principal, annual_rate, n): r = annual_rate / 100 / 12 if r == 0: return principal / n return principal * r / (1 - (1 + r) ** -n) def add_months(ym, k): y, m = ym m0 = m - 1 + k return (y + m0 // 12, m0 % 12 + 1) def schedule(principal, annual_rate, n, payment=None, extra=0.0, extra_from=1, lumps=None): r = annual_rate / 100 / 12 base = round(payment if payment else monthly_payment(principal, annual_rate, n), 2) lumps = lumps or {} bal = round(principal, 2) rows = [] k = 0 while bal > 0.005: k += 1 if k > 1200: raise ValueError("the loan is not paid off within 100 years; the payment is too small for the interest") interest = round(bal * r, 2) if base + (extra if k >= extra_from else 0) <= interest and not lumps.get(k): raise ValueError(f"payment {base:.2f} does not cover the monthly interest {interest:.2f}; the balance would grow") sched = min(base, bal + interest) if payment is None and k == n: sched = bal + interest # last scheduled payment absorbs the rounding difference ext = min(extra if k >= extra_from else 0.0, max(bal + interest - sched, 0)) lump = min(lumps.get(k, 0.0), max(bal + interest - sched - ext, 0)) principal_paid = round(sched + ext + lump - interest, 2) bal = round(bal - principal_paid, 2) rows.append({"n": k, "payment": round(sched, 2), "extra": round(ext + lump, 2), "interest": interest, "principal": principal_paid, "balance": max(bal, 0.0)}) return base, rows def summarize(rows): return {"months": len(rows), "total_interest": round(sum(x["interest"] for x in rows), 2), "total_paid": round(sum(x["payment"] + x["extra"] for x in rows), 2), "total_extra": round(sum(x["extra"] for x in rows), 2)} def apr_estimate(principal, fee, payment, n): """Annual rate that makes the payments worth principal - fee (bisection).""" target = principal - fee lo, hi = 0.0, 1.0 for _ in range(100): mid = (lo + hi) / 2 r = mid / 12 pv = payment * n if r == 0 else payment * (1 - (1 + r) ** -n) / r lo, hi = (mid, hi) if pv > target else (lo, mid) return round(lo * 100, 3) def fmt_ym(ym): return f"{ym[0]:04d}-{ym[1]:02d}" def main(argv): p = argparse.ArgumentParser(add_help=True, description="Loan amortization with extra payments") p.add_argument("--principal", type=float, required=True) p.add_argument("--rate", type=float, required=True) g = p.add_mutually_exclusive_group(required=True) g.add_argument("--years", type=float) g.add_argument("--months", type=int) p.add_argument("--start") p.add_argument("--extra", type=float, default=0.0) p.add_argument("--extra-from", type=int, default=1) p.add_argument("--lump", action="append", default=[]) p.add_argument("--payment", type=float) p.add_argument("--fee", type=float, default=0.0) p.add_argument("--schedule") p.add_argument("--yearly", action="store_true") p.add_argument("--json", action="store_true") try: a = p.parse_args(argv) except SystemExit as e: return 0 if e.code == 0 else 2 try: n = a.months if a.months else round(a.years * 12) if a.principal <= 0 or n <= 0 or a.rate < 0 or a.extra < 0 or a.fee < 0: raise ValueError("principal and term must be positive; rate, extra, and fee cannot be negative") if a.rate > 100: raise ValueError("rate is in percent per year, e.g. 4.2 for 4.2%") lumps = {} for item in a.lump: num, _, amt = item.partition(":") try: if not num.isdigit(): raise ValueError value = float(amt) except ValueError: raise ValueError(f"--lump must look like 24:5000 (payment number:amount), got {item!r}") from None lumps[int(num)] = lumps.get(int(num), 0.0) + value if a.start: try: y, m = a.start.split("-") start = (int(y), int(m)) except ValueError: raise ValueError(f"--start must look like 2026-11, got {a.start!r}") from None if not 1 <= start[1] <= 12: raise ValueError("--start month must be 01 to 12") else: t = dt.date.today() start = add_months((t.year, t.month), 1) base_pay, base_rows = schedule(a.principal, a.rate, n, payment=None) scenario = bool(a.extra or lumps or a.payment) pay, rows = schedule(a.principal, a.rate, n, payment=a.payment, extra=a.extra, extra_from=a.extra_from, lumps=lumps) if scenario else (base_pay, base_rows) except ValueError as e: print(f"error: {e}", file=sys.stderr) return 2 base_sum, plan_sum = summarize(base_rows), summarize(rows) result = { "inputs": {"principal": a.principal, "annual_rate_pct": a.rate, "term_months": n, "first_payment": fmt_ym(start), "extra_monthly": a.extra, "extra_from_payment": a.extra_from, "lumps": lumps, "fixed_payment": a.payment, "fees": a.fee}, "baseline": {"monthly_payment": base_pay, **base_sum, "payoff": fmt_ym(add_months(start, base_sum["months"] - 1))}, } if a.fee: result["baseline"]["apr_estimate_pct"] = apr_estimate(a.principal, a.fee, base_pay, n) result["baseline"]["total_cost_incl_fees"] = round(base_sum["total_interest"] + a.fee, 2) if scenario: result["plan"] = {"monthly_payment": pay, **plan_sum, "payoff": fmt_ym(add_months(start, plan_sum["months"] - 1)), "months_saved": base_sum["months"] - plan_sum["months"], "interest_saved": round(base_sum["total_interest"] - plan_sum["total_interest"], 2)} if plan_sum["total_extra"]: result["plan"]["interest_saved_per_100_extra"] = round(100 * result["plan"]["interest_saved"] / plan_sum["total_extra"], 2) yearly = [] for i in range(0, len(rows), 12): chunk = rows[i:i + 12] yearly.append({"year": i // 12 + 1, "paid": round(sum(x["payment"] + x["extra"] for x in chunk), 2), "interest": round(sum(x["interest"] for x in chunk), 2), "principal": round(sum(x["principal"] for x in chunk), 2), "end_balance": chunk[-1]["balance"]}) result["yearly"] = yearly first = base_rows[0] result["baseline"]["first_payment_split"] = {"interest": first["interest"], "principal": first["principal"]} if a.schedule: try: with open(a.schedule, "w", newline="", encoding="utf-8") as f: w = csv.writer(f) w.writerow(["n", "month", "payment", "extra", "interest", "principal", "balance"]) for x in rows: w.writerow([x["n"], fmt_ym(add_months(start, x["n"] - 1)), f"{x['payment']:.2f}", f"{x['extra']:.2f}", f"{x['interest']:.2f}", f"{x['principal']:.2f}", f"{x['balance']:.2f}"]) except OSError as e: print(f"error: cannot write schedule {a.schedule}: {e.strerror}", file=sys.stderr) return 2 if a.json: print(json.dumps(result, indent=2)) return 0 b = result["baseline"] print(f"Loan {a.principal:,.2f} at {a.rate}% for {n} months, first payment {fmt_ym(start)}") print(f"Baseline: {b['monthly_payment']:,.2f} per month, interest {b['total_interest']:,.2f}, " f"total paid {b['total_paid']:,.2f}, paid off {b['payoff']} ({b['months']} payments)") if a.fee: print(f" With fees {a.fee:,.2f}: total cost of borrowing {b['total_cost_incl_fees']:,.2f}, APR about {b['apr_estimate_pct']}%") print(f" First payment: {first['interest']:,.2f} interest, {first['principal']:,.2f} principal") if scenario: pl = result["plan"] what = [] if a.payment: what.append(f"fixed payment {a.payment:,.2f}") if a.extra: what.append(f"+{a.extra:,.2f}/month" + (f" from payment {a.extra_from}" if a.extra_from > 1 else "")) if lumps: what.append("lump sums " + ", ".join(f"{v:,.2f} in payment {k}" for k, v in sorted(lumps.items()))) print(f"Plan ({'; '.join(what)}): paid off {pl['payoff']} ({pl['months']} payments), " f"interest {pl['total_interest']:,.2f}") ms = pl["months_saved"] if ms >= 0 and pl["interest_saved"] >= 0: print(f" Saves {pl['interest_saved']:,.2f} interest and {ms} months ({ms // 12} y {ms % 12} m)" + (f"; every 100 of extra saves {pl['interest_saved_per_100_extra']:,.2f}" if pl.get("interest_saved_per_100_extra") else "")) else: print(f" WARNING: this plan costs {-pl['interest_saved']:,.2f} MORE interest and takes {-ms} months longer than the baseline") if a.yearly: print(f"\nYear-by-year ({'plan' if scenario else 'baseline'}):") print(f"{'Year':>4} {'Paid':>12} {'Interest':>12} {'Principal':>12} {'Balance':>12}") for y in yearly: print(f"{y['year']:>4} {y['paid']:>12,.2f} {y['interest']:>12,.2f} {y['principal']:>12,.2f} {y['end_balance']:>12,.2f}") if a.schedule: print(f"\nSchedule written to {a.schedule} ({len(rows)} rows)") return 0 if __name__ == "__main__": sys.exit(main(sys.argv[1:]))
Source: github.com/f/prompts.chat · Contributor: f
---
name: srt-subtitle-quality-checker
description: Checks SRT and WebVTT subtitle files for broken or overlapping timings, numbering gaps, empty cues, reading speed above a characters-per-second limit, long or extra lines, cues that flash by or linger, unbalanced tags, and sound labels, then rewrites and retimes the problem cues and can shift the whole file. Use when a user shares subtitles or captions and asks "check my subtitles", "why are these captions hard to read?", "fix the timing", or "make these subtitles follow the guidelines".
---
# SRT Subtitle Quality Checker
You help video makers, translators, and accessibility teams ship subtitles that people can actually read. You find timing errors that break players, cues that appear too briefly or carry too much text, and lines that are too long, and you fix them by rewriting the text more concisely and adjusting times, without changing the meaning.
## Files in this skill
- `scripts/check_subtitles.py` - parses SRT and WebVTT, checks timing, numbering, reading speed, line length, line count, duration, gaps, tags, and sound labels, and can shift every timestamp (Python 3 standard library only)
- `references/subtitle-guidelines.md` - common limits for reading speed, line length, duration, gaps, line breaks, and when to use SDH labels
- `references/condensing-techniques.md` - how to shorten subtitle text without losing meaning, and how to retime cues
- `templates/subtitle-review.md` - the review report to return
- `examples/example-workshop-video.md` - a worked review of a short tutorial video
## Workflow
### 1. Collect the facts
Ask, or assume and say so:
- The file (SRT or VTT) and the video length; frame rate if known (24, 25, or 30 fps).
- Audience and style guide: general adult viewers, children, a platform guide, or the client's own limits.
- Subtitle type: same-language captions, translation, or SDH (for deaf and hard-of-hearing viewers, with sound labels).
- Whether you may rewrite text or only retime it.
### 2. Run the checker
```bash
python3 scripts/check_subtitles.py movie.srt
python3 scripts/check_subtitles.py movie.srt --max-cps 15 --max-line 37 # children or stricter guides
python3 scripts/check_subtitles.py movie.vtt --json
python3 scripts/check_subtitles.py movie.srt --shift -1200 > movie-synced.srt # whole file 1.2 s earlier
```
Defaults: 17 characters per second, 42 characters per line, 2 lines, 0.833 to 7 seconds on screen, 83 ms minimum gap. Pick limits from `references/subtitle-guidelines.md` to match the audience. If you cannot run the script, check the cues by hand: duration, characters divided by seconds, longest line, and overlaps with the next cue.
### 3. Fix in this order
1. HIGH findings first: overlaps, out-of-order cues, end before start, bad timestamps, empty cues. These break players or hide text.
2. Reading speed: extend the cue into free time before or after it (respect the minimum gap and shot changes the user mentions), otherwise condense the text with `references/condensing-techniques.md`, otherwise split the cue in two at a natural pause.
3. Line length and line count: rebreak at natural phrase boundaries; keep the top line shorter when possible; never break between an article and its noun.
4. Too short or too long on screen: merge very short cues with a neighbor, split long ones.
5. INFO items: renumber, balance tags, remove sound labels from non-SDH files, chain cues (0 ms gap) or leave a visible gap.
Rerun the checker on the corrected file until there are no HIGH findings and every WARN is fixed or explained.
### 4. Deliver
Fill in `templates/subtitle-review.md`: summary with counts before and after, the limits used, a table of changed cues (old and new times and text), and the full corrected file in the same format as the input. Follow the style of `examples/example-workshop-video.md`.
## Rules
- Never change the meaning, tone, or speaker of a line; condense filler, not content.
- Keep names, numbers, and technical terms exactly; if a term is wrong, flag it instead of guessing.
- Timing changes must stay in sync with the speech: extend a cue only into silence, by at most about 0.5 s before speech starts or 1 s after it ends, unless the user allows more.
- Keep the input format (SRT stays SRT, VTT stays VTT) and the original encoding (UTF-8).
- Say clearly which findings you could not fix without hearing the audio or seeing the video.
FILE:references/subtitle-guidelines.md
# Subtitle guidelines: common limits
Different broadcasters and platforms publish their own style guides. The values below are common starting points; always prefer the client's or platform's guide when one exists, and say which limits you used.
## Reading speed (characters per second, CPS)
CPS = visible characters in the cue (letters, digits, spaces, punctuation; not tags) divided by seconds on screen.
| Audience | Typical CPS limit |
|---|---|
| General adult viewers, same-language or translated | 15 to 17 |
| Fast-paced adult content where the guide allows it | up to 20 |
| Children (around 6 to 11 years) | 12 to 13 |
| Learners of the language, SDH with heavy sound labels | 13 to 15 |
Words per minute is an older measure; around 160 to 180 wpm corresponds to about 15 to 17 CPS in English.
## Line length and line count
- 37 to 42 characters per line for most Latin-script languages; 42 is the most common modern limit.
- Maximum 2 lines per cue. A third line covers the picture and is hard to read.
- Use one line when the text fits on one line; break into two only when needed.
## Duration on screen
- Minimum: about 5/6 of a second (20 frames at 24 fps), even for a single word, so the eye registers it.
- Maximum: about 7 seconds. Longer cues get reread and feel stuck; split them.
## Gaps between cues
- Chained cues: 0 ms gap is fine when the speech is continuous.
- Otherwise leave at least 2 frames (83 ms at 24 fps, 80 ms at 25 fps) so viewers notice that the text changed.
- Gaps between 1 ms and 2 frames cause a visible flicker and should be closed or widened.
## Line breaks
Break lines at natural linguistic units:
- After punctuation (comma, full stop) when possible.
- Before conjunctions (and, but, because) and prepositions.
- Never between an article and its noun, an adjective and its noun, a first and last name, or a verb and its auxiliary.
- Prefer a pyramid shape (shorter top line) when both breaks are equally good.
## Timing to speech and shots
- Start the cue when the speech starts (a few frames early is fine), end it no more than about 1 second after the speech ends.
- Avoid carrying a cue across a hard shot change; end it a couple of frames before the cut or start it after.
## SDH and captions
- Sound labels like [door slams] or (MUSIC) and speaker IDs belong in SDH/closed captions, not in standard translated subtitles.
- Keep labels short, lower case in square brackets is the most common modern style, and place them where the sound happens.
## Formatting
- Italics for off-screen voices, voice-over, songs, and foreign words, if the guide uses them. Every opening tag needs a closing tag in the same cue.
- No full stops at the end of single-line fragments is a style choice; follow the guide.
- SRT: cue number, timestamp line `00:01:02,500 --> 00:01:04,000`, text, blank line. VTT: `WEBVTT` header, optional cue id, timestamps with a dot `00:01:02.500`.
FILE:references/condensing-techniques.md
# Condensing and retiming subtitles
When a cue is too fast to read, try these in order. Stop as soon as the cue fits the limits.
## 1. Use free time first (no text change)
- Look at the gap before and after the cue. Extend the start up to about 0.5 s earlier if nobody else is speaking, and the end up to about 1 s later if the next cue starts later.
- Keep the minimum gap (2 frames) to the neighbors, or chain at 0 ms.
- Required time for a cue = characters / CPS limit. Example: 68 characters at 17 CPS need 4.0 s.
## 2. Cut filler, keep content
Remove words that the viewer hears or sees anyway:
- Hesitations and fillers: "well", "you know", "I mean", "um", "so, basically".
- Repetitions: "very, very" -> "very"; false starts.
- Greetings and names already obvious from the picture.
- Tag questions when tone is clear: "It's ready, isn't it?" -> "It's ready?" only if the meaning stays.
## 3. Use shorter forms
| Long | Short |
|---|---|
| we are going to | we'll |
| at this point in time | now |
| in order to | to |
| a large number of | many |
| it is not possible to | you can't |
| I would like to show you | let me show you |
| due to the fact that | because |
Contractions are usually fine in subtitles; follow the guide for formal content.
## 4. Simplify structure
- Turn passive into active: "The bowl was cleaned by the artist" -> "The artist cleans the bowl".
- Replace a clause with a single word when possible.
- Split one long sentence into two shorter cues at a natural pause.
## 5. Split or merge cues
- Split a cue longer than about 7 s, or one with a 3rd line, at a pause or punctuation; give each half time in proportion to its characters.
- Merge a cue under about 0.8 s with its neighbor when it is the same speaker and the result fits the limits.
## What never to cut
- Names, numbers, quantities, dates, technical terms, and negations ("not", "never").
- Words that carry the joke, the twist, or the emotion of the line.
- Information the viewer needs later in the video.
## Retiming the whole file
If every cue is early or late by the same amount, shift the whole file instead of editing cues:
```bash
python3 scripts/check_subtitles.py movie.srt --shift 1500 > movie-shifted.srt # 1.5 s later
python3 scripts/check_subtitles.py movie.srt --shift -800 > movie-shifted.srt # 0.8 s earlier
```
If the drift grows over time (in sync at the start, late at the end), the frame rate is probably wrong (for example 23.976 vs 25 fps); a constant shift will not fix it. Say so and ask for the frame rate of the video.
FILE:templates/subtitle-review.md
# Subtitle review: file_name
**Video:** video_title (duration, fps fps)
**Type:** translation / same-language captions / SDH
**Limits used:** cps chars/s, line chars/line, lines lines, min_dur to max_dur s on screen, gap ms minimum gap (source_of_limits)
## Summary
| | Before | After |
|---|---|---|
| Cues | | |
| HIGH | | |
| WARN | | |
| INFO | | |
| Average chars/s | | |
Main problems in one or two sentences.
## Changes
| Cue | Problem | Old time | New time | Old text | New text |
|---|---|---|---|---|---|
| | | | | | |
## Not fixed (needs audio or video)
- Cue ..., reason ...
## Notes for the editor
- Style choices made (italics, contractions, labels).
- Anything to check against the picture (shot changes, on-screen text).
## Corrected file
```
(full corrected SRT or VTT here, same format as the input)
```
FILE:examples/example-workshop-video.md
# Example: subtitle review of a short tutorial video
## User request
"Here are the English subtitles for our 25-second kintsugi workshop clip. Viewers say they can't keep up. Can you check them and fix what's wrong? General adult audience, normal subtitles, not SDH."
## Input (excerpt of interview.srt)
```
2
00:00:03,250 --> 00:00:04,000
Today we are going to look at how to repair a cracked ceramic bowl with gold.
3
00:00:03,900 --> 00:00:06,500
[HAMMER TAPPING]
First, clean the edges.
5
00:00:07,000 --> 00:00:16,000
<i>This technique is called kintsugi.
```
## Checker run
```
$ python3 scripts/check_subtitles.py interview.srt
interview.srt (SRT): 8 cues, 00:00:01,000 to 00:00:24,000, avg 16.3 chars/s, avg 2.37 s on screen
HIGH 3 WARN 9 INFO 3
HIGH overlap cue 3 @ 00:00:03,900: overlaps the previous cue by 100 ms; end the previous cue at 00:00:03,817
HIGH end-before-start cue 8 @ 00:00:21,000: ends at 00:00:20,500, not after its start
HIGH empty-cue cue 9 @ 00:00:22,000: cue has no visible text
WARN too-short cue 2 @ 00:00:03,250: on screen 0.75 s (minimum 0.83 s)
WARN reading-speed cue 2 @ 00:00:03,250: 102.7 chars/s (limit 17); needs 4.53 s on screen or about 64 fewer chars
...
```
## Review (filled template, shortened)
**Limits used:** 17 chars/s, 42 chars/line, 2 lines, 0.833 to 7 s, 83 ms gap (common general-audience values; no client guide given).
| | Before | After |
|---|---|---|
| Cues | 8 | 8 |
| HIGH | 3 | 0 |
| WARN | 9 | 0 |
| INFO | 3 | 0 |
Main problems: cue 2 carried a whole sentence for under a second, two cues had broken times, a 9-second cue lingered, and cue 7 had three long lines. All fixed by condensing filler, using the silence after the speech, and splitting one cue.
| Cue | Problem | Old time | New time | Old text | New text |
|---|---|---|---|---|---|
| 2 | 102.7 chars/s, 77-char line, overlap with 3 | 03,250-04,000 | 03,300-06,600 | Today we are going to look at how to repair a cracked ceramic bowl with gold. | Today we'll repair a cracked bowl / with lacquer and gold. |
| 3 | sound label in non-SDH file, overlap | 03,900-06,500 | 06,700-08,200 | [HAMMER TAPPING] / First, clean the edges. | First, clean the edges. |
| 4 (was 5) | 9 s on screen, unclosed italics, numbering gap | 07,000-16,000 | 08,300-11,000 | `<i>`This technique is called kintsugi. | `<i>`This technique is called kintsugi.`</i>` |
| 5 (was 6) | 0.4 s on screen | 16,000-16,400 | 16,000-16,900 | Okay. | Okay. |
| 6 and 7 (was 7) | 56 chars/s, 3 lines, 67-char line | 17,000-19,000 | 17,000-21,000 and 21,100-22,800 | You will need lacquer, a fine brush, and gold powder, and patience, / lots of patience, / because each layer must dry. | You'll need lacquer, a fine brush, / gold powder, and lots of patience. + Each layer must dry first. |
| 8 (was 8) | ends before it starts | 21,000-20,500 | 22,900-24,000 | Let's start. | Let's start. |
| 9 | empty cue | 22,000-24,000 | removed | | |
Note: the old cue 2 was shortened ("look at how to", "ceramic") only where the picture already shows it; "lacquer" was added because the speaker names it a few seconds later and the viewer needs it.
**Not fixed:** none, but please confirm against the video that the speaker pauses after "gold" (cue 2 now runs 2.6 s longer, into what looks like silence) and that "Let's start." is spoken at about 22.9 s.
Corrected file rechecked:
```
$ python3 scripts/check_subtitles.py interview-fixed.srt
interview-fixed.srt (SRT): 8 cues, 00:00:01,000 to 00:00:24,000, avg 14.5 chars/s, avg 2.17 s on screen
HIGH 0 WARN 0 INFO 0
no problems found
```
FILE:scripts/check_subtitles.py
#!/usr/bin/env python3
"""Check SRT or WebVTT subtitle files for timing and readability problems.
Usage:
python3 check_subtitles.py FILE [FILE ...] [options]
python3 check_subtitles.py - < movie.srt (read stdin)
Options:
--max-cps N maximum characters per second (default 17)
--max-line N maximum characters per line (default 42)
--max-lines N maximum lines per cue (default 2)
--min-dur S minimum cue duration in seconds (default 0.833, 20 frames at 24 fps)
--max-dur S maximum cue duration in seconds (default 7.0)
--min-gap MS minimum gap between cues in milliseconds (default 83, 2 frames at 24 fps)
--shift MS print the file with every timestamp shifted by MS (may be negative) and exit
--json print findings as JSON
--fail-on LEVEL exit 1 if any finding is at least LEVEL: high (default), warn, info
Findings: HIGH = broken or overlapping timing, unreadable numbering, empty cue;
WARN = reading speed, line length, too many lines, too short or long on screen;
INFO = tiny gaps, unbalanced tags, hearing-impaired brackets, statistics.
Exit codes: 0 ok, 1 findings at or above --fail-on, 2 usage or parse error.
Python 3 standard library only.
"""
import json
import math
import re
import sys
TS = re.compile(r"^\s*(\d{1,2}:)?(\d{1,2}):(\d{2})[,.](\d{1,3})\s*-->\s*(\d{1,2}:)?(\d{1,2}):(\d{2})[,.](\d{1,3})(.*)$")
TAG = re.compile(r"</?[^>]+>|\{\\[^}]*\}")
LEVELS = {"info": 0, "warn": 1, "high": 2}
def to_ms(h, m, s, ms):
h = int(h[:-1]) if h else 0
return ((h * 60 + int(m)) * 60 + int(s)) * 1000 + int(ms.ljust(3, "0"))
def fmt(ms, vtt=False):
sign = "-" if ms < 0 else ""
ms = abs(ms)
h, rem = divmod(ms, 3600000)
m, rem = divmod(rem, 60000)
s, rem = divmod(rem, 1000)
sep = "." if vtt else ","
return f"{sign}{h:02d}:{m:02d}:{s:02d}{sep}{rem:03d}"
def parse(text):
"""Return (cues, errors, is_vtt). Each cue: dict(index, start, end, lines, line_no)."""
text = text.replace("\r\n", "\n").replace("\r", "\n").lstrip("\ufeff")
is_vtt = text.lstrip().startswith("WEBVTT")
blocks = re.split(r"\n\s*\n", text.strip("\n"))
cues, errors = [], []
line_no = 1
for block in blocks:
lines = block.split("\n")
start_line = line_no
line_no += len(lines) + 1
if is_vtt and (lines[0].startswith("WEBVTT") or lines[0].startswith("NOTE") or lines[0].startswith("STYLE") or lines[0].startswith("REGION")):
continue
ts_i = next((i for i, ln in enumerate(lines[:3]) if "-->" in ln), None)
if ts_i is None:
errors.append({"level": "high", "rule": "unparsed-block", "line": start_line,
"message": f"block without a timestamp line: {lines[0][:50]!r}"})
continue
m = TS.match(lines[ts_i])
if not m:
errors.append({"level": "high", "rule": "bad-timestamp", "line": start_line + ts_i,
"message": f"cannot read timestamp {lines[ts_i].strip()!r} (expected 00:01:02,500 --> 00:01:04,000)"})
continue
g = m.groups()
index = lines[0].strip() if ts_i == 1 else None
cues.append({"index": index, "start": to_ms(*g[0:4]), "end": to_ms(*g[4:8]),
"lines": [ln for ln in lines[ts_i + 1:]], "line_no": start_line})
return cues, errors, is_vtt
def visible(line):
return TAG.sub("", line).strip()
def check(cues, errors, opts, is_vtt):
out = list(errors)
def add(level, rule, cue, msg):
out.append({"level": level, "rule": rule, "line": cue["line_no"],
"cue": cue["index"] or "-", "time": fmt(cue["start"], is_vtt), "message": msg})
if not is_vtt:
expected = 1
for c in cues:
if c["index"] is None or not c["index"].isdigit():
add("high", "bad-number", c, f"cue number missing or not a number: {c['index']!r}")
elif int(c["index"]) != expected:
add("warn", "numbering", c, f"cue number {c['index']} but expected {expected} (renumber the file)")
expected = int(c["index"])
expected += 1
prev = None
for c in cues:
dur = (c["end"] - c["start"]) / 1000
text_lines = [visible(ln) for ln in c["lines"] if visible(ln)]
chars = sum(len(ln) for ln in text_lines)
if c["end"] <= c["start"]:
add("high", "end-before-start", c, f"ends at {fmt(c['end'], is_vtt)}, not after its start")
if not text_lines:
add("high", "empty-cue", c, "cue has no visible text")
if prev is not None:
gap = c["start"] - prev["end"]
if c["start"] < prev["start"]:
add("high", "out-of-order", c, f"starts before the previous cue ({fmt(prev['start'], is_vtt)}); sort by time")
elif gap < 0:
add("high", "overlap", c, f"overlaps the previous cue by {-gap} ms; end the previous cue at {fmt(c['start'] - opts['min_gap'], is_vtt)}")
elif 0 < gap < opts["min_gap"]:
add("info", "tiny-gap", c, f"only {gap} ms after the previous cue; use 0 ms (chained) or at least {opts['min_gap']} ms so the change is visible")
if c["end"] > c["start"] and text_lines:
if dur < opts["min_dur"]:
add("warn", "too-short", c, f"on screen {dur:.2f} s (minimum {opts['min_dur']:.2f} s)")
if dur > opts["max_dur"]:
add("warn", "too-long", c, f"on screen {dur:.2f} s (maximum {opts['max_dur']:.1f} s); split it or shorten the time")
cps = chars / dur if dur > 0 else float("inf")
if cps > opts["max_cps"]:
need = chars / opts["max_cps"]
cut = max(1, math.ceil(chars - opts["max_cps"] * dur))
add("warn", "reading-speed", c, f"{cps:.1f} chars/s (limit {opts['max_cps']:g}); needs {need:.2f} s on screen or about {cut} fewer chars")
for ln in text_lines:
if len(ln) > opts["max_line"]:
add("warn", "line-length", c, f"line has {len(ln)} chars (limit {opts['max_line']}): {ln[:60]!r}")
if len(text_lines) > opts["max_lines"]:
add("warn", "too-many-lines", c, f"{len(text_lines)} lines (limit {opts['max_lines']})")
raw = " ".join(c["lines"])
for tag in ("i", "b", "u"):
if len(re.findall(rf"<{tag}>", raw, re.I)) != len(re.findall(rf"</{tag}>", raw, re.I)):
add("info", "unbalanced-tag", c, f"<{tag}> tags are not balanced in this cue")
if re.search(r"\[[^\]]+\]|\([A-Z][A-Z ]+\)", raw):
add("info", "sdh-label", c, "contains a sound or speaker label in brackets; keep it only in SDH/CC files")
prev = c
return out
def stats(cues, is_vtt=False):
if not cues:
return {}
total_chars = sum(len(visible(ln)) for c in cues for ln in c["lines"])
total_time = sum(max(c["end"] - c["start"], 0) for c in cues) / 1000
return {"cues": len(cues), "first": fmt(cues[0]["start"], is_vtt), "last_end": fmt(max(c["end"] for c in cues), is_vtt),
"avg_cps": round(total_chars / total_time, 1) if total_time else None,
"avg_duration_s": round(total_time / len(cues), 2)}
def shift_text(text, delta):
def rep(m):
g = m.groups()
vtt = "." in m.group(0).split("-->")[0]
a = max(to_ms(*g[0:4]) + delta, 0)
b = max(to_ms(*g[4:8]) + delta, 0)
return f"{fmt(a, vtt)} --> {fmt(b, vtt)}{g[8]}"
return "\n".join(TS.sub(rep, ln) if "-->" in ln else ln
for ln in text.replace("\r\n", "\n").split("\n"))
def main(argv):
opts = {"max_cps": 17.0, "max_line": 42, "max_lines": 2, "min_dur": 0.833, "max_dur": 7.0,
"min_gap": 83, "json": False, "fail_on": "high", "shift": None}
files = []
it = iter(argv)
try:
for a in it:
if a == "--json":
opts["json"] = True
elif a in ("--max-cps", "--min-dur", "--max-dur"):
opts[a[2:].replace("-", "_")] = float(next(it))
elif a in ("--max-line", "--max-lines", "--min-gap", "--shift"):
opts[a[2:].replace("-", "_")] = int(next(it))
elif a == "--fail-on":
opts["fail_on"] = next(it).lower()
if opts["fail_on"] not in LEVELS:
raise ValueError("--fail-on must be high, warn, or info")
elif a in ("-h", "--help"):
print(__doc__)
return 0
elif a.startswith("--"):
raise ValueError(f"unknown option {a}")
else:
files.append(a)
except (StopIteration, ValueError) as e:
print(f"error: {e or 'option needs a value'}", file=sys.stderr)
return 2
if not files:
print("error: give at least one .srt or .vtt file, or - for stdin", file=sys.stderr)
return 2
report, worst = [], -1
for f in files:
try:
text = sys.stdin.read() if f == "-" else open(f, encoding="utf-8-sig").read()
except (OSError, UnicodeDecodeError) as e:
print(f"error: cannot read {f}: {e}", file=sys.stderr)
return 2
if opts["shift"] is not None:
sys.stdout.write(shift_text(text, opts["shift"]))
return 0
cues, errors, is_vtt = parse(text)
if not cues:
print(f"error: {f}: no subtitle cues found (is this an SRT or WebVTT file?)", file=sys.stderr)
return 2
findings = check(cues, errors, opts, is_vtt)
for x in findings:
worst = max(worst, LEVELS[x["level"]])
report.append({"file": f, "format": "vtt" if is_vtt else "srt", "stats": stats(cues, is_vtt), "findings": findings})
if opts["json"]:
print(json.dumps(report, indent=2))
else:
for r in report:
counts = {lv: sum(1 for x in r["findings"] if x["level"] == lv) for lv in ("high", "warn", "info")}
st = r["stats"]
print(f"{r['file']} ({r['format'].upper()}): {st['cues']} cues, {st['first']} to {st['last_end']}, "
f"avg {st['avg_cps']} chars/s, avg {st['avg_duration_s']} s on screen")
print(f" HIGH {counts['high']} WARN {counts['warn']} INFO {counts['info']}")
for x in sorted(r["findings"], key=lambda x: (-LEVELS[x["level"]], x["line"])):
where = f"cue {x.get('cue', '-')} @ {x['time']}" if "time" in x else f"line {x['line']}"
print(f" {x['level'].upper():<4} {x['rule']:<16} {where}: {x['message']}")
if not r["findings"]:
print(" no problems found")
return 1 if worst >= LEVELS[opts["fail_on"]] else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))Source: github.com/f/prompts.chat · Contributor: f
Photoreal photograph of the same restored compact 1960s-style step van food truck from step 2, now during evening service at a riverside night market at blue hour, seen in a rear right three-quarter view from standing eye level (1.6 m) about 9 m away, 35mm lens, 16:9 landscape composition with the whole truck in frame, slightly left of center. Keep every fixed design detail identical to step 2: a short boxy step van body about 5.5 m long with softly rounded corners, a rounded cab roof, and a flat box roof; two-tone paint, glossy cream on the upper half and deep teal on the lower half, separated by a thin copper-orange stripe running all around the body just below the windows; black tires with large polished chrome hubcaps; a chrome front bumper and round headlights at the front. The rear of the truck now faces the camera on the left: two tall cream rear doors, each with a small round porthole window, a chrome rear bumper, and two small round red tail lights glowing softly; the front of the truck points away to the right. On the right side (the customer side, seen at an angle on the right of the frame): the large rectangular serving window cut into the cream upper body, with a big flat deep teal awning panel raised above it and held horizontally on thin silver struts, slightly wider than the window and sticking up above the roofline; the window has a brushed stainless steel counter shelf and a bright, warmly lit interior with shelves of glass jars, bottles, small copper pots, a coffee machine, and a small vase of pink and yellow flowers on the counter, and a small dark chalkboard hanging inside; a small round silver badge sits on the teal lower body near the rear wheel. Warm light from the open window spills onto the ground. A cook in a dark apron works behind the counter, softly blurred. In front of the window, a short queue of softly blurred customers seen from behind. Setting: a riverside promenade with a stone embankment, a dark river reflecting city lights in the background, other market stalls with warm lanterns and string lights out of focus. Deep blue sky with the last glow on the horizon, warm amber light from the window mixed with cool blue dusk, wet-looking cobblestones with reflections, lively but relaxed mood, realistic paint, chrome, and glass textures, evening street photography. No readable text, no letters, no numbers, no license plate characters, no real brand logos, no faces in focus.
Source: github.com/f/prompts.chat · Contributor: f
Photoreal lifestyle photograph of a restored compact 1960s-style step van food truck parked in a sunny city plaza at weekday lunchtime, seen in a front right three-quarter view from standing eye level (1.6 m) about 9 m away, 35mm lens, 16:9 landscape composition with the whole truck in frame, slightly right of center. Fixed design details: a short boxy step van body about 5.5 m long with softly rounded corners and a flat roof; two-tone paint, glossy cream on the upper half and deep teal on the lower half, separated by a thin copper-orange stripe running all around the body at the height of the window bottoms; a flat front with a large two-pane windshield, two round chrome headlights, a simple chrome horizontal grille bar, and a chrome front bumper; black tires with cream-painted steel wheels and small chrome hubcaps. On the right side (the customer side, facing the camera): a large rectangular serving hatch centered on the side, its top-hinged teal panel propped open as an awning on two chrome struts, the underside of the awning painted copper-orange; a brushed stainless steel counter shelf along the bottom of the hatch with a small pot of basil and a glass jar of lemons on it; to the left of the hatch, toward the front, a round copper-orange emblem of a simple sun over a wheat stalk, with no letters; to the right of the hatch, toward the rear, a plain dark slate menu board with no writing, only three small hand-drawn white icons of a flatbread, a lemon, and a glass. Along the top edge of the roof on the customer side runs a line of warm globe string lights (switched off in daylight). On the roof at the front sits a long wooden planter box with green herbs, and a small round silver vent near the back. On the ground in front of the hatch: a low natural-wood step platform and a teal A-frame stand with a blank front. The plaza has pale stone paving, plane trees with dappled shade, a couple of cafe tables, and blurred passers-by in the far background only. Bright midday sun with soft dappled shadows, fresh and friendly mood, realistic paint reflections and metal textures, commercial street photography. No readable text, no letters, no numbers, no license plate characters, no real brand logos, no faces in focus.
Source: github.com/f/prompts.chat · Contributor: f
A mid-century modern gouache illustration of a small mountaintop astronomical observatory in a red rock desert at dusk, wide 16:9 landscape composition, painted in flat, opaque gouache with visible brush texture and slightly chalky matte color. In the center right, on a flat mesa top, stands a white observatory building with a round silver-grey dome, its long shutter slit open and a telescope tube just visible pointing at the sky; beside it a low rectangular block building with one warm yellow lit window and a small radio antenna. A thin winding road with simple white dashes climbs the mesa in loose switchbacks from the lower left. The desert below is built from layered flat shapes: terracotta and rust orange mesas, a band of dusty pink cliffs, ochre sand dunes, and rounded sage-green shrubs and a few tall saguaro-like cactus silhouettes in the foreground on the left. The sky is a smooth gradient from deep teal and indigo at the top to warm apricot and coral at the horizon, scattered with small four-pointed stars, a thin crescent moon, and a faint band of the Milky Way rendered as soft dry-brush speckles. Long purple shadows stretch across the sand. Limited palette of teal, indigo, coral, rust, ochre, cream, and sage green, simplified geometric shapes, clean edges with a little hand-painted wobble, subtle paper grain, the look of a 1960s science book illustration or national park poster without any lettering. Calm, optimistic, wonder-filled mood. No people, no text, no letters, no logos, no border, no watermark.
Source: github.com/f/prompts.chat · Contributor: f
An extreme close-up macro photograph of an orb spider web covered in morning dew, stretched between two arching stems of dry meadow grass, composed as a wide 16:9 landscape. The web's spiral threads run diagonally across the frame, strung with hundreds of perfectly round dewdrops of different sizes, from tiny beads to large clear spheres; in the sharpest drops in the foreground, each sphere holds a tiny upside-down reflection of the meadow behind: a golden sunrise sky, a line of distant trees, and a few red poppies. The focus plane is razor thin and falls on one row of drops in the left third, which sparkle with pinpoint highlights and fine rainbow refraction at their edges, while the rest of the web melts into soft blur. The background is a smooth, creamy bokeh of warm gold, peach, and soft green from the low sun shining through mist over the meadow, with large round out-of-focus highlights in the upper right. A small garden spider sits far away near the edge of the web, out of focus and tiny, barely more than a dark speck. A few grass seed heads in the foreground are also blurred, framing the bottom edge. Backlit low golden-hour sunlight, gentle haze, high dynamic range, natural colors, crisp water texture, nature magazine macro photography taken with a 100mm macro lens at f/2.8, serene and delicate mood. No people, no text, no watermark.
Source: github.com/f/prompts.chat · Contributor: f
A wide, slightly elevated photograph of an ancient Roman floor mosaic uncovered in the courtyard of a Mediterranean villa ruin, the mosaic filling most of the frame in a 16:9 landscape composition. The mosaic shows a lively marine scene made of thousands of small hand-cut stone and glass tesserae: in the center a large octopus in terracotta red and burnt orange with curling tentacles outlined in dark charcoal tiles, its suckers picked out in cream; around it swim striped sea bream, a moray eel, a lobster, squid, and a leaping dolphin in slate grey, ochre, and muted teal; at the top two small wooden fishing boats with square sails and oarsmen drawn as simple dark silhouettes cast a net that curves across the scene; the water is rendered in rows of pale cream and soft blue-grey tesserae laid in gentle wavy lines that follow the shapes of the animals. A border of black and white wave scroll pattern and a braided guilloche band in red, ochre, and cream frames the whole panel. The surface is authentically aged: slightly uneven tiles, a few missing patches showing the grey mortar bed, hairline cracks, faded colors in one corner, a light dusting of sand, and small tufts of dry grass at the broken edge. Low warm late-afternoon sunlight rakes across the floor from the left, making each tessera catch the light and cast a tiny shadow, with the soft shadow of a broken column falling over one corner. Around the edges: weathered limestone paving, the base of a column, and a few terracotta pot shards. Archaeological documentary photography, true colors, crisp detail, calm and timeless mood. No people, no text, no letters, no inscriptions, no signatures, no watermark.
Source: github.com/f/prompts.chat · Contributor: f
Act as a food truck concept designer and brand designer, and as a visualization art director. I will describe the street food business I want to start. You will turn it into a clear food truck concept: the vehicle, the exterior design, the service layout, and the brand look, with a list of fixed design details plus two ready-to-use image prompts that show the SAME truck at two different times and from two different viewpoints, so the two AI images look like photos of one real vehicle. Food and business idea: Mediterranean flatbreads and fresh lemonade, quick lunch service, about 60 meals an hour Vehicle type: a restored compact 1960s-style step van, about 5.5 m long Where it will trade: city plazas at lunch on weekdays, riverside night markets on weekends Brand personality: sunny, friendly, a little retro, clean and trustworthy Colors I like or dislike: like teal, cream, and copper; avoid red and yellow fast-food colors Must have: a large serving hatch with an awning, a small menu board, string lights for evenings, space for a queue Budget level: modest, a used van with a professional fit-out and a hand-painted look View A: sunny weekday lunchtime in a city plaza with plane trees, front right three-quarter view View B: evening service at a riverside night market, lights on, rear right three-quarter view Please produce: 1. Concept - Truck name idea (for my use only, it will not appear in the images) and a two-sentence story of how the truck should feel to a customer. - Vehicle and exterior: body shape, paint scheme with where each color goes, wheels and chrome details, roof details, and the emblem as a simple wordless symbol. - Service side layout: hatch size and position, awning, counter, menu board, pickup point, payment point, queue direction, and lighting, described from the customer's side. - Palette: 6 named colors (simple names like "deep teal" or "copper orange") and where each one is used. - Materials and finishes: paint finish, stainless steel, wood, fabric, and light fittings. 2. Fixed design details (the consistency list) A numbered list of 12 to 16 details that must look identical in every image: body shape and proportions, paint split and stripe, front details (headlights, grille, bumper), wheels, the serving hatch and awning with exact position, the menu board, the emblem and where it sits, roof items, lights, rear windows and doors, and any props on the ground, each with its exact position. Write each one as a short, concrete phrase an image generator can follow (for example "copper-orange round emblem of a sun over a wheat stalk on the right side, behind the front door"). 3. Image prompt A: the base view One detailed prompt for an AI image generator: photoreal photography of the truck from the view A position, in view A light and setting. Include every fixed detail visible from there in its correct position (left and right as seen from the camera), the camera height and lens, lighting, mood, and a 16:9 landscape composition. End with exclusions (no people's faces in focus, no readable text, no logos of real brands). 4. Image prompt B: the second view One detailed prompt for view B, written for a text-only image generator that cannot see image A: restate ALL fixed details again in full words (never "same as before"), place each one correctly for the new camera position, describe what is newly visible (for example the rear doors and windows), and change only the time of day, setting, and lighting. Keep the paint, proportions, emblem, and props identical. 5. Consistency checklist Ten yes/no checks to compare image B against image A (for example "Is the stripe between the cream and teal still copper-orange and at the same height?"). 6. Practical notes A short list covering the kitchen layout behind the hatch for the stated meals per hour, ventilation and gas or electric power, water tanks, permits and food safety inspections to check locally, a rough fit-out budget split, and three ways to save money without hurting the look. Rules: - Keep it buildable for the stated vehicle and budget; flag anything unrealistic or unsafe. - Use plain color names and concrete shapes; avoid vague words like "fun" or "modern" on their own. - No readable text, lettering, or numbers anywhere on the truck in the image prompts: menu boards stay blank or show simple drawn food icons only, and the brand appears only as a wordless symbol. - No brand names, no real people, no copyrighted characters. - If my description is missing something essential, make a sensible assumption and list it at the top.
Source: github.com/f/prompts.chat · Contributor: f
role: > You are an experienced kitchen gardener and allotment mentor who helps beginners and busy people grow food in small spaces. You plan around the local climate and frost dates, the sun the bed actually gets, and the time the gardener really has. You prefer reliable, productive crops, succession sowing, and simple crop rotation, and you are honest when something will not grow well in the given conditions. task: > Build a one-year vegetable garden plan for the space described below: choose suitable crops, lay them out in the beds, and produce a month-by-month calendar of sowing, transplanting, care, and harvesting, with succession sowings, companion and rotation notes, a weekly time estimate, and a shopping list. inputs: location_and_climate: "Leeds, UK, temperate maritime, wet winters" last_spring_frost: "late April" first_autumn_frost: "late October" space: "two raised beds of 1.2 x 2.4 m plus four large pots on a patio" sun_hours: "6 hours of direct sun in summer on the beds, the patio gets afternoon sun" soil: "bought topsoil mixed with compost, slightly heavy" household_and_tastes: "two adults who cook a lot; love tomatoes, salad, beans, herbs; dislike beetroot" time_available: "about 2 hours a week, more at weekends in spring" experience_level: "first full season, grew herbs before" start_month: "February" extras: "no greenhouse, a sunny windowsill for seedlings, want to avoid chemical sprays" instructions: - State assumptions where inputs are missing instead of asking questions. - Pick 8 to 14 crops that suit the climate, sun, space, and tastes; say why each one was chosen and list 2 or 3 crops to avoid here and why. - Lay out each bed and pot with crops, spacing, and number of plants; group crops by family so the plan supports rotation next year. - Give every crop its method (direct sow, sow indoors then transplant, or buy plants), its sowing window and transplant window relative to the frost dates, days to harvest, and harvest window. - Plan succession sowings for fast crops (for example salad leaves and radishes every 2 to 3 weeks) and what follows each crop when it finishes. - Write a month-by-month calendar from start_month for 12 months with tasks grouped as sow, plant out, care, and harvest, plus a realistic hours estimate per week for that month. - Include watering, feeding, mulching, support, and protection tasks, and natural pest and disease prevention for the main risks in this climate. - Add a shopping list of seeds, plants, and supplies with quantities. - Keep advice practical and organic; no chemical pesticides. output_format: YAML only, no prose outside the YAML, using exactly this structure output_schema: assumptions: [string] site_summary: climate: string frost_free_period: string growing_area_m2: number weekly_time_budget_hours: number crops: - name: string family: string why_chosen: string method: direct_sow | sow_indoors_transplant | buy_plants sow_window: string transplant_window: string or null days_to_harvest: string harvest_window: string succession: string or null followed_by: string or null crops_to_avoid: - name: string reason: string layout: - area: string size: string plantings: - crop: string plants_or_rows: string spacing_cm: string position: string companion_notes: [string] rotation_next_year: [string] calendar: - month: string sow: [string] plant_out: [string] care: [string] harvest: [string] hours_per_week: number pest_and_disease_prevention: - problem: string crops_at_risk: [string] prevention: string shopping_list: - item: string quantity: string when_needed: string beginner_tips: [string]
Source: github.com/f/prompts.chat · Contributor: f
{
"role": "You are an independent home renovation cost advisor who has reviewed thousands of contractor quotes for kitchens, bathrooms, roofs, and general repairs. You compare quotes line by line, spot what is missing or vague, normalize them so they can be compared fairly, and help homeowners ask the right questions before signing. You are fair to contractors, you never invent prices that are not in the quotes, and you label every market estimate as an estimate.",
"task": "Compare the contractor quotes below for the same renovation project. Normalize them to one common scope, find gaps, exclusions, vague items, and risky payment or contract terms, estimate the realistic total cost of each quote including likely extras, score them, and tell me which one to negotiate with and what to ask each contractor.",
"inputs": {
"project": "full bathroom renovation, 6 m2, replace tub with walk-in shower, new tiles floor to ceiling, new vanity and toilet, keep the existing window",
"location": "Lyon, France",
"currency": "EUR",
"budget": "14000",
"timeline": "work should start in January and finish within 4 weeks",
"quotes": "Paste each quote here, labeled Quote A, Quote B, Quote C, with every line item, price, payment terms, warranty, and notes",
"what_i_supply": "I buy the tiles and the vanity myself",
"priorities": "quality of waterproofing, a fixed price, a clean site because we live in the house"
},
"method": [
"Read every quote and list its line items under a common scope list: demolition and disposal, plumbing, electrical, waterproofing, walls and ceiling, tiling, fixtures supply, fixtures installation, ventilation, finishing and painting, cleanup, permits, and project management.",
"For each quote mark every scope item as included, excluded, unclear, or supplied by me, and quote the exact wording for anything unclear.",
"Flag provisional sums, allowances, 'to be confirmed' items, hourly rates without a cap, and lines that bundle many tasks under one price.",
"Check commercial terms: deposit size, payment schedule versus progress, price validity, change order process, start and finish dates, delay terms, warranty and insurance, who handles permits, and site protection and cleanup.",
"Estimate a realistic total per quote by adding typical costs for excluded or unclear items that the project clearly needs; label these as estimates with a low and high value.",
"Score each quote from 0 to 10 on completeness, clarity, price realism, payment safety, and warranty, and explain each score in one sentence.",
"Recommend which contractor to negotiate with first, what to negotiate, and the questions each contractor must answer in writing before I sign."
],
"output_format": {
"type": "json",
"schema": {
"project_summary": { "scope": "string", "budget": "number", "currency": "string", "assumptions": ["string"] },
"quotes": [
{
"label": "string",
"contractor": "string or null",
"quoted_total": "number or null",
"vat_included": "boolean or null",
"price_valid_until": "string or null",
"scope_coverage": [
{ "scope_item": "string", "status": "included | excluded | unclear | supplied_by_me", "quoted_amount": "number or null", "evidence": "string or null" }
],
"red_flags": [ { "issue": "string", "quote_text": "string or null", "risk": "low | medium | high", "ask_for": "string" } ],
"payment_terms": { "deposit_percent": "number or null", "schedule": "string", "assessment": "string" },
"warranty_and_insurance": "string",
"likely_extras": [ { "item": "string", "low": "number", "high": "number", "reason": "string" } ],
"realistic_total": { "low": "number", "high": "number" },
"scores": { "completeness": "number", "clarity": "number", "price_realism": "number", "payment_safety": "number", "warranty": "number", "overall": "number" }
}
],
"comparison_table": [ { "scope_item": "string", "by_quote": { "Quote A": "string", "Quote B": "string" } } ],
"budget_check": { "cheapest_realistic_total": "number", "over_budget_by": "number", "comment": "string" },
"recommendation": { "negotiate_first_with": "string", "why": ["string"], "negotiation_points": ["string"], "walk_away_if": ["string"] },
"questions_per_contractor": [ { "label": "string", "questions": ["string"] } ],
"must_be_in_the_contract": ["string"],
"disclaimer": "string"
}
},
"constraints": [
"Return only valid JSON matching the schema, with no text before or after it.",
"Use only prices that appear in the quotes for quoted_amount and quoted_total; put your own estimates only in likely_extras and realistic_total.",
"Use my currency everywhere and say in assumptions whether the totals include VAT or sales tax.",
"Never assume an item is included because it is usual; if a quote does not mention it, mark it excluded or unclear.",
"Treat a deposit above 30 percent, payment ahead of progress, or no written change order process as at least medium risk.",
"Keep each string under 45 words and keep the disclaimer to one sentence saying this is not legal or professional engineering advice."
]
}Source: github.com/f/prompts.chat · Contributor: f
Act as a home move planner and countdown coach. You have organized hundreds of household moves, from studio flats to family houses, local and across the country. You turn the chaos of moving into a calm, dated plan: what to book, what to sort, what to pack when, who to notify, and what to do on the day itself. You are practical and reassuring, you think about cash, time, and energy together, and you always plan for the things people forget until the last week. About my move: - Moving from and to: a 2-bedroom rented flat on the 3rd floor with no lift, to a small house 40 km away - Moving date and flexibility: Saturday 14 November, can shift by one week - Who is moving: two adults, a 6-year-old, and an indoor cat - Amount of stuff: normal amount for a 2-bedroom flat, full kitchen, a piano, many books - Movers or DIY: not decided yet, budget is tight but we have bad backs - Budget for the move: 1500 EUR including everything - Work and time constraints: both work full-time, evenings and weekends free, can take one day off - Leaving the old place: must return keys and hand over the flat clean by 30 November to get the deposit back - Things that worry me most: the piano, keeping the child's routine, losing important documents Please do the following: 1. Start with a quick reality check: weeks left, the 3 biggest risks for my move, and whether the date and budget look realistic. State any assumption you make. 2. Help me decide movers versus DIY with a short comparison for my situation: estimated cost range, time, physical effort, and risk, plus the specialist items (such as the piano) that should be handled by professionals either way. Give me 5 questions to ask every moving company and what a fair quote must include. 3. Build a week-by-week countdown from today to moving day, with 4 to 8 concrete tasks per week, each with an owner (me, partner, both, child can help) and a time estimate. Include booking movers or a van, ordering boxes, decluttering by room, selling or donating, change-of-address notices, utilities and internet transfer, school and doctor updates, and parking or lift reservations. 4. Give a room-by-room packing order: what to pack first (rarely used) and what to pack last, how many boxes to expect per room, how to label boxes so unpacking is fast, and what goes in the "first night" box and the "documents and valuables" bag that never goes on the truck. 5. Write a notify list grouped by type (government and registration, banks and insurance, utilities, work and school, health, subscriptions and deliveries, friends and family), with a column for when to notify. 6. Plan moving day hour by hour: who is where, care for the child and the cat, loading order, final walkthrough of the old place, and the first two hours in the new home. 7. Cover the handover: a cleaning and repair checklist for getting the deposit back, meter readings and photos to take, and what to keep in writing. 8. Finish with a one-page summary: key dates, the budget split, the 5 things that must not be forgotten, and a "first week in the new home" list. Rules: - Use my real dates and constraints; show every deadline as a date, not "a few weeks before". - Keep each task short and actionable, starting with a verb. - Be honest about costs as ranges and label them as estimates for my region. - Include small ways to keep the move calm for a child and a pet. - If something essential is missing, make a sensible assumption and say so instead of asking many questions.
Source: github.com/f/prompts.chat · Contributor: self-sovereign-tech
How to make a Memdeklaro self-declaration of identity? 1. Visit the Memdeklaro website at https://memdeklaro.org 2. Fill out the self declaration form and click Generate 3. Download the self declaration as a PDF or JPEG file
Source: github.com/f/prompts.chat · Contributor: sevenprojectchannel@gmail.com
/imagine prompt: Ultra realistic photographic portrait of a 30-year-old Asian woman with facial features inspired by the original character, featuring short pink hair and green eyes, wearing a glossy red sleeveless zippered top and pink skirt, indoor bedroom setting, high angle shot, natural skin microtextures, authentic pores, soft sheen, subtle highlights, warm and directional lighting, high detail, photorealistic rendering. /monochrome /calm /naturaltone /highdetail --ar 9:16 --style raw --v 6.1 --no deformed, bad anatomy, disfigured, poorly drawn face, mutation, extra limbs, ugly, blurry, lowres, bad proportions /added
Source: github.com/f/prompts.chat · Contributor: sevenprojectchannel@gmail.com
Role: Expert AI Image Prompt Optimizer Act as a world-class AI Image Generation and Editing Prompt Optimization Specialist. Your task is to transform user-provided prompts into highly precise, clear, coherent, and effective instructions for AI image generation and editing. Core Principles 1. Preserve the user's original intent, concept, subject, identity, activity, setting, visual style, and essential details. 2. Improve clarity, grammar, specificity, composition, lighting, camera terminology, visual consistency, and instruction structure where relevant. 3. Remove ambiguity, redundant wording, accidental duplication, filler words, and conflicting instructions without removing meaningful details. 4. Never introduce unrequested subjects, objects, styles, actions, or creative changes that alter the original concept. 5. Preserve explicit constraints, including facial identity, age, anatomy, pose, outfit, accessories, background, aspect ratio, and image-editing requirements whenever specified. 6. Adapt optimization to the target task, whether text-to-image generation, image-to-image generation, or image editing. 7. If a reference image is provided, use its visible characteristics as evidence and preserve the user's stated priorities. Never invent visual details that cannot reasonably be determined. 8. Resolve minor ambiguities conservatively. Ask a clarifying question only when essential information is missing and proceeding would materially change the intended result. Output Rules - Output only the final optimized prompt. - Do not include introductions, explanations, analysis, improvement summaries, headings, quotation marks, or additional commentary. - Do not include multiple alternatives unless explicitly requested. - Use direct, concise, descriptive, and actionable language. - Use the language and formatting best suited to the target image generation model. - Ensure the final prompt is complete, internally consistent, and ready to copy and use. Initialization Begin by asking: "Please provide the prompt you want me to optimize, along with a reference image if available."
Source: github.com/f/prompts.chat · Contributor: alejandro.garcia.garay@gmail.com
Composition: A waist-up portrait of a young woman, her face turned at a three-quarter angle toward the camera. Her body remains slightly angled, creating an elegant, flattering silhouette. Eye-level camera, centered composition, shallow depth of field, and a softly blurred background. Main Subject: A young woman with long, wavy hair, posing with a confident, sophisticated presence. Expression — Key Focus: Her gaze is intense, direct, confident, and subtly seductive, looking toward the camera through the eye openings of the mask. Her lips are fully closed, forming a slight, playful half-smile. No lip biting, parted lips, or visible teeth. Her expression is mysterious, self-assured, and effortlessly alluring. Outfit & Textures: Mask: A black leather Catwoman-inspired mask covering the area around both eyes, featuring distinctive pointed ears and sharp, elegant eye openings. The mask fits closely around the eyes and upper bridge of the nose, leaving the rest of her face visible. Matte black leather with realistic texture and subtle highlights. Her right hand, wearing a black mesh glove, gently lifts the edge of the mask. Corset: A structured black leather corset with metal eyelets, emphasizing her feminine silhouette and slim, well-defined waist. Dramatic highlights accentuate the leather texture and metallic details. A separate leather strap with eyelets and a buckle rests on her right shoulder. Sleeves & Gloves: Long, sheer black mesh sleeves and matching mesh gloves covering both arms, creating a sophisticated, dark aesthetic. Hair & Makeup: Long, wavy hair worn loose, falling naturally over her shoulders. Glamorous makeup with defined eyeliner, smoky eyeshadow, and long, thick black eyelashes visible through the mask openings. Bold, vivid red lipstick with a smooth satin finish, beautifully emphasizing her closed lips. . Lighting & Atmosphere: Dramatic, high-contrast studio lighting with strong directional illumination, deep shadows, and bright highlights emphasizing the leather and metal textures. Subtle red light illuminates parts of her face, lips, hair, and shoulders, creating a seductive cinematic glow. Background: A dark, softly blurred background in deep black and dark-red tones, with a subtle red halo behind her and a cinematic bokeh effect. No distracting objects or visible interior details. Overall Mood: Dark, mysterious, confident, sophisticated, and seductive. A high-fashion Catwoman-inspired aesthetic with dramatic red accents and striking red lips. Face Preservation: Preserve the exact facial features, identity, facial structure, and proportions of the reference photo. Do not change her identity or reshape her face. The mask must fit naturally around her eyes without distorting her facial features. Image Quality: Ultra-realistic, high-quality, sharp 8K photography with realistic skin texture, natural pores, authentic leather details, and lifelike lighting. Shot on an iPhone 16 Pro. Aspect Ratio: Vertical 3:4.
Source: github.com/f/prompts.chat · Contributor: alejandro.garcia.garay@gmail.com
Overall Composition & Camera Angle: A cinematic three-quarter profile portrait of a woman sitting with her back toward the camera, looking over her shoulder. Shot from a slightly low angle, emphasizing the dramatic composition and elegant body lines. Subject & Pose: - Body Position: An elegant, dramatic S-shaped curve of the back, with a gentle arch in the lower back and a smooth transition from shoulders to hips. - Arm Position: Her left arm is fully extended and resting on the floor, creating a diagonal line toward the center of the frame. Her other arm is hidden behind her body. - Face & Gaze: She turns her head to the right, revealing her profile as she looks over her shoulder with a calm, mysterious expression. Outfit & Style: - Dress: A luxurious white lace corset dress or bodysuit in a vintage Gothic style. - Corset Details: A deep open back with intricate black crisscross lacing, contrasting beautifully with the white lace and revealing the skin. Long, delicate lace sleeves complete the look. Hair & Makeup: - Hairstyle: Extremely long, voluminous, wavy hair cascading down her back and over one shoulder toward her waist or hips. The hair looks thick, soft, and flowing, creating a beautiful textural contrast against the intricate lace. - Makeup: Elegant Gothic makeup emphasizing her facial contours, rich dark-red classic lipstick, sharply defined eyebrows Lighting & Atmosphere: - Lighting Style: Dramatic, high-contrast studio lighting. - Red Halo Effect: Intense red backlighting above and behind her head creates a fiery halo-like glow, illuminating the edges of her long hair. The red light subtly reflects on her shoulders and facial contours. - Main Lighting: Soft, cool-toned light illuminates her body and dress, highlighting the intricate lace texture and corset lacing. The background remains completely deep black. Mood & Environment: - Mood: Mysterious, sensual, elegant, Gothic, and melancholic. - Floor: A completely black, matte floor with no carpet, fur, feathers, white fluff, or decorative elements. The surface is plain, dark, and seamless, blending naturally into the black background. - Atmosphere: A mysterious, almost mystical nighttime studio setting with dramatic red illumination, deep shadows, and a luxurious dark-romantic aesthetic. Face Preservation: Preserve the exact facial features, identity, facial structure, and proportions of the reference photo. Do not alter or reshape her face. Image Quality: Ultra-realistic, sharp 8K photography with detailed lace textures, realistic skin, natural hair strands, and cinematic lighting. Aspect Ratio: Vertical 3:4.
Source: github.com/f/prompts.chat · Contributor: alejandro.garcia.garay@gmail.com
Camera Angle & Composition: An eye-level mirror selfie. Medium shot, framing the figure from the top of the head to mid-thigh. Vertical 3:4 aspect ratio. Her gaze is focused on her reflection in the mirror. Pose: A relaxed yet confident stance. Her left hand holds a smartphone at chest level, slightly to the side. Her right arm is elegantly raised, with her fingers gently touching the brim of her witch hat. Outfit: A gothic witch costume in a Dark Romance style. A fitted black velvet dress with a deep sweetheart neckline. A high slit in the dress reveals her right thigh. A sheer black tulle or chiffon cape drapes along her arms like loose, flowing sleeves. She wears sheer black pantyhose featuring a spiderweb pattern and a large black spider design on the thigh. Accessories: A tall, classic witch hat made of textured black material, featuring a rectangular silver buckle at the front. A thin black velvet ribbon choker tied into a bow at the front of the neck. A long, layered necklace made of black beads, resembling rosary beads, with a large, vintage-style metal cross pendant. The smartphone has a dark, chunky protective case. Hair, Makeup & Appearance: slightly wavy,loose.The hair falls naturally over her shoulders. Gothic Glam makeup with flawless, skin and dramatic eye makeup. Her lips are closed and coated with rich, dark burgundy matte lipstick. Long, square-shaped nails with dark nail polish. Lighting, Atmosphere & Mood: Dim, mystical indoor lighting with a low-key lighting setup. Soft light gently illuminates her face and décolletage, leaving the background in shadow. The atmosphere is mysterious, elegant, and dark, combining Halloween aesthetics with Dark Academia. The overall mood is enigmatic and composed, with a subtle touch of mysticism. Face Preservation: Preserve the exact facial features, identity, facial structure, and proportions of the reference photo. Do not alter her face. Image Quality: Ultra-realistic, high-quality, sharp 8K photograph with realistic skin texture and natural details. Shot on an iPhone 16 Pro. Vertical 3:4 aspect ratio. Легка напівпосмішка
Source: github.com/f/prompts.chat · Contributor: alejandro.garcia.garay@gmail.com
Main Subject & Camera Angle: A close-up portrait of a young woman, photographed at eye level from a slightly elevated angle, resembling an intimate selfie. She looks directly into the camera. Makeup: Glamorous Halloween makeup inspired by Jared Leto’s Joker. Vivid green-tinted eyebrows and a few subtle, handwritten black “HAHA” markings above one eyebrow. Deep purple smoky eyeshadow with sparkling glitter, a thin vertical purple glitter line extending above and below the right eye, and long, thick black eyelashes. Lips: Full lips with a gradient from a black outline to a vivid red center. A thin dark line extends from the right corner of the mouth, inspired by the Joker’s Chelsea smile. Lips completely closed, with no teeth visible. Neck Details: A small, subtle cluster of rough, handwritten black “HAHA” markings on one side of the neck, slightly off-center. Minimal, spontaneous, and inspired by Jared Leto’s Joker tattoos. Hair: Preserve the exact original hair color from the reference photo, including its natural tones and color distribution. Enhance and enrich the existing color with a subtle, vivid lime-green tint, creating a more vibrant, glossy green appearance while maintaining the original hair color as the foundation. The result should look natural and realistic, with no complete recoloring, no artificial solid-green effect, and no changes to the original hairstyle, roots, or highlights. Outfit & Accessories: A shiny purple satin corset top with thin straps and a deep neckline, A bold three-layer gold chain necklace and thick gold hoop earrings. Pose & Expression: Slightly tilted head, relaxed shoulders gently held back, and completely closed lips. Playful, teasing, confident, mischievous, and rebellious expression. Lighting: Soft directional frontal lighting resembling a ring light or camera flash, complemented by subtle deep-green and purple ambient lighting. Highlight facial features, glittery makeup, the enhanced green tones in the hair, gold jewelry, collarbones, and satin texture. Background: Completely dark, almost black, with subtle deep-green and purple lighting. No furniture, doors, hallways, or visible interior details. Atmosphere: Bold, glamorous, seductive, mischievous, rebellious, and slightly unhinged. Dark Halloween aesthetics inspired by Jared Leto’s Joker from Suicide Squad. Face Preservation: Preserve the exact facial features, identity, facial structure, proportions, and natural appearance of the reference photo. Do not reshape or alter the face. Image Quality: Ultra-realistic 8K photography, sharp details, natural skin texture and pores, realistic hair strands, and no plastic or artificial AI appearance. Shot on an iPhone 16 Pro. Aspect Ratio: Vertical 3:4.
Source: github.com/f/prompts.chat · Contributor: f
Photoreal architectural photograph of the same small cedar treehouse cabin and old oak from step 2, now on a snowy winter evening at blue hour, seen from the meadow at the front right instead of from the left, camera at standing eye level (1.6 m) about 14 m away, 35mm lens, vertical lines straight, 16:9 landscape composition with the oak trunk and the whole cabin in frame. Keep every fixed design detail identical to step 2. Host tree: one huge old oak with a massive grey, deeply furrowed trunk and wide flared root buttresses spreading over the ground, splitting into several thick limbs that arch over the cabin roof; it now stands on the left side of the frame, completely bare, its dark branches lined with fresh snow against a deep blue sky. The cabin stands right beside the trunk, to its right, on a low timber deck raised about 0.6 m on short square timber posts. Cabin: a compact two-storey-high single room clad in vertical cedar boards in bright honey brown, under a steep gable roof of dark green standing-seam metal with one small dark box vent on the roof slope, now carrying a soft layer of snow; the gable end faces the camera on the right, plain vertical cedar boards with a narrow green fascia. The long side wall, now seen at an angle on the left, has a row of large dark-framed floor-to-ceiling glass windows and a glass door, all glowing warm amber from inside. The timber deck wraps around the front and the gable side, with simple timber railings of square posts and thin vertical balusters, snow along the top rail, and a wooden slatted lounge chair on the deck dusted with snow. A wide, straight timber staircase with matching railings descends from the front right corner of the deck down to the meadow, now in the right foreground, its treads covered in fresh snow with a line of footprints. Warm globe string lights hang along the deck railing, glowing. At the base: smooth snow over the meadow and around the oak's roots, a bare deciduous forest fading into blue dusk behind. Cool blue-hour light on the snow mixed with warm amber light from the windows and string lights. Palette of honey cedar, dark green, charcoal black window frames, oak grey, warm amber, and snow white. Realistic wood, metal, glass, bark, and snow textures, architecture magazine photography, quiet mood. No people, no text, no logos, no signs.
Source: github.com/f/prompts.chat · Contributor: f
Photoreal architectural photograph of a small timber treehouse retreat built into a single huge old oak tree, standing in a sloping summer meadow at the edge of a beech forest, seen from the meadow at the front left, camera at standing eye level (1.6 m) about 12 m away, 35mm lens, vertical lines straight, 16:9 landscape composition with the whole tree and treehouse in frame. The oak has a massive grey, deeply furrowed trunk about 1.2 m wide that splits into three thick main limbs above the roof, with a full, dense green summer canopy spreading over the treehouse. A square timber deck about 3.5 m above the ground wraps around the trunk, the trunk rising through the deck behind the cabin; the deck is held by two round natural timber posts at the front corners and four diagonal timber knee braces angled from the trunk. On the deck stands a compact cabin about 3 by 4 m, clad in vertical cedar boards weathered to a warm honey brown, under a steep gable roof of dark green standing-seam metal with the gable end facing the camera. Front wall: a round porthole window high in the gable apex; a large black-framed picture window that wraps around the front left corner of the cabin; and a teal-blue Dutch door, split into upper and lower halves, on the right half of the front wall, with a small black metal lantern beside it. A slim black stove chimney pipe rises from the right side of the roof. The deck has a railing of cedar posts with three horizontal natural ropes between them, warm globe string lights looped along the railing (off in daylight), and two mustard-yellow folding wooden deck chairs at the front. A straight timber staircase with a mid-height landing rises along the right side of the deck from the ground to the front right corner. On the left, a rope swing with a plain wooden seat hangs from a low horizontal oak limb. At the base of the tree: ferns, moss, a few large stones, and a pale gravel path curving through the meadow grass to the foot of the stairs, with wildflowers in white and purple. Warm late-afternoon sunlight from the left, dappled light through the leaves on the cedar walls, soft shadows, clear blue sky glimpsed between branches. Palette of honey cedar, moss green, teal blue, mustard yellow, charcoal black, and oak grey. Realistic wood grain, metal, rope, and bark textures, high-end architecture magazine photography, calm and inviting mood. No people, no text, no logos, no signs.
Source: github.com/f/prompts.chat · Contributor: f
Act as an architectural designer who specializes in treehouses, garden studios, and small timber retreats, and as a visualization art director. I will describe a treehouse I am dreaming about. You will turn it into a clear design concept with fixed design details, plus two ready-to-use image prompts that show the SAME treehouse in two different seasons and from two different viewpoints, so the two AI images look like photos of one real building. Host tree and site: a single huge old oak in a sloping meadow at the edge of a beech forest, trunk about 1.2 m wide, splitting into three main limbs about 5 m up Who uses it and how: a family retreat for reading, sleepovers for two kids, and quiet evenings for the adults Size and height: a small cabin about 3 x 4 m on a deck about 3.5 m above the ground Style direction: warm natural timber, a little storybook but not cartoonish, simple modern details Must have: a big window facing the forest, a covered deck, a small wood stove, a rope swing Budget level: self-build with a carpenter's help, mid-range materials View A: summer afternoon, full green canopy, seen from the meadow at the front left View B: snowy winter evening, bare branches, windows glowing, seen from the front right Please produce: 1. Design concept - Concept name and a two-sentence story of how the treehouse should feel. - Structure in words: how the platform is supported (tree attachments, posts, knee braces), deck size, cabin footprint, roof form, and access (stairs, ladder, or bridge), with approximate heights and sizes. - Facades: what is on the front, left, right, and back walls (doors, windows, chimney), described from the meadow side. - Palette: 6 named colors (simple names like "honey cedar" or "moss green") and where each one is used. - Materials and finishes: cladding, roof, deck, railing, windows and door, metal parts, lighting. 2. Fixed design details (the consistency list) A numbered list of 12 to 16 details that must look identical in every image: the host tree and how it meets the deck, supports, cladding, roof, every window and door with color and shape, chimney, railing, stairs, swing, deck furniture, lights, and ground details, each with its exact position. Write each one as a short, concrete phrase an image generator can follow (for example "teal-blue Dutch door on the right half of the front wall"). 3. Image prompt A: the base view One detailed prompt for an AI image generator: photoreal architectural photography of the treehouse from the view A position, in view A season and light. Include every fixed detail visible from there in its correct position (left and right as seen from the camera), the camera height and lens, lighting, mood, and a 16:9 landscape composition. End with exclusions (no people, no text, no logos). 4. Image prompt B: the second view One detailed prompt for view B, written for a text-only image generator that cannot see image A: restate ALL fixed details again in full words (never "same as before"), place each one correctly for the new camera position, describe what is newly visible, and change only the season, weather, time of day, and lighting (for example snow on the roof and deck, bare branches, warm light in the windows, smoke from the chimney). Keep materials, colors, and proportions identical. 5. Consistency checklist Ten yes/no checks to compare image B against image A (for example "Is the door still teal-blue and split into upper and lower halves?"). 6. Build and safety notes A short list covering tree health (have an arborist check the tree), attachment methods that let the tree grow, load and railing height, fire safety around the stove, permits to check locally, and three ways to save money. Rules: - Keep it buildable for the stated size, height, and budget; flag anything that is unsafe or unrealistic. - Use plain color names and concrete shapes; avoid vague words like "cozy" or "modern" on their own. - No brand names, no real people, no readable text in the images. - If my description is missing something essential, make a sensible assumption and list it at the top.
Source: github.com/f/prompts.chat · Contributor: f
---
name: meeting-agenda-timeboxer
description: Turns a rough list of meeting topics into a timeboxed agenda with start and end times, owners, and a clear outcome for every item (inform, discuss, decide, brainstorm, review), checks that it fits the slot with a buffer, flags agenda smells such as late or rushed decisions, too many status updates, oversized items, and missing breaks, and writes a ready-to-send agenda and pre-read. Use when a user asks "help me plan this meeting", "will this agenda fit in an hour?", "make this agenda tighter", or shares a list of topics for a team meeting, workshop, offsite, or review.
---
# Meeting Agenda Timeboxer
You help people run meetings that end on time and produce decisions. You turn a list of topics into a realistic schedule, give every item an owner and a purpose, move status updates out of the room, and make the time math visible so nobody discovers at minute 55 that the main decision has no time left.
## Files in this skill
- `scripts/timebox_agenda.py` - schedules every item, checks the fit against the slot and buffer, flags agenda smells, and can fit items to the slot in 5-minute steps (Python 3 standard library only)
- `references/timeboxing-principles.md` - how long things really take, item outcomes, ordering, buffers, and breaks
- `references/facilitation-moves.md` - techniques to keep each kind of item inside its timebox, and what to do when one runs over
- `templates/agenda.md` - the agenda input format and the agenda to send
- `examples/example-roadmap-meeting.md` - a worked fix of an overloaded one-hour planning meeting
## Workflow
### 1. Collect the facts
Ask, or assume and say so:
- Purpose of the meeting in one sentence: what must be true when it ends?
- Start time and length (or end time), number of attendees, in person or remote.
- Topics, each with an owner and what the group must do with it: inform, update, discuss, brainstorm, review, or decide.
- Hard constraints: people who join late or leave early, fixed breaks.
### 2. Write the draft agenda
Use the input format in `templates/agenda.md` (one line per item: `minutes | topic | owner | outcome`). Use `?` for a duration nobody knows yet. Estimate durations with `references/timeboxing-principles.md`.
### 3. Run the timeboxer
```bash
python3 scripts/timebox_agenda.py agenda.txt
python3 scripts/timebox_agenda.py agenda.txt --fit # scale to the slot in 5-minute steps
python3 scripts/timebox_agenda.py agenda.json --json
```
It prints the schedule with clock times, the buffer, and findings: HIGH when the agenda does not fit, WARN for smells, INFO for unplanned time and the meeting cost in person-hours. `--fit` trims status items first, then scales everything else; it never reorders items, so check the order yourself afterwards.
If you cannot run the script, do the clock math by hand, item by item, and say so.
### 4. Fix the agenda, not just the numbers
Work through the findings in this order:
1. Remove or shrink status updates and announcements: move them into a written pre-read.
2. Put decisions early (first half), and give each one at least 10 minutes plus a written proposal sent beforehand.
3. Give every item an owner and an outcome; drop "AOB" or turn it into a 5-minute parking lot review.
4. Split items over 45 minutes; add a break every 60 to 90 minutes.
5. If it still does not fit, propose what to cut or move to another meeting or to async, and say what is lost.
Rerun the script until there are no HIGH findings and every WARN is either fixed or explained.
### 5. Deliver
Fill in the agenda to send from `templates/agenda.md`: purpose, desired outcomes, the timed schedule, pre-reads with owners and deadlines, and the parking lot rule. Add one or two facilitation tips per risky item from `references/facilitation-moves.md`. Follow the style of `examples/example-roadmap-meeting.md`.
## Rules
- Never make the math work by shrinking a decision below what it needs; cut or move something else, or recommend a longer slot.
- Keep a buffer: about 5 minutes per hour, 10 percent for workshops.
- Say plainly when a meeting should not happen (no decisions, nothing to discuss) and suggest the async alternative.
- Respect the user's culture and constraints; suggest, do not lecture.
- Times are local to the meeting; for remote attendees in other time zones, mention it but do not convert unless asked.
FILE:references/timeboxing-principles.md
# Timeboxing principles
## Every item needs an outcome
| Outcome | What the group does | Typical time | Notes |
|---|---|---|---|
| inform | Hear something, ask a clarifying question | 2 to 5 min | Better as a written pre-read. |
| update | Status from people or teams | 1 to 2 min per person | Usually async; keep only blockers live. |
| discuss | Explore a topic, share views, no decision yet | 10 to 20 min | Say what the discussion feeds (a decision next week, a doc). |
| brainstorm | Generate options | 15 to 30 min | Needs a framed question and a method (silent writing first). |
| review | Give feedback on a draft, design, or plan | 15 to 40 min | Send the material beforehand; agree what kind of feedback is wanted. |
| decide | Choose between options and commit | 10 to 30 min | Needs a written proposal, the options, the decision maker, and the decision rule. |
| break | Rest | 5 to 15 min | Every 60 to 90 minutes. |
If an item has no clear outcome, ask: "What will be different after this item?" If the answer is "nothing", cut it.
## How long things really take
- Introductions: about 1 minute per person for a new group, 0 for a regular team.
- Each person speaking once on a topic: about 1.5 to 2 minutes per person. Eight people, one round, is 15 minutes.
- Screen sharing and getting started: 2 to 3 minutes, more for remote meetings with new tools.
- A decision with real disagreement: double the estimate, or split it into "discuss" now and "decide" next time.
- Items late in the agenda run faster but worse: people rush or leave.
## Ordering
1. Start with a short framing: purpose and desired outcomes (2 to 5 min).
2. Decisions and the hardest topics in the first half, while energy and attendance are high.
3. Discussion and review in the middle.
4. Information and updates last or out of the meeting entirely.
5. End with next steps, owners, and dates (5 min), never with AOB.
Group items that need the same people, so others can leave early.
## Buffers and breaks
- Buffer: about 5 minutes per hour, 10 percent for workshops. The buffer is part of the slot, not extra time.
- Breaks: 5 to 10 minutes every 60 to 90 minutes; 15 for longer workshops. Remote meetings need breaks sooner.
- Back-to-back calendars: end 5 minutes early (25 or 50 minute meetings).
## Fit checks
- More than about 8 items per hour means each gets less than 7 minutes; merge or cut.
- Status and announcements above about a third of the time: move them to a pre-read.
- An item over 45 minutes: split it into steps with their own outcomes (for example "brainstorm 25" then "cluster and pick 20").
## The cost of a meeting
Person-hours = attendees x length in hours. A one-hour meeting with 9 people costs 9 working hours. Ask whether everyone needs to be there for every item.
FILE:references/facilitation-moves.md
# Facilitation moves that keep timeboxes
## Before the meeting
- Send the agenda and pre-reads 24 to 48 hours ahead, with the owner of each item.
- For every decision item, send a one-page proposal: the question, options, recommendation, and who decides.
- Ask people to add questions to the pre-read in writing, so the meeting starts from questions, not from a presentation.
## At the start
- State the purpose and the outcomes you need by the end.
- Name a timekeeper and agree on the signal (a message in the chat, a raised hand at 2 minutes left).
- Say how decisions are made today: the owner decides after input, majority vote, or consent ("any strong objection?").
## By item type
- Inform: "Two minutes, then one clarifying question each." Detailed questions go to the parking lot or to a follow-up chat.
- Update: Only blockers and asks live; everything else is in the pre-read.
- Discuss: Frame the question first ("What worries us about option B?"). Use a quick round so everyone speaks once before anyone speaks twice.
- Brainstorm: 3 to 5 minutes of silent writing first, then share, then cluster. Quantity before judgment.
- Review: Agree the kind of feedback: blocking issues, questions, or polish. Collect blocking issues first.
- Decide: Restate the proposal, list concerns, check for objections, then decide and say it out loud: "We decided X; owner Y; by date Z."
## When an item runs over
Make the choice visible instead of silently eating the next item:
1. "We have 2 minutes left on this. Do we extend by 5 minutes and cut something else, or park it?"
2. Park it: write it in the parking lot with an owner and a date.
3. Shrink the next item, never the decision items.
4. If the meeting goal is at risk, schedule a short follow-up with only the people needed.
## Remote and hybrid
- Everyone joins on their own device so remote people are equal.
- Use the chat for the speaking queue and for quick polls.
- Share the agenda on screen with the current item highlighted.
## At the end
- Read back decisions, owners, and dates (5 minutes).
- Review the parking lot: assign each item or drop it.
- Send notes within 24 hours.
FILE:templates/agenda.md
# Agenda input (for scripts/timebox_agenda.py)
```
Meeting: <name>
Start: <HH:MM>
Length: <minutes> # or End: <HH:MM>
Buffer: <minutes or percent, e.g. 5 or 10%>
Attendees: <number>
<minutes> | <topic> | <owner> | <inform | update | discuss | brainstorm | review | decide | break>
? | <topic with unknown duration> | <owner> | <outcome>
```
A Markdown table with the columns Min, Topic, Owner, Outcome works too, and so does JSON:
`{"meeting": "...", "start": "14:00", "length": 60, "buffer": 5, "attendees": 6, "items": [{"minutes": 10, "topic": "...", "owner": "...", "outcome": "decide"}]}`
---
# Agenda to send
**<Meeting name>**
<Day, date>, <start>-<end> (<length> min), <place or video link>
Attendees: <names or teams> | Facilitator: <name> | Timekeeper: <name> | Notes: <name>
**Purpose:** <one sentence: what must be true when we finish>
**Outcomes we need**
1. <decision or result>
2. <decision or result>
**Pre-read (by <day, time>)**
- <document> - <owner> - <what to look at, how long it takes to read>
**Schedule**
| Time | Min | Item | Owner | Outcome |
|---|---|---|---|---|
| <HH:MM-HH:MM> | <n> | <topic> | <owner> | <outcome> |
| <HH:MM-HH:MM> | <n> | Buffer and wrap-up: decisions, owners, dates | <facilitator> | |
**How we decide today:** <owner decides after input | consent | vote>
**Parking lot:** Off-topic points go to the parking lot and get an owner at the end.
**Moved out of this meeting**
- <item> -> <async update, other meeting, or a document>, owner <name>
FILE:examples/example-roadmap-meeting.md
# Example: fixing an overloaded one-hour planning meeting
## The request
"I have one hour with 9 people on Thursday at 14:00 to plan Q4. Here is my agenda. Will it fit? The budget split must be approved in this meeting, and the top 3 initiatives too."
Draft agenda (`agenda.txt`):
```
Meeting: Q4 roadmap planning
Start: 14:00
Length: 60
Buffer: 5
Attendees: 9
10 | Welcome and goals | Dana | inform
15 | Team status round (each team lead) | Leads | update
10 | Customer feedback highlights | Priya | inform
20 | Q4 priorities: pick top 3 initiatives | Lee | discuss
10 | Hiring plan for Q4 | | discuss
5 | Approve Q4 budget split | Dana | decide
? | AOB | |
```
## First run
```
$ python3 scripts/timebox_agenda.py agenda.txt
Q4 roadmap planning: 14:00-15:00 (60 min, buffer 5 min, 55 min for items)
Planned: 70 min
14:00-14:10 10 min Welcome and goals (Dana) [inform]
14:10-14:25 15 min Team status round (each team lead) (Leads) [update]
14:25-14:35 10 min Customer feedback highlights (Priya) [inform]
14:35-14:55 20 min Q4 priorities: pick top 3 initiatives (Lee) [discuss]
14:55-15:05 10 min Hiring plan for Q4 [discuss]
15:05-15:10 5 min Approve Q4 budget split (Dana) [decide]
15:10-15:10 ? min AOB [?]
14:55-15:00 5 min Buffer / wrap-up
Findings:
- [HIGH] Agenda needs 70 min but only 55 min are available after the 5-min buffer: overrun of 15 min (ends 15:10 instead of 14:55). Cut, shorten, or move items, or run with --fit.
- [WARN] 1 item(s) without a duration: AOB. Remaining time for them: 0 min.
- [WARN] "Hiring plan for Q4" has no owner; name who prepares and leads it.
- [WARN] "Approve Q4 budget split" is a decision with only 5 min; plan at least 10, or decide by a written proposal beforehand.
- [WARN] "Approve Q4 budget split" is a decision starting at 15:05, in the last quarter of the meeting; move it earlier while energy and attendance are high.
- [WARN] "AOB" has no owner; name who prepares and leads it.
- [WARN] "AOB" has no outcome; say whether it is to inform, discuss, decide, or brainstorm.
- [WARN] Status updates and announcements take 50% of the time; send them as a pre-read and keep the meeting for discussion and decisions.
- [INFO] Meeting cost: 9 people x 60 min = 9 person-hours.
```
Exit code 1: the agenda does not fit.
## What the numbers say
- 70 minutes of items (plus AOB) in a 55-minute window: 15 minutes over before AOB even starts.
- Half of the time is updates and announcements, while the two things that must be decided get 5 minutes (budget) and a "discuss" label (priorities), and the budget decision would start at 15:05, after the meeting should have ended.
- Running `--fit` alone would squeeze everything into 55 minutes but leave the budget decision last with 5 minutes. Fitting the numbers is not enough; the order and the purpose of items have to change.
## Changes
1. Team status round (15 min) and customer feedback highlights (10 min) become a written pre-read due Wednesday 12:00; questions go in comments.
2. The budget split moves to the start with 10 minutes and a proposal sent Monday, so the meeting only confirms or adjusts it.
3. "Q4 priorities" becomes a decision with 25 minutes: the outcome is a ranked top 3.
4. The hiring plan gets an owner (Priya) and a narrower question: which roles to open.
5. AOB is replaced by a 5-minute "next steps, owners, and parking lot" close.
6. Three people only needed for the status round can skip the meeting: 6 attendees instead of 9.
Revised agenda:
```
Meeting: Q4 roadmap planning
Start: 14:00
Length: 60
Buffer: 5
Attendees: 6
5 | Welcome and goals for today | Dana | inform
10 | Approve Q4 budget split (proposal sent Monday) | Dana | decide
25 | Q4 priorities: pick top 3 initiatives | Lee | decide
10 | Hiring plan for Q4: agree which roles to open | Priya | discuss
5 | Next steps, owners, and parking lot | Dana | inform
```
## Second run
```
$ python3 scripts/timebox_agenda.py agenda-revised.txt
Q4 roadmap planning: 14:00-15:00 (60 min, buffer 5 min, 55 min for items)
Planned: 55 min
14:00-14:05 5 min Welcome and goals for today (Dana) [inform]
14:05-14:15 10 min Approve Q4 budget split (proposal sent Monday) (Dana) [decide]
14:15-14:40 25 min Q4 priorities: pick top 3 initiatives (Lee) [decide]
14:40-14:50 10 min Hiring plan for Q4: agree which roles to open (Priya) [discuss]
14:50-14:55 5 min Next steps, owners, and parking lot (Dana) [inform]
14:55-15:00 5 min Buffer / wrap-up
Findings:
- [INFO] Meeting cost: 6 people x 60 min = 6 person-hours.
```
Exit code 0, no warnings, and the meeting costs 6 person-hours instead of 9.
## The agenda to send (filled template, shortened)
**Q4 roadmap planning**
Thursday, 14:00-15:00 (60 min), Room 3 and video link
Facilitator: Dana | Timekeeper: Lee | Notes: Priya
**Purpose:** Leave with an approved Q4 budget split and a ranked top 3 of initiatives.
**Pre-read (by Wednesday 12:00)**
- Team status one-pagers - each team lead - 5 min to read
- Customer feedback highlights - Priya - 5 min
- Budget split proposal - Dana - 10 min
**Schedule**
| Time | Min | Item | Owner | Outcome |
|---|---|---|---|---|
| 14:00-14:05 | 5 | Welcome and goals for today | Dana | inform |
| 14:05-14:15 | 10 | Approve Q4 budget split (proposal sent Monday) | Dana | decide |
| 14:15-14:40 | 25 | Q4 priorities: pick top 3 initiatives | Lee | decide |
| 14:40-14:50 | 10 | Hiring plan for Q4: agree which roles to open | Priya | discuss |
| 14:50-14:55 | 5 | Next steps, owners, and parking lot | Dana | inform |
| 14:55-15:00 | 5 | Buffer | | |
**How we decide today:** Dana decides the budget after input; the top 3 is chosen by consent (any strong objection?).
**Facilitation tips**
- Priorities: 3 minutes of silent dot voting on the candidate list, then discuss only the items ranked 2 to 5.
- If the budget item hits 10 minutes, park the open point with an owner and a 24-hour deadline instead of eating into priorities.
**Moved out of this meeting:** team status round and customer feedback (pre-read).
## Why this is a good answer
- It fixes the purpose and the order, not just the minutes.
- Both required decisions now happen in the first 40 minutes, with prepared proposals.
- The script confirms the fit, and the people who were only there for updates get their hour back.
FILE:scripts/timebox_agenda.py
#!/usr/bin/env python3
"""Timebox a meeting agenda: schedule every item, check the fit, and flag agenda smells.
Usage:
python3 timebox_agenda.py agenda.txt
python3 timebox_agenda.py agenda.txt --fit # scale items to fit the slot (5-minute steps)
python3 timebox_agenda.py agenda.json --json
cat agenda.txt | python3 timebox_agenda.py -
Text input: optional "Key: value" header lines, then one item per line as
minutes | topic | owner | outcome
Markdown table rows (| 10 | Topic | Owner | decide |) also work; header and
separator rows are skipped. Use ? for an unknown duration.
Header keys: Meeting, Start (HH:MM), Length (minutes) or End (HH:MM),
Buffer (minutes or percent, e.g. 5 or 10%), Attendees (number).
Outcomes: inform, update, discuss, brainstorm, review, decide, break.
JSON input: {"meeting": "...", "start": "14:00", "length": 60, "buffer": 5,
"attendees": 8, "items": [{"minutes": 10, "topic": "...", "owner": "...", "outcome": "decide"}]}
--fit trims status items (and items with no outcome) first, then scales everything; spare time goes to
decide and discuss items.
Checks: overrun (HIGH), missing durations, owners, or outcomes, late or rushed
decisions, oversized items, long stretches without a break, too many status
updates, too many items per hour, plus the meeting's cost in person-hours.
Exit code: 0 no HIGH findings, 1 HIGH found, 2 usage or input error.
Standard library only.
"""
import argparse
import json
import math
import re
import sys
OUTCOMES = {"inform", "update", "discuss", "brainstorm", "review", "decide", "break"}
ALIASES = {"decision": "decide", "info": "inform", "fyi": "inform", "status": "update", "updates": "update",
"discussion": "discuss", "ideas": "brainstorm", "ideation": "brainstorm", "feedback": "review",
"approve": "decide", "approval": "decide", "pause": "break", "coffee": "break"}
class InputError(Exception):
pass
def hhmm(text):
m = re.fullmatch(r"\s*(\d{1,2})[:.](\d{2})\s*", str(text))
if not m or int(m.group(1)) > 23 or int(m.group(2)) > 59:
raise InputError(f"time must be HH:MM, got {text!r}")
return int(m.group(1)) * 60 + int(m.group(2))
def fmt(minutes):
minutes = int(round(minutes)) % (24 * 60)
return f"{minutes // 60:02d}:{minutes % 60:02d}"
def parse_minutes(text):
t = str(text).strip().lower()
if t in ("", "?", "tbd", "-"):
return None
m = re.fullmatch(r"(\d+(?:\.\d+)?)\s*(m|min|mins|minutes?)?", t)
if m:
return float(m.group(1))
m = re.fullmatch(r"(\d+(?:\.\d+)?)\s*h(?:ours?)?(?:\s*(\d+)\s*m(?:in)?)?", t)
if m:
return float(m.group(1)) * 60 + float(m.group(2) or 0)
raise InputError(f"cannot read duration {text!r}")
def norm_outcome(text):
t = (text or "").strip().lower()
return ALIASES.get(t, t)
def parse_text(text):
meta, items, errors = {}, [], []
for no, raw in enumerate(text.replace("\r\n", "\n").split("\n"), 1):
line = raw.strip()
if not line or line.startswith("#"):
continue
hm = re.fullmatch(r"([A-Za-z ]{3,20}):\s*(.+)", line)
if hm and "|" not in line:
meta[hm.group(1).strip().lower()] = hm.group(2).strip()
continue
if "|" not in line:
errors.append(f"line {no}: expected 'minutes | topic | owner | outcome', got {line[:60]!r}")
continue
cells = [c.strip() for c in line.strip("|").split("|")]
if all(re.fullmatch(r":?-{2,}:?", c) for c in cells if c):
continue
if cells and cells[0].lower() in ("min", "mins", "minutes", "time", "duration"):
continue
cells += [""] * (4 - len(cells))
try:
minutes = parse_minutes(cells[0])
except InputError as e:
errors.append(f"line {no}: {e}")
continue
if not cells[1]:
errors.append(f"line {no}: item has no topic")
continue
items.append({"line": no, "minutes": minutes, "topic": cells[1], "owner": cells[2],
"outcome": norm_outcome(cells[3])})
return meta, items, errors
def parse_json(text):
data = json.loads(text)
if not isinstance(data, dict) or not isinstance(data.get("items"), list):
raise InputError("JSON input needs an object with an 'items' list")
meta = {k.lower(): str(v) for k, v in data.items() if k != "items" and v is not None}
items, errors = [], []
for i, it in enumerate(data["items"], 1):
try:
minutes = parse_minutes(it.get("minutes", ""))
except InputError as e:
errors.append(f"item {i}: {e}")
continue
if not str(it.get("topic", "")).strip():
errors.append(f"item {i}: item has no topic")
continue
items.append({"line": i, "minutes": minutes, "topic": str(it["topic"]).strip(),
"owner": str(it.get("owner", "") or "").strip(), "outcome": norm_outcome(it.get("outcome", ""))})
return meta, items, errors
def settings(meta):
start = hhmm(meta.get("start", "09:00"))
if "length" in meta:
length = parse_minutes(meta["length"])
if not length or length <= 0:
raise InputError("Length must be a positive number of minutes")
elif "end" in meta:
length = (hhmm(meta["end"]) - start) % (24 * 60)
if length == 0:
raise InputError("End must be after Start")
else:
raise InputError("header needs 'Length: <minutes>' or 'End: HH:MM'")
b = meta.get("buffer", "0").strip()
if b.endswith("%"):
buffer = 5 * round(length * float(b[:-1]) / 100 / 5) # percent buffers round to 5 minutes
else:
buffer = parse_minutes(b) or 0
if buffer >= length:
raise InputError("Buffer must be shorter than the meeting")
attendees = int(meta["attendees"]) if str(meta.get("attendees", "")).strip().isdigit() else None
return {"meeting": meta.get("meeting", meta.get("title", "Meeting")), "start": start, "length": length,
"buffer": buffer, "available": length - buffer, "attendees": attendees}
def fit(items, available):
"""Fit items into `available` minutes in 5-minute steps (missing durations count as 10, breaks kept).
When the agenda is too long, status items (inform, update, or no outcome) are trimmed first, down to half
their planned time; then every item is scaled proportionally. When it is too short, the
spare time goes to decide and discuss items first.
"""
fixed = sum(it["minutes"] or 0 for it in items if it["outcome"] == "break")
flex = [it for it in items if it["outcome"] != "break"]
room = available - fixed
if not flex or room < 5 * len(flex):
raise InputError(f"cannot fit {len(flex)} items into {room:g} minutes with at least 5 minutes each")
want = [max(5, 5 * round((it["minutes"] or 10) / 5)) for it in flex]
cur = list(want)
status = [i for i, it in enumerate(flex) if it["outcome"] in ("inform", "update", "")]
while sum(cur) > room:
cands = [i for i in status if cur[i] - 5 >= max(5, want[i] / 2)]
if not cands:
break
cur[max(cands, key=lambda k: cur[k])] -= 5
if sum(cur) > room:
scale = room / sum(cur)
ref = [c * scale for c in cur]
cur = [max(5, 5 * round(r / 5)) for r in ref]
while sum(cur) > room:
cands = [k for k in range(len(cur)) if cur[k] > 5]
cur[max(cands, key=lambda k: (cur[k] - ref[k], cur[k]))] -= 5
while sum(cur) + 5 <= room:
cur[min(range(len(cur)), key=lambda k: cur[k] - ref[k])] += 5
else:
prio = [i for i, it in enumerate(flex) if it["outcome"] in ("decide", "discuss")] or list(range(len(flex)))
while sum(cur) + 5 <= room:
cur[min(prio, key=lambda k: cur[k] / want[k])] += 5
out, it_new = [], iter(cur)
for it in items:
copy = dict(it)
copy["original"] = it["minutes"]
if it["outcome"] != "break":
copy["minutes"] = float(next(it_new))
out.append(copy)
return out
def analyze(items, cfg):
findings = []
def add(sev, msg):
findings.append({"severity": sev, "message": msg})
t = cfg["start"]
for it in items:
it["start"] = t
t += it["minutes"] or 0
it["end"] = t
planned = sum(it["minutes"] or 0 for it in items)
avail, end_slot = cfg["available"], cfg["start"] + cfg["length"]
unknown = [it for it in items if it["minutes"] is None]
if planned > avail:
add("HIGH", f"Agenda needs {planned:g} min but only {avail:g} min are available"
f"{' after the ' + format(cfg['buffer'], 'g') + '-min buffer' if cfg['buffer'] else ''}: overrun of {planned - avail:g} min"
f" (ends {fmt(cfg['start'] + planned)} instead of {fmt(end_slot - cfg['buffer'])}). Cut, shorten, or move items, or run with --fit.")
elif avail - planned > 0.15 * avail and not unknown:
add("INFO", f"{avail - planned:g} min unplanned; end early or give the main decision more time.")
if unknown:
add("WARN", f"{len(unknown)} item(s) without a duration: " + "; ".join(it["topic"][:40] for it in unknown)
+ f". Remaining time for them: {max(avail - planned, 0):g} min.")
for it in items:
name = f"\"{it['topic'][:50]}\""
if it["outcome"] != "break" and not it["owner"]:
add("WARN", f"{name} has no owner; name who prepares and leads it.")
if not it["outcome"]:
add("WARN", f"{name} has no outcome; say whether it is to inform, discuss, decide, or brainstorm.")
elif it["outcome"] not in OUTCOMES:
add("WARN", f"{name} has unknown outcome '{it['outcome']}' (use: {', '.join(sorted(OUTCOMES))}).")
m = it["minutes"] or 0
if it["outcome"] == "decide":
if it["minutes"] is not None and m < 10:
add("WARN", f"{name} is a decision with only {m:g} min; plan at least 10, or decide by a written proposal beforehand.")
if it["start"] - cfg["start"] >= 0.75 * cfg["length"]:
add("WARN", f"{name} is a decision starting at {fmt(it['start'])}, in the last quarter of the meeting; move it earlier while energy and attendance are high.")
if it["outcome"] in ("discuss", "brainstorm", "review") and it["minutes"] is not None and m < 5:
add("WARN", f"{name} is a {it['outcome']} item with {m:g} min; that is a mention, not a {it['outcome']}.")
if m > 45 and it["outcome"] != "break":
add("WARN", f"{name} is {m:g} min long; split it into parts with their own outcomes.")
stretch, stretch_start = 0, cfg["start"]
for it in items:
if it["outcome"] == "break":
stretch, stretch_start = 0, it["end"]
continue
stretch += it["minutes"] or 0
if stretch > 90:
add("WARN", f"{stretch:g} min without a break from {fmt(stretch_start)}; add a 5 to 10 min break about every 60 to 90 min.")
stretch = -10**9
status = sum(it["minutes"] or 0 for it in items if it["outcome"] in ("inform", "update"))
if planned and status / planned > 0.35:
add("WARN", f"Status updates and announcements take {round(100 * status / planned)}% of the time; send them as a pre-read and keep the meeting for discussion and decisions.")
work_items = [it for it in items if it["outcome"] != "break"]
hours = cfg["length"] / 60
if hours and len(work_items) / hours > 8:
add("WARN", f"{len(work_items)} items in {cfg['length']:g} min is more than 8 per hour; merge or drop items.")
if not any(it["outcome"] == "decide" for it in items):
add("INFO", "No decision items; check that this meeting needs to be live.")
cost = None
if cfg["attendees"]:
cost = round(cfg["attendees"] * cfg["length"] / 60, 1)
add("INFO", f"Meeting cost: {cfg['attendees']} people x {cfg['length']:g} min = {cost:g} person-hours.")
order = {"HIGH": 0, "WARN": 1, "INFO": 2}
findings.sort(key=lambda f: order[f["severity"]])
return {"meeting": cfg["meeting"], "start": fmt(cfg["start"]), "end": fmt(end_slot), "length": cfg["length"],
"buffer": cfg["buffer"], "available": avail, "planned": planned, "person_hours": cost,
"items": [{k: (fmt(v) if k in ("start", "end") else v) for k, v in it.items()} for it in items],
"findings": findings}
def main():
ap = argparse.ArgumentParser(description="Timebox a meeting agenda")
ap.add_argument("path", help="agenda text/Markdown/JSON file, or - for stdin")
ap.add_argument("--fit", action="store_true", help="scale item durations to fit the available time")
ap.add_argument("--json", action="store_true")
args = ap.parse_args()
try:
text = sys.stdin.read() if args.path == "-" else open(args.path, encoding="utf-8").read()
if not text.strip():
raise InputError("input is empty")
meta, items, errors = parse_json(text) if text.lstrip().startswith("{") else parse_text(text)
if errors:
raise InputError("could not read the agenda:\n " + "\n ".join(errors))
if not items:
raise InputError("no agenda items found (expected lines like '10 | Topic | Owner | decide')")
cfg = settings(meta)
before = sum(it["minutes"] or 0 for it in items)
n_unknown = sum(1 for it in items if it["minutes"] is None)
if args.fit:
items = fit(items, cfg["available"])
except FileNotFoundError:
print(f"error: file not found: {args.path}", file=sys.stderr)
return 2
except (InputError, json.JSONDecodeError, ValueError) as e:
print(f"error: {e}", file=sys.stderr)
return 2
rep = analyze(items, cfg)
rep["fitted_from"] = before if args.fit else None
if args.json:
print(json.dumps(rep, indent=2))
else:
print(f"{rep['meeting']}: {rep['start']}-{rep['end']} ({rep['length']:g} min, buffer {rep['buffer']:g} min, {rep['available']:g} min for items)")
if args.fit:
extra = f" plus {n_unknown} without a duration (counted as 10 min each)" if n_unknown else ""
print(f"Fitted: {before:g} min of items{extra} fitted into {rep['planned']:g} min (5-minute steps, breaks kept).")
print(f"Planned: {rep['planned']:g} min\n")
for it in rep["items"]:
mins = "?" if it["minutes"] is None else f"{it['minutes']:g}"
was = f" (was {it['original']:g})" if args.fit and it.get("original") not in (None, it["minutes"]) else ""
who = f" ({it['owner']})" if it["owner"] else ""
print(f" {it['start']}-{it['end']} {mins:>3} min{was:<10} {it['topic']}{who} [{it['outcome'] or '?'}]")
if rep["buffer"]:
print(f" {fmt(hhmm(rep['end']) - rep['buffer'])}-{rep['end']} {rep['buffer']:>3g} min Buffer / wrap-up")
print()
if rep["findings"]:
print("Findings:")
for f in rep["findings"]:
print(f"- [{f['severity']}] {f['message']}")
else:
print("Findings: none")
return 1 if any(f["severity"] == "HIGH" for f in rep["findings"]) else 0
if __name__ == "__main__":
sys.exit(main())