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)