diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 2d24add..138c65e 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -13,7 +13,7 @@
# linters, and carry this same hook so committing with one of those configs is
# gated too. This root config is what `pre-commit install` wires up, and it is
# the only one that covers the subprojects without a config of their own
-# (aur-publish/, guard-lib/, crdt_sync_dart/, file_length/, ...).
+# (guard-lib/, crdt_sync_dart/, file_length/, ...).
#
# The path is repo-relative on purpose. pre-commit always runs hooks from the
# git root, so this resolves the same locally and on a CI runner -- an absolute
diff --git a/aur-publish/README.md b/aur-publish/README.md
deleted file mode 100644
index 46333e9..0000000
--- a/aur-publish/README.md
+++ /dev/null
@@ -1,45 +0,0 @@
-# aur-publish
-
-Hands-off publishing of kuhy's Flutter apps to the AUR.
-
-```bash
-~/src/utils/aur-publish/publish.sh # wait for registration, then publish
-~/src/utils/aur-publish/publish.sh --dry-run # everything except the push
-~/src/utils/aur-publish/publish.sh --skip-wait # account already works
-~/src/utils/aur-publish/publish.sh --only habit-stack
-```
-
-## What it does
-
-1. Polls `/register` until AUR signup reopens, opens it in a browser and
- prints the SSH key to paste. **Signup is the only manual step** — it
- requires email confirmation.
-2. Waits for `ssh aur@aur.archlinux.org` to authenticate.
-3. Then, unattended, per package: resolve the newest `vX.Y.Z` tag → set
- `pkgver` → `updpkgsums` → `makepkg -C` → `namcap` → AUR rule check →
- `pacman -U` → launch check → `.SRCINFO` → commit → push.
-
-Every package is built, installed and **launched** before it is pushed, so a
-broken package cannot reach the AUR. A package failing any gate is skipped and
-reported; the others still publish. Safe to re-run.
-
-## anubis_gate.py
-
-The AUR sits behind [Anubis](https://anubis.techaro.lol/), which answers HTML
-requests with a proof-of-work challenge page carrying **HTTP 200**. A plain
-`curl` therefore reports 200 for a page that is actually unreachable — which is
-exactly how "is registration back?" gets answered wrong. This module solves the
-challenge and reports the status of the *real* page behind it.
-
-```bash
-python3 anubis_gate.py /register # prints the true status code
-```
-
-## AUR rules enforced before pushing
-
-The AUR does not review uploads, but per the submission guidelines "packages
-that violate the rules may be deleted without warning". `check_aur_rules()`
-blocks a push on the mechanically checkable ones: `# Maintainer:` line,
-`x86_64` in `arch`, no `replaces=` (reserved for renames — use `conflicts`),
-not already in an official repo, `pkgdesc`/`url`/`license` present, and
-`.SRCINFO` matching the PKGBUILD.
diff --git a/aur-publish/anubis_gate.py b/aur-publish/anubis_gate.py
deleted file mode 100644
index 79de9bd..0000000
--- a/aur-publish/anubis_gate.py
+++ /dev/null
@@ -1,138 +0,0 @@
-"""Fetch an AUR URL from behind the Anubis anti-bot gate.
-
-The AUR sits behind Anubis (https://anubis.techaro.lol/), which answers every
-HTML request with a proof-of-work challenge page carrying HTTP **200**. A bare
-`curl` therefore reports 200 for a page that is actually unreachable, which is
-exactly how "is /register back up?" gets answered wrong.
-
-This module solves the challenge and reports the status of the *real* page
-behind it, so the caller can distinguish:
-
- * 200 -> genuinely available
- * 503 -> reached the site; that endpoint is disabled server-side
- * other-> whatever the app actually said
-
-Exit codes mirror the HTTP status so shell callers can branch on them:
-0 for 200, 1 for anything else, 2 for a local/transport failure.
-"""
-
-from __future__ import annotations
-
-import argparse
-import hashlib
-import http.cookiejar
-import json
-import re
-import sys
-import time
-import urllib.error
-import urllib.parse
-import urllib.request
-
-BASE = "https://aur.archlinux.org"
-UA = "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0"
-CHALLENGE_RE = re.compile(
- r'',
- re.S,
-)
-PASS_PATH = "/.within.website/x/cmd/anubis/api/pass-challenge"
-# A solve at difficulty 4 takes ~0.01s; this ceiling only guards a server-side
-# difficulty spike, so the poller degrades instead of spinning forever.
-MAX_NONCE = 50_000_000
-
-
-class _NoRedirect(urllib.request.HTTPRedirectHandler):
- """Keep redirects visible instead of silently following them."""
-
- def redirect_request(self, *_args, **_kwargs): # noqa: D102 - see class doc
- return None
-
-
-def _opener() -> urllib.request.OpenerDirector:
- jar = http.cookiejar.CookieJar()
- opener = urllib.request.build_opener(
- urllib.request.HTTPCookieProcessor(jar), _NoRedirect
- )
- opener.addheaders = [
- ("User-Agent", UA),
- ("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"),
- ]
- return opener
-
-
-def _solve(random_data: str, difficulty: int) -> tuple[int, str, float]:
- """Find a nonce whose sha256 digest starts with `difficulty` zero hex chars."""
- prefix = "0" * difficulty
- started = time.time()
- for nonce in range(MAX_NONCE):
- digest = hashlib.sha256(f"{random_data}{nonce}".encode()).hexdigest()
- if digest.startswith(prefix):
- return nonce, digest, time.time() - started
- raise RuntimeError(f"no nonce found below {MAX_NONCE} at difficulty {difficulty}")
-
-
-def fetch(path: str, timeout: int = 30) -> tuple[int, str]:
- """Return (status, body) for `path`, clearing Anubis first if challenged."""
- opener = _opener()
- url = f"{BASE}{path}"
-
- try:
- with opener.open(url, timeout=timeout) as resp:
- body = resp.read().decode("utf-8", "replace")
- status = resp.status
- except urllib.error.HTTPError as exc:
- return exc.code, exc.read().decode("utf-8", "replace")
-
- match = CHALLENGE_RE.search(body)
- if not match:
- return status, body
-
- challenge = json.loads(match.group(1))["challenge"]
- nonce, digest, elapsed = _solve(
- challenge["randomData"], int(challenge["difficulty"])
- )
- query = urllib.parse.urlencode(
- {
- "id": challenge["id"],
- "response": digest,
- "nonce": str(nonce),
- "redir": path,
- "elapsedTime": str(int(elapsed * 1000)),
- }
- )
- try:
- opener.open(f"{BASE}{PASS_PATH}?{query}", timeout=timeout)
- except urllib.error.HTTPError:
- # A redirect or non-2xx here is fine; what matters is the cookie the
- # opener's jar just captured.
- pass
-
- try:
- with opener.open(url, timeout=timeout) as resp:
- return resp.status, resp.read().decode("utf-8", "replace")
- except urllib.error.HTTPError as exc:
- return exc.code, exc.read().decode("utf-8", "replace")
-
-
-def main() -> int:
- parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument("path", help="AUR path, e.g. /register")
- parser.add_argument(
- "--print-body", action="store_true", help="write the page body to stdout"
- )
- args = parser.parse_args()
-
- try:
- status, body = fetch(args.path)
- except (urllib.error.URLError, RuntimeError, TimeoutError) as exc:
- print(f"transport error: {exc}", file=sys.stderr)
- return 2
-
- print(status)
- if args.print_body:
- sys.stdout.write(body)
- return 0 if status == 200 else 1
-
-
-if __name__ == "__main__":
- sys.exit(main())
diff --git a/aur-publish/lib/aur_publish_one.sh b/aur-publish/lib/aur_publish_one.sh
deleted file mode 100644
index ee19327..0000000
--- a/aur-publish/lib/aur_publish_one.sh
+++ /dev/null
@@ -1,107 +0,0 @@
-#!/bin/bash
-
-# ============================================================================
-# Building, verifying and pushing one package to the AUR.
-#
-# Sourced by publish.sh, never executed: these functions read the readonly
-# config (AUR_HOST, AUR_ROOT, DRY_RUN, PUBLISHED, FAILED) that the entry script declares, and
-# a `readonly` re-run inside a twice-sourced lib would abort under `set -e`.
-# ============================================================================
-
-set -euo pipefail
-
-publish_one() {
- local pkg="$1" repo="$2" wrapper="$3" binary="$4" expected="$5"
- local dir="$AUR_ROOT/$pkg"
- local version
-
- log "$pkg"
-
- [[ -d "$repo" ]] || { warn "$pkg: no repo at $repo"; return 1; }
- [[ -f "$dir/PKGBUILD" ]] || { warn "$pkg: no PKGBUILD at $dir"; return 1; }
-
- version="$(newest_tag "$repo")"
- [[ -n "$version" ]] || { warn "$pkg: no vX.Y.Z tag on the remote"; return 1; }
- info "newest release tag: v$version"
-
- cd "$dir"
- sed -i -E "s/^pkgver=.*/pkgver=$version/" PKGBUILD
- # The tarball for a tag contains the PKGBUILD, so the checksum can only be
- # computed after the tag exists — never committed ahead of time.
- updpkgsums >/dev/null 2>&1 || { warn "$pkg: updpkgsums failed (tag missing?)"; return 1; }
-
- info "building"
- if ! env PATH=/usr/bin:/bin:/usr/local/bin makepkg -Cf --noconfirm >/dev/null 2>&1; then
- warn "$pkg: makepkg failed"
- return 1
- fi
-
- local built
- built="$(find . -maxdepth 1 -name "$pkg-$version-*.pkg.tar.zst" | head -1)"
- [[ -n "$built" ]] || { warn "$pkg: no package produced"; return 1; }
-
- # namcap's "ELF files outside of a valid path ('opt/')" is a false positive
- # for /opt-installed apps, so only genuinely new errors are worth failing on.
- local findings
- findings="$(namcap "$built" 2>&1 | grep -E ' E: ' | grep -v "outside of a valid path" || true)"
- if [[ -n "$findings" ]]; then
- warn "$pkg: namcap errors:"
- printf ' %s\n' "$findings"
- return 1
- fi
-
- info "installing and launching"
- sudo pacman -U --noconfirm "$built" >/dev/null 2>&1 \
- || { warn "$pkg: pacman -U failed"; return 1; }
- verify_installed "$binary" "$expected" "$wrapper" || { warn "$pkg: launch check failed"; return 1; }
-
- # Generated before the rule check, which verifies it matches the PKGBUILD.
- makepkg --printsrcinfo > .SRCINFO
- check_aur_rules "$pkg" || return 1
-
- # The AUR repo is a separate remote living alongside the build dir; adding
- # it here (rather than cloning into a fresh path) keeps the verified
- # PKGBUILD and the thing being pushed byte-identical.
- if [[ ! -d .git ]]; then
- git init -q -b master .
- git remote add origin "ssh://aur@$AUR_HOST/$pkg.git"
- fi
- git remote get-url origin >/dev/null 2>&1 \
- || git remote add origin "ssh://aur@$AUR_HOST/$pkg.git"
-
- # Fetch first: an existing AUR package must be built on, not overwritten.
- if git fetch -q origin master 2>/dev/null; then
- git reset -q --soft FETCH_HEAD 2>/dev/null || true
- fi
-
- cat > .gitignore <<'EOF'
-# Build products; the AUR tracks only the recipe.
-*.pkg.tar.zst
-*.tar.gz
-src/
-pkg/
-EOF
-
- git add PKGBUILD .SRCINFO .gitignore
- if git diff --cached --quiet; then
- info "already up to date on the AUR; nothing to push"
- PUBLISHED+=("$pkg v$version (unchanged)")
- return 0
- fi
-
- git -c user.name='Krzysztof Rudnicki' \
- -c user.email='krzysztofrudnicki0@gmail.com' \
- commit -q -m "$pkg $version-1"
-
- if ((DRY_RUN)); then
- info "DRY RUN — not pushing"
- PUBLISHED+=("$pkg v$version (dry run)")
- return 0
- fi
-
- info "pushing to the AUR"
- git push -q origin master 2>&1 | tail -3 || { warn "$pkg: push failed"; return 1; }
- PUBLISHED+=("$pkg v$version")
- return 0
-}
-
diff --git a/aur-publish/lib/aur_registration.sh b/aur-publish/lib/aur_registration.sh
deleted file mode 100644
index c7b2ce9..0000000
--- a/aur-publish/lib/aur_registration.sh
+++ /dev/null
@@ -1,107 +0,0 @@
-#!/bin/bash
-
-# ============================================================================
-# Waiting for AUR registration and SSH access to come up.
-#
-# Sourced by publish.sh, never executed: these functions read the readonly
-# config (AUR_HOST, SSH_KEY, GATE, POLL_SECONDS) that the entry script declares, and
-# a `readonly` re-run inside a twice-sourced lib would abort under `set -e`.
-# ============================================================================
-
-set -euo pipefail
-
-register_status() {
- python3 "$GATE" /register 2>/dev/null | head -1
-}
-
-open_browser() {
- local url="$1"
- local browser
- for browser in xdg-open thorium-browser chromium google-chrome-stable firefox librewolf; do
- if command -v "$browser" >/dev/null 2>&1; then
- "$browser" "$url" >/dev/null 2>&1 &
- info "Opened $url in $browser"
- return 0
- fi
- done
- warn "No browser found; open this yourself: $url"
-}
-
-ensure_key() {
- if [[ ! -f "$SSH_KEY" ]]; then
- log "Generating an AUR SSH key (none at $SSH_KEY)"
- ssh-keygen -t ed25519 -f "$SSH_KEY" -N '' -C 'kuhy@aur'
- fi
- if ! grep -q "Host $AUR_HOST" "$HOME/.ssh/config" 2>/dev/null; then
- log "Adding $AUR_HOST to ~/.ssh/config"
- mkdir -p "$HOME/.ssh"
- cat >> "$HOME/.ssh/config" <&1 |
- grep -qiE 'interactive shell is disabled|Welcome to AUR'
-}
-
-wait_for_registration() {
- local status
- status="$(register_status)"
- if [[ "$status" == "200" ]]; then
- log "AUR registration is OPEN"
- else
- log "AUR registration is closed (HTTP $status). Polling every ${POLL_SECONDS}s."
- info "Ctrl-C to stop; re-run any time, nothing is lost."
- while :; do
- sleep "$POLL_SECONDS"
- status="$(register_status)"
- if [[ "$status" == "200" ]]; then
- log "Registration is BACK (HTTP 200)"
- break
- fi
- printf ' %s still HTTP %s\n' "$(date +%H:%M)" "$status"
- done
- fi
-
- open_browser "https://$AUR_HOST/register"
- cat </dev/null |
- awk '{print $2}' |
- sed 's|refs/tags/||' |
- grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' |
- sed 's/^v//' |
- sort -V |
- tail -1
-}
-
-verify_installed() {
- local binary="$1" expected="$2" wrapper="$3" output
-
- # An instance left over from a previous run would make the wrapper print
- # "already running" and hand off, which passes trivially and turns the
- # strongest gate in this script into the weakest. Clear it first so the
- # check always exercises a real cold start.
- #
- # Resolved through /proc//exe rather than any form of name or
- # command-line matching. Every argv-based approach selects the wrong
- # processes here:
- # * `pkill -x` compares against the kernel's 15-character comm field, and
- # two of these wrappers are longer (habit_stack_desktop is 19), so a
- # name match would silently never fire.
- # * `pkill -f`, and equally `ps -eo args` piped through a pattern, match
- # any process whose command line merely *mentions* the path — this
- # script, the shell running it, and the matching tool itself all
- # qualify, so the run kills its own shell instead of the app. A
- # $$/$PPID guard does not cover it, because the offending shell is
- # often a more distant ancestor.
- # /proc//exe is a kernel-maintained symlink to the binary actually
- # being executed, so it cannot collide with an incidental mention.
- # The wrapper name is unique per app, so the /opt// segment is left as
- # a glob instead of threading the package name through another parameter.
- #
- # The link is read without `-f`: it is already fully resolved, and by the
- # time this runs `pacman -U` has replaced the binary, so a leftover process
- # points at an unlinked inode that the kernel reports with a " (deleted)"
- # suffix. That suffix has to be stripped or the match silently misses the
- # one process this exists to kill.
- local pid exe killed=()
- for pid in /proc/[0-9]*; do
- pid="${pid#/proc/}"
- exe="$(readlink "/proc/$pid/exe" 2>/dev/null)" || continue
- exe="${exe% (deleted)}"
- [[ "$exe" == /opt/*/bin/"$wrapper" ]] || continue
- kill -TERM "$pid" 2>/dev/null && killed+=("$pid")
- done
- if ((${#killed[@]})); then
- info "stopped a running instance so the launch check is meaningful"
- # `kill` only reports that the signal was sent. Waiting for the process
- # to actually go keeps a slow exit from failing the cold start below on
- # a port that is still bound.
- local waited=0
- while ((waited < 100)) && kill -0 "${killed[@]}" 2>/dev/null; do
- sleep 0.1
- ((waited++))
- done
- fi
-
- output="$(timeout 15 "/usr/bin/$binary" 2>&1 | head -3 || true)"
- if grep -qF "$expected" <<<"$output"; then
- return 0
- fi
- warn "unexpected startup output: $output"
- return 1
-}
-
-# The AUR does not review uploads, but "packages that violate the rules may be
-# deleted without warning" (AUR submission guidelines). These are the rules that
-# are mechanically checkable, enforced here so a violation blocks the push
-# rather than surfacing later as a deletion request.
-check_aur_rules() {
- local pkg="$1" problems=()
-
- grep -q '^# Maintainer:' PKGBUILD \
- || problems+=("missing '# Maintainer:' comment on line 1")
-
- grep -qE "^arch=\(.*x86_64" PKGBUILD \
- || problems+=("x86_64 not in arch=() — packages without it are not allowed")
-
- # `replaces` makes pacman rip out the named package on the next -Sy, which
- # is only correct for a genuine rename. `conflicts` is the right tool.
- grep -q '^replaces=' PKGBUILD \
- && problems+=("uses replaces=; AUR rules reserve it for renames — use conflicts=")
-
- # A package already in an official repo must never be duplicated.
- if pacman -Si "$pkg" >/dev/null 2>&1; then
- problems+=("$pkg exists in an official repo — must not be submitted")
- fi
-
- grep -qE '^(pkgdesc|url|license)=' PKGBUILD \
- || problems+=("missing pkgdesc/url/license")
-
- # A stale .SRCINFO is the single most common upload rejection.
- if ! diff -q <(makepkg --printsrcinfo) .SRCINFO >/dev/null 2>&1; then
- problems+=(".SRCINFO does not match PKGBUILD")
- fi
-
- if ((${#problems[@]})); then
- warn "$pkg: AUR rule violations:"
- printf ' - %s\n' "${problems[@]}"
- return 1
- fi
- info "AUR rule checks passed"
- return 0
-}
-
diff --git a/aur-publish/publish.sh b/aur-publish/publish.sh
deleted file mode 100755
index a64a33d..0000000
--- a/aur-publish/publish.sh
+++ /dev/null
@@ -1,148 +0,0 @@
-#!/bin/bash
-
-# ============================================================================
-# Publish kuhy's three Flutter apps to the AUR, hands-off.
-#
-# Run this once AUR registration is back. It:
-# 1. Waits for https://aur.archlinux.org/register to stop returning 503,
-# then opens it in a browser and prints the SSH key to paste.
-# (Signup is the ONLY manual step: it needs an email confirmation, and
-# creating accounts is not something this script will do for you.)
-# 2. Waits for `ssh aur@aur.archlinux.org` to authenticate.
-# 3. From there, fully unattended, for each package:
-# newest release tag -> pkgver -> updpkgsums -> makepkg -C -> namcap
-# -> pacman -U -> launch check -> .SRCINFO -> clone AUR repo
-# -> commit -> push.
-#
-# Every package is BUILT, INSTALLED and LAUNCHED before it is pushed, so a
-# broken package cannot reach the AUR. A package that fails any gate is
-# skipped and reported; the others still publish.
-#
-# Idempotent and resumable: re-running skips work that is already done, and
-# an already-published package is updated rather than duplicated.
-# ============================================================================
-
-set -euo pipefail
-
-SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
-readonly SCRIPT_DIR
-readonly GATE="$SCRIPT_DIR/anubis_gate.py"
-readonly AUR_HOST="aur.archlinux.org"
-readonly SSH_KEY="$HOME/.ssh/aur"
-readonly AUR_ROOT="$HOME/sdk/aur"
-
-# package:repo:wrapper-process:cli-name:expected-startup-string
-# The wrapper process name is NOT derivable from the package name
-# (diet-guard-app runs diet_guard_desktop), so it is listed explicitly.
-readonly PACKAGES=(
- "todo-flutter:$HOME/src/todo:todo_desktop:todo:serving on http://localhost:8730"
- "habit-stack:$HOME/src/habit_stack:habit_stack_desktop:habit-stack:serving on http://localhost:8731"
- "diet-guard-app:$HOME/src/diet-guard:diet_guard_desktop:diet-guard-app:serving on http://localhost:8732"
-)
-
-POLL_SECONDS=300
-SKIP_WAIT=0
-DRY_RUN=0
-ONLY=""
-FAILED=()
-PUBLISHED=()
-
-usage() {
- cat <\033[0m %s\n' "$*"; }
-info() { printf ' %s\n' "$*"; }
-warn() { printf '\033[1;33m ! %s\033[0m\n' "$*" >&2; }
-die() { printf '\033[1;31m==> ERROR: %s\033[0m\n' "$*" >&2; exit 1; }
-
-require_tools() {
- local missing=()
- local tool
- for tool in git makepkg updpkgsums namcap ssh python3 curl; do
- command -v "$tool" >/dev/null 2>&1 || missing+=("$tool")
- done
- if ((${#missing[@]})); then
- info "Installing missing tools: ${missing[*]}"
- # namcap/updpkgsums come from namcap + pacman-contrib; the rest are base.
- sudo pacman -S --needed --noconfirm namcap pacman-contrib git openssh python \
- || die "could not install: ${missing[*]}"
- fi
-}
-
-# ---------------------------------------------------------------------------
-# Phase 1: wait for registration, then for the key to work
-# ---------------------------------------------------------------------------
-
-# The rest lives in lib/, sourced (not executed) so every function sees the
-# readonly config above and the mutable FAILED/PUBLISHED arrays main() reads.
-# shellcheck source=lib/aur_registration.sh
-source "$SCRIPT_DIR/lib/aur_registration.sh"
-# shellcheck source=lib/aur_verify.sh
-source "$SCRIPT_DIR/lib/aur_verify.sh"
-# shellcheck source=lib/aur_publish_one.sh
-source "$SCRIPT_DIR/lib/aur_publish_one.sh"
-
-main() {
- require_tools
- [[ -f "$GATE" ]] || die "missing $GATE"
- mkdir -p "$AUR_ROOT"
-
- ensure_key
- if ((SKIP_WAIT)); then
- ssh_works || die "SSH key is not accepted yet; drop --skip-wait to wait for it"
- else
- wait_for_registration
- wait_for_ssh
- fi
-
- local entry pkg repo wrapper binary expected
- for entry in "${PACKAGES[@]}"; do
- IFS=: read -r pkg repo wrapper binary expected <<<"$entry"
- [[ -z "$ONLY" || "$ONLY" == "$pkg" ]] || continue
- if ! publish_one "$pkg" "$repo" "$wrapper" "$binary" "$expected"; then
- FAILED+=("$pkg")
- fi
- done
-
- log "Summary"
- if ((${#PUBLISHED[@]})); then
- printf ' published: %s\n' "${PUBLISHED[@]}"
- fi
- if ((${#FAILED[@]})); then
- printf '\033[1;31m FAILED: %s\033[0m\n' "${FAILED[@]}"
- info "Nothing broken was pushed; fix and re-run (safe to repeat)."
- return 1
- fi
- if ((${#PUBLISHED[@]} == 0)); then
- warn "nothing to do"
- return 1
- fi
- printf '\n Check: https://aur.archlinux.org/packages/todo-flutter\n'
- return 0
-}
-
-while [[ $# -gt 0 ]]; do
- case $1 in
- --skip-wait) SKIP_WAIT=1; shift ;;
- --dry-run) DRY_RUN=1; shift ;;
- --only) ONLY="$2"; shift 2 ;;
- --poll) POLL_SECONDS="$2"; shift 2 ;;
- -h|--help) usage ;;
- *) die "Unknown option: $1" ;;
- esac
-done
-
-main "$@"
diff --git a/dopamine-ux/DOCS-dopamine-ux-index.md b/dopamine-ux/DOCS-dopamine-ux-index.md
deleted file mode 100644
index f6790bf..0000000
--- a/dopamine-ux/DOCS-dopamine-ux-index.md
+++ /dev/null
@@ -1,165 +0,0 @@
-# Dopamine UX — session prompts
-
-Ten pasteable prompts derived from *"The Dopamine Architecture: How UX Designers
-Hold User Attention in 2026"* (london-post.co.uk), applied to kuhy's repos.
-
-Paste **one file's contents** into a fresh Claude session. Each is
-self-contained: it restates the findings it depends on, so no session needs the
-context of another.
-
-## The two tests — every change must pass both
-
-The article's four mechanics (variable rewards, micro-animations, sound design,
-near-miss) are in scope, aimed at **kuhy's own stated goals**. They are gated by
-two questions, not by an allow/deny list:
-
-1. **Is the displayed state true?** A progress ring at 93% is legitimate *iff*
- you are actually at 93%. A streak is legitimate iff it counts real days.
- Dopamine may come from framing, timing and salience — **never from a
- fabricated number**.
-2. **Is the variance real?** A random pick from a real pool (next workout, next
- task) is legitimate. Injected random bonus points to manufacture uncertainty
- are not.
-
-### The load-bearing corollary
-
-**Reward the recording, never the number recorded.** In diet-guard the
-satisfying moment fires on *logging a meal*, not on hitting a calorie target —
-otherwise the app rewards eating less rather than recording honestly, and
-corrupts its own data. Same shape in workout_app (reward logging a workout, not
-beating a PB) and todo (reward capture, not completion count).
-
-## Running order
-
-`01` gates everything. `07` gates `08`. Everything else is independent.
-
-| # | File | Repo | Findings |
-|---|---|---|---|
-| 01 | `01-motion-tokens.md` | `~/src/utils` | 3 |
-| 02 | `02-structural-check.md` | `~/src/utils` | 2 |
-| 03 | todo note `85ca329b-9809-412c-a47f-eddf055322cf` | `~/src/diet-guard` | 4 |
-| 04 | todo note `73a20837-6480-4b31-b966-1898a4e7272e` | `~/src/screen-locker` | 3 |
-| 05 | todo note `e50d6a03-ae7d-4da5-bbd3-c33b17f6f074` | `~/src/screen-locker/stronglift_replacement/workout_app` | 4 |
-| 06 | `06-todo.md` | `~/src/todo` | 3 |
-| 07 | `07-dufs-theme.md` | `~/src/dufs-cloud` | 2 |
-| 08 | todo note `e301e090-b2ee-4e26-8387-00f356b63521` | `~/src/dufs-cloud` | 4 |
-| 09 | todo note `9e1e4922-9f29-4bad-bcf4-96826aa8ffa4` | `~/src/wake-alarm` | 2 |
-
-**27 ranked findings total.**
-
-### Dependency graph
-
-```
-01-motion-tokens ──┬─→ 03, 04, 05, 09 (transcribe tokens locally)
- ├─→ 06 (bump design_system-v0.2.0)
- └─→ 07 ─→ 08 (bump web_ui-v0.3.2)
-02-structural-check (independent; run after 01 so it has motion tokens to check)
-```
-
-## The top three findings fleet-wide
-
-Ranked by value-over-effort from the survey data:
-
-1. **screen-locker streak render** (prompt 04) — `snapshot.streak` is already
- populated and no renderer reads it. Zero new state, zero conftest change,
- zero new dependency.
-2. **diet-guard `_onLogMeal` feedback** (prompt 03) — the flagship case.
- Dependency-free via `HapticFeedback` from `flutter/services.dart`.
-3. **workout_app finish-workout cue** (prompt 05) — `audioplayers`, `vibration`,
- the `assets:` block and the VIBRATE permission are **already present**. Only
- the call is missing.
-
-## Distributed copies, and the completion protocol
-
-Each prompt is **also copied into the repo it acts on**, at
-`/prompts/dopamine-ux-*.md`, so you can open a repo and say
-"do dopamine-ux-" without going hunting. **This directory stays the source
-of truth** — if a prompt needs correcting, fix it here and re-distribute.
-
-| Prompt | Distributed to |
-|---|---|
-| 01 | `~/src/utils/prompts/dopamine-ux-01-motion-tokens.md` — deleted (done) |
-| 02 | `~/src/utils/prompts/dopamine-ux-02-structural-check.md` |
-| 03 | `~/src/diet-guard/prompts/dopamine-ux-diet-guard.md` |
-| 04 | `~/src/screen-locker/prompts/dopamine-ux-04-screen-locker.md` — deleted (done) |
-| 05 | `~/src/screen-locker/prompts/dopamine-ux-05-workout-app.md` — deleted (done) |
-| 06 | `~/src/todo/prompts/dopamine-ux-todo.md` |
-| 07 | `~/src/dufs-cloud/prompts/dopamine-ux-07-theme.md` |
-| 08 | `~/src/dufs-cloud/prompts/dopamine-ux-08-motion.md` |
-| 09 | `~/src/wake-alarm/prompts/dopamine-ux-wake-alarm.md` |
-
-When a session finishes a prompt it **deletes its repo copy** (in the same
-commit as the implementation) and **appends a row below**. A finished prompt left
-in a repo is indistinguishable from a pending one, and the next session re-runs
-it. Partial completion is never recorded as DONE — the file is edited to state
-what remains instead.
-
-### Completion log
-
-| Prompt | Status | Impl commit | Note |
-|---|---|---|---|
-| 01-motion-tokens.md | DONE 2026-08-22 | `f741896` | `motion.md` frozen; `AppDuration`/`AppCurve` + `--duration-*`/`--ease-*` shipped. Tags: `design_system-v0.2.0`, `web_ui-v0.3.2`. |
-| 02-structural-check.md | not started | — | — |
-| todo note `85ca329b-9809-412c-a47f-eddf055322cf` | not started | — | — |
-| todo note `73a20837-6480-4b31-b966-1898a4e7272e` | DONE 2026-11-01 | `89c65bd` | Status window `_section_streak` + week-transition reward text (kept in `extra_benefits_state.json`); the dead 09:00 early-bird claim is not shown. Per kuhy: no daily-streak state, no shutdown time, no freezes. Commit is on a worktree branch, unpushed. |
-| todo note `e50d6a03-ae7d-4da5-bbd3-c33b17f6f074` | DONE 2026-11-01 | `e938201` | Set/warmup `selectionClick`, finish `mediumImpact` + generated chime (music_theory, pinned tag) + dialog arrival, `AppDuration`/`AppCurve` in theme.dart, streak counts up, reduced motion honoured, one Settings "Sound" switch for chime and break-end (foreground service too). Worktree commits, unpushed; `62c1c2c` covers old export/import gaps. |
-| 06-todo.md | PARTIAL 2026-11-02 (step 1 DONE; step 2 sound REJECTED by kuhy) | `kuhyx/todo-app 2ffae26` | haptic + checkmark on capture, appRoute motion tokens, animated wizard bar; no sound by decision |
-| 07-dufs-theme.md | not started | — | — |
-| todo note `e301e090-b2ee-4e26-8387-00f356b63521` | not started | — | — |
-| todo note `9e1e4922-9f29-4bad-bcf4-96826aa8ffa4` | not started | — | — |
-
-## Keeping these prompts honest
-
-Every path and symbol cited across these files is machine-checked:
-
-```
-python3 ~/src/utils/dopamine-ux/verify_prompts.py
-```
-
-Exit 0 means every cited path resolves and every cited symbol is still greppable
-in the file it is attributed to. **Re-run it after any refactor of the surveyed
-repos** — a prompt pointing at a renamed function is worse than no prompt, and a
-path check alone cannot catch that (which is why it greps symbols too).
-
-Verified 2026-08-16: 146 paths, 30 symbols, all passing.
-
-## Rules that apply to every prompt
-
-1. **Tag cuts and version bumps are explicit steps.** `todo` consumes
- `design_system` at git ref `design_system-v0.1.0`; `dufs-cloud/web` consumes
- `@kuhyx/web-ui` at `web_ui-v0.3.1`. Adding tokens to those packages does
- nothing for consumers until a new tag is cut. Prompt 01 ends by cutting
- `design_system-v0.2.0` and `web_ui-v0.3.2`; prompts 06 and 08 begin by
- bumping to those literal refs.
-2. **Anchor on symbols, not line numbers.** Line numbers in these prompts are
- "as of 2026-08-16" and will drift. The symbol name is the real anchor —
- grep for it rather than trusting the line.
-3. **Reduce-motion is mandatory and is NOT the sound toggle.** Web:
- `@media (prefers-reduced-motion: reduce)`. Flutter:
- `MediaQuery.of(context).disableAnimations`. The OS motion preference is
- honoured automatically; the sound opt-out is a separate user-facing toggle.
-4. **Motion and haptics first, sound second.** Haptics need no dependency;
- sound needs a pubspec dep, an `assets:` block, and a settings toggle. Each
- prompt stages them separately so a short session still lands the valuable
- half, and sound stays independently revertable.
-5. **Sound ships on (opt-out), never in screen-locker enforcement paths.**
-6. **250-line cap** applies to code and prose alike (`~/src/utils/file_length`).
-
-## Source
-
-Article findings, verbatim from the piece:
-
-- **Variable reward schedules** — unpredictable outcomes produce stronger
- dopaminergic activity than guaranteed ones. Duolingo streaks, feed ordering,
- pull-to-refresh.
-- **Micro-animations** — "a button that bounces on press, a like counter that
- ticks upward with a slight delay". Replaces latency with feedback, so waiting
- reads as responsiveness rather than frustration.
-- **Sound design** — "operates below the threshold of conscious attention";
- the article's judgement is that it is more powerful than visual design and
- gets less critical attention.
-- **Near-miss effects** — a fitness ring at ninety-three per cent creates
- discomfort that drives re-engagement, and works *even when the user is
- explicitly told the mechanism*.
-- Core claim: **dopamine is released in anticipation of a reward, not on
- receipt. The uncertainty is the trigger.**
diff --git a/dopamine-ux/verify_prompts.py b/dopamine-ux/verify_prompts.py
deleted file mode 100644
index 842b8d1..0000000
--- a/dopamine-ux/verify_prompts.py
+++ /dev/null
@@ -1,209 +0,0 @@
-#!/usr/bin/env python3
-"""Verify every path and symbol cited in the dopamine-ux prompt files.
-
-A copy-pasteable prompt that points at a file which does not exist is worse than
-no prompt, so this gate adjudicates with an exit code rather than by eye.
-
-Two checks:
- 1. Every cited on-disk path resolves.
- 2. Every cited symbol is greppable in the file it is attributed to. A path
- check alone cannot catch a stale line number; a symbol grep can.
-"""
-
-from __future__ import annotations
-
-import re
-import sys
-from pathlib import Path
-
-PROMPT_DIR = Path.home() / "src/utils" / "dopamine-ux"
-HOME = Path.home()
-
-PATH_RE = re.compile(r"`(~?/[^`\s]+|[\w.\-]+/[\w./\-]+\.\w+)`")
-
-# Not on-disk paths despite matching the shape above.
-SKIP_SUBSTRINGS = (
- "://",
- "$",
- "*",
- "<",
- ">",
- "…",
- "devices/", # Firebase/crdt key path
- "settings/advanced", # Firebase key path
- "android.permission",
- "android.intent",
- "/sdcard/",
- "/etc/",
- "/dev/input/",
- "/opt/",
- "com.kuhy",
- "app-release.apk",
- "build/app/outputs", # build outputs
- "flutter/services.dart",
- "flutter/material.dart", # package imports
-)
-
-# Files a prompt CREATES. Citing them forward is correct, so absence is expected
-# -- but pairing each with its creating prompt still catches a typo.
-CREATED_BY_PROMPT = {
- "~/src/utils/unified-design-system/motion.md": "01",
- "~/src/utils/unified-design-system/scripts/structural_check.py": "02",
-}
-
-# Cited precisely to state that they do NOT exist.
-ASSERTED_ABSENT = {
- "~/gatelock",
- # Distributed prompt copies are `git rm`d on completion (see the protocol in
- # 00-INDEX.md), but 00-INDEX.md keeps citing them in its "Distributed copies"
- # table. Asserting ABSENT rather than dropping the row keeps the check
- # two-directional: a finished prompt reappearing in a repo is itself a
- # failure, because the next session would re-run it. Move a row here as each
- # prompt completes.
- "~/src/utils/prompts/dopamine-ux-01-motion-tokens.md",
-}
-
-# Generic prose fragments rather than citations of one specific file.
-PROSE_FRAGMENTS = {"lib/ui/theme.dart", "lib/main.dart", "pubspec.yaml"}
-
-# Symbols each prompt claims exist, as (repo-relative file, symbol).
-SYMBOL_CLAIMS = [
- ("~/src/diet-guard/app/lib/screens/log_meal_screen.dart", "_onLogMeal"),
- ("~/src/diet-guard/app/lib/widgets/today_progress_card.dart", "TodayProgressCard"),
- ("~/src/diet-guard/app/lib/widgets/streak_summary_row.dart", "StreakSummaryRow"),
- ("~/src/diet-guard/app/lib/screens/log_meal_progress.dart", "buildTodayProgress"),
- ("~/src/diet-guard/app/lib/services/app_settings_service.dart", "_writeToDisk"),
- ("~/src/screen-locker/screen_locker/_status_types.py", "StatusSnapshot"),
- ("~/src/screen-locker/screen_locker/_extra_benefits.py", "process_week_transition"),
- ("~/src/screen-locker/screen_locker/_extra_benefits.py", "current_streak"),
- ("~/src/screen-locker/screen_locker/_unlock_view.py", "unlock_screen"),
- ("~/src/screen-locker/screen_locker/_log_mixin.py", "write_signed_entry"),
- ("~/src/screen-locker/screen_locker/tests/conftest.py", "_ISOLATED_STATE"),
- ("~/src/screen-locker/screen_locker/_weekly_check.py", "WEEKLY_WORKOUT_MINIMUM"),
- (
- "~/src/screen-locker/stronglift_replacement/workout_app/lib/screens/"
- "workout_screen_session.dart",
- "_playBreakEndCue",
- ),
- (
- "~/src/screen-locker/stronglift_replacement/workout_app/lib/screens/"
- "workout_screen_finish.dart",
- "_persistFinishedWorkout",
- ),
- (
- "~/src/screen-locker/stronglift_replacement/workout_app/lib/widgets/"
- "workout_summary_dialog.dart",
- "WorkoutSummaryDialog",
- ),
- (
- "~/src/screen-locker/stronglift_replacement/workout_app/lib/services/"
- "storage_service.dart",
- "_getSetting",
- ),
- (
- "~/src/screen-locker/stronglift_replacement/workout_app/lib/widgets/"
- "exercise_tile_rows.dart",
- "AnimatedContainer",
- ),
- ("~/src/todo/lib/data/app_settings.dart", "withAdvancedMode"),
- ("~/src/todo/lib/ui/settings_screen.dart", "SwitchListTile"),
- ("~/src/dufs-cloud/web/src/hooks/use-cloud-index.ts", "walkInto"),
- ("~/src/dufs-cloud/app/lib/services/cloud_index.dart", "buildCloudIndex"),
- ("~/src/dufs-cloud/web/src/lib/download.ts", "buildSelectionZip"),
- ("~/src/dufs-cloud/app/lib/services/download_zip.dart", "buildSelectionZip"),
- ("~/src/wake-alarm/phone_app/lib/screens/home_screen.dart", "_setPhoneAlarm"),
- ("~/src/wake-alarm/wake_alarm/_audio.py", "_play_on_all_sinks"),
- ("~/src/utils/unified-design-system/scripts/palette_check.py", "NON_COLOUR_CSS"),
- ("~/src/utils/unified-design-system/scripts/palette_map.py", "PALETTE"),
- ("~/src/utils/design_system/lib/src/feedback.dart", "showToast"),
- ("~/src/utils/design_system/lib/src/tokens.dart", "AppSpacing"),
- ("~/src/utils/gatelock/gatelock/_window.py", "LockConfig"),
-]
-
-
-def expand(raw: str) -> Path:
- return HOME / raw[2:] if raw.startswith("~/") else Path(raw)
-
-
-def candidate_paths(text: str) -> set[str]:
- return {
- raw
- for raw in PATH_RE.findall(text)
- if not any(s in raw for s in SKIP_SUBSTRINGS)
- and raw not in PROSE_FRAGMENTS
- and not re.search(r"\.\w+:\d+$", raw) # trailing :line suffix
- }
-
-
-def prompt_roots(text: str) -> list[Path]:
- roots = []
- for label in ("Repo", "App", "Flutter app"):
- for m in re.finditer(rf"{label}: `(~/[^`]+)`", text):
- roots.append(expand(m.group(1)))
- return roots
-
-
-def check_paths() -> list[str]:
- failures = []
- checked = 0
- for prompt in sorted(PROMPT_DIR.glob("*.md")):
- text = prompt.read_text()
- roots = prompt_roots(text)
- for raw in sorted(candidate_paths(text)):
- if raw in ASSERTED_ABSENT:
- if expand(raw).exists():
- failures.append(
- f"{prompt.name}: `{raw}` is asserted ABSENT but EXISTS"
- )
- continue
- if raw in CREATED_BY_PROMPT:
- owner = CREATED_BY_PROMPT[raw]
- cites_owner = re.search(rf"[Pp]rompt {owner}", text) is not None
- if not prompt.name.startswith(owner) and not cites_owner:
- failures.append(
- f"{prompt.name}: cites `{raw}` without noting prompt "
- f"{owner} creates it"
- )
- continue
-
- checked += 1
- if raw.startswith(("~/", "/")):
- if not expand(raw).exists():
- failures.append(f"{prompt.name}: `{raw}` MISSING")
- elif not any((r / raw).exists() for r in roots):
- failures.append(
- f"{prompt.name}: `{raw}` not found under any of "
- f"{[str(r) for r in roots]}"
- )
- print(f"paths: checked {checked}")
- return failures
-
-
-def check_symbols() -> list[str]:
- failures = []
- for raw_file, symbol in SYMBOL_CLAIMS:
- path = expand(raw_file)
- if not path.exists():
- failures.append(f"symbol source MISSING: {raw_file}")
- continue
- if symbol not in path.read_text(errors="replace"):
- failures.append(f"symbol `{symbol}` NOT FOUND in {raw_file}")
- print(f"symbols: checked {len(SYMBOL_CLAIMS)}")
- return failures
-
-
-def main() -> int:
- n_prompts = len(list(PROMPT_DIR.glob("*.md")))
- print(f"verifying {n_prompts} prompt files in {PROMPT_DIR}")
- failures = check_paths() + check_symbols()
- if failures:
- print(f"\nFAIL -- {len(failures)} problem(s):")
- for f in failures:
- print(f" {f}")
- return 1
- print("\nPASS -- every cited path exists and every cited symbol is present")
- return 0
-
-
-if __name__ == "__main__":
- sys.exit(main())
diff --git a/md_naming/migrate/plan.py b/md_naming/migrate/plan.py
index 67b48c4..7e7aa95 100644
--- a/md_naming/migrate/plan.py
+++ b/md_naming/migrate/plan.py
@@ -132,10 +132,7 @@ def rewrite(text: str, old: Path, new: Path) -> str:
#: The remaining files are plain reference docs, renamed mechanically to
-#: DOCS-.md. The one exception is the dopamine-ux programme:
-#: 00-INDEX records 8 of its 9 parts as "not started", so those parts are
-#: outstanding work and become TODO, not DOCS. The index itself is a record.
-AUTO_TASK_PREFIXES = ("utils/dopamine-ux/0",)
+#: DOCS-.md.
def slugify(stem: str) -> str:
@@ -150,8 +147,6 @@ def auto_name(rel: str) -> tuple[str, str]:
# A misspelled README is a README, not a doc needing a new namespace.
if stem.lower() == "readme":
return "README.md", "record"
- if rel.startswith(AUTO_TASK_PREFIXES) and "INDEX" not in stem:
- return f"TODO-{slugify(stem)}.md", "task"
return f"DOCS-{slugify(stem)}.md", "record"
diff --git a/md_naming/migrate/triage.json b/md_naming/migrate/triage.json
index 9e25053..6ccb5ce 100644
--- a/md_naming/migrate/triage.json
+++ b/md_naming/migrate/triage.json
@@ -42,7 +42,6 @@
"testsAndMisc/docs/shell-split-verification.md": "DOCS-shell-split-verification.md",
"testsAndMisc/linux_configuration/docs/SUMMARY.md": "DOCS-pacman-security-layers.md",
"utils/gatelock/docs/scrollable-viewport.md": "DOCS-scrollable-viewport.md",
- "utils/dopamine-ux/00-INDEX.md": "DOCS-dopamine-ux-index.md",
"digital-wellbeing/llm-notes/units-and-tasks.md": "DOCS-shutdown-units-reference.md",
"digital-wellbeing/README_COMPULSIVE_BLOCK_LLM.md": "DOCS-compulsive-block-reference.md",
"digital-wellbeing/README_MIDNIGHT_SHUTDOWN_LLM.md": "DOCS-midnight-shutdown-reference.md",
diff --git a/md_naming/tests/test_migrate.py b/md_naming/tests/test_migrate.py
index f84c932..79c2da9 100644
--- a/md_naming/tests/test_migrate.py
+++ b/md_naming/tests/test_migrate.py
@@ -90,12 +90,6 @@ def test_auto_name_fixes_readme_casing() -> None:
assert auto_name("x/Readme.md") == ("README.md", "record")
-def test_dopamine_ux_parts_are_tasks_but_index_is_not() -> None:
- """00-INDEX records the programme; 01-09 are its unstarted parts."""
- assert auto_name("utils/dopamine-ux/03-diet-guard.md")[1] == "task"
- assert auto_name("utils/dopamine-ux/00-INDEX.md")[1] == "record"
-
-
def test_classified_paths_do_not_overlap() -> None:
"""A file cannot be both a task and a record."""
assert not set(classify.TASKS) & set(classify.RECORDS)