Plan
Where fuzzyworld-tools goes from here: from "a static mirror of my repos" to a small, open-source code host built on one rule: DreamHost only serves files; other machines do the work.
This is a planning document. Nothing in it is promised; the order is the current priority. Last updated 2026-11-03.
Who it is for. First, me: I build the site I want and need (no GitLab, no WordPress, as few machines as possible). Second, anyone who wants to get going with the fewest resources. Anything that only makes sense for my setup is tagged ๐ plugin: it should be optional and separable, so that the core stays small enough for most people.
The machines
| Machine | Does | Can't do |
|---|---|---|
| DreamHost (shared hosting) | serves static files with no rate limits; git over ssh; hourly cron; post-receive hooks |
long-running processes, Chromium, V8's JIT (Node runs --jitless), native addons |
| TrueNAS (home, always on) | Docker: CI runner, headless browsers, screenshots, code-server; already mirrors every repo | be reached from the internet. It pulls work from DreamHost over ssh instead |
| Laptop | fw deploy, interactive work; can act as a second runner |
stay on |
Everything on the sites stays pre-generated: no app server, no database, no accounts, repo content is
never executed. Where a feature needs JavaScript, it is one script we ship (CSP script-src 'self',
never anything from a repo).
Now (prioritized)
All done: โ live since 2026-10-27 (v1.1.0), unless noted.
- โ Apps index: newest deploy first (2026-11-04: now newest code commit first, and both the code and apps indexes are tables, not tiles). (It once listed the commits of each deploy; removed 2026-10-27 as noise.)
- โ LICENSE file (MIT) in this repo. Each repo's license on its summary page.
- โ
Principles in four places:
/why/,/how/, a new/principles/page, and a "Philosophy" section in the README./how/is still a draft thatinstall.shdoesn't install (see Backlog); the principle is in it for when it goes live. - โ File navigation
- "Open in VS Code" link on every summary page (
vscode://vscode.git/clone?url=โฆ). - Syntax highlighting at generation time (Pygments in
~/fw/pylib; no JavaScript). - Per-file history, blame, and browsing every branch not yet merged into the default branch.
- VS Code in the browser at
apps.fuzzyworld.net/vscode/?repo=<name>: thevscode-webbuild plus a read-only file-system extension readingfiles.jsonandobjects/<sha>from code.fuzzyworld.net. - โ CI runner on TrueNAS. See CI. Verified live with nordic-skating and tower.
- โ Screenshots as a built-in runner job. See Screenshots. Live for nordic-skating and tower.
Next (in this order)
The order goes from cheap to expensive, and each step reuses what the one before it built.
- Runners without TrueNAS (decided 2026-10-28; see Runners): runner tags, the laptop as a runner when
idle and plugged in, secrets in a folder on each runner, and "no runners" as a normal state. Goes first.
Built and committed 2026-10-31 (a499715); deployed 2026-10-31 (server, then the TrueNAS runner, then the laptop runner with
--authorize, in that order: a new runner against the old server has every heartbeat refused). Smoke-tested: both runners poll DreamHost and find nothing to run; no real job has gone through them yet. Review follow-ups (claim check ondone, tolerantrelease) deployed the same day. - Feeds: Atom per repo (commits, releases), for app deploys, and site-wide. Smallest step: the data is already
gathered for the log pages and the apps index. The blog reuses it.
1b. Blog at
blog.fuzzyworld.net(see Blog): a generator that turns a folder or repo of Markdown into a blog from one JSON file, plus a one-time WordPress import. Replaces WordPress. - Releases: a page per tag (the tag's message as notes),
git archive.zip/.tar.gz, and files the runner builds (APKs, extensions). Import the release assets already in~/code/gitlab-export/*/release-assets/. Comes early because some of those files exist nowhere else. Adds a releases feed to step 1. - Stats and heatmap, built together, since both are computed from the same pass over the history when pages are generated: - stats: languages by bytes, size, commit count, share of commits made with AI (see Provenance) - heatmap: a commit-activity SVG per repo and site-wide
- Wiki: render a repo's
docs/(orwiki/) folder with a sidebar. Import the GitLab wikis in~/code/gitlab-export/*/wikis.json. Static rendering only; editing comes with pull requests. - Issues (built 2026-11-03): see Issues. The first feature that reads email; builds the mailbox plumbing. 5b. Blog comments by email (see Comments): right after Issues, because it reuses the mailbox plumbing.
- Pull requests: see Pull requests. Last, because it reuses the issues mailbox plumbing and needs the CI runner (approved CI runs on contributed code).
Runners
Goal (2026-10-28): TrueNAS becomes optional (๐ plugin), and the laptop can do the work. Running jobs needs no always-on machine; a small setup is a DreamHost account and a laptop.
- No runners is a normal state. Jobs stay in
pending/until a runner claims them (the queue is files, so this already works). The dashboard says "waiting for a runner" instead of looking broken (a runner counts as gone after 15 minutes without a claim; each claim writes~/fw/runners/<name>). - Laptop runner (launchd via
runner/install-laptop.sh, every 2 minutes), only when it will not hurt my work: on AC power, CPU at least half idle, Docker running, containers at a low CPU share, no new claims once I get busy (checked between jobs; a job already running is not stopped). Everything scheduled runs then. It assumes the laptop is on a few hours a day; that is enough for me. - Lease. A claimed job needs a heartbeat (
beat, every 30 seconds, from a thread in the runner). A job whose runner went quiet for ~2 minutes (lid closed) goes back topending/instead of waiting 2 hours. If that runner wakes up and finishes anyway, its results are uploaded too, and the job may have run twice: accepted. Itsdoneis refused, so it cannot remove the new holder's claim. Runners that send no heartbeat (old versions) keep the 2-hour rule. - Tags (
docker,chrome,android): jobtags:are honoured, and screenshots needchrome. The runner learns what a pipeline needs only after cloning, so a runner that lacks a tag gives the job back (release); the server remembers and does not offer it to that runner again. Simple, at the cost of one clone per declined job. - Secrets. Each runner has a folder (
โฆ/secrets/<repo>.env; on TrueNAS/mnt/main-2025/fw-runner/secrets/). A deploy job taggedsecrets(a reserved tag: "this repo's secrets") runs only on a runner that has the file; no repo uses it yet. Only default-branch and tag jobs get secrets. Put them on the laptop and TrueNAS is not needed. Laptop theft means rotating them (FileVault on). - Stretch goal: a secure secrets API (๐):
runner-apihands each job its secrets over ssh, per runner key, so no runner stores them. Wait for user requests or downtime.
Blog
blog.fuzzyworld.net is WordPress today (theme Scrawl, 144 posts, 4 pages, 45 comments, 15 media files, 3 categories,
16 tags, one author). Goal: the same site, generated by this repo, with no WordPress.
Built and live 2026-10-31 at writing.fuzzyworld.net (queue item Q4; not linked from anywhere yet). The generator is
server/blog.py (+ server/atom.py, server/site-blog/), the importer lib/wp_import.py; commands and behavior are in
docs/blog.md. Decisions taken: build on the laptop from Dropbox and upload only the audited output folder (never
the source); footer "All rights reserved" notice with an AI-training line and a robots.txt blocking AI crawlers; the
WordPress text wins when a source file differs (similarity under 0.95: 100 of the 142 posts are written from WordPress
into ~/Blog/wordpress/); the old site's custom CSS is carried over (hidden <br>, dotted scene breaks, underlined
chapter headings); a light/dark switch and reading-progress bar added (they needed script-src 'self'). The Atom
writer exists now, so Next 1 (Feeds) can reuse it. Done 2026-11-01 (Q5/Q6): blog.fuzzyworld.net 301-redirects every path (query kept) to writing.; til.fuzzyworld.net answers 410 Gone. Both WordPress folders stay on the server, unserved; the original .htaccess of each is saved as .htaccess.wp-20261101. Backup: a WordPress export of the blog (Pavel, ~/Downloads). The ancient old.blog.fuzzyworld.net copy was moved to ~/old/ on the server (it was not served). Still to do: building from the push hook after the move to a private repo,
the til blog: built 2026-11-01 (Q14), config ~/til/fw-blog.json (4 posts, WordPress slugs kept, one image in ~/til/media/), same generator; uploaded the same day (the WordPress folder was moved to ~/old/ on the server first, because fw blog deploy runs rsync --delete). Below, the plan as written before the
build: it still says blog.fuzzyworld.net in places; read that as writing.fuzzyworld.net.
What is there (read from its public API, 2026-10-28):
- Posts at /YYYY/MM/DD/<slug>/, an RSS feed at /feed/, and monthly archives. Categories hardly matter ("Uncategorized"
has 138 posts). Tags are the real structure (a story series is a tag, e.g. Dice, Walkabout, Writing Excuses).
- Pages: Highlights (the front page: a chronological list of the work I consider good, with an AI-use note),
Blog (all posts), Collections (series in reading order, a hand-written list), Upcoming.
- 2 posts are password-protected (Rasputin, Belfast). They are not carried over (see Protected posts below).
- A reading time on each post, media (images, plus epub/kpf/pdf downloads of Wick Canyon), and comments.
- Comments: 45 (36 top-level, 8 replies). They get imported, as static text with the author's name (no emails).
Source: ~/Blog (Dropbox for now; a private git repo later): 286 Markdown files, YYYY-MM-DD-name.md, no front
matter. The title is the first # heading. The post is only the ## Writing section; ## Spelling (cspell words)
and ## Removed writing (cut text) are private notes and are never published. 164 files have a ## Writing section;
the others are notes. WordPress turns single line breaks into <br>, so the renderer must too.
Findings that shape the design:
- File names do not match published URLs. Only 13 of 144 posts match date-slug: the publish date differs from the
draft date, and some names differ (belfast-porn.md is published as /belfast/). The import must record the mapping
(published date, slug, tags, highlighted) somewhere.
- So the generator must keep the old URLs working: same path pattern and the same slugs.
Post metadata (decided 2026-10-28): front matter and config, so the tool does not force edits to source files. Precedence, highest first:
- front matter in the file (
date,slug,tags,highlight,published); - per-file overrides in the blog's JSON file, keyed by file name (a "sidecar" for people who will not or cannot edit their files);
- defaults from the file: date and name from
YYYY-MM-DD-name.md, title from the first# heading.
Nothing is required: a plain folder of Markdown works. Nothing is published unless marked (published in front
matter or an entry in the config): ~/Blog has 286 files and 144 posts are live. This is the same rule as repo
visibility (private unless exactly public). For ~/Blog, the import writes the sidecar (your files stay untouched;
easy to undo), and a later command can convert it to front matter, e.g. when the posts move to a private git repo.
Protected posts (decided 2026-10-28): not carried over. The importer skips password-protected posts (Rasputin,
Belfast) and the comments on them, and says so. They stay drafts in ~/Blog. ๐ plugin idea for later: .htaccess
basic auth first; client-side encryption only if needed (it adds a script and allows offline guessing).
Design:
- One JSON file describes a blog (title, base URL, source folder, which section is the post body, URL pattern, tag
list, highlight list, collections, feed). The tool is gen.py run on a repo of Markdown, from the push hook.
- Reuse what exists: Python-Markdown, Pygments, the site CSS, the feed code from step 1.
- Import (๐ plugin): one-time script that reads the WordPress API: posts, comments, media, and a redirect map.
- The editor and the Dropbox โ private-git-repo move come after the generator, not with it.
- Built 2026-11-02 (Q91/Q92): fw blog watch <config> (server/blog_watch.py; docs/blog.md, "The watcher"): a laptop
launchd agent (fw blog watch install) runs one tick every 60 s per blog config. After a quiet period it mirrors the
Dropbox source into a local git repo (snapshot commits on dropbox-mirror, never main) and, with autodeploy, deploys
edits only when the set of output files, the generator (code/templates), the config and the deploy target are
unchanged (anything else is held back with a notification; the baseline is deployed.json, also written by a manual fw blog deploy and guarded by the
state folder's lock). Run on ONE machine only. Not built yet:
the cutover fw blog move <config> --to repo|dropbox (copy, build both, require identical output, repoint the ~/Blog
symlink, keep the old copy), then Dropbox is dropped and the Air uses a clone plus a simple fw blog sync; the server
remote and the push-hook build (Q25).
- Needs the blog.fuzzyworld.net subdomain pointed at a folder (DreamHost panel).
Comments
Decided 2026-10-28: by email, no accounts. Revised 2026-11-01: a web form (server/comments/post.php, written, never run
or installed) was designed too, but only the
email path is enabled (Pavel: a form means code that runs on every web request, which is more attack surface than
he wants; see the tiers in the README, form = T2, email = T1). The form stays in the repo, off by default.
- A "comment by email" link on each post is a mailto: to the comments mailbox, with the post in the subject
(Comment [writing:/2024/05/01/slug/]). A cron job (server/comments.py ingest) reads the comments mailbox, over IMAP
(a hosted DreamHost mailbox; mail arrives through MailChannels) or from a local Maildir, and puts each comment in a pending queue.
- Identity is the sender's verified address (the topmost Authentication-Results header must show DMARC pass, or DKIM
pass for the From domain). The address is never shown or stored, only a hash; only the display name is shown.
- Moderation: a person's first comment waits for my approval (fw comments list|approve|reject|remove|untrust);
with approve --trust that sender is published on arrival from then on. Limits: 4000 characters, 5 per sender per 24 hours, 200 pending.
A deletion request is honoured.
- Approved comments are written as one static JSON file per post (/_comments/<post path>.json) which blog.js
renders with textContent, so a comment appears without a rebuild. fw blog deploy excludes /_comments/.
Imported WordPress comments stay baked into the pages. Readers without JavaScript see only the imported ones.
- The form (off): server/comments/post.php on its own subdomain, comments_form in the blog config; every
form comment is moderated. Enabling it is a decision for later; the upsides are no email client needed, the
downsides are a public PHP endpoint, spam, and unverified identities.
- ๐ Google sign-in plugin: would need an endpoint that checks the Google token (T2), so it stays a plugin.
CI dashboard
โ Built 2026-10-27 as proposed (docs/ci.md: "Watching CI"):
code.fuzzyworld.net/ci/: one static page for all public repos: queued jobs, running pipelines (which job, for how long), and the last 50 results. Rebuilt on every queue change and every upload from a runner.- Live logs: while a job runs, the runner uploads its log every ~15 seconds, so the job's page shows it growing.
- Pages that show something running reload themselves every 15 seconds (
<meta http-equiv="refresh">), so no JavaScript is needed and the site'sscript-src 'none'stays. - Private repos: never named on the public page. For those,
fw ci-listin the terminal (and afw ci-watchthat follows a run and prints each job's result).
Backlog
- URGENT (Pavel, 2026-11-04): AI usage analysis per repo, on the CI/usage pages. There is a lot of public chatter
about Claude's usage limits, so each repo should show what its AI use looks like over time. It builds on the
existing per-commit "Usage" tab (
repo_usage/usage_costsinserver/gen.py, from theFW-Tokens-*commit trailers) and on the planned "Stats and heatmap" step (Next 3). Candidate views, as static SVG charts (no JS): AI usage across time (output tokens, cached reads, cost per week or per session); AI share of commits and of pull requests over time (Co-Authored-By, with the Provenance caveats); lines of code added and removed over time; usage per line of code (output tokens, and estimated cost, per added line, per week and per commit, with the median and the hot commits already marked on the Usage tab); and a site-wide roll-up across repos. Open points: how to price tokens (a per-model price table kept in the repo, stated as an estimate); what a "line" is (added lines in non-generated, non-vendored files); sessions that span several repos; commits without trailers (the ChatGPT era) shown as unknown, never as zero. Data stays derived from git; no transcripts are published. Q107 stage 1 (done 2026-11-04): trailers that make cost computable (FW-Tokens-Method: 2, per-modelFW-Model-Tokens, written byfw usage --trailers; each message id counted once, subagents included); per-commit estimated cost fromserver/prices.json(list prices,as_of; unknown stays unknown); one weekly "output tokens" SVG chart on the Usage tab with a hatched strip for commits without data. Old method-1 trailers are left alone (they ran about 1.5-2x high). Q107 stages 2 and 3 (done 2026-11-04): the Usage tab also charts estimated cost, cached reads, average cost per commit and AI-marked commits (two lines) per week or month, and the Sessions table has an est. cost column; the site-wide/usage/page (build_site_usage, called frombuild_index, public published repos only) has the same charts plus a per-repo table (unknown costs are hatched, never zero; the page is rebuilt on everyfw publishfrom a per-repo summary cached in the state dir by HEAD sha). Reserved top-level names (usage,ci,projects, ...:SITE_RESERVEDinserver/gen.py) are never treated as repos by the index or the roll-up. Remaining: (4) optional: lines added/removed per week and usage per added line. - File finder and code search (one shipped script plus a prebuilt index per repo)
- code-server on TrueNAS, for editing in the browser (private, over Tailscale)
- Provenance stats and transcript publishing (see below)
- An app at every commit:
apps.fuzzyworld.net/<app>/@<sha>/(a service worker at/<app>/would cover those paths; must be solved first) - OpenGraph images (link previews) from the screenshots
- Deploy automatically when CI passes on
main - Run GitHub Actions workflows through
act(10 repos have.github/workflows) - Publish the shared-origin deploy checks as a reusable lint
- Discuss: an Advent of Code wrapper app (idea added 2026-10-28; Pavel thinks it has merit). A static page: sign in with GitHub, fetch the user's own puzzle data from adventofcode.com (it differs per user), show the puzzle beside an in-browser editor holding a TypeScript function that takes the logically named test data, run the code on a button press, submit the answer to adventofcode and show whether it is right. To settle: how to sign in to adventofcode from a static page (its session cookie, CORS, and its automation rules), where the code runs (a worker in the browser), and how it relates to the existing AoC harness
- Found while building "Now" (2026-10-27):
- VS Code in the browser: Markdown preview and other webviews need a separate domain per view
(a wildcard subdomain, e.g.
*.vscode.fuzzyworld.net, set up in the DreamHost panel) - VS Code in the browser: build from current VS Code source instead of the
vscode-web1.91.1 package (2024) - VS Code in the browser: project-wide TypeScript IntelliSense (turned off; the web TS server rejects our URI scheme)
- โ
Renamed
.gitlab-ci.ymlโ.fw-ci.ymlin 37 repos (2026-10-27; blister's is only local, never pushed). READMEs that said "deployed to GitLab Pages" are fixed in 11 repos (2026-10-28; edits waiting to be committed in each). Still to do: the same in basin, chance-it, flight, herd, nuts, takeover (their local copies are behind, see below), the.mdplans/designs that still say it (e.g. tower's design.md, decisions.md), and droppingpagesjobs that only published to GitLab Pages - Old
.gitlab-ci.ymlfiles:node:*-alpineimages have nogit, so installing thefuzzyworld-toolsgit dependency fails (e.g. bubbles). Switch those jobs to anode:22image, or deletepagesjobs thatfw deployreplaced - โ
The 34 merged
fuzzyworld-deploybranches on DreamHost were deleted (2026-10-28). Local clones and the TrueNAS mirror may still listorigin/fuzzyworld-deployuntil pruned (git fetch --prune) - CI
cache:support (a per-repo volume on TrueNAS), andservices: - Branch pages: link to the default branch's page when a file is identical, instead of a copy
- Found in review (2026-10-27): discussed, not done
- Site pages: all now in
server/content/(gen.py content), including404.html;/how/is_how.html(draft, not built) /how/goes live: finish the draft and renameserver/content/_how.htmltohow.html(/why/already says it is coming)- CI secrets: none are set, so jobs that deploy elsewhere fail (
dreamhost's rsync, engye's store uploads). They go in<runner>/secrets/<repo>.env(TrueNAS:/mnt/main-2025/fw-runner/secrets/; default branch and tag jobs only) - Look at the results of the 37 rename pushes' CI runs (north-star and nuts failed; not looked at yet)
- explodorama:
test/demo.test.ts"decoy" timed out at vitest's 5-second default on TrueNAS (passed the day before); a longertestTimeoutfor that test, or a faster test - tower: its screenshot caught the loading screen.
"screenshots": [{ "path": "", "size": "phone", "wait": 8000 }]is added tofuzzyworldin itspackage.json(2026-10-28); it takes effect once committed and pushed, and the next CI run takes the new shot - Local copies behind DreamHost, with uncommitted edits in the same files as the incoming commits: basin, chance-it, flight, herd; takeover has staged changes. Pull them by hand
- Housekeeping (noticed 2026-10-27, never discussed): nothing is cleaned up yet
~/fw/ci/<repo>/<sha>/on DreamHost keeps every pipeline (pages show only the newest 100 per repo)runner.logon TrueNAS grows forever- the runner's Docker images on TrueNAS (about 5 GB so far); a periodic
docker image prune
Principles
Wording approved 2026-10-26:
Things made with AI should be shared freely with the world. The code and apps here were made with AI, so they are MIT-licensed: use them, change them, share them.
My conversations with the AI are different. My words and my way of working are mine to publish or not. I will publish a conversation only when I choose to.
CI
โ Live since 2026-10-27. docs/ci.md is the full reference; this is the summary. It is the same pattern as GitLab runners: a runner registers, pulls jobs, runs them in containers, and reports back. The difference is that there's no coordinator service: the coordinator is files on DreamHost.
- Queue.
post-receiverunsfw ci-queue, which writes~/fw/queue/pending/<id>.json(repo, ref, sha, default branch, time) for each pushed branch or tag with a.fw-ci.yml,.gitlab-ci.ymlorpackage.json. - Claim. The runner (
runner/fw-runner.py, TrueNAS cron every 2 minutes) runsssh dh claim <runner>throughserver/runner-api. The server moves the job torunning/with onerename, which is atomic, so two runners can never take the same job. The same step puts jobs running for over 2 hours back inpending/. - Registration = an ssh key. The runner's key is limited in
authorized_keystocommand=".../server/runner-api": it can claim jobs, fetch repos and upload results, and can't get a shell. - Run. The runner clones over ssh and runs each job in a throwaway Docker container from the job's
image. Inside jobs,originis the read-only https clone URL; the runner's key never enters a container. - Report. Logs and
pipeline.jsonare uploaded to~/fw/ci/<repo>/<sha>/(a running job's log every 15 seconds);gen.pyrenders โ/โ on the log and summary pages, a page per pipeline with its logs, a badge, and the dashboard at/ci/. - Updates. The runner restarts itself between jobs when
runner/install.shhas replaced it. - Two runners. Tags, the heartbeat lease and the laptop runner are deployed (2026-10-31; see Runners).
Job definitions: .fw-ci.yml (decided 2026-10-27: our own name; the format stays a subset of GitLab CI's,
so moving a repo over is a rename; the old .gitlab-ci.yml name still works until then). 42 repos have one, and they use only image,
stages, script, before_script, variables, rules (branch conditions), needs, artifacts, cache.
36 of them are GitLab Pages jobs; a pages job maps to fw deploy. An unsupported keyword fails the job with
a clear message; it is never silently ignored. Repos without the file fall back to fuzzyworld.checks from
package.json. engye (20 KB: Android builds, Play Store, Firefox extension) is the hard case and the test of
how far the subset needs to go.
Security. Secrets (/mnt/main-2025/fw-runner/secrets/<repo>.env on TrueNAS) are given only to jobs on the
default branch and tags. None are set yet (see Backlog). Code from contributors will run only after I approve it,
because it runs on a machine in my home (part of Pull requests).
Screenshots
โ
Live since 2026-10-27. A built-in runner job, after a successful pipeline on the default branch of an app
(fuzzyworld.app in package.json):
- builds the latest
mainthe wayfw deploydoes (fw deploy --here --dry-run), serves it at/<app>/in a Playwright container with no network, and photographs it. This is the commit CI just tested, which may be newer than what is deployed - configured in
package.json:"screenshots": [{ "path": "", "size": "phone", "wait": 1500 }, { "path": "?demo", "size": "desktop" }], or a command for apps that need clicks first ("screenshots": "pnpm shots", writing PNGs to$FW_SHOTS_DIR), orfalse. With no config: one phone shot of the start page - apps without runner screenshots show the images in the repo's
screenshots/folder instead (public repos) - shown on the apps index and repo summary pages; OpenGraph images are in the Backlog
Issues
- Reports arrive at
bugs-k7q4+<repo>@fuzzyworld.net(only the address was live before 2026-11-03; the ingest and the pages were built that day, see the task list under Report replays). - A cron job reads the mailbox (IMAP, Python standard library) and renders each thread as static pages.
- Reporters' email addresses are never shown.
- Configurable per repo, the same way as visibility:
git config fuzzyworld.issues public|reviewed|private, set withfw issues <repo> <mode>: public: a report whose sender passed email authentication is published as it arrives (an unauthenticated one waits for approval)reviewed: a report is published only after I mark it publicprivate: nothing is published; reports are still stored in~/fw/issues(and listed byfw issues list)- anything other than exactly
publicorreviewedcounts asprivate, so nothing is published by accident. A private repo's issues are always private, whatever this says. - Import the GitLab issues and notes in
~/code/gitlab-export/*/issues.json.
Report replays (Q104; planned 2026-11-03, built 2026-11-03 in this repo)
Idea from the Explodorama bug mails: a report can carry a machine-readable payload (for the game: the run
record, a few KB of JSON: seed, rules version and actions), and the issue page can then show what the reporter
saw as a looping animation. The game side (the replay page, its limits, how old rules are loaded) is in
~/code/explodorama/PLAN.md ยง20. This repo's part:
- Intake stays email. DreamHost only serves files, so no upload endpoint (no abuse surface, no server code).
The existing mailbox cron reads
Run record: {โฆ}(or whatever line an app agrees on) out of a report. The mail's quoted-printable text is decoded first (the game'stools/report-replay.mjsalready does it in Node; port the same rule to Python, and keep a test with a real report). - Store it beside the issue. For a repo whose issue mode allows publishing, the cron writes the payload as
a static file next to the issue page (
โฆ/issues/<n>/record.json), under the same visibility rule as the report itself:publicas it arrives,reviewedonly after I mark it public,privatenever. The payload is validated first (size โค 64 KB, parses as finite JSON, an integerseedandsimVersion, a listactions) and only the keyssimVersion, seed, startLevel, startCharge, options, actionsare kept; a record with an address-like string is refused. Anything else stays only in my mailbox. Reporter emails are still never shown; the device string stays out of the page by default. - Link a viewer, don't build one. An app announces its viewer in
package.json, for example"fuzzyworld": { "replay": "replay.html?report={record}" }; with it, an issue page that has a payload shows "Watch what happened", a plain link to that page of the app, with{record}set to the stored file. Without the key, nothing changes. This keeps the core free of any game knowledge (๐ plugin-shaped: an app opts in). - CSP. The viewer is the app's own script on the app's own origin, so the site-wide
script-src 'self'policy (Q99) holds; the record is data, never inserted as HTML. The issue page itself needs no script. - Old rules. A viewer may need the sim of the report's own rules version. An app can publish those as
static files at deploy time (Explodorama:
/explodorama/sim/<simVersion>.js); fuzzyworld-tools only has to letfw deploycarry such extra files (a per-app list of globs inpackage.json) and keep old ones. The runner (TrueNAS) can build them on a tag; DreamHost has no Node. - Admin panel (Q102). On the laptop, the inbox lists reports with a Replay link per report (the same viewer,
served locally with the dev page's
&rules=mode, which can use any commit from git), and the buttons that mark a report public, close it, or turn it into a regression fixture in the app's repo (test/fixtures/). The panel is a thin local web page overfwcommands; it holds no state of its own. Decided 2026-11-03: local-only, never hosted on DreamHost. Every panel action is privileged (publish, merge, deploy), and a login form on shared hosting would make the most valuable target on the site; the local panel's security is that it is unreachable, and its authority is the ssh key already held. It also keeps "DreamHost only serves files". Cost: no triage from a phone (reports still arrive as email, and the replay link works anywhere because it is a static page). Possible later add, not planned: one narrow endpoint behind a per-report signed link in the report mail ("mark this report public"), with no login and no general admin surface.
This repo's task list (2026-11-03; Pavel: "add to the plan, start implementing"). Items 1-4 were built
on 2026-11-03 (server/issues.py, lib/deploy.mjs); the first line of each says so. Not yet committed or
deployed is deliberately not recorded here. In dependency order:
- Issues (built 2026-11-03; Next 5): cron ingest of the bugs mailbox over IMAP (reuse the
comments.py/comments-ingest.shplumbing), one static page per thread underโฆ/<repo>/issues/<n>/,fw issues <repo> public|reviewed|private(anything else = private), reporter addresses never shown, and a "mark public" action forreviewedmode. - Record extraction (built 2026-11-03): decode the quoted-printable mail, find the
Run record: {โฆ}line, validate (โค 64 KB, valid JSON, expected keys), writerecord.jsonbeside the issue under the issue's visibility rule; device string stays out of the page. Test with a real report (Explodorama'stest/fixtures/q101-bomb-pass.jsonand its.eml). - App opt-in (built 2026-11-03): a
replaykey in the"fuzzyworld"block ofpackage.json("replay": "replay.html?report={record}"); the issue-page generator reads it and shows "Watch what happened". No key, no change. fw deploycarries extra files, kept forever (built 2026-11-03): a per-app glob list ("extra": ["sim/*.js"]) published with the deploy; extra files are protected from--deletethrough an rsync filter (uploaded and updated, never removed), so a later deploy never deletes old rules versions.
No CSP change (the viewer is the app's own script) and no server code on DreamHost. The admin panel (Q102, local-only) comes after all four.
Order: (1) the game's production replay page and limits (its PLAN ยง20.2 steps 1-2), by hand, with a stored
record; (2) the cron extracting and publishing record.json and the replay key; (3) per-rules-version sim
files in fw deploy; (4) the panel. Each step is useful alone.
Decided 2026-11-03 (Pavel): the viewer opens as its own page (a plain link, no iframe); a reviewed
report's record is published at the same moment as its text; old sim/ files are kept forever; compact records
in the game's mailto: link (game PLAN ยง20.2 step 4) come after the cron (step 2); the published page defaults
to the plain recorded run, with comparison as an option (game PLAN ยง20.2 step 5). Nothing open here.
Admin panel (fw admin, Q102; designed 2026-11-04, decisions by Pavel)
Version 1 built 2026-11-04 (lib/admin.py, tests/test_admin.py; server side: issues list|show --json, issues record, comments list --json). Not yet tried against the real server (tested only with a fake runner). Decisions made while building:
- Login: a one-time login URL (random key) that sets a session cookie; everything else needs the cookie, plus the per-launch POST token and Host / Origin / Sec-Fetch-Site checks.
- Watch = dev replay: a link to the deployed viewer when the record is public, "Download record" always, and "Prepare dev replay" (runs the app's report-replay npm script with the record and --rules <commit>; needs npm run dev running in the app).
- Recently approved comments (the 20 newest) with a Remove button are in v1, not later.
- FW_CODE_DIR (also --code-dir, default ~/code) is configurable: where the apps' working copies live.
- Deploy tab (Q111 stage 1, built 2026-11-04): read-only; fw deploys --json (server/deploys.py) joins the deploy ledger (~/fw/state/deployed.json, written by bin/deploy-tools, keyed <repo>/<target>) and each app's .fuzzyworld.json with the repo's HEAD. Targets are declared in package.json fuzzyworld.deploy. Next: stage 2, a "Deploy now" button that deploys origin/main from a clean export (confirm page with details, background run, progress page); stage 3, any repo declares targets, apps included, tested on nordic-skating.
- Still not in v1: "make this a regression fixture", pull requests, CI, blog state.
A local-only web page over fw commands (see "Report replays": never hosted on DreamHost). fw admin starts a
small Python (stdlib) server on 127.0.0.1 and opens the browser; every button runs the same fw ... command over
ssh that Pavel would type, so the panel holds no state and has no authority beyond his ssh key.
- Decided: version 1 is reports and blog comments together (both are "read it, then approve or reject");
no JavaScript at first (server-rendered pages and
POSTforms, a full reload after each action); the command isfw admin. - Safety: bind to 127.0.0.1 only; check the
Hostheader (DNS rebinding); every action is aPOSTcarrying a random token made at launch; a confirm step for destructive actions; never show passwords. - Version 1: (1) one list of reports across repos (repo, number, title, state, visibility, record, age; filters "waiting for approval" and "has a replay"); (2) a report page with the full stored text, including unpublished reports, the details table and thread, and Approve / Hide / Close / Reopen and the repo's mode switch; (3) blog comments: pending ones with approve, reject, trust sender; (4) a Watch link per report with a record (the dev replay page until the game's production viewer exists).
- Needs from the existing code:
fw issues list --json;fw issues record <repo> <n>(read an unpublished record); a machine-readablefw comments list. - Later, each useful alone: pull requests (after contrib intake), CI and runners, blog build/deploy and watcher
state (Q100), "make this report a regression fixture" (writes
test/fixtures/in the app's repo). - Backlog (Q108, Pavel 2026-11-04): the admin page on the live site, working with the local
fw admin. A page on code.fuzzyworld.net checks whether the visitor's machine runsfw admin(a probe ofhttp://127.0.0.1:<port>) and, if so, shows the admin UI there while all data and actions go browser to localhost; the live site never sees reports, addresses or tokens (the hosted page is a static shell). Feasible, with conditions: (1) it needs JavaScript on that one page (allowed by the "local JS is OK" policy, nothing else on the site changes); (2)fw admingains a JSON API with CORS for exactly the live origin, a bearer token (the current cookie login does not work cross-site), strict Origin / Host checks and the Private Network Access preflight header; (3) browsers differ: Chrome and Firefox allow an https page to call 127.0.0.1, newer Chrome asks the user once for "local network access", Safari may block it as mixed content, so test all three and keep the local page (fw adminas today) as the fallback; (4) the token reaches the live page through a linkfw adminprints (token in the URL fragment, which is never sent to a server), kept insessionStorage. Not started.
Pull requests
- Contributions arrive at
contrib-68uq+<repo>@fuzzyworld.netas a branch URL or patches (already live). Forward-only today (2026-11-03): likebugs-k7q4, it must be recreated as a fully hosted mailbox before a cron job can read it over IMAP, and so that the receiving server'sAuthentication-Resultsheader is trusted (a forwarded copy carries the destination's header instead). Give it its own account, never the comments one. - A cron job fetches each one into
refs/contrib/<n>in the bare repo, never ontomain. - It renders a review page: the diff, CI results (after I approve the run), and the discussion from email replies.
- Transcripts. A submitter can attach the AI conversation behind the change, so the reviewer sees exactly where they're coming from. It is the submitter's choice, published under the submitter's terms, and only on that pull request.
- I merge locally, as now.
- Import the GitLab merge requests in
~/code/gitlab-export/*/merge_requests.json.
Provenance
- AI share. Count commits with an AI
Co-Authored-By:trailer, per repo and site-wide. - Transcripts (Claude Code session โ rendered page):
- opt-in, one conversation at a time
- with a redaction pass (paths, email addresses, tokens) and my review before anything is published
- each published transcript has a consent record: who gave permission, when, for which conversation
fw transcript withdraw <id>removes it from the sites- transcripts are not MIT; they say "shown with permission, all rights reserved"
- First candidate: the Firefox bug-fix conversation. Permission was given 2026-10-26 and can be withdrawn. It is in a different session, so it still goes through redaction and review first.
- My own conversations (like the ones that built this tool) are not published.