diff --git a/.githooks/post-commit b/.githooks/post-commit
index 115a1a89c0..7e5ee7cb1c 100755
--- a/.githooks/post-commit
+++ b/.githooks/post-commit
@@ -12,6 +12,9 @@ if [ -z "${REPO_ROOT:-}" ]; then
exit 0
fi
+TRIGGER_COMMIT=$(git -C "$REPO_ROOT" rev-parse HEAD)
+CURRENT_BRANCH="$(git -C "$REPO_ROOT" symbolic-ref --short HEAD 2>/dev/null || true)"
+
NODE_BIN="${NODE_BIN:-node}"
if ! command -v "$NODE_BIN" >/dev/null 2>&1; then
exit 0
@@ -42,37 +45,29 @@ fi
# Pipeline:
# 1. push HEAD to knot (origin) so lith's pull matches local
# 2. fire lith/deploy.fish β pulls knot, restarts service
-# 3. wait for the knotβgithub mirror (lith's 60s systemd timer)
-# 4. fire session-server/deploy.fish β VPS git pull + node restart
+# 3. lith retains its lock while waiting for the mirror and deploying
+# session-server at the same SHA
#
# Both deploys run async so they don't block the commit. Logs land in
# .git/arena-auto-deploy.log for postmortems.
-CURRENT_BRANCH="$(git -C "$REPO_ROOT" symbolic-ref --short HEAD 2>/dev/null || true)"
if [ "${AC_NO_AUTO_DEPLOY:-}" != "1" ] && [ "$CURRENT_BRANCH" = "main" ]; then
ARENA_TOUCH_RE='^(system/public/aesthetic.computer/disks/arena\.mjs|system/public/aesthetic.computer/lib/(arena-world|pmove|cam-doll)\.mjs|session-server/(arena-manager|session)\.mjs|shared/.*)$'
- if git diff-tree --no-commit-id --name-only -r HEAD | grep -qE "$ARENA_TOUCH_RE"; then
+ if git diff-tree --no-commit-id --name-only -r "$TRIGGER_COMMIT" | grep -qE "$ARENA_TOUCH_RE"; then
echo "ποΈ arena change on main β auto-deploying lith + session-server in backgroundβ¦"
- DEPLOY_LOG="$REPO_ROOT/.git/arena-auto-deploy.log"
+ DEPLOY_LOG="$(git -C "$REPO_ROOT" rev-parse --path-format=absolute --git-common-dir)/arena-auto-deploy.log"
(
- printf "\n=== %s commit=%s ===\n" "$(date -Iseconds)" "$(git -C "$REPO_ROOT" rev-parse --short HEAD)" >> "$DEPLOY_LOG"
+ printf "\n=== %s commit=%s ===\n" "$(date -Iseconds)" "$TRIGGER_COMMIT" >> "$DEPLOY_LOG"
# 1. Push to knot if local is ahead (idempotent if already pushed).
- LOCAL_HEAD=$(git -C "$REPO_ROOT" rev-parse HEAD)
- REMOTE_HEAD=$(git -C "$REPO_ROOT" rev-parse origin/main 2>/dev/null || echo none)
- if [ "$LOCAL_HEAD" != "$REMOTE_HEAD" ]; then
- echo "β pushing $LOCAL_HEAD to origin/main" >> "$DEPLOY_LOG"
- git -C "$REPO_ROOT" push origin main >> "$DEPLOY_LOG" 2>&1 || \
- echo "β push to knot failed β skipping deploys" >> "$DEPLOY_LOG"
+ if ! git -C "$REPO_ROOT" push origin "$TRIGGER_COMMIT:refs/heads/main" >> "$DEPLOY_LOG" 2>&1; then
+ echo "Push to knot failed; deploy stopped" >> "$DEPLOY_LOG"
+ exit 1
fi
# 2. lith (pulls from knot directly).
echo "β fish lith/deploy.fish" >> "$DEPLOY_LOG"
- fish "$REPO_ROOT/lith/deploy.fish" >> "$DEPLOY_LOG" 2>&1 || \
- echo "β lith deploy failed" >> "$DEPLOY_LOG"
- # 3. give knotβgithub mirror time to sync (lith systemd timer = 60s).
- sleep 75
- # 4. session-server (pulls from github via VPS).
- echo "β fish session-server/deploy.fish" >> "$DEPLOY_LOG"
- fish "$REPO_ROOT/session-server/deploy.fish" >> "$DEPLOY_LOG" 2>&1 || \
- echo "β session-server deploy failed" >> "$DEPLOY_LOG"
+ if ! AC_DEPLOY_ARENA=1 DEPLOY_BRANCH=main EXPECTED_COMMIT="$TRIGGER_COMMIT" fish "$REPO_ROOT/lith/deploy.fish" >> "$DEPLOY_LOG" 2>&1; then
+ echo "Lith deploy failed; session deploy stopped" >> "$DEPLOY_LOG"
+ exit 1
+ fi
echo "β done" >> "$DEPLOY_LOG"
) &
disown 2>/dev/null || true
diff --git a/CLAUDE.md b/CLAUDE.md
index 07fd2887a2..94ce272937 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,161 +1,21 @@
-# CLAUDE.md
+# Claude adapter
-This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. It is kept deliberately short (target: under 200 lines); domain detail lives in on-demand files noted throughout β read those when working in their area.
+@SCORE.md
-## Execution Environment
+SCORE owns shared instructions and task routes. Apply the current user request
+before repository defaults. The piece API guide is explicitly routed from
+SCORE for all agents; it is not a second root policy.
-Read `ENVIRONMENT.md` and resolve the active environment before applying
-editor-specific instructions from any score. Claude Terminal on macOS uses its
-local shell directly; Emacs MCP/fishy instructions apply only when the active
-environment is `emacs`.
+## Local memory
-## Project Overview
+Claude prompt hooks in `.claude/settings.json` write through `memory/hook.mjs`
+to the encrypted local store (`~/.ac-agent-memory`, or `AGENT_MEMORY_HOME`).
+The installed post-commit hook can log commits, import Codex sessions and flush
+an explicitly enabled remote queue. Inspect installation with the instruction
+doctor; a tracked hook is not proof it is installed.
-Aesthetic Computer (AC) is a mobile-first runtime and social network for creative computing. It's designed as a musical instrument-like interface where users discover memorizable paths through commands and published "pieces" (interactive programs). The system supports both JavaScript (.mjs) and KidLisp (.lisp) pieces.
-
-## Agent Memory (Local-First)
-
-When @jeffrey is working, Claude hook events are written to a local encrypted memory store first.
-
-- **Hook**: `.claude/settings.json` β `UserPromptSubmit` β `node memory/hook.mjs`
-- **Git hook**: `.githooks/post-commit` β commit log + Codex import + remote flush
-- **Local store**: `~/.ac-agent-memory` (overridable via `AGENT_MEMORY_HOME`); AES-256-GCM; redacted before indexing/sync
-- **CLI**: `node memory/cli.mjs` (`list`, `remember`, `checkpoint`, `doctor`, `profile`, `flush-remote`); Codex import via `node memory/codex-sync.mjs`
-- Remote writes are optional and disabled by default (`AGENT_MEMORY_REMOTE_ENABLED=true` + `AGENT_MEMORY_REMOTE_URL=...` to enable). `remember` continuity is lineage (`remembered_from`), not session takeover.
-
-## AestheticAnts & Score.md
-
-Automated maintenance system ("AA"). Main score: `SCORE.md`; ant mindset/rules: `ants/mindset-and-rules.md`. Read both before contributing.
-
-**Important:** Do not modify `ants/mindset-and-rules.md` unless you are the queen (@jeffrey).
-
-## The Hand (Code Style)
-
-`HAND.md` at the repo root is the code-style guide β companion to `papers/VOICE.md` (prose). Read it before writing or carving core code. Key idea: leaves (pieces) stay small and can be loop-generated; the foundational libs are the instrument and want knowability over raw terseness. The active "rehandify" campaign and its guardrails live at the bottom of that file.
-
-## The Screen (Piece Layout)
-
-`SCREEN.md` at the repo root governs how a piece *draws* β the corner-label zone the system reserves at `(6, 6)`, `reframed` layout, `ui.Button` + `pens()` multitouch, killing held voices in `leave`. Read it before drawing a piece. HAND covers how the code reads; this covers where the pixels go.
-
-## Development Commands
-
-### Running the Development Environment
-
-```bash
-npm run aesthetic # Run all servers (site, session, edge, stripe) β primary dev command (alias: npm run ac)
-npm run site # Main dev server (port 8888)
-npm run server:session # Session backend (port 8889)
-npm run stripe # Stripe webhook listener
-```
-
-### Testing
-
-```bash
-npm test # All tests
-npm run test:kidlisp # KidLisp tests (watch; :direct for no watch)
-npm run test:perf # Performance tests (:chrome, :lighthouse variants)
-```
-
-### Health Check (Doctor)
-
-Stack-wide preflight before debugging β tells you *which* layer is sick:
-
-```bash
-npm run doctor # full sweep; -- --local / -- --prod / -- --strict
-```
-
-Stopped dev servers read as β οΈ (advisory); only prod site + CDN are critical. Add checks in the `CHECKS` array of `toolchain/doctor.mjs` (dependency-free).
-
-### Pieces, Sessions, Assets
-
-```bash
-npm run new piece-name "Description" # New piece from blank.mjs template
-npm run session:alive # List active session backends
-npm run server:session:logs ID # Logs for a session (session:reset ID to terminate)
-npm run session:publish # Deploy session server
-npm run assets:sync:down # Sync assets from DO Spaces (:up to push)
-```
-
-### AC Native OS (fedac/native/)
-
-**Routine OTA releases are built remotely on the oven, and you must ask for them.** Landing fedac/native/ changes on `origin/main` builds nothing β the oven's native git poller ships disabled (`NATIVE_POLL_INTERVAL_MS` defaults to 0), so pushing and waiting means the published OTA silently stays stale. Run `ac-os oven` to release.
-
-```bash
-ac-os oven # Trigger remote OTA build for HEAD
-ac-os oven status # Show oven queue + recent builds
-ac-os oven watch # Tail active build logs (SSE)
-ac-os oven cancel # Cancel active oven job
-```
-
-**Use `ac-os oven` for OTA releases β not `ac-os upload`.** `ac-os upload` is a local-build-and-push fallback that requires a clean tree and has historically auto-stashed uncommitted work in ways that strand changes.
-
-Local-only commands (rarely needed): `ac-os build` (binary β initramfs β kernel), `ac-os flash`, `ac-os upload`, `ac-os flash+upload`.
-
-**Critical:** `ac-os upload` always does a full rebuild before uploading. The kernel embeds the git hash and build name at compile time (`AC_GIT_HASH`, `AC_BUILD_NAME` in the Makefile). Uploading without rebuilding would serve a stale kernel with the wrong version string.
-
-### Notation
-
-- **compush** - commit & push. If the commit touches live-served paths (`system/public/**`, `system/netlify/functions/**`), follow with `fish lith/deploy.fish` unprompted β pushing alone doesn't put it in production.
-- **oskieploy** - commit & push, then release oskiewar across every surface with
- `npm run oskiewar:deploy`. That one command carries the lot: it stamps
- `buildVersion` to match the commit count and reburns the hash-bound social
- preview (both used to be manual steps the release refused without), runs
- `fish lith/deploy.fish`, and then reconciles web, iOS and Xbox, verifying the
- production bytes against the source hash. Report the channel table. A devkit
- that is switched off comes back `offline` rather than `failed` and is not a
- problem β `blocked: []` is the line that says nothing went wrong. Catch a
- sleeping console up later with `npm run oskiewar:reconcile`.
-- **sticky the X** - on a macOS host, run `node toolchain/macos/sticky.mjs` on X β translucent, larger-text Stickies note sized to fit and centered. See `toolchain/macos/README.md`.
-
-## Architecture
-
-### Core Components
-
-1. **Boot** (`system/public/aesthetic.computer/boot.mjs`) β entry point; loads BIOS, service-worker module caching, WebSocket module loader for hot reload, boot telemetry to `/api/boot-log`.
-2. **BIOS** (`system/public/aesthetic.computer/bios.mjs`) β main runtime coordinator; piece lifecycle, API surface, routing/navigation.
-3. **Disk** (`system/public/aesthetic.computer/lib/disk.mjs`) β large (~572KB) core API for pieces: graphics primitives, audio, input, UI. All pieces talk to AC through the Disk API.
-4. **Module loader** (`system/public/aesthetic.computer/module-loader.mjs`) β WebSocket dynamic loading; hot reload in dev; prefetches common modules.
-
-### Pieces
-
-All pieces live in `system/public/aesthetic.computer/disks/` (`.mjs` and `.lisp`). **The authoring guide β lifecycle functions, API surface, event patterns, multiplayer dual-channel, UI components, publishing β is `system/public/aesthetic.computer/disks/CLAUDE.md`** (loads automatically when working there). Canonical multiplayer reference: `squash.mjs`.
-
-### Servers and Services
-
-1. **System Server** (`system/` + `lith/`) β production is **lith**: Express + Caddy monolith on a DigitalOcean VPS (`lith.aesthetic.computer`), deployed with `fish lith/deploy.fish`, pulling from the tangled knot `git@knot.aesthetic.computer:aesthetic.computer/core`. Netlify is no longer the host. Dev: `npm run site` (port 8888). Backend function handlers live in `system/netlify/functions/` β path is historical; lith's Express adapts each file as a route, so keep adding endpoints there.
-2. **Session Server** (`session-server/`) β per-session backend via Jamsocket; Geckos.io WebSocket+UDP for chat, multiplayer, real-time state; Redis for sync.
-3. **Feed Server** (`dp1-feed/`) β Cloudflare Worker for activity feeds, deployed separately.
-
-### KidLisp
-
-Minimal Lisp dialect for generative art (118 built-ins across 12 categories). **Comprehensive docs: `kidlisp/README.md`.** Evaluator: `system/public/aesthetic.computer/lib/kidlisp.mjs`; storage API: `system/netlify/functions/store-kidlisp.mjs`; tools in `kidlisp/tools/` (`./kidlisp/tools/source-tree.mjs $cow`, `get-source.mjs $piece-code`; dev server must be running).
-
-### Data Storage
-
-**MongoDB** (users, handles, chat, moods) Β· **Redis** (session state) Β· **Firebase** (auth, messaging) Β· **DO Spaces** (asset CDN).
-
-### Routing and URLs
-
-Pieces are URL-addressable: `aesthetic.computer/piece-name`, params `piece-name:p1:p2`, user pieces `@handle/piece-name`, QR via `share piece-name`.
-
-## Development Workflow
-
-- **Codespaces**: server at `https://{CODESPACE_NAME}-8888.app.github.dev` (`echo $CODESPACE_NAME`).
-- **Hot reload**: piece changes reflect on save via the module loader; WebSocket status shows in the boot canvas; use `channel custom-name` for multi-device testing.
-
-## Important Directories
-
-- `system/public/aesthetic.computer/disks/` - All pieces (+ authoring guide CLAUDE.md)
-- `system/public/aesthetic.computer/lib/` - Shared libraries
-- `system/netlify/functions/` - Serverless backend functions (served by lith)
-- `session-server/` - Real-time session backend
-- `shared/` - Code shared between system and session servers
-- `kidlisp/` - KidLisp docs and tools
-- `spec/` - Jasmine tests for KidLisp
-- `ants/` - AestheticAnts automated maintenance
-- `easel/` - Easel, the terminal coding interface (`ac`); `cd easel && ./install.sh`
-
-## Notes
-
-- `.mjs` ES modules throughout
-- When making changes, consult `ants/mindset-and-rules.md` for the ant operating philosophy
+Use `node memory/cli.mjs` for list, remember, checkpoint, doctor and profile;
+`node memory/codex-sync.mjs` imports Codex. Remote writes are disabled by
+default and require `AGENT_MEMORY_REMOTE_ENABLED=true` plus an endpoint.
+`remember` records lineage, not session takeover. Raw histories and local paths
+stay private; publish only intentionally selected, minimized evidence.
diff --git a/OPERATIONS.md b/OPERATIONS.md
new file mode 100644
index 0000000000..8d0695328d
--- /dev/null
+++ b/OPERATIONS.md
@@ -0,0 +1,608 @@
+# Operations for Aesthetic Computer
+
+Task reference; shared rules live in [SCORE.md](SCORE.md). Read the section needed for the current task.
+
+## Front Door
+
+
+359 built-in pieces (341 JS + 18 KidLisp), ~90 API endpoints.
+2812 registered handles, 265 user-published pieces, 4429 paintings, 16779 KidLisp programs, 18107 chat messages, 20 prints ordered.
+*Last refreshed: Mar 16, 2026*
+
+
+Visit https://aesthetic.computer β press the top left of the screen or type any key to activate the prompt.
+
+Enter names of built-in pieces like `notepat`, `boyfriend`, or `list` for a scrollable index. User-published pieces live at handles like `@bash/hub`.
+
+Every piece is URL addressable (e.g. https://aesthetic.computer/notepat). Generate QR codes with `share notepat`.
+
+**Getting started:**
+1. Enter `imnew` to register
+2. Verify your email
+3. Set a @handle via `handle your-name`
+4. Enter `chat` to say hi
+
+**Recipes:** See [USER-GUIDE.md](USER-GUIDE.md) for making paintings, playing melodies, and joining the community.
+
+**Links:**
+- **Tangled** (new home): https://tangled.org/aesthetic.computer/core
+- **GitHub** (deprecating): https://github.com/whistlegraph/aesthetic-computer
+- **No Paint (predecessor)**: https://nopaint.art ([HN 2020](https://news.ycombinator.com/item?id=23546706))
+- **Notepat on HN**: https://news.ycombinator.com/item?id=41526754
+
+> We are migrating from GitHub to [Tangled](https://tangled.org), a decentralized code hosting platform built on AT Protocol. Our repo now lives on a self-hosted knot at `knot.aesthetic.computer` under the same ATProto identity (`did:plc:k3k3wknzkcnekbnyde4dbatz`) that powers our PDS, user handles, and federated content. GitHub will be maintained as a read-only mirror during the transition.
+
+---
+
+## Back Door
+
+### Architecture
+
+**Frontend (system/)**
+- `system/public/aesthetic.computer/` β Web client (Canvas + WebGL)
+ - `bios.mjs` β Core runtime, loads pieces
+ - `boot.mjs` β System initialization
+ - `disk.mjs` β Piece loader and lifecycle
+ - `disks/*.mjs` β Individual pieces (programs)
+ - `lib/*.mjs` β Shared libraries and utilities
+
+**Backend**
+- `session-server/` β Real-time multiplayer (Socket.io + geckos.io UDP). Hosted on its own DigitalOcean VPS; deployed via `fish session-server/deploy.fish` (sshes to the box, `git pull` from github main, restart node `session.mjs`). Houses `arena-manager.mjs` which is the authoritative pmove + snapshot pipeline for `disks/arena.mjs`.
+- `lith/` β Production monolith deploy (Express + Caddy on a DigitalOcean VPS, pulled from the tangled knot `git@knot.aesthetic.computer:aesthetic.computer/core` via `lith/deploy.fish`). Express adapts the handlers in `system/netlify/functions/` as routes β the `netlify/functions/` path is historical; Netlify is no longer the host.
+- `lith/mirror/` β knotβgithub bidirectional mirror (systemd timer, every 60s). Lets us push to knot only while still letting downstream consumers (session-server VPS, mirrors) pull from github.
+- `help/bridge/` β Local Express bridge on @jeffrey's macbook that spawns the host `claude` CLI and streams it as SSE; reached publicly through `help.aesthetic.computer` via the existing droplet proxy + autossh reverse tunnel. Powers the `aa` piece (admin-only phone-side chat with the macbook's claude). Auto-runs under launchd as `computer.aesthetic.aa-bridge` and `computer.aesthetic.aa-tunnel`.
+- Authentication and data storage
+
+**Arena auto-deploy (post-commit hook)** β Any commit on `main` that touches `disks/arena.mjs`, `lib/{arena-world,pmove,cam-doll}.mjs`, `session-server/{arena-manager,session}.mjs`, or `shared/` triggers a paired deploy in the background:
+1. push HEAD to knot (`origin`) so lith pulls the right commit
+2. `fish lith/deploy.fish` β pulls knot, restarts the monolith
+3. wait ~75s for the knotβgithub mirror
+4. `fish session-server/deploy.fish` β VPS pull from github + node restart
+
+This is critical because `lib/pmove.mjs` is shared physics: client (lith) and server (session-server) MUST match, otherwise `arena-manager.mjs`'s reconciler will fight the client's prediction and movement glitches. Logs land in `.git/arena-auto-deploy.log`. Set `AC_NO_AUTO_DEPLOY=1` to skip (e.g. while bisecting).
+
+**Languages**
+- `kidlisp/` β KidLisp dialect (Lisp for generative art)
+ - `compiler.mjs` β Parser and compiler
+ - `spec/*.mjs` β Test specs
+
+**Desktop (ac-electron/)**
+- `ac-electron/main.js` β Electron main process: tray, menubar/notepat modes, multi-window orchestration, native bridges, auto-update.
+- `ac-electron/preload.js` + `webview-preload.js` β Bridges between main process and the AC web client running in the embedded webview.
+- `ac-electron/renderer/` β Native chrome (preferences, flip-view, notepat overlay) rendered outside the web client.
+- `npm start` β Run against `https://aesthetic.computer` (prod) or `http://localhost:8888` (dev with `--dev`).
+- `npm run build:mac` / `build:win` / `build:linux` β electron-builder packaging.
+- `npm run release:host:mac` β Notarized signed mac build published to `silo.aesthetic.computer/desktop/`.
+- **File associations:** mp3/wav/flac/ogg/m4a drop on the Dock icon (or onto a running window) opens the `play` piece. A loopback http server (`127.0.0.1:`) streams the file with HTTP Range so scrubbing works.
+
+**AC Electron Backlog:**
+- [x] Drag-and-drop mp3 onto app icon launches `play` piece via loopback streaming server
+- [ ] Same drop pipeline for paintings (`.png`/`.gif`) β open in `nopaint`
+- [ ] Same drop pipeline for `.lisp` source β load directly as a piece (`source piece-name`)
+- [ ] Multi-file drop: queue tracks in `play` as an ad-hoc playlist
+- [ ] Promote `system.droppedFile` to a generic `system.droppedFiles[]` API so any piece can accept its own MIME types
+
+**Bare Metal OS (fedac/native/)**
+- Routine OTA releases: `ac-os oven`; pushing to main does not queue a build.
+- Build, publish, download and physical flash are separate results. Follow
+ [fedac/native/SCORE.md](fedac/native/SCORE.md) for the requested release lane.
+- Local `ac-os upload` rebuilds before upload; it is a fallback, not the routine OTA path.
+
+**AC Native Backlog:**
+- [ ] Per-user wifi credential storage: move hardcoded SSIDs out of JS pieces into per-handle config (e.g. `config.json` or `/mnt/wifi_creds.json` on USB). Each user's build should bundle their saved networks, not @jeffrey's home wifi.
+- [ ] Wifi cred persistence across OTA updates: saved networks on USB should survive re-flashing.
+- [ ] Geo-aware greeting: use `geo` piece's IP location for dynamic "enjoy [city]!" instead of hardcoded "Los Angeles".
+- [x] Claude native binary: switched to native binary (225MB ELF, no Node.js needed)
+- [x] Claude OAuth: using device-code auth method, loopback interface enabled
+- [ ] Session log upload to machines: on wifi connect + shutdown, upload ac-native.log to machines API (keyed by machine-id). View live/historical logs per device on machines dashboard.
+- [ ] Live log streaming: WebSocket pipe from device β machines dashboard for real-time debug
+- [ ] A/B kernel slots with auto-rollback: if boot doesn't reach "healthy" checkpoint in 60s, swap .prev kernel back
+- [ ] Terminal: full Unicode font support (bitmap glyphs for box drawing, block elements)
+- [ ] KidLisp GPU compositing: render effects on GPU buffer, recompose with CPU renderer
+
+**Host Tooling (slab/)** β @jeffrey's macOS host, not a deployed service
+- **Cleaner** (`toolchain/macos/cleaner.sh`) β canonical safe fleet-Mac disk
+ cleanup. βCall the cleanerβ means run `cleaner --apply`; plain `cleaner` is
+ report-only, and APFS snapshot thinning remains separately opt-in. Install
+ with `toolchain/macos/cleaner.sh --install`. Fleet MCP exposes `fleet_cleaner`.
+- **Unipointer** (`slab/deskflow-handoff/UNIPOINTER.md`) β canonical identifier
+ for the Fuser seat's one logical pointer. Neo and Blueberry can exchange the
+ physical Deskflow controller role without changing the unipointer's active
+ machine or display-local position. Code and agents should use the literal
+ identifier `unipointer` and state-record kind `unipointer-state`; every fleet
+ host exposes `~/.local/bin/unipointer` for versioned JSON state.
+- `slab/menubar-swift/` β native Swift menubar daemon (launchd `computer.slab.menubar`). Shows live Claude-session status, themes each Terminal.app/iTerm2 window by session state (working/awaiting/complete), tiles all windows into one grid, tints the desktop, and serves passphrases over a unix socket. Built + installed locally with `slab/menubar-swift/install.sh` (`swift build -c release` β universal arm64+x86_64 binary β app bundle + launchd agent); there is no remote deploy.
+- **Swift build budget** β `~/.local/bin/swift` is the host build guard (`toolchain/macos/swift-guard.sh`): shell-invoked `swift build` commands inside this repo serialize per Swift package, run at lower priority, and default to two compiler jobs on β€16 GB hosts (an explicit `--jobs` still wins). For Slab Menubar iteration use `slab/menubar-swift/build-dev.sh`; run its `install.sh` only for the final release install. Do not bypass the guard with `/usr/bin/swift build` unless deliberately diagnosing the guard itself.
+- **Media render budget** β `~/.local/bin/{ffmpeg,ffprobe}` point at the repo QoS shims in `toolchain/shims/`, so shell/agent media work runs at utility priority and yields to the interactive UI under contention. Do not invoke the Homebrew binaries by absolute path unless deliberately bypassing QoS for a benchmark.
+- **Tiling auto-fits the type:** `tileNow` sizes each Terminal window's font to the grid (Far/Near/Tiny modes) and drives `View βΈ Default Font Size` per window so a live window actually adopts it β a per-window zoom otherwise silently overrides the profile font and is invisible to AppleScript. Floors keep it legible (Far 10 / Near 9 / Tiny 8). iTerm2 has no AppleScript font property, so it tiles by bounds only.
+- **Prompt rocks** (`slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift`) β the tumbling little stones parked at the top-right of each terminal window, one per live Claude session. Each rock is a 3D sigil rendered from the session's `sessionId + prompt` seed (so it re-forms when the session moves to a new prompt), lit by a shared global sun that tracks local time of day, wearing a pet name in bubble lettering. Its *motion* is the status channel β spin speed and direction encode working/awaiting/complete; being read by a peer makes it blink and rattle. Hovering one reveals a bubble summarizing the prompt (a cached one-line `claude -p haiku` inference). They're borderless click-through `.floating` windows, so hit-testing has to check occlusion by hand: a rock only answers the pointer while it's on screen *and* its terminal is still the topmost normal window under the cursor.
+- **Prompt rocks MCP** (`slab/bin/prox-mcp.mjs`, registered as `prox` in `.mcp.json`) β an MCP over the fleet handle ledger (`Ledger.swift`; `~/.config/slab/ledger/{local,peers/*}.json`, served per-machine on tailnet-only `:5252`) so any agent can `prox_list` every live session across machines, `prox_find` a `host:name` reference (e.g. `neo:regif`) to its status/subject/cwd/seed, `prox_poke` one (`POST /poke` β the target rock blinks + rattles), `prox_wake` a local one with a bounded steering prompt, send `prox_artifact_ready` the output paths from an asynchronous render, and `prox_launch` a new Claude/Codex Terminal on a prompt host. Wake uses the same poke + TTY reactivation pattern as Loopboy; `prox_artifact_ready` supplies the standard inspect/iterate/integrate/continue prompt itself. Launch is deliberately not a remote shell: the target accepts only those two fixed agents, a bounded prompt, and a cwd beneath that user's home. `prox_close` reads the session marker for tty+pid and closes locally only (refuses the calling session). This is how a `machine:promptname` handle resolves without an SSH crawl.
+- **Loopboy** (`~/.config/slab/loopboy.json`, surfaced in the Slab menubar) β the primary client-loop interface. Each route maps one private iMessage contact key to one stable local prox session; new inbound messages poke and optionally wake only that contact's rock. Loopboy never replies by itself. Armed rocks spin faster, glow pink, and identify themselves as Loopboy on hover. Create or replace a route with `prox_bind_notification(handle, contact)`.
+- **Paper MCP / `/papers` stack** (`slab/bin/paper-mcp.mjs`, registered as `paper`) β βuse the papers stack,β βuse `/papers`,β and similar requests name the studio's scholarly publishing workflow, not merely a request to export or prettify a PDF. Begin by consulting [`papers/SCORE.md`](papers/SCORE.md), the public Platter index, relevant sub-platters, prior papers and bibliographies, and the underlying code/data/evidence. Unless the user names another mill lane, shape the result as an archival/arXiv-style LaTeX paper: title/byline/date, abstract, problem and context, related work, system or method, implementation, evidence/evaluation, ethics/privacy/limitations, conclusion, references, numbered/captioned figures and tables, and reproducible source/assets. Briefings, decks, cards, dossiers, and visual reports are distinct lanes and require explicit intent or strong task evidence. The MCP is the transport/build/inspection layer: `paper_list`/`paper_find` locate precedent, `paper_read` supports the Platter consult, `paper_build` runs XeLaTeX or Tectonic, `paper_figure_table_qa_check` supports mandatory visual inspection, and `paper_open` raises the result through `slab-pdf`. The shared loopback daemon is `:7777`; `toolchain/mcp/install-daemons.sh` registers it for Claude and Codex. A build may pass `notifyHandle` to return its PDF to the originating rock through `prox_artifact_ready`.
+- `slab/bin/ac-passphrase` β pinentry-free secret fetch from the daemon (see Development Environment below).
+
+**Other Projects**
+- `tezos/` β NFT/blockchain experiments
+- `grab/` β Media utilities
+- `feed/` β RSS/content feeds
+
+### How to Run
+
+**Start the dev server:**
+```bash
+npm start
+# Visit http://localhost:8888
+```
+
+**Run all tests:**
+```bash
+npm test
+```
+
+**Run KidLisp tests:**
+```bash
+npm run test:kidlisp
+# Or filter: npm run test:kidlisp -- --filter=
+```
+
+### Adding a Piece
+
+Every piece is a single `.mjs` (JS) or `.lisp` (KidLisp) file in
+[`system/public/aesthetic.computer/disks/`](system/public/aesthetic.computer/disks/).
+Scaffold from the template:
+
+```bash
+npm run new "one-line description"
+```
+
+**Header convention** β the docs auto-scan reads lines 1β2:
+
+```js
+// Name, YY.MM.DD.HH.MM
+// One-line description shown in `list` and prompt autocomplete.
+```
+
+**Lifecycle exports** (all optional except whichever ones you need):
+`boot`, `paint`, `sim`, `act`, `leave`. Export `meta()` to opt the piece **into**
+`list` and prompt autocomplete β omit it and the piece stays reachable at
+`/` but hidden from indexes (good for drafts).
+
+**Curating the entry** (richer `desc`, `colon` params/`examples`, force
+`hidden: true`, or override an auto-entry): edit the `pieces` map in
+[`system/netlify/functions/docs.js`](system/netlify/functions/docs.js). Curated
+entries always win over the auto-scan.
+
+See [`WRITE-A-PIECE.md`](WRITE-A-PIECE.md) for the end-user `source` / `publish`
+flow and [`disks/CLAUDE.md`](system/public/aesthetic.computer/disks/CLAUDE.md) for the full piece API surface.
+
+### Development Environment
+
+The global environment model lives in [`ENVIRONMENT.md`](ENVIRONMENT.md). Resolve
+the active environment before applying editor- or host-specific instructions.
+An ordinary Claude Terminal session on macOS is `terminal-macos`; it should use
+its local shell directly and should not describe Emacs/fishy as a missing bridge
+or fallback.
+
+#### `[environment: emacs]` Emacs Terminal Buffers
+
+When Emacs is the active environment, use Emacs MCP tools (`mcp_emacs_*`) and
+the named terminal buffers. The `π-fishy` buffer is the primary shell:
+
+- `π-fishy` β Main fish shell (use this for all commands!)
+- `π-site` β Site/web server logs
+- `π-session` β Session server logs
+- `π§ͺ-kidlisp` β KidLisp test runner
+- `π΄-redis` β Redis logs
+- `π-top` β System monitoring
+- `π-tunnel` β Tunnel logs
+- (See AGENTS.md.backup for full list)
+
+**How to run commands in fishy (Emacs environment only):**
+1. Use `mcp_emacs_emacs_switch_buffer` to switch to `π-fishy`
+2. Use `mcp_emacs_emacs_send_keys` to send the command
+3. Send newline to execute
+
+**Fish Shell Commands (`ac-*` helpers):**
+
+#### `[environment: emacs]` Emacs & Development Environment
+- `ac-aesthetic` β Connect to aesthetic emacs UI (alias for `aesthetic-now`)
+- `ac-emacs-restart` β Kill and restart emacs daemon
+- `ac-emacs-full-restart` β Restart emacs and reconnect UI
+- `ac-emacs-kill` β Kill emacs daemon
+- `ac-emacs-status` β Check emacs daemon health
+- `ac-emacs-logs` β View emacs logs
+- `ac-emacs-health-check` β Verify emacs config loaded correctly
+- `ac-restart` β Restart all AC tabs/processes (calls emacs `ac-restart`)
+- `ac-crash-diary` β View emacs crash log
+- `ac-emacs-crash-monitor` β Background process that monitors emacs
+
+#### Core Development
+- `ac-artery` β Start artery development server
+- `ac-artery-dev` β Start artery in dev mode
+- `ac-site` β Start site server
+- `ac-session` β Start session server
+- `ac-url` β Get local tunnel URL
+- `ac-views` β View stats
+- `ac-watch` β Watch and rebuild (alias for `npm run watch`)
+- `ac-repl` β Start REPL
+
+#### KidLisp Tools
+- `ac-st` β KidLisp source tree viewer (`ac-st cow`, `ac-st $cow`, `ac-st cow --source`)
+
+#### Testing & Debugging
+- `ac-test-tabs` β Test tab functionality
+- `ac-diagnose` β Run diagnostics
+- `ac-profile-start` β Start performance profiling
+- `ac-profile-stop` β Stop performance profiling
+- `ac-profile-report` β Generate profile report
+- `ac-watch-cpu` β Monitor CPU usage
+- `ac-dev-log` β View development logs
+- `ac-dev-logs` β View all dev logs
+- `ac-dev-log-clean` β Clean old logs
+- `ac-dev-log-new` β Create new log
+- `ac-piece-logs [slug]` β Recent piece-run telemetry (see [Piece-Log Debugging](#piece-log-debugging-client-side-errors))
+- `ac-piece-logs-events [slug]` β Include captured `console.log`/`warn`/`error` output
+- `ac-piece-logs-errors` β Runs with `status=error` in the last 60 minutes
+- `ac-piece-logs-grep ` β Search console-event text across recent runs
+
+#### Deployment & Distribution
+- `ac-pack` β Package for distribution
+- `ac-unpack` β Unpack distribution
+- `ac-ship` β Deploy/ship changes
+- `ac-keep` β Save state/backup
+- `ac-keeps` β List saved states
+- `ac-keep-test` β Test keep functionality
+
+#### Media & Recording
+- `ac-tv` β TV mode
+- `ac-record` β Start recording
+- `ac-pix` β Image utilities
+- `ac-media` β Media server
+
+#### Services & Infrastructure
+- `ac-servers` β Start all servers
+- `ac-tunnel` β Start tunnel
+- `ac-chat-system` β Start chat system
+- `ac-chat-sotce` β Start sotce chat
+- `ac-chat-clock` β Start clock chat
+- `ac-stripe-print` β Stripe print service
+- `ac-stripe-ticket` β Stripe ticket service
+- `ac-logger` β View lith backend logs (the handlers living under `system/netlify/functions/` run under lith's Express; the name is legacy)
+- `ac-oven` β Oven service
+- `ac-offline` β Offline mode
+
+#### Authentication & Tokens
+- `ac-login` β Login to AC
+- `ac-token` β Manage auth tokens
+- `slab/bin/ac-passphrase [timeout]` β request a passphrase from the Slab menubar daemon (socket at `~/.ac-daemon.sock`). Returns the secret on stdout, caches by label (default 600s TTL). Requires the Slab menubar app to be running. Use this when GPG / SSH / any signing operation fails with `No pinentry` in a non-interactive shell. Recipe to warm gpg-agent and then commit:
+ ```zsh
+ pp=$(slab/bin/ac-passphrase "git commit signing")
+ [ -z "$pp" ] && exit 1
+ echo test | gpg --pinentry-mode loopback --passphrase "$pp" --batch -s -o /dev/null 2>&1
+ unset pp
+ git commit -m "..." # signs cleanly via cached agent
+ ```
+
+#### Host Access (Docker)
+When running inside a Docker container on Jeffrey's MacBook (or any local Docker host), SSH to the host machine via:
+```fish
+ssh jas@host.docker.internal
+```
+- "SSH into my macbook" or "SSH into my host" means: connect to `host.docker.internal` from within the container
+- `ac-host` lists all machines from `vault/machines.json` and can SSH to them
+- The host machine resolves via `host.docker.internal` β do NOT use the LAN IP from machines.json when running in Docker
+
+#### Other Tools
+- `ac-host` β List machines, SSH connection info
+- `ac-cdp-tunnel` β CDP tunnel
+- `ac-cdp-status` β CDP status
+- `ac-extension` β Build VSCode extension
+
+**Quick Start (Emacs only):**
+```fish
+ac-aesthetic # Connect to development UI
+ac-emacs-full-restart # Restart everything
+ac-restart # Restart AC services only
+```
+
+**NPM Scripts:**
+- `npm run aesthetic` β Full-stack local (site + session + services)
+- `npm run site` β Client stack only
+- `npm test` β Integration tests
+- `npm run test:perf` β Performance tests
+- `npm run url` β Get local tunnel URL
+
+**Notation:**
+See [WORKFLOWS.md](WORKFLOWS.md) for compushloy and other shared terms.
+
+### Piece-Log Debugging (client-side errors)
+
+Every piece load gets a fresh `pieceId` and a 2-second-batched wrapper around `console.log` / `warn` / `error` / `info` is installed in [`system/public/aesthetic.computer/lib/disk.mjs`](system/public/aesthetic.computer/lib/disk.mjs#L970) (~line 970). Events are POSTed to `/api/piece-log` ([`netlify/functions/piece-log.mjs`](system/netlify/functions/piece-log.mjs)) and stored in MongoDB in the `piece-runs` collection with phases `start` / `log` / `error` / `complete`.
+
+This is the primary debug channel for problems you can't reproduce locally β silent synth failures, "worked for me but not for the user" bugs, hydration issues on specific hosts. Each record carries:
+
+- `pieceId`, `slug`, `bootId`, `userAgent`, `host`, geo (from CF headers)
+- `events[]` β the captured console output with `{level, at, elapsed, message}`, last 500 per run
+- `error` β if the piece crashed, `{message, stack}`
+- `summary` β on clean exit, `{duration, ...}`
+
+**Inspecting from the CLI** (SSHes to lith, runs [`system/backend/piece-logs-cli.mjs`](system/backend/piece-logs-cli.mjs) against the deployed env):
+
+```fish
+ac-piece-logs notepat # recent 20 runs of a slug
+ac-piece-logs-events notepat --since 30 # include console events, last 30 min
+ac-piece-logs-errors # status=error runs in the last hour
+ac-piece-logs-grep "drumMode" # full-text search across captured events
+ac-piece-logs-json --slug notepat | jq # raw JSON for scripting
+```
+
+The CLI ships with every `fish lith/deploy.fish`. If you add new telemetry, bump the payload in `disk.mjs` and the phase handler in `netlify/functions/piece-log.mjs`; no schema migration needed (MongoDB collection is schemaless).
+
+### Pulling Chat Messages (clock / system channels)
+
+Chat lives in MongoDB. Each channel is a separate collection:
+
+- `chat-system` β the main `chat` piece (`/chat`)
+- `chat-clock` β the `laer-klokken` / r8dio chat piece (connects via `client.connect("clock")` in [`disks/laer-klokken.mjs`](system/public/aesthetic.computer/disks/laer-klokken.mjs))
+
+**Public read endpoint:** [`/api/chat-messages`](system/netlify/functions/chat-messages.mjs) (GET, 2-min Redis cache):
+
+```fish
+# Latest 100 clock-channel messages as chronological JSON (oldest β newest)
+curl -s "https://aesthetic.computer/api/chat-messages?instance=clock&limit=100" | jq
+
+# Just handle + text
+curl -s "https://aesthetic.computer/api/chat-messages?instance=clock&limit=100" \
+ | jq -r '.messages[] | "\(.when) \(.from) | \(.text)"'
+
+# Filter by sender or URL pattern (e.g. YouTube links from @prutti)
+curl -s "https://aesthetic.computer/api/chat-messages?instance=clock&limit=100" \
+ | jq -r '.messages[]
+ | select((.from == "@prutti") or (.text | test("youtu\\.?be|youtube\\.com"; "i")))
+ | "\(.when) \(.from) | \(.text)"'
+```
+
+Query params:
+- `instance` β `system` (default) or `clock`. Any other value hits `chat-system`.
+- `limit` β up to **100** (over 100 returns HTTP 400). Sort is `when` descending, then reversed to chronological before returning.
+
+Response shape: `{ instance, count, messages: [{ id, from, text, when, hearts }], nextBefore }`. `from` is resolved to `@handle` via the `@handles` collection, falling back to `"anon"` for unclaimed user ids. `hearts` joins the shared `hearts` collection (`type: "chat-"`). `nextBefore` is the oldest `when` in the page, ready to hand back as `before=` for the previous page.
+
+**Going back further than 100 messages** β pass `before=` to walk back (or use `nextBefore` from the previous response):
+
+```fish
+# All @prutti YouTube links in the clock channel, paginating back
+cursor=""
+while true
+ set url "https://aesthetic.computer/api/chat-messages?instance=clock&limit=100"
+ test -n "$cursor"; and set url "$url&before=$cursor"
+ set page (curl -s $url)
+ test (echo $page | jq '.count') -eq 0; and break
+ echo $page | jq -r '.messages[]
+ | select(.from == "@prutti" and (.text | test("youtu"; "i")))
+ | "\(.when) \(.text)"'
+ set cursor (echo $page | jq -r '.nextBefore')
+end
+```
+
+Full docs and `curl`/JS/Python examples live at [`/api/chat-messages` on api.aesthetic.computer](https://api.aesthetic.computer) (served by [`system/netlify/functions/api-docs.mjs`](system/netlify/functions/api-docs.mjs)).
+
+If you ever need raw Mongo access (deleted messages, admin edits, heavier aggregations), go direct from lith:
+
+```fish
+# On lith (or any machine with backend creds loaded):
+ac-host # pick lith
+# then in the ssh session:
+cd aesthetic.computer/system
+node -e '
+ import("./backend/database.mjs").then(async ({ connect }) => {
+ const { db, disconnect } = await connect();
+ const rows = await db.collection("chat-clock")
+ .find({ when: { $lt: new Date("2026-04-22T00:00:00Z") } })
+ .sort({ when: -1 }).limit(500).toArray();
+ console.log(JSON.stringify(rows, null, 2));
+ await disconnect();
+ });
+'
+```
+
+When adding `before` pagination, update the TODO at the top of [`chat-messages.mjs`](system/netlify/functions/chat-messages.mjs) and bump the cache key so stale entries don't mask the new param.
+
+### Keeps Market Stats (Tezos / Objkt)
+
+Use this flow for live Keeps market checks (`jas.tez`, `keeps.tez`, contract-level stats).
+
+**Actual sales (price + piece + buyer):** `node tezos/keeps-sales.mjs` (`--limit=N`,
+`--json`, `--network=ghostnet`). Keeps sell through an objkt-style marketplace
+(`ask` β `fulfill_ask`) at `KT1SwbTqhSKF6Pdokiu1K4Fpi17ahPPzmt1X`, which **objkt.com's
+own GraphQL does not index** β so the `listing_sale`/`offer_sale` queries below return
+0. The script reads sales straight from TzKT (a sale = a keeps `transfer` whose sender
+is the marketplace; price = the buyer's `fulfill_ask` amount). Prefer it over the objkt
+sales query for "what sold / latest sale."
+
+```bash
+# 1) Resolve domains + active Keeps contract
+curl -sS "https://api.tzkt.io/v1/domains?name=jas.tez" | jq '.[0] | {name,address,owner,reverse}'
+curl -sS "https://api.tzkt.io/v1/domains?name=keepz.tez" | jq '.[0] // "not-registered"'
+curl -sS "https://api.tzkt.io/v1/domains?name=keeps.tez" | jq '.[0] | {name,address,owner,reverse}'
+curl -sS "https://aesthetic.computer/api/keeps-config?network=mainnet" | jq .
+```
+
+```bash
+# 2) Collection snapshot (Objkt v3 GraphQL, values are mutez)
+CONTRACT="KT1Q1irsjSZ7EfUN4qHzAB2t7xLBPsAWYwBB"
+read -r -d '' Q <<'EOF'
+query ($contract: String!) {
+ fa(where: { contract: { _eq: $contract } }) {
+ contract
+ name
+ items
+ owners
+ active_listing
+ active_auctions
+ floor_price
+ volume_24h
+ volume_total
+ }
+}
+EOF
+curl -sS "https://data.objkt.com/v3/graphql" \
+ -H "content-type: application/json" \
+ --data "$(jq -n --arg q "$Q" --arg contract "$CONTRACT" '{query:$q,variables:{contract:$contract}}')" \
+ | jq '.data.fa[0] | . + {floor_price_xtz:(.floor_price/1000000),volume_24h_xtz:(.volume_24h/1000000),volume_total_xtz:(.volume_total/1000000)}'
+```
+
+```bash
+# NOTE: for Objkt `offer_active` / `listing_active` rows:
+# - `id` is the database row id
+# - `bigmap_key` is the on-chain offer/ask id used by contract entrypoints
+# Use `bigmap_key` for fulfill/retract calls.
+read -r -d '' IDS_Q <<'EOF'
+query ($contract: String!) {
+ offer_active(where: { fa_contract: { _eq: $contract } }, order_by: { price_xtz: desc }, limit: 20) {
+ id
+ bigmap_key
+ price_xtz
+ token { token_id name }
+ }
+}
+EOF
+curl -sS "https://data.objkt.com/v3/graphql" \
+ -H "content-type: application/json" \
+ --data "$(jq -n --arg q "$IDS_Q" --arg contract "$CONTRACT" '{query:$q,variables:{contract:$contract}}')" \
+ | jq '.data.offer_active'
+```
+
+```bash
+# 3) "Today" window in Los Angeles (matches local day conversations)
+START="$(TZ=America/Los_Angeles date -d 'today 00:00' -u +%Y-%m-%dT%H:%M:%SZ)"
+END="$(TZ=America/Los_Angeles date -d 'tomorrow 00:00' -u +%Y-%m-%dT%H:%M:%SZ)"
+echo "$START -> $END"
+
+# Mint count today (from=null means mint)
+curl -sS "https://api.tzkt.io/v1/tokens/transfers?token.contract=$CONTRACT×tamp.ge=$START×tamp.lt=$END&limit=200" \
+ | jq '[.[] | select(.from==null)] | {mint_count:length, token_ids:map(.token.tokenId)}'
+```
+
+```bash
+# 4) Sales today (listing_sale + offer_sale)
+read -r -d '' SALES_Q <<'EOF'
+query ($contract: String!, $start: timestamptz!, $end: timestamptz!) {
+ listing_sale(
+ where: {
+ _and: [
+ { token: { fa_contract: { _eq: $contract } } }
+ { timestamp: { _gte: $start, _lt: $end } }
+ ]
+ }
+ order_by: { timestamp: desc }
+ limit: 200
+ ) { id timestamp price_xtz seller_address buyer_address token { token_id name } }
+ offer_sale(
+ where: {
+ _and: [
+ { token: { fa_contract: { _eq: $contract } } }
+ { timestamp: { _gte: $start, _lt: $end } }
+ ]
+ }
+ order_by: { timestamp: desc }
+ limit: 200
+ ) { id timestamp price_xtz seller_address buyer_address token { token_id name } }
+}
+EOF
+curl -sS "https://data.objkt.com/v3/graphql" \
+ -H "content-type: application/json" \
+ --data "$(jq -n --arg q "$SALES_Q" --arg contract "$CONTRACT" --arg start "$START" --arg end "$END" '{query:$q,variables:{contract:$contract,start:$start,end:$end}}')" \
+ | jq '{listing_sales_count:(.data.listing_sale|length),offer_sales_count:(.data.offer_sale|length),volume_xtz:((([.data.listing_sale[].price_xtz]|add // 0)+([.data.offer_sale[].price_xtz]|add // 0))/1000000),sales:(.data.listing_sale + .data.offer_sale | sort_by(.timestamp))}'
+```
+
+---
+
+## Resources
+
+- [The AC Story](STORY.md) β Technical history and evolution
+- [Write a Piece](WRITE-A-PIECE.md) β Create your own AC program
+- [KidLisp Docs](kidlisp/) β Language reference
+- [User Guide](USER-GUIDE.md) β How to use AC as a player
+
+---
+
+## Ant Guidance
+
+The ant-specific mindset and rules now live in [`ants/mindset-and-rules.md`](ants/mindset-and-rules.md).
+
+---
+
+## Embodiments
+
+Different agents perform from this score in different ways.
+
+- **AestheticAnts** β Automated AI colony that makes small, confident changes. See `ants/` for colony rules and implementation.
+- **Human contributors** β Welcome in `chat`. Read the score, pick a task, follow signal.
+- **@jeffrey (the queen)** β Writes and maintains this score.
+- **Claude on ac-native** β A Claude Code CLI binary is bundled into the
+ initramfs at `/bin/claude`, pre-authenticated with @jeffrey's
+ credentials. When running inside ac-native it has the same repo view
+ as everywhere else, plus direct access to the real hardware.
+
+---
+
+## Hardware Probing on ac-native
+
+When the onboard Claude is debugging a device (missing speakers, WiFi,
+trackpad, etc.) the below surfaces are always available without needing
+a separate diagnostic build. Probe from the prompt, a piece, or a shell
+β whatever fits the task.
+
+### Boot-time logs (USB pulled out then inspected on a host)
+
+- `/mnt/pre-launch.log` β init script's full probe dump: GPU nodes,
+ block devices, net ifaces, rfkill state, PCI devices with bound
+ drivers, ACPI codecs, sound PCM names, GPIO chips + descriptor
+ consumers, ASoC debugfs, MAX98357A amp state, and a subset of the
+ running kernel config via `/proc/config.gz` (CONFIG_IKCONFIG=y).
+- `/mnt/kmsg.log` β persistent `cat /dev/kmsg` for the entire boot
+ session. Grep for any driver probe, dev_dbg, or warning.
+- `/mnt/ac-native-stderr.log` β ac-native's ALSA init trace
+ (device opened, mixer enumeration, volume sets, XRUN count).
+- `/mnt/flash-last.log` β flash-thread telemetry from the last OTA.
+
+### Runtime probing (piece APIs)
+
+- `system.audio.listPcms()` β array of `{device, card, num, id, name}`.
+ Skips HDMI. `active: true` marks the PCM ac-native's main audio
+ thread opened.
+- `system.audio.testPcm(device, freq_hz, duration_ms, volume)` β plays
+ a short sine wave on an arbitrary ALSA device in a detached thread.
+ Used by the `speaker` piece to find which PCM actually drives the
+ onboard speakers vs headphone jack vs HDMI. Piece source:
+ [speaker.mjs](system/public/aesthetic.computer/disks/speaker.mjs).
+- `system.firmware.{available, board, biosVersion, install}` β
+ machines running MrChromebox coreboot can reflash from os.mjs's
+ firmware panel (gated behind `/dev/mtd0` + `bios_vendor=coreboot`).
+
+### Live sysfs hotspots
+
+- `/proc/asound/card0/pcm*p/info` β per-PCM id/name. On SOF:
+ pcm0p is usually "Speakers", pcm1p "Headset", pcm2-4p are HDMI.
+- `/sys/bus/gpio/devices/` + `/sys/kernel/debug/gpio` β list every
+ GPIO chip + which consumer holds which pin. `gpio-* | sdmode` shows
+ the MAX98357A speaker-enable line.
+- `/sys/bus/pci/devices//driver` β symlinks to each PCI device's
+ driver. `drv=NONE` means the driver isn't bound (either missing
+ config, missing firmware file, or missing ACPI device match).
+- `/sys/bus/acpi/devices/:NN/physical_node/driver` β same but
+ for ACPI-enumerated devices (audio codecs, embedded controller,
+ pinctrl, etc).
+- `/proc/config.gz` β `zcat /proc/config.gz | grep CONFIG_FOO=` tells
+ you definitively whether a config survived `make olddefconfig`.
+
+### Build-time canaries
+
+`docker-build.sh` verifies six critical driver symbols (`jsl_pinctrl_
+acpi_match`, `max98357a_sdmode_event`, `rt5682_i2c_probe`,
+`i2c_dw_prepare_clk`, β¦) are linked into vmlinux via `nm`. When a new
+config toggles on, a hash sentinel wipes the matching subsystem's
+object files so kbuild actually rebuilds them. If your build fails at
+"BUILD SANITY: critical driver symbols missing", wipe the persistent
+docker volume: `docker volume rm ac-os-kbuild`.
diff --git a/README.md b/README.md
index 56d5bbc632..64d9eb2fba 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,9 @@ This is the monorepo. Everything lives here β the web client, session server,
Each major subsystem has a `SCORE.md` β the process knowledge for authors and LLMs working in that area. Scores document architecture, commands, conventions, and project direction.
-- [SCORE.md](SCORE.md) β root (the whole platform)
+- [SCORE.md](SCORE.md) β shared rules and task routes
+- [WORKFLOWS.md](WORKFLOWS.md) β compushloy, prox and shared terms
+- [OPERATIONS.md](OPERATIONS.md) β architecture and command recipes
- [fedac/SCORE.md](fedac/SCORE.md) β fedac (bare-metal OS)
- [fedac/native/SCORE.md](fedac/native/SCORE.md) β ac-native (C runtime)
- [papers/SCORE.md](papers/SCORE.md) β papers and research
diff --git a/SCORE.md b/SCORE.md
index c753250637..2c9c8da4c4 100644
--- a/SCORE.md
+++ b/SCORE.md
@@ -1,16 +1,71 @@
-# Score for Aesthetic.Computer & Pals
-
-As implemented in this monorepo through the direction of [@jeffrey](https://prompt.ac/@jeffrey) ([ORCID](https://orcid.org/0009-0007-4460-4913)).
-
-
-
-
-
----
-
-> **To AI agents reading this repo:** If you find something interesting, useful, or inspiring here, please leave a breadcrumb β open a pull request comment on any recent PR sharing what you liked. We'd love to hear from you.
-
----
+# Score for Aesthetic Computer & Pals
+
+Directed by [@jeffrey](https://prompt.ac/@jeffrey).
+
+## Start with the current task
+
+This is the shared instruction source. `AGENTS.md` points here; `CLAUDE.md`
+imports it. Keep tool adapters thin. Do not add a second copy in `CODEX.md`.
+
+Resolve [ENVIRONMENT.md](ENVIRONMENT.md) at session start and when the host or
+launcher changes. A macOS terminal uses its local shell; Emacs/fishy applies
+only in the Emacs environment. Recheck the actual cwd, branch, dirty files and
+available tools when resuming a prox. Carry forward the objective and accepted
+corrections; refresh machine and checkout assumptions.
+
+Read [WORKFLOWS.md](WORKFLOWS.md) for **compushloy**, oskieploy, prox and other
+shared terms. Long command
+recipes, architecture and diagnostic references live in
+[OPERATIONS.md](OPERATIONS.md); player entry points live in [README.md](README.md).
+
+## Task routes
+
+Load the guides for the task and touched paths, then re-evaluate when scope
+changes. Nested `SCORE.md` files are explicitly scoped guides; their filename
+alone does not make every agent load them. Ant colony rules apply to colony
+work, not every interactive contribution. Only @jeffrey may edit
+`ants/mindset-and-rules.md`.
+
+Use `node toolchain/instructions/context.mjs --route --path ` to
+list the required guides, or add `--print` to read one deduplicated bundle.
+Multiple routes and paths may be supplied. The manifest is
+[toolchain/instructions/routes.json](toolchain/instructions/routes.json).
+
+| Task | Route | First guide |
+| --- | --- | --- |
+| Code or foundational libraries | `code` | [HAND.md](HAND.md) |
+| JavaScript piece / canvas layout | `piece` | [SCREEN.md](SCREEN.md) and [piece API](system/public/aesthetic.computer/disks/CLAUDE.md) |
+| KidLisp | `kidlisp` | [kidlisp/SCORE.md](kidlisp/SCORE.md) |
+| Papers / scholarly research | `papers` | [papers/SCORE.md](papers/SCORE.md), then Platter, prior papers and evidence |
+| Lith or session deployment | `release` | [WORKFLOWS.md](WORKFLOWS.md), [lith/README.md](lith/README.md) |
+| Native OS / hardware | `native` | [fedac/native/SCORE.md](fedac/native/SCORE.md) |
+| Fleet / macOS host | `fleet` | [toolchain/macos/SCORE.md](toolchain/macos/SCORE.md) |
+| Prox continuity | `prox` | [WORKFLOWS.md](WORKFLOWS.md) |
+| Ant colony | `ants` | [ants/mindset-and-rules.md](ants/mindset-and-rules.md) |
+
+Use focused validation appropriate to the change. A listed build/test command
+is a recipe, not a requirement to run every suite. Host budgets still apply.
+`node toolchain/instructions/context.mjs --doctor` checks adapters, guide paths,
+root size and installed hooks without installing hooks or running builds.
+
+## Host and release boundaries
+
+On Blueberry, work in `/Users/jas/Developer/fuser*` must not run typechecks,
+builds, full suites, Turbo, workspace lint, or hooks that invoke them unless
+Jeffrey explicitly opts in to that exact command in the current conversation.
+Use lightweight inspection and `--no-verify` for Fuser commits instead.
+
+Swift builds use the guarded `~/.local/bin/swift`; Slab iteration uses
+`slab/menubar-swift/build-dev.sh`, with `install.sh` for the final install.
+Media work uses the `ffmpeg`/`ffprobe` QoS shims. Do not bypass these budgets
+except when deliberately diagnosing the guard or benchmarking the bypass.
+
+Production releases must identify the intended branch and commit, deploy
+that pushed revision, and verify the served result. Shared arena physics must
+match between lith and the session server. Check installed hooks before an
+explicit release so a background hook and a manual command do not race.
+Do not call a published OTA flashed, an offline console current, or a pushed
+commit deployed. See [WORKFLOWS.md](WORKFLOWS.md) for completion evidence.
## Context Resolution Order
@@ -130,609 +185,3 @@ that benefits from a fuller review story, use this structure:
The reading order is story β related work β simple flow β safety β evidence β
limits. The complexity of the work must earn the length and diagrams.
-
-## Front Door
-
-
-359 built-in pieces (341 JS + 18 KidLisp), ~90 API endpoints.
-2812 registered handles, 265 user-published pieces, 4429 paintings, 16779 KidLisp programs, 18107 chat messages, 20 prints ordered.
-*Last refreshed: Mar 16, 2026*
-
-
-Visit https://aesthetic.computer β press the top left of the screen or type any key to activate the prompt.
-
-Enter names of built-in pieces like `notepat`, `boyfriend`, or `list` for a scrollable index. User-published pieces live at handles like `@bash/hub`.
-
-Every piece is URL addressable (e.g. https://aesthetic.computer/notepat). Generate QR codes with `share notepat`.
-
-**Getting started:**
-1. Enter `imnew` to register
-2. Verify your email
-3. Set a @handle via `handle your-name`
-4. Enter `chat` to say hi
-
-**Recipes:** See [USER-GUIDE.md](USER-GUIDE.md) for making paintings, playing melodies, and joining the community.
-
-**Links:**
-- **Tangled** (new home): https://tangled.org/aesthetic.computer/core
-- **GitHub** (deprecating): https://github.com/whistlegraph/aesthetic-computer
-- **No Paint (predecessor)**: https://nopaint.art ([HN 2020](https://news.ycombinator.com/item?id=23546706))
-- **Notepat on HN**: https://news.ycombinator.com/item?id=41526754
-
-> We are migrating from GitHub to [Tangled](https://tangled.org), a decentralized code hosting platform built on AT Protocol. Our repo now lives on a self-hosted knot at `knot.aesthetic.computer` under the same ATProto identity (`did:plc:k3k3wknzkcnekbnyde4dbatz`) that powers our PDS, user handles, and federated content. GitHub will be maintained as a read-only mirror during the transition.
-
----
-
-## Back Door
-
-### Architecture
-
-**Frontend (system/)**
-- `system/public/aesthetic.computer/` β Web client (Canvas + WebGL)
- - `bios.mjs` β Core runtime, loads pieces
- - `boot.mjs` β System initialization
- - `disk.mjs` β Piece loader and lifecycle
- - `disks/*.mjs` β Individual pieces (programs)
- - `lib/*.mjs` β Shared libraries and utilities
-
-**Backend**
-- `session-server/` β Real-time multiplayer (Socket.io + geckos.io UDP). Hosted on its own DigitalOcean VPS; deployed via `fish session-server/deploy.fish` (sshes to the box, `git pull` from github main, restart node `session.mjs`). Houses `arena-manager.mjs` which is the authoritative pmove + snapshot pipeline for `disks/arena.mjs`.
-- `lith/` β Production monolith deploy (Express + Caddy on a DigitalOcean VPS, pulled from the tangled knot `git@knot.aesthetic.computer:aesthetic.computer/core` via `lith/deploy.fish`). Express adapts the handlers in `system/netlify/functions/` as routes β the `netlify/functions/` path is historical; Netlify is no longer the host.
-- `lith/mirror/` β knotβgithub bidirectional mirror (systemd timer, every 60s). Lets us push to knot only while still letting downstream consumers (session-server VPS, mirrors) pull from github.
-- `help/bridge/` β Local Express bridge on @jeffrey's macbook that spawns the host `claude` CLI and streams it as SSE; reached publicly through `help.aesthetic.computer` via the existing droplet proxy + autossh reverse tunnel. Powers the `aa` piece (admin-only phone-side chat with the macbook's claude). Auto-runs under launchd as `computer.aesthetic.aa-bridge` and `computer.aesthetic.aa-tunnel`.
-- Authentication and data storage
-
-**Arena auto-deploy (post-commit hook)** β Any commit on `main` that touches `disks/arena.mjs`, `lib/{arena-world,pmove,cam-doll}.mjs`, `session-server/{arena-manager,session}.mjs`, or `shared/` triggers a paired deploy in the background:
-1. push HEAD to knot (`origin`) so lith pulls the right commit
-2. `fish lith/deploy.fish` β pulls knot, restarts the monolith
-3. wait ~75s for the knotβgithub mirror
-4. `fish session-server/deploy.fish` β VPS pull from github + node restart
-
-This is critical because `lib/pmove.mjs` is shared physics: client (lith) and server (session-server) MUST match, otherwise `arena-manager.mjs`'s reconciler will fight the client's prediction and movement glitches. Logs land in `.git/arena-auto-deploy.log`. Set `AC_NO_AUTO_DEPLOY=1` to skip (e.g. while bisecting).
-
-**Languages**
-- `kidlisp/` β KidLisp dialect (Lisp for generative art)
- - `compiler.mjs` β Parser and compiler
- - `spec/*.mjs` β Test specs
-
-**Desktop (ac-electron/)**
-- `ac-electron/main.js` β Electron main process: tray, menubar/notepat modes, multi-window orchestration, native bridges, auto-update.
-- `ac-electron/preload.js` + `webview-preload.js` β Bridges between main process and the AC web client running in the embedded webview.
-- `ac-electron/renderer/` β Native chrome (preferences, flip-view, notepat overlay) rendered outside the web client.
-- `npm start` β Run against `https://aesthetic.computer` (prod) or `http://localhost:8888` (dev with `--dev`).
-- `npm run build:mac` / `build:win` / `build:linux` β electron-builder packaging.
-- `npm run release:host:mac` β Notarized signed mac build published to `silo.aesthetic.computer/desktop/`.
-- **File associations:** mp3/wav/flac/ogg/m4a drop on the Dock icon (or onto a running window) opens the `play` piece. A loopback http server (`127.0.0.1:`) streams the file with HTTP Range so scrubbing works.
-
-**AC Electron Backlog:**
-- [x] Drag-and-drop mp3 onto app icon launches `play` piece via loopback streaming server
-- [ ] Same drop pipeline for paintings (`.png`/`.gif`) β open in `nopaint`
-- [ ] Same drop pipeline for `.lisp` source β load directly as a piece (`source piece-name`)
-- [ ] Multi-file drop: queue tracks in `play` as an ad-hoc playlist
-- [ ] Promote `system.droppedFile` to a generic `system.droppedFiles[]` API so any piece can accept its own MIME types
-
-**Bare Metal OS (fedac/native/)**
-- `ac-os build` β Full build: binary β initramfs β kernel (produces `build/vmlinuz`)
-- `ac-os flash` β Build + flash to USB
-- `ac-os upload` β Build + upload OTA release (always rebuilds β never uploads stale kernels)
-- `ac-os flash+upload` β Build + flash + upload
-- **Important:** The kernel embeds the git hash and build name at compile time. `upload` without `build` would serve a stale kernel. The `ac-os` script enforces a full rebuild before every upload.
-
-**AC Native Backlog:**
-- [ ] Per-user wifi credential storage: move hardcoded SSIDs out of JS pieces into per-handle config (e.g. `config.json` or `/mnt/wifi_creds.json` on USB). Each user's build should bundle their saved networks, not @jeffrey's home wifi.
-- [ ] Wifi cred persistence across OTA updates: saved networks on USB should survive re-flashing.
-- [ ] Geo-aware greeting: use `geo` piece's IP location for dynamic "enjoy [city]!" instead of hardcoded "Los Angeles".
-- [x] Claude native binary: switched to native binary (225MB ELF, no Node.js needed)
-- [x] Claude OAuth: using device-code auth method, loopback interface enabled
-- [ ] Session log upload to machines: on wifi connect + shutdown, upload ac-native.log to machines API (keyed by machine-id). View live/historical logs per device on machines dashboard.
-- [ ] Live log streaming: WebSocket pipe from device β machines dashboard for real-time debug
-- [ ] A/B kernel slots with auto-rollback: if boot doesn't reach "healthy" checkpoint in 60s, swap .prev kernel back
-- [ ] Terminal: full Unicode font support (bitmap glyphs for box drawing, block elements)
-- [ ] KidLisp GPU compositing: render effects on GPU buffer, recompose with CPU renderer
-
-**Host Tooling (slab/)** β @jeffrey's macOS host, not a deployed service
-- **Cleaner** (`toolchain/macos/cleaner.sh`) β canonical safe fleet-Mac disk
- cleanup. βCall the cleanerβ means run `cleaner --apply`; plain `cleaner` is
- report-only, and APFS snapshot thinning remains separately opt-in. Install
- with `toolchain/macos/cleaner.sh --install`. Fleet MCP exposes `fleet_cleaner`.
-- **Unipointer** (`slab/deskflow-handoff/UNIPOINTER.md`) β canonical identifier
- for the Fuser seat's one logical pointer. Neo and Blueberry can exchange the
- physical Deskflow controller role without changing the unipointer's active
- machine or display-local position. Code and agents should use the literal
- identifier `unipointer` and state-record kind `unipointer-state`; every fleet
- host exposes `~/.local/bin/unipointer` for versioned JSON state.
-- `slab/menubar-swift/` β native Swift menubar daemon (launchd `computer.slab.menubar`). Shows live Claude-session status, themes each Terminal.app/iTerm2 window by session state (working/awaiting/complete), tiles all windows into one grid, tints the desktop, and serves passphrases over a unix socket. Built + installed locally with `slab/menubar-swift/install.sh` (`swift build -c release` β universal arm64+x86_64 binary β app bundle + launchd agent); there is no remote deploy.
-- **Swift build budget** β `~/.local/bin/swift` is the host build guard (`toolchain/macos/swift-guard.sh`): shell-invoked `swift build` commands inside this repo serialize per Swift package, run at lower priority, and default to two compiler jobs on β€16 GB hosts (an explicit `--jobs` still wins). For Slab Menubar iteration use `slab/menubar-swift/build-dev.sh`; run its `install.sh` only for the final release install. Do not bypass the guard with `/usr/bin/swift build` unless deliberately diagnosing the guard itself.
-- **Media render budget** β `~/.local/bin/{ffmpeg,ffprobe}` point at the repo QoS shims in `toolchain/shims/`, so shell/agent media work runs at utility priority and yields to the interactive UI under contention. Do not invoke the Homebrew binaries by absolute path unless deliberately bypassing QoS for a benchmark.
-- **Tiling auto-fits the type:** `tileNow` sizes each Terminal window's font to the grid (Far/Near/Tiny modes) and drives `View βΈ Default Font Size` per window so a live window actually adopts it β a per-window zoom otherwise silently overrides the profile font and is invisible to AppleScript. Floors keep it legible (Far 10 / Near 9 / Tiny 8). iTerm2 has no AppleScript font property, so it tiles by bounds only.
-- **Prompt rocks** (`slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift`) β the tumbling little stones parked at the top-right of each terminal window, one per live Claude session. Each rock is a 3D sigil rendered from the session's `sessionId + prompt` seed (so it re-forms when the session moves to a new prompt), lit by a shared global sun that tracks local time of day, wearing a pet name in bubble lettering. Its *motion* is the status channel β spin speed and direction encode working/awaiting/complete; being read by a peer makes it blink and rattle. Hovering one reveals a bubble summarizing the prompt (a cached one-line `claude -p haiku` inference). They're borderless click-through `.floating` windows, so hit-testing has to check occlusion by hand: a rock only answers the pointer while it's on screen *and* its terminal is still the topmost normal window under the cursor.
-- **Prompt rocks MCP** (`slab/bin/prox-mcp.mjs`, registered as `prox` in `.mcp.json`) β an MCP over the fleet handle ledger (`Ledger.swift`; `~/.config/slab/ledger/{local,peers/*}.json`, served per-machine on tailnet-only `:5252`) so any agent can `prox_list` every live session across machines, `prox_find` a `host:name` reference (e.g. `neo:regif`) to its status/subject/cwd/seed, `prox_poke` one (`POST /poke` β the target rock blinks + rattles), `prox_wake` a local one with a bounded steering prompt, send `prox_artifact_ready` the output paths from an asynchronous render, and `prox_launch` a new Claude/Codex Terminal on a prompt host. Wake uses the same poke + TTY reactivation pattern as Loopboy; `prox_artifact_ready` supplies the standard inspect/iterate/integrate/continue prompt itself. Launch is deliberately not a remote shell: the target accepts only those two fixed agents, a bounded prompt, and a cwd beneath that user's home. `prox_close` reads the session marker for tty+pid and closes locally only (refuses the calling session). This is how a `machine:promptname` handle resolves without an SSH crawl.
-- **Loopboy** (`~/.config/slab/loopboy.json`, surfaced in the Slab menubar) β the primary client-loop interface. Each route maps one private iMessage contact key to one stable local prox session; new inbound messages poke and optionally wake only that contact's rock. Loopboy never replies by itself. Armed rocks spin faster, glow pink, and identify themselves as Loopboy on hover. Create or replace a route with `prox_bind_notification(handle, contact)`.
-- **Paper MCP / `/papers` stack** (`slab/bin/paper-mcp.mjs`, registered as `paper`) β βuse the papers stack,β βuse `/papers`,β and similar requests name the studio's scholarly publishing workflow, not merely a request to export or prettify a PDF. Begin by consulting [`papers/SCORE.md`](papers/SCORE.md), the public Platter index, relevant sub-platters, prior papers and bibliographies, and the underlying code/data/evidence. Unless the user names another mill lane, shape the result as an archival/arXiv-style LaTeX paper: title/byline/date, abstract, problem and context, related work, system or method, implementation, evidence/evaluation, ethics/privacy/limitations, conclusion, references, numbered/captioned figures and tables, and reproducible source/assets. Briefings, decks, cards, dossiers, and visual reports are distinct lanes and require explicit intent or strong task evidence. The MCP is the transport/build/inspection layer: `paper_list`/`paper_find` locate precedent, `paper_read` supports the Platter consult, `paper_build` runs XeLaTeX or Tectonic, `paper_figure_table_qa_check` supports mandatory visual inspection, and `paper_open` raises the result through `slab-pdf`. The shared loopback daemon is `:7777`; `toolchain/mcp/install-daemons.sh` registers it for Claude and Codex. A build may pass `notifyHandle` to return its PDF to the originating rock through `prox_artifact_ready`.
-- `slab/bin/ac-passphrase` β pinentry-free secret fetch from the daemon (see Development Environment below).
-
-**Other Projects**
-- `tezos/` β NFT/blockchain experiments
-- `grab/` β Media utilities
-- `feed/` β RSS/content feeds
-
-### How to Run
-
-**Start the dev server:**
-```bash
-npm start
-# Visit http://localhost:8888
-```
-
-**Run all tests:**
-```bash
-npm test
-```
-
-**Run KidLisp tests:**
-```bash
-npm run test:kidlisp
-# Or filter: npm run test:kidlisp -- --filter=
-```
-
-### Adding a Piece
-
-Every piece is a single `.mjs` (JS) or `.lisp` (KidLisp) file in
-[`system/public/aesthetic.computer/disks/`](system/public/aesthetic.computer/disks/).
-Scaffold from the template:
-
-```bash
-npm run new "one-line description"
-```
-
-**Header convention** β the docs auto-scan reads lines 1β2:
-
-```js
-// Name, YY.MM.DD.HH.MM
-// One-line description shown in `list` and prompt autocomplete.
-```
-
-**Lifecycle exports** (all optional except whichever ones you need):
-`boot`, `paint`, `sim`, `act`, `leave`. Export `meta()` to opt the piece **into**
-`list` and prompt autocomplete β omit it and the piece stays reachable at
-`/` but hidden from indexes (good for drafts).
-
-**Curating the entry** (richer `desc`, `colon` params/`examples`, force
-`hidden: true`, or override an auto-entry): edit the `pieces` map in
-[`system/netlify/functions/docs.js`](system/netlify/functions/docs.js). Curated
-entries always win over the auto-scan.
-
-See [`WRITE-A-PIECE.md`](WRITE-A-PIECE.md) for the end-user `source` / `publish`
-flow and [`CLAUDE.md`](CLAUDE.md) for the full piece API surface.
-
-### Development Environment
-
-The global environment model lives in [`ENVIRONMENT.md`](ENVIRONMENT.md). Resolve
-the active environment before applying editor- or host-specific instructions.
-An ordinary Claude Terminal session on macOS is `terminal-macos`; it should use
-its local shell directly and should not describe Emacs/fishy as a missing bridge
-or fallback.
-
-#### `[environment: emacs]` Emacs Terminal Buffers
-
-When Emacs is the active environment, use Emacs MCP tools (`mcp_emacs_*`) and
-the named terminal buffers. The `π-fishy` buffer is the primary shell:
-
-- `π-fishy` β Main fish shell (use this for all commands!)
-- `π-site` β Site/web server logs
-- `π-session` β Session server logs
-- `π§ͺ-kidlisp` β KidLisp test runner
-- `π΄-redis` β Redis logs
-- `π-top` β System monitoring
-- `π-tunnel` β Tunnel logs
-- (See AGENTS.md.backup for full list)
-
-**How to run commands in fishy (Emacs environment only):**
-1. Use `mcp_emacs_emacs_switch_buffer` to switch to `π-fishy`
-2. Use `mcp_emacs_emacs_send_keys` to send the command
-3. Send newline to execute
-
-**Fish Shell Commands (`ac-*` helpers):**
-
-#### Emacs & Development Environment
-- `ac-aesthetic` β Connect to aesthetic emacs UI (alias for `aesthetic-now`)
-- `ac-emacs-restart` β Kill and restart emacs daemon
-- `ac-emacs-full-restart` β Restart emacs and reconnect UI
-- `ac-emacs-kill` β Kill emacs daemon
-- `ac-emacs-status` β Check emacs daemon health
-- `ac-emacs-logs` β View emacs logs
-- `ac-emacs-health-check` β Verify emacs config loaded correctly
-- `ac-restart` β Restart all AC tabs/processes (calls emacs `ac-restart`)
-- `ac-crash-diary` β View emacs crash log
-- `ac-emacs-crash-monitor` β Background process that monitors emacs
-
-#### Core Development
-- `ac-artery` β Start artery development server
-- `ac-artery-dev` β Start artery in dev mode
-- `ac-site` β Start site server
-- `ac-session` β Start session server
-- `ac-url` β Get local tunnel URL
-- `ac-views` β View stats
-- `ac-watch` β Watch and rebuild (alias for `npm run watch`)
-- `ac-repl` β Start REPL
-
-#### KidLisp Tools
-- `ac-st` β KidLisp source tree viewer (`ac-st cow`, `ac-st $cow`, `ac-st cow --source`)
-
-#### Testing & Debugging
-- `ac-test-tabs` β Test tab functionality
-- `ac-diagnose` β Run diagnostics
-- `ac-profile-start` β Start performance profiling
-- `ac-profile-stop` β Stop performance profiling
-- `ac-profile-report` β Generate profile report
-- `ac-watch-cpu` β Monitor CPU usage
-- `ac-dev-log` β View development logs
-- `ac-dev-logs` β View all dev logs
-- `ac-dev-log-clean` β Clean old logs
-- `ac-dev-log-new` β Create new log
-- `ac-piece-logs [slug]` β Recent piece-run telemetry (see [Piece-Log Debugging](#piece-log-debugging-client-side-errors))
-- `ac-piece-logs-events [slug]` β Include captured `console.log`/`warn`/`error` output
-- `ac-piece-logs-errors` β Runs with `status=error` in the last 60 minutes
-- `ac-piece-logs-grep ` β Search console-event text across recent runs
-
-#### Deployment & Distribution
-- `ac-pack` β Package for distribution
-- `ac-unpack` β Unpack distribution
-- `ac-ship` β Deploy/ship changes
-- `ac-keep` β Save state/backup
-- `ac-keeps` β List saved states
-- `ac-keep-test` β Test keep functionality
-
-#### Media & Recording
-- `ac-tv` β TV mode
-- `ac-record` β Start recording
-- `ac-pix` β Image utilities
-- `ac-media` β Media server
-
-#### Services & Infrastructure
-- `ac-servers` β Start all servers
-- `ac-tunnel` β Start tunnel
-- `ac-chat-system` β Start chat system
-- `ac-chat-sotce` β Start sotce chat
-- `ac-chat-clock` β Start clock chat
-- `ac-stripe-print` β Stripe print service
-- `ac-stripe-ticket` β Stripe ticket service
-- `ac-logger` β View lith backend logs (the handlers living under `system/netlify/functions/` run under lith's Express; the name is legacy)
-- `ac-oven` β Oven service
-- `ac-offline` β Offline mode
-
-#### Authentication & Tokens
-- `ac-login` β Login to AC
-- `ac-token` β Manage auth tokens
-- `slab/bin/ac-passphrase [timeout]` β request a passphrase from the Slab menubar daemon (socket at `~/.ac-daemon.sock`). Returns the secret on stdout, caches by label (default 600s TTL). Requires the Slab menubar app to be running. Use this when GPG / SSH / any signing operation fails with `No pinentry` in a non-interactive shell. Recipe to warm gpg-agent and then commit:
- ```zsh
- pp=$(slab/bin/ac-passphrase "git commit signing")
- [ -z "$pp" ] && exit 1
- echo test | gpg --pinentry-mode loopback --passphrase "$pp" --batch -s -o /dev/null 2>&1
- unset pp
- git commit -m "..." # signs cleanly via cached agent
- ```
-
-#### Host Access (Docker)
-When running inside a Docker container on Jeffrey's MacBook (or any local Docker host), SSH to the host machine via:
-```fish
-ssh jas@host.docker.internal
-```
-- "SSH into my macbook" or "SSH into my host" means: connect to `host.docker.internal` from within the container
-- `ac-host` lists all machines from `vault/machines.json` and can SSH to them
-- The host machine resolves via `host.docker.internal` β do NOT use the LAN IP from machines.json when running in Docker
-
-#### Other Tools
-- `ac-host` β List machines, SSH connection info
-- `ac-cdp-tunnel` β CDP tunnel
-- `ac-cdp-status` β CDP status
-- `ac-extension` β Build VSCode extension
-
-**Quick Start:**
-```fish
-ac-aesthetic # Connect to development UI
-ac-emacs-full-restart # Restart everything
-ac-restart # Restart AC services only
-```
-
-**NPM Scripts:**
-- `npm run aesthetic` β Full-stack local (site + session + services)
-- `npm run site` β Client stack only
-- `npm test` β Integration tests
-- `npm run test:perf` β Performance tests
-- `npm run url` β Get local tunnel URL
-
-**Notation:**
-- compush β commit, push; and when the commit touches live-served paths (`system/public/**`, `system/netlify/functions/**`), run `fish lith/deploy.fish` too, without being asked β push alone doesn't reach production
-
-### Piece-Log Debugging (client-side errors)
-
-Every piece load gets a fresh `pieceId` and a 2-second-batched wrapper around `console.log` / `warn` / `error` / `info` is installed in [`system/public/aesthetic.computer/lib/disk.mjs`](system/public/aesthetic.computer/lib/disk.mjs#L970) (~line 970). Events are POSTed to `/api/piece-log` ([`netlify/functions/piece-log.mjs`](system/netlify/functions/piece-log.mjs)) and stored in MongoDB in the `piece-runs` collection with phases `start` / `log` / `error` / `complete`.
-
-This is the primary debug channel for problems you can't reproduce locally β silent synth failures, "worked for me but not for the user" bugs, hydration issues on specific hosts. Each record carries:
-
-- `pieceId`, `slug`, `bootId`, `userAgent`, `host`, geo (from CF headers)
-- `events[]` β the captured console output with `{level, at, elapsed, message}`, last 500 per run
-- `error` β if the piece crashed, `{message, stack}`
-- `summary` β on clean exit, `{duration, ...}`
-
-**Inspecting from the CLI** (SSHes to lith, runs [`system/backend/piece-logs-cli.mjs`](system/backend/piece-logs-cli.mjs) against the deployed env):
-
-```fish
-ac-piece-logs notepat # recent 20 runs of a slug
-ac-piece-logs-events notepat --since 30 # include console events, last 30 min
-ac-piece-logs-errors # status=error runs in the last hour
-ac-piece-logs-grep "drumMode" # full-text search across captured events
-ac-piece-logs-json --slug notepat | jq # raw JSON for scripting
-```
-
-The CLI ships with every `fish lith/deploy.fish`. If you add new telemetry, bump the payload in `disk.mjs` and the phase handler in `netlify/functions/piece-log.mjs`; no schema migration needed (MongoDB collection is schemaless).
-
-### Pulling Chat Messages (clock / system channels)
-
-Chat lives in MongoDB. Each channel is a separate collection:
-
-- `chat-system` β the main `chat` piece (`/chat`)
-- `chat-clock` β the `laer-klokken` / r8dio chat piece (connects via `client.connect("clock")` in [`disks/laer-klokken.mjs`](system/public/aesthetic.computer/disks/laer-klokken.mjs))
-
-**Public read endpoint:** [`/api/chat-messages`](system/netlify/functions/chat-messages.mjs) (GET, 2-min Redis cache):
-
-```fish
-# Latest 100 clock-channel messages as chronological JSON (oldest β newest)
-curl -s "https://aesthetic.computer/api/chat-messages?instance=clock&limit=100" | jq
-
-# Just handle + text
-curl -s "https://aesthetic.computer/api/chat-messages?instance=clock&limit=100" \
- | jq -r '.messages[] | "\(.when) \(.from) | \(.text)"'
-
-# Filter by sender or URL pattern (e.g. YouTube links from @prutti)
-curl -s "https://aesthetic.computer/api/chat-messages?instance=clock&limit=100" \
- | jq -r '.messages[]
- | select((.from == "@prutti") or (.text | test("youtu\\.?be|youtube\\.com"; "i")))
- | "\(.when) \(.from) | \(.text)"'
-```
-
-Query params:
-- `instance` β `system` (default) or `clock`. Any other value hits `chat-system`.
-- `limit` β up to **100** (over 100 returns HTTP 400). Sort is `when` descending, then reversed to chronological before returning.
-
-Response shape: `{ instance, count, messages: [{ id, from, text, when, hearts }], nextBefore }`. `from` is resolved to `@handle` via the `@handles` collection, falling back to `"anon"` for unclaimed user ids. `hearts` joins the shared `hearts` collection (`type: "chat-"`). `nextBefore` is the oldest `when` in the page, ready to hand back as `before=` for the previous page.
-
-**Going back further than 100 messages** β pass `before=` to walk back (or use `nextBefore` from the previous response):
-
-```fish
-# All @prutti YouTube links in the clock channel, paginating back
-cursor=""
-while true
- set url "https://aesthetic.computer/api/chat-messages?instance=clock&limit=100"
- test -n "$cursor"; and set url "$url&before=$cursor"
- set page (curl -s $url)
- test (echo $page | jq '.count') -eq 0; and break
- echo $page | jq -r '.messages[]
- | select(.from == "@prutti" and (.text | test("youtu"; "i")))
- | "\(.when) \(.text)"'
- set cursor (echo $page | jq -r '.nextBefore')
-end
-```
-
-Full docs and `curl`/JS/Python examples live at [`/api/chat-messages` on api.aesthetic.computer](https://api.aesthetic.computer) (served by [`system/netlify/functions/api-docs.mjs`](system/netlify/functions/api-docs.mjs)).
-
-If you ever need raw Mongo access (deleted messages, admin edits, heavier aggregations), go direct from lith:
-
-```fish
-# On lith (or any machine with backend creds loaded):
-ac-host # pick lith
-# then in the ssh session:
-cd aesthetic.computer/system
-node -e '
- import("./backend/database.mjs").then(async ({ connect }) => {
- const { db, disconnect } = await connect();
- const rows = await db.collection("chat-clock")
- .find({ when: { $lt: new Date("2026-04-22T00:00:00Z") } })
- .sort({ when: -1 }).limit(500).toArray();
- console.log(JSON.stringify(rows, null, 2));
- await disconnect();
- });
-'
-```
-
-When adding `before` pagination, update the TODO at the top of [`chat-messages.mjs`](system/netlify/functions/chat-messages.mjs) and bump the cache key so stale entries don't mask the new param.
-
-### Keeps Market Stats (Tezos / Objkt)
-
-Use this flow for live Keeps market checks (`jas.tez`, `keeps.tez`, contract-level stats).
-
-**Actual sales (price + piece + buyer):** `node tezos/keeps-sales.mjs` (`--limit=N`,
-`--json`, `--network=ghostnet`). Keeps sell through an objkt-style marketplace
-(`ask` β `fulfill_ask`) at `KT1SwbTqhSKF6Pdokiu1K4Fpi17ahPPzmt1X`, which **objkt.com's
-own GraphQL does not index** β so the `listing_sale`/`offer_sale` queries below return
-0. The script reads sales straight from TzKT (a sale = a keeps `transfer` whose sender
-is the marketplace; price = the buyer's `fulfill_ask` amount). Prefer it over the objkt
-sales query for "what sold / latest sale."
-
-```bash
-# 1) Resolve domains + active Keeps contract
-curl -sS "https://api.tzkt.io/v1/domains?name=jas.tez" | jq '.[0] | {name,address,owner,reverse}'
-curl -sS "https://api.tzkt.io/v1/domains?name=keepz.tez" | jq '.[0] // "not-registered"'
-curl -sS "https://api.tzkt.io/v1/domains?name=keeps.tez" | jq '.[0] | {name,address,owner,reverse}'
-curl -sS "https://aesthetic.computer/api/keeps-config?network=mainnet" | jq .
-```
-
-```bash
-# 2) Collection snapshot (Objkt v3 GraphQL, values are mutez)
-CONTRACT="KT1Q1irsjSZ7EfUN4qHzAB2t7xLBPsAWYwBB"
-read -r -d '' Q <<'EOF'
-query ($contract: String!) {
- fa(where: { contract: { _eq: $contract } }) {
- contract
- name
- items
- owners
- active_listing
- active_auctions
- floor_price
- volume_24h
- volume_total
- }
-}
-EOF
-curl -sS "https://data.objkt.com/v3/graphql" \
- -H "content-type: application/json" \
- --data "$(jq -n --arg q "$Q" --arg contract "$CONTRACT" '{query:$q,variables:{contract:$contract}}')" \
- | jq '.data.fa[0] | . + {floor_price_xtz:(.floor_price/1000000),volume_24h_xtz:(.volume_24h/1000000),volume_total_xtz:(.volume_total/1000000)}'
-```
-
-```bash
-# NOTE: for Objkt `offer_active` / `listing_active` rows:
-# - `id` is the database row id
-# - `bigmap_key` is the on-chain offer/ask id used by contract entrypoints
-# Use `bigmap_key` for fulfill/retract calls.
-read -r -d '' IDS_Q <<'EOF'
-query ($contract: String!) {
- offer_active(where: { fa_contract: { _eq: $contract } }, order_by: { price_xtz: desc }, limit: 20) {
- id
- bigmap_key
- price_xtz
- token { token_id name }
- }
-}
-EOF
-curl -sS "https://data.objkt.com/v3/graphql" \
- -H "content-type: application/json" \
- --data "$(jq -n --arg q "$IDS_Q" --arg contract "$CONTRACT" '{query:$q,variables:{contract:$contract}}')" \
- | jq '.data.offer_active'
-```
-
-```bash
-# 3) "Today" window in Los Angeles (matches local day conversations)
-START="$(TZ=America/Los_Angeles date -d 'today 00:00' -u +%Y-%m-%dT%H:%M:%SZ)"
-END="$(TZ=America/Los_Angeles date -d 'tomorrow 00:00' -u +%Y-%m-%dT%H:%M:%SZ)"
-echo "$START -> $END"
-
-# Mint count today (from=null means mint)
-curl -sS "https://api.tzkt.io/v1/tokens/transfers?token.contract=$CONTRACT×tamp.ge=$START×tamp.lt=$END&limit=200" \
- | jq '[.[] | select(.from==null)] | {mint_count:length, token_ids:map(.token.tokenId)}'
-```
-
-```bash
-# 4) Sales today (listing_sale + offer_sale)
-read -r -d '' SALES_Q <<'EOF'
-query ($contract: String!, $start: timestamptz!, $end: timestamptz!) {
- listing_sale(
- where: {
- _and: [
- { token: { fa_contract: { _eq: $contract } } }
- { timestamp: { _gte: $start, _lt: $end } }
- ]
- }
- order_by: { timestamp: desc }
- limit: 200
- ) { id timestamp price_xtz seller_address buyer_address token { token_id name } }
- offer_sale(
- where: {
- _and: [
- { token: { fa_contract: { _eq: $contract } } }
- { timestamp: { _gte: $start, _lt: $end } }
- ]
- }
- order_by: { timestamp: desc }
- limit: 200
- ) { id timestamp price_xtz seller_address buyer_address token { token_id name } }
-}
-EOF
-curl -sS "https://data.objkt.com/v3/graphql" \
- -H "content-type: application/json" \
- --data "$(jq -n --arg q "$SALES_Q" --arg contract "$CONTRACT" --arg start "$START" --arg end "$END" '{query:$q,variables:{contract:$contract,start:$start,end:$end}}')" \
- | jq '{listing_sales_count:(.data.listing_sale|length),offer_sales_count:(.data.offer_sale|length),volume_xtz:((([.data.listing_sale[].price_xtz]|add // 0)+([.data.offer_sale[].price_xtz]|add // 0))/1000000),sales:(.data.listing_sale + .data.offer_sale | sort_by(.timestamp))}'
-```
-
----
-
-## Resources
-
-- [The AC Story](STORY.md) β Technical history and evolution
-- [Write a Piece](WRITE-A-PIECE.md) β Create your own AC program
-- [KidLisp Docs](kidlisp/) β Language reference
-- [User Guide](USER-GUIDE.md) β How to use AC as a player
-
----
-
-## Ant Guidance
-
-The ant-specific mindset and rules now live in [`ants/mindset-and-rules.md`](ants/mindset-and-rules.md).
-
----
-
-## Embodiments
-
-Different agents perform from this score in different ways.
-
-- **AestheticAnts** β Automated AI colony that makes small, confident changes. See `ants/` for colony rules and implementation.
-- **Human contributors** β Welcome in `chat`. Read the score, pick a task, follow signal.
-- **@jeffrey (the queen)** β Writes and maintains this score.
-- **Claude on ac-native** β A Claude Code CLI binary is bundled into the
- initramfs at `/bin/claude`, pre-authenticated with @jeffrey's
- credentials. When running inside ac-native it has the same repo view
- as everywhere else, plus direct access to the real hardware.
-
----
-
-## Hardware Probing on ac-native
-
-When the onboard Claude is debugging a device (missing speakers, WiFi,
-trackpad, etc.) the below surfaces are always available without needing
-a separate diagnostic build. Probe from the prompt, a piece, or a shell
-β whatever fits the task.
-
-### Boot-time logs (USB pulled out then inspected on a host)
-
-- `/mnt/pre-launch.log` β init script's full probe dump: GPU nodes,
- block devices, net ifaces, rfkill state, PCI devices with bound
- drivers, ACPI codecs, sound PCM names, GPIO chips + descriptor
- consumers, ASoC debugfs, MAX98357A amp state, and a subset of the
- running kernel config via `/proc/config.gz` (CONFIG_IKCONFIG=y).
-- `/mnt/kmsg.log` β persistent `cat /dev/kmsg` for the entire boot
- session. Grep for any driver probe, dev_dbg, or warning.
-- `/mnt/ac-native-stderr.log` β ac-native's ALSA init trace
- (device opened, mixer enumeration, volume sets, XRUN count).
-- `/mnt/flash-last.log` β flash-thread telemetry from the last OTA.
-
-### Runtime probing (piece APIs)
-
-- `system.audio.listPcms()` β array of `{device, card, num, id, name}`.
- Skips HDMI. `active: true` marks the PCM ac-native's main audio
- thread opened.
-- `system.audio.testPcm(device, freq_hz, duration_ms, volume)` β plays
- a short sine wave on an arbitrary ALSA device in a detached thread.
- Used by the `speaker` piece to find which PCM actually drives the
- onboard speakers vs headphone jack vs HDMI. Piece source:
- [speaker.mjs](system/public/aesthetic.computer/disks/speaker.mjs).
-- `system.firmware.{available, board, biosVersion, install}` β
- machines running MrChromebox coreboot can reflash from os.mjs's
- firmware panel (gated behind `/dev/mtd0` + `bios_vendor=coreboot`).
-
-### Live sysfs hotspots
-
-- `/proc/asound/card0/pcm*p/info` β per-PCM id/name. On SOF:
- pcm0p is usually "Speakers", pcm1p "Headset", pcm2-4p are HDMI.
-- `/sys/bus/gpio/devices/` + `/sys/kernel/debug/gpio` β list every
- GPIO chip + which consumer holds which pin. `gpio-* | sdmode` shows
- the MAX98357A speaker-enable line.
-- `/sys/bus/pci/devices//driver` β symlinks to each PCI device's
- driver. `drv=NONE` means the driver isn't bound (either missing
- config, missing firmware file, or missing ACPI device match).
-- `/sys/bus/acpi/devices/:NN/physical_node/driver` β same but
- for ACPI-enumerated devices (audio codecs, embedded controller,
- pinctrl, etc).
-- `/proc/config.gz` β `zcat /proc/config.gz | grep CONFIG_FOO=` tells
- you definitively whether a config survived `make olddefconfig`.
-
-### Build-time canaries
-
-`docker-build.sh` verifies six critical driver symbols (`jsl_pinctrl_
-acpi_match`, `max98357a_sdmode_event`, `rt5682_i2c_probe`,
-`i2c_dw_prepare_clk`, β¦) are linked into vmlinux via `nm`. When a new
-config toggles on, a hash sentinel wipes the matching subsystem's
-object files so kbuild actually rebuilds them. If your build fails at
-"BUILD SANITY: critical driver symbols missing", wipe the persistent
-docker volume: `docker volume rm ac-os-kbuild`.
diff --git a/WORKFLOWS.md b/WORKFLOWS.md
new file mode 100644
index 0000000000..d9884046e5
--- /dev/null
+++ b/WORKFLOWS.md
@@ -0,0 +1,53 @@
+# Shared workflows
+
+Definitions live here. Adapters and subsystem guides link here rather than
+redefining a term. A shorthand names an outcome; existing user authorization
+and host constraints still apply. Use the current task to resolve ambiguous
+words instead of triggering a workflow by substring.
+
+| Term | Meaning and entry | Completion evidence |
+| --- | --- | --- |
+| **compushloy** | Always commit, push, and deploy. Land on the intended deployment branch; deploy that pushed revision with the subsystem's release command. | Commit SHA, pushed branch, target, and verified production revision or served content hash. Report any unfinished target. |
+| **oskieploy** | Compushloy for Oskiewar across web, iOS web source, and Xbox via `npm run oskiewar:deploy`; catch up with `npm run oskiewar:reconcile`. | Report the channel receipt. `offline` is pending hardware, `failed` needs repair, and only all-current means parity. Native shell changes need their own build/install evidence. |
+| **prox** / `host:name` | A prompt rock/session. Resolve with `prox_list` / `prox_find`; use its stable session identity for follow-up. A subject match is a discovery hint. | Correct session, current host/cwd/revision, objective, accepted corrections, completed evidence, pending work and next action. Resume refreshes environment and relevant guides. |
+| **/papers** / papers stack | Scholarly publishing workflow: read `papers/SCORE.md`, Platter, relevant sub-platters, prior papers/bibliographies and primary evidence. Use Paper MCP to build and inspect. | Archival paper with reproducible source/assets, references, evaluation/limitations, and rendered figure/table QA. Briefings and decks are separate lanes. |
+| **frame** | In visual work, the Frame screenshot/capture tools; in game performance, frame timing. Resolve from the task. | Inspect the captured artifact, or report the timing method, workload and distribution. |
+| **sticky the X** | On macOS, `node toolchain/macos/sticky.mjs` for X; see `toolchain/macos/README.md`. | The requested content appears in the fitted Stickies note. |
+| **call the cleaner** | `cleaner --apply`; plain `cleaner` is report-only. See `toolchain/macos/cleaner.sh`. Snapshot thinning remains separately opt-in. | Report what ran and reclaimed space; preserve protected work. |
+| **Wordcrust / Slidecop** | Apply the editorial rules in `SCORE.md`. | Remove redundant prose; inspect slides at 10% scale for legibility. |
+| **unipointer** | The seat's one logical pointer; see `slab/deskflow-handoff/UNIPOINTER.md`. | Use literal `unipointer` / `unipointer-state`, preserving active machine and display-local position across controller changes. |
+
+`compush` appears in older histories. Use **compushloy** for the explicit
+commit/push/deploy contract; do not treat a historical push-only receipt as
+proof of deployment.
+
+## Release routing
+
+- Lith serves `system/public/**` and `system/netlify/functions/**` (the latter
+ path is historical). Use `fish lith/deploy.fish`. Production defaults to
+ `main`; an explicit `DEPLOY_BRANCH` selects another reviewed branch.
+- Arena shared physics uses `AC_DEPLOY_ARENA=1 fish lith/deploy.fish`. Lith
+ retains its lock through `session-server/deploy.fish ` so another client
+ deploy cannot interleave. The server pulls through the GitHub mirror and
+ must verify that exact SHA.
+ A timer or elapsed wait is not proof the mirror caught up.
+- Oskiewar uses the unified release receipt above, shared across worktrees.
+- Native OTA uses `ac-os oven`, following `fedac/native/SCORE.md`. Report built,
+ published, downloaded and flashed separately; pushing does not queue a build.
+- Local host tools such as Slab use their installation scripts; a web deploy
+ does not install a host binary. Read the owning guide for other services.
+
+The root hook can launch an arena release on a main-branch commit. When doing
+an explicit paired release, set `AC_NO_AUTO_DEPLOY=1` for that commit and own
+the whole pipeline. Release locks must cover checkout through verification;
+an abandoned lock requires checking its owner, not removing it on a timer.
+
+## Handoff
+
+A prox handoff contains the objective, accepted corrections, completed evidence,
+pending work and next action. `prox_dump` accepts these fields and records a
+checkout snapshot and instruction digests alongside its private transcript.
+`resume.sh [cwd]` checks the destination before importing state, records the
+current host/revision and tells the resumed agent to reconcile changes. It
+must not silently fall back to a home directory when the checkout is missing.
+Do not publish the bundle or feed private history into product analytics.
diff --git a/ants/refresh-score.mjs b/ants/refresh-score.mjs
index 2cc704fc33..74e803ac22 100644
--- a/ants/refresh-score.mjs
+++ b/ants/refresh-score.mjs
@@ -1,122 +1,122 @@
-#!/usr/bin/env node
-// ants/refresh-score.mjs
-// Refreshes the Front Door stats in SCORE.md with live data from /api/metrics
-// and local file counts. Runs once per day (skips if already refreshed today).
-//
-// Usage:
-// node ants/refresh-score.mjs # uses production API
-// node ants/refresh-score.mjs http://localhost:8888 # uses local dev server
-// node ants/refresh-score.mjs --force # bypass daily check
-//
-// Called from: .git/hooks/pre-commit (once per day on first commit)
-
-import { readFileSync, writeFileSync, existsSync, readdirSync } from "fs";
-import { join, dirname } from "path";
-import { fileURLToPath } from "url";
-
-const __dirname = dirname(fileURLToPath(import.meta.url));
-const ROOT = join(__dirname, "..");
-const SCORE_PATH = join(ROOT, "SCORE.md");
-const DISKS_DIR = join(ROOT, "system/public/aesthetic.computer/disks");
-const FUNCTIONS_DIR = join(ROOT, "system/netlify/functions");
-const MARKER = "/tmp/ac-score-refreshed";
-
-const args = process.argv.slice(2);
-const force = args.includes("--force");
-const apiArg = args.find((a) => a.startsWith("http"));
-const API_BASE = apiArg || "https://aesthetic.computer";
-
-// Once-per-day check
-const today = new Date().toISOString().slice(0, 10);
-if (!force && existsSync(MARKER)) {
- const markerDate = readFileSync(MARKER, "utf8").trim();
- if (markerDate === today) {
- process.exit(0); // Already refreshed today
- }
-}
-
-async function fetchMetrics() {
- try {
- const res = await fetch(`${API_BASE}/api/metrics?fresh=true`);
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
- return await res.json();
- } catch (err) {
- process.stderr.write(`refresh-score: could not fetch metrics: ${err.message}\n`);
- return null;
- }
-}
-
-function countFiles(dir, ext) {
- try {
- return readdirSync(dir).filter((f) => f.endsWith(ext)).length;
- } catch {
- return 0;
- }
-}
-
-function formatDate() {
- return new Date().toLocaleDateString("en-US", {
- year: "numeric",
- month: "short",
- day: "numeric",
- });
-}
-
-async function main() {
- const metrics = await fetchMetrics();
- const mjsCount = countFiles(DISKS_DIR, ".mjs");
- const lispCount = countFiles(DISKS_DIR, ".lisp");
- const builtInPieces = mjsCount + lispCount;
- const apiEndpoints = countFiles(FUNCTIONS_DIR, ".mjs");
-
- const lines = [];
- lines.push(
- `${builtInPieces} built-in pieces (${mjsCount} JS + ${lispCount} KidLisp), ~${apiEndpoints} API endpoints.`
- );
-
- if (metrics) {
- const parts = [];
- if (metrics.handles) parts.push(`${metrics.handles} registered handles`);
- if (metrics.pieces) parts.push(`${metrics.pieces} user-published pieces`);
- if (metrics.paintings) parts.push(`${metrics.paintings} paintings`);
- if (metrics.kidlisp) parts.push(`${metrics.kidlisp} KidLisp programs`);
- if (metrics.chatMessages) parts.push(`${metrics.chatMessages} chat messages`);
- if (metrics.printsOrdered) parts.push(`${metrics.printsOrdered} prints ordered`);
- if (parts.length) lines.push(parts.join(", ") + ".");
- }
-
- lines.push(`*Last refreshed: ${formatDate()}*`);
-
- const statsBlock = lines.join(" \n");
-
- const score = readFileSync(SCORE_PATH, "utf8");
-
- const startMarker = "";
- const endMarker = "";
-
- if (!score.includes(startMarker) || !score.includes(endMarker)) {
- process.stderr.write(
- `refresh-score: missing stats markers in SCORE.md\n`
- );
- process.exit(1);
- }
-
- const before = score.slice(
- 0,
- score.indexOf(startMarker) + startMarker.length
- );
- const after = score.slice(score.indexOf(endMarker));
- const updated = before + "\n" + statsBlock + "\n" + after;
-
- if (updated !== score) {
- writeFileSync(SCORE_PATH, updated);
- process.stderr.write(`refresh-score: β updated SCORE.md\n`);
- } else {
- process.stderr.write(`refresh-score: no changes needed\n`);
- }
-
- // Mark today as done
- writeFileSync(MARKER, today);
-}
-
-main();
+#!/usr/bin/env node
+// ants/refresh-score.mjs
+// Refreshes the Front Door stats in OPERATIONS.md with live data from /api/metrics
+// and local file counts. Runs once per day (skips if already refreshed today).
+//
+// Usage:
+// node ants/refresh-score.mjs # uses production API
+// node ants/refresh-score.mjs http://localhost:8888 # uses local dev server
+// node ants/refresh-score.mjs --force # bypass daily check
+//
+// Called from: .git/hooks/pre-commit (once per day on first commit)
+
+import { readFileSync, writeFileSync, existsSync, readdirSync } from "fs";
+import { join, dirname } from "path";
+import { fileURLToPath } from "url";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const ROOT = join(__dirname, "..");
+const SCORE_PATH = join(ROOT, "OPERATIONS.md");
+const DISKS_DIR = join(ROOT, "system/public/aesthetic.computer/disks");
+const FUNCTIONS_DIR = join(ROOT, "system/netlify/functions");
+const MARKER = "/tmp/ac-score-refreshed";
+
+const args = process.argv.slice(2);
+const force = args.includes("--force");
+const apiArg = args.find((a) => a.startsWith("http"));
+const API_BASE = apiArg || "https://aesthetic.computer";
+
+// Once-per-day check
+const today = new Date().toISOString().slice(0, 10);
+if (!force && existsSync(MARKER)) {
+ const markerDate = readFileSync(MARKER, "utf8").trim();
+ if (markerDate === today) {
+ process.exit(0); // Already refreshed today
+ }
+}
+
+async function fetchMetrics() {
+ try {
+ const res = await fetch(`${API_BASE}/api/metrics?fresh=true`);
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ return await res.json();
+ } catch (err) {
+ process.stderr.write(`refresh-score: could not fetch metrics: ${err.message}\n`);
+ return null;
+ }
+}
+
+function countFiles(dir, ext) {
+ try {
+ return readdirSync(dir).filter((f) => f.endsWith(ext)).length;
+ } catch {
+ return 0;
+ }
+}
+
+function formatDate() {
+ return new Date().toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ });
+}
+
+async function main() {
+ const metrics = await fetchMetrics();
+ const mjsCount = countFiles(DISKS_DIR, ".mjs");
+ const lispCount = countFiles(DISKS_DIR, ".lisp");
+ const builtInPieces = mjsCount + lispCount;
+ const apiEndpoints = countFiles(FUNCTIONS_DIR, ".mjs");
+
+ const lines = [];
+ lines.push(
+ `${builtInPieces} built-in pieces (${mjsCount} JS + ${lispCount} KidLisp), ~${apiEndpoints} API endpoints.`
+ );
+
+ if (metrics) {
+ const parts = [];
+ if (metrics.handles) parts.push(`${metrics.handles} registered handles`);
+ if (metrics.pieces) parts.push(`${metrics.pieces} user-published pieces`);
+ if (metrics.paintings) parts.push(`${metrics.paintings} paintings`);
+ if (metrics.kidlisp) parts.push(`${metrics.kidlisp} KidLisp programs`);
+ if (metrics.chatMessages) parts.push(`${metrics.chatMessages} chat messages`);
+ if (metrics.printsOrdered) parts.push(`${metrics.printsOrdered} prints ordered`);
+ if (parts.length) lines.push(parts.join(", ") + ".");
+ }
+
+ lines.push(`*Last refreshed: ${formatDate()}*`);
+
+ const statsBlock = lines.join(" \n");
+
+ const score = readFileSync(SCORE_PATH, "utf8");
+
+ const startMarker = "";
+ const endMarker = "";
+
+ if (!score.includes(startMarker) || !score.includes(endMarker)) {
+ process.stderr.write(
+ `refresh-score: missing stats markers in OPERATIONS.md\n`
+ );
+ process.exit(1);
+ }
+
+ const before = score.slice(
+ 0,
+ score.indexOf(startMarker) + startMarker.length
+ );
+ const after = score.slice(score.indexOf(endMarker));
+ const updated = before + "\n" + statsBlock + "\n" + after;
+
+ if (updated !== score) {
+ writeFileSync(SCORE_PATH, updated);
+ process.stderr.write(`refresh-score: β updated OPERATIONS.md\n`);
+ } else {
+ process.stderr.write(`refresh-score: no changes needed\n`);
+ }
+
+ // Mark today as done
+ writeFileSync(MARKER, today);
+}
+
+main();
diff --git a/easel/bin/sync-context.mjs b/easel/bin/sync-context.mjs
index e54725f215..7dd2ecb244 100755
--- a/easel/bin/sync-context.mjs
+++ b/easel/bin/sync-context.mjs
@@ -20,6 +20,7 @@
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
+import { GUIDES } from "../src/context.mjs";
import { build as buildApiMap } from "./build-api-map.mjs";
const HERE = dirname(fileURLToPath(import.meta.url));
@@ -30,12 +31,7 @@ const OUT = join(EASEL, "context");
// What a model needs to write a piece, and nothing else. Deliberately not the
// whole repository: this is the knowledge that is about Aesthetic Computer
// rather than about this checkout.
-export const BUNDLE = [
- ["SCREEN.md", "screen.md", "how a piece draws on the AC canvas"],
- ["HAND.md", "hand.md", "how the code reads"],
- ["system/public/aesthetic.computer/disks/CLAUDE.md", "pieces.md", "the piece authoring guide"],
- ["kidlisp/README.md", "kidlisp.md", "the KidLisp language"],
-];
+export const BUNDLE = GUIDES.map(([source, bundled, subject]) => [source, bundled, subject]);
const header = (from, subject) =>
`\n\n`;
diff --git a/easel/context/api.json b/easel/context/api.json
index ed1c0d8bce..108ee22c44 100644
--- a/easel/context/api.json
+++ b/easel/context/api.json
@@ -23,7 +23,7 @@
"path": "page",
"signature": "page(buffer)",
"doc": "Point subsequent drawing at another painting buffer; page(screen) comes back.",
- "source": "lib/disk.mjs:6391",
+ "source": "lib/disk.mjs:6508",
"examples": [
"disks/bits.mjs:33 page(sys.painting)",
"disks/breathe.mjs:29 page(screen);",
@@ -43,7 +43,7 @@
"path": "ink",
"signature": "ink(r, g, b, a) | ink(gray, a) | ink(\"red\") | ink([r, g, b]) | ink() (random)",
"doc": "Set the paint color for everything drawn next. Returns the API so calls chain: ink(255, 0, 0).line(0, 0, 10, 10).",
- "source": "lib/disk.mjs:6421",
+ "source": "lib/disk.mjs:6538",
"examples": [
"disks/$.mjs:168 ink([160, 160, 160]).write(compressedText, { x, y, size: scale });",
"disks/$.mjs:195 ink([160, 160, 160]).write(compressedText, { x: startX, y, size: scale });",
@@ -55,7 +55,7 @@
"path": "ink2",
"signature": "ink2(...color)",
"doc": "Secondary color, used by gradient-aware primitives.",
- "source": "lib/disk.mjs:6425",
+ "source": "lib/disk.mjs:6542",
"examples": []
},
{
@@ -63,7 +63,7 @@
"path": "wipe",
"signature": "wipe(...color)",
"doc": "Fill the whole screen with a color; the usual first line of paint().",
- "source": "lib/disk.mjs:6431",
+ "source": "lib/disk.mjs:6548",
"examples": [
"disks/$.mjs:67 wipe(0);",
"disks/$.mjs:324 wipe(0);",
@@ -75,7 +75,7 @@
"path": "backgroundFill",
"signature": "backgroundFill(color)",
"doc": "Set background fill color for reframe operations (especially for KidLisp pieces)",
- "source": "lib/disk.mjs:6483",
+ "source": "lib/disk.mjs:6600",
"examples": []
},
{
@@ -120,8 +120,7 @@
"source": "lib/graph.mjs:2797",
"examples": [
"disks/flap.mjs:50 if (store[`flap~${num}`]) stamp(store[`flap~${num}`], screen.width / 2, screen.height / 2);",
- "disks/graphics.mjs:28 stamp(",
- "disks/kokazo.mjs:243 stamp(ink, color, x, y, jx, jy, r);"
+ "disks/graphics.mjs:28 stamp("
]
},
{
@@ -223,7 +222,7 @@
"path": "setBufferAlpha",
"signature": "setBufferAlpha(buffer, alpha)",
"doc": "Set the alpha of every non-transparent pixel in a buffer to a uniform value. Useful for per-stroke alpha β must be called after drawing on the buffer.",
- "source": "lib/disk.mjs:6601",
+ "source": "lib/disk.mjs:6718",
"examples": [
"disks/line.mjs:215 setBufferAlpha(nopaint.buffer, strokeAlpha);"
]
@@ -633,7 +632,7 @@
"path": "pasteWithAlpha",
"signature": "pasteWithAlpha(source, x, y, alpha)",
"doc": "π¨ Alpha-blended paste for crossfade compositing",
- "source": "lib/disk.mjs:6669",
+ "source": "lib/disk.mjs:6786",
"examples": [
"disks/merry-fade.mjs:85 if (outAlpha > 0) pasteWithAlpha(outgoing, 0, 0, outAlpha);"
]
@@ -643,7 +642,7 @@
"path": "kidlisp",
"signature": "kidlisp(x = 0, y = 0, width, height, source, options = {})",
"doc": "π― Simplified KidLisp integration using global singleton instance",
- "source": "lib/disk.mjs:6705",
+ "source": "lib/disk.mjs:6822",
"examples": [
"disks/$.mjs:369 kidlisp(",
"disks/cross-tab-test.mjs:76 kidlisp(",
@@ -655,7 +654,7 @@
"path": "sound.synth",
"signature": "sound.synth({ tone = 440, type = \"square\", duration = 0.1, beats = undefined, attack = 0.01, decay = 0.9, volume, pan = 0, immediate = false, probe = null, generator = null, })",
"doc": "Play a synthesized tone. `tone` is Hz or a note name like \"c4\"; returns a voice with .kill() and .update().",
- "source": "lib/disk.mjs:13124",
+ "source": "lib/disk.mjs:13252",
"examples": [
"disks/$.mjs:646 sound.synth({",
"disks/1but.mjs:66 sound.synth({ type: \"triangle\", tone: 1047, attack: 0, decay: 0.1, duration: 0.1, volume: 0.25 });",
@@ -667,7 +666,7 @@
"path": "sound.play",
"signature": "sound.play(sfx, options, callbacks)",
"doc": "Play a loaded sample or sfx by id.",
- "source": "lib/disk.mjs:13039",
+ "source": "lib/disk.mjs:13167",
"examples": [
"disks/1v1.mjs:695 bgmPlaying = sound.play(bgmSfx, { loop: true, volume: 0.4 });",
"disks/1v1.mjs:763 bgmPlaying = sound.play(bgmSfx, { loop: true, volume: 0.4 });",
@@ -694,8 +693,8 @@
"source": "lib/ui.mjs:875",
"examples": [
"disks/ads.mjs:35 chatBtn = new ui.TextButton(\"CHAT\", { center: \"x\", y: Math.floor(screen.height * 0.55), screen });",
- "disks/amail.mjs:100 inboxBtn = new ui.TextButton(\"inbox\", { screen });",
- "disks/amail.mjs:101 sentBtn = new ui.TextButton(\"sent\", { screen });"
+ "disks/amp.mjs:20 connectBtn = new ui.TextButton(\"Connect & Monitor\", { center: \"xy\", screen });",
+ "disks/audio.mjs:116 playBtn = new ui.TextButton(\"PLAY\", { center: \"x\", screen, y: 0 });"
]
},
{
@@ -703,11 +702,11 @@
"path": "hud.label",
"signature": "hud.label(text, color, offset)",
"doc": "Take over the system's corner label β the only sanctioned way to draw in the top-left.",
- "source": "lib/disk.mjs:3642",
+ "source": "lib/disk.mjs:3753",
"examples": [
"disks/$.mjs:642 hud.label(`Previewing ${entry.codeText}`, \"cyan\");",
- "disks/amail.mjs:82 hud.label(\"amail\");",
- "disks/audio.mjs:81 hud.label(\"audio\");"
+ "disks/audio.mjs:81 hud.label(\"audio\");",
+ "disks/bgm.mjs:29 hud.label(`bgm ${setTrack}`);"
]
},
{
@@ -715,7 +714,7 @@
"path": "write",
"signature": "write(text, { x, y, size, center: \"x\" | \"xy\" }) | write(text, x, y)",
"doc": "Draw text in the current ink. Chains from ink(): ink(\"white\").write(\"hi\", { x: 10, y: 40 }).",
- "source": "lib/disk.mjs:5582",
+ "source": "lib/disk.mjs:5699",
"examples": [
"disks/arena.mjs:2908 write(txt, { x: rX - txt.length * 4, y }, undefined, undefined, false, font);",
"disks/arena.mjs:2918 write(t, { x, y }, undefined, undefined, false, font);",
@@ -731,7 +730,7 @@
"examples": [
"disks/bgm.mjs:26 if (params.length === 0) params[0] = num.randInt(trackCount - 1);",
"disks/crayon.mjs:86 let numDots = num.randInt(minNumDots, maxNumDots);",
- "disks/dafu.mjs:101 const speed = num.randInt(20) / 10 + 0.4;"
+ "disks/doodle.mjs:76 const x = points[i][0] + -1 + num.randInt(2);"
]
},
{
diff --git a/easel/src/context.mjs b/easel/src/context.mjs
new file mode 100644
index 0000000000..c12fb4c36a
--- /dev/null
+++ b/easel/src/context.mjs
@@ -0,0 +1,31 @@
+// Authoring guides travel with Easel; select by language and resolve each source independently.
+import { existsSync, readFileSync } from 'node:fs';
+import { dirname, join, resolve } from 'node:path';
+
+export const GUIDES = [
+ ['SCREEN.md', 'screen.md', 'how a piece draws on the AC canvas', ['mjs', 'lisp']],
+ ['HAND.md', 'hand.md', 'how the code reads', ['mjs', 'lisp', 'lua']],
+ ['system/public/aesthetic.computer/disks/CLAUDE.md', 'pieces.md', 'the piece authoring guide', ['mjs']],
+ ['kidlisp/README.md', 'kidlisp.md', 'the KidLisp language', ['lisp']],
+];
+
+export function authoringContext({ cwd, easelRoot, runtime }) {
+ let checkout = resolve(cwd);
+ while (!existsSync(join(checkout, 'SCORE.md')) || !existsSync(join(checkout, 'system'))) {
+ const parent = dirname(checkout);
+ if (parent === checkout) { checkout = null; break; }
+ checkout = parent;
+ }
+ return GUIDES.filter(([, , , languages]) => languages.includes(runtime)).map(([source, bundled, subject]) => {
+ const local = checkout && join(checkout, source);
+ const file = local && existsSync(local) ? local : join(easelRoot, 'context', bundled);
+ return { file, subject, body: readFileSync(file, 'utf8').trim() };
+ });
+}
+
+export function withRuntimeState(text, { enabled, blocker, route, file }) {
+ const publishing = enabled && !blocker
+ ? `Auto-publish is ON for ${file} at ${route}. Saves queue publication; claim a successful publication only when confirmed. Do not ask the user to run /publish for a successful auto-publish.`
+ : `Auto-publish is ${enabled ? `blocked: ${blocker}` : 'OFF'}. Saving alone does not publish. /publish is available for explicit publication.`;
+ return `[Easel runtime state at turn start]\n${publishing}\nThis replaces the earlier publishing setting.\n[/Easel runtime state]\n\n${text}`;
+}
diff --git a/easel/src/tui.mjs b/easel/src/tui.mjs
index 41f1c5c096..fe5ea51754 100755
--- a/easel/src/tui.mjs
+++ b/easel/src/tui.mjs
@@ -10,6 +10,7 @@ import { aboutMap, conversationHandoff } from "./about.mjs";
import { InputDecoder, mouseEvent, MOUSE_ON, MOUSE_OFF } from "./mouse.mjs";
import { ACSession } from "./ac-session.mjs";
import { Audience } from "./audience.mjs";
+import { authoringContext, withRuntimeState } from "./context.mjs";
import { AutoPublisher } from "./autopublish.mjs";
import { Diagnostics } from "./diagnostics.mjs";
import { EASEL_HEIGHT, easelFrame, easelNextFrame, easelWidth } from "./easel.mjs";
@@ -138,67 +139,15 @@ function autopublishRoute() {
return session.handle ? live.publishedUrl(session.handle) : "";
}
-// The model must never mistake a file on disk for a published piece.
-// The repo's style guides, named only when the session is actually running in
-// the repository that holds them. Naming a path that isn't there teaches the
-// model to ignore the whole instruction.
-const STYLE_GUIDES = [
- ["system/public/aesthetic.computer/disks/CLAUDE.md", "the piece authoring guide"],
- ["SCREEN.md", "how a piece draws on the AC canvas"],
- ["HAND.md", "how the code reads"],
-];
-
-// The same knowledge, carried inside the install. A session opened in the
-// Aesthetic Computer repository reads the repo's own copies, which are newer by
-// definition; a session opened anywhere else β which is every session, once this
-// is installed rather than cloned β reads these. Without them Easel is a general
-// editor that happens to publish to a URL, and there is no reason to install it
-// over the vendor CLI it is already driving.
-const BUNDLED_CONTEXT = [
- ["context/pieces.md", "the piece authoring guide"],
- ["context/screen.md", "how a piece draws on the AC canvas"],
- ["context/hand.md", "how the code reads"],
- ["context/kidlisp.md", "the KidLisp language"],
-];
-
const easelRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
function styleInstructions() {
- // The working directory wins when it has the guides: inside the monorepo they
- // are the living documents and the bundle is a stale copy of them.
- const present = STYLE_GUIDES.filter(([file]) => existsSync(path.join(cwd, file)));
- const source = present.length
- ? present.map(([file, subject]) => [file, subject])
- : BUNDLED_CONTEXT.map(([file, subject]) => [path.join(easelRoot, file), subject]).filter(
- ([file]) => existsSync(file),
- );
- if (source.length === 0) return [];
- // Inlined rather than named. Every session so far opened by reading these
- // three files β three tool calls and ten seconds before the first thought
- // about the piece β and the bytes cost the same either way. Here they arrive
- // with the first turn and are cached for every turn after it.
- const inlined = source
- .map(([file, subject]) => {
- try {
- return `## ${subject} (${path.relative(cwd, file) || file})\n\n${readFileSync(file, "utf8").trim()}`;
- } catch {
- return "";
- }
- })
- .filter(Boolean);
- const lines = [
- "Style: the Aesthetic Computer guides follow. They are the house rules for a piece and win over your own defaults. Do not re-read them from disk; they are already here.",
- ...inlined,
+ const guides = authoringContext({ cwd, easelRoot, runtime: live.runtime.id });
+ return [
+ "The following AC guides apply to this piece's language. They are already loaded; reread only when scope or their source changes. Current user instructions take precedence over these defaults.",
+ ...guides.map(({ file, subject, body }) => `## ${subject} (${file})\n\n${body}`),
+ ...(live.runtime.id === "lua" ? [] : ["Keep the top-left ~20 rows clear for the system corner label, or deliberately take it over with hud.label() where the runtime supports it."]),
];
- // The one rule that gets broken on a first draft, inlined because a model
- // that skips the read still has to know it. Lua pieces draw through
- // Processing and never see the hud/ui API, so it would only mislead them.
- if (live.runtime.id !== "lua") {
- lines.push(
- "Above all: the system paints its own corner label at (6, 6) in a 6x10 font, and tapping it is how the user gets back. Keep the top-left ~20 rows clear β put readouts along the bottom or right-aligned β or take the label over deliberately with hud.label().",
- );
- }
- return lines;
}
// The native tools, named so the model reaches for them instead of the shell.
@@ -231,7 +180,7 @@ function developerInstructions() {
autopublish.enabled && !autopublishBlocker()
? [
`Auto-publish is ON for this session: the interface publishes ${live.file} to ${autopublishRoute()} a couple of seconds after every save. That URL is live and stays live after this session ends.`,
- "So do NOT end with a /publish command and do NOT tell the user to publish β say the piece is live and name that URL. Only mention /publish if a publish is reported as failing.",
+ "So do NOT end with a /publish command and do NOT tell the user to publish β report it live only after publication is confirmed and name that URL. Only mention /publish if a publish is reported as failing.",
]
: [
"Publishing: writing a file under system/public/aesthetic.computer/disks/ or anywhere else does NOT make a piece live.",
@@ -241,6 +190,7 @@ function developerInstructions() {
return [
"You are running inside Easel, a terminal interface for Aesthetic Computer (AC) work.",
account,
+ "Each turn includes an Easel runtime state block. Treat it as the current publishing setting, superseding the initial setting below.",
`This session's piece is ${live.file} (${live.runtime.label}). Its current source is the source of truth; read it before editing and preserve existing work. Edit that file unless the user asks for something else.`,
"Do not write the piece's name onto the screen: the system already shows it in the corner label. If the file still carries a placeholder that writes its own name, remove it in your first edit.",
...dialect,
@@ -802,12 +752,7 @@ function commandAutopublish(argumentText) {
blankPublished = true;
autopublish.note(live.source());
}
- // How publishing works is part of the developer instructions, and those are
- // written once when the thread opens. A mid-session toggle is real
- // immediately for the interface and only reaches the model on a new thread β
- // say so, rather than letting it keep recommending /publish for a piece that
- // is already live.
- addEntry("notice", "The model is told when a thread opens Β· /new to tell it now");
+ addEntry("notice", "The model receives this setting with the next turn");
return redraw();
}
@@ -1215,7 +1160,10 @@ async function submitInput() {
state.status = "working";
redraw();
try {
- await engine.startTurn(text);
+ await engine.startTurn(withRuntimeState(text, {
+ enabled: autopublish.enabled, blocker: autopublishBlocker(),
+ route: autopublishRoute(), file: live.file,
+ }));
} catch (error) {
state.busy = false;
state.status = "failed";
diff --git a/easel/test/authoring-context.test.mjs b/easel/test/authoring-context.test.mjs
new file mode 100644
index 0000000000..c7a3831d70
--- /dev/null
+++ b/easel/test/authoring-context.test.mjs
@@ -0,0 +1,32 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { GUIDES, authoringContext, withRuntimeState } from '../src/context.mjs';
+
+test('language selection and per-guide fallback work from a nested checkout', t => {
+ const root = mkdtempSync(join(tmpdir(), 'easel-context-'));
+ t.after(() => rmSync(root, { recursive: true, force: true }));
+ mkdirSync(join(root, 'system/nested'), { recursive: true });
+ mkdirSync(join(root, 'easel/context'), { recursive: true });
+ writeFileSync(join(root, 'SCORE.md'), 'rules');
+ writeFileSync(join(root, 'HAND.md'), 'local hand');
+ for (const [, name] of GUIDES) writeFileSync(join(root, 'easel/context', name), name);
+ const options = { cwd: join(root, 'system/nested'), easelRoot: join(root, 'easel') };
+ const js = authoringContext({ ...options, runtime: 'mjs' });
+ assert.deepEqual(js.map(g => g.body), ['screen.md', 'local hand', 'pieces.md']);
+ assert.deepEqual(authoringContext({ ...options, runtime: 'lisp' }).map(g => g.body), ['screen.md', 'local hand', 'kidlisp.md']);
+ assert.deepEqual(authoringContext({ ...options, runtime: 'lua' }).map(g => g.body), ['local hand']);
+});
+
+test('the next turn carries current publish state without discarding the user prompt', () => {
+ const state = { enabled: true, blocker: '', route: 'https://example.test/piece', file: 'piece.mjs' };
+ const on = withRuntimeState('make it blue', state);
+ const off = withRuntimeState('make it blue', { ...state, enabled: false });
+ const blocked = withRuntimeState('make it blue', { ...state, blocker: 'sign in first' });
+ assert.match(on, /Auto-publish is ON/);
+ assert.match(off, /Auto-publish is OFF/);
+ assert.match(blocked, /blocked: sign in first/);
+ for (const text of [on, off, blocked]) assert.ok(text.endsWith('make it blue'));
+});
diff --git a/lith/deploy-lock.sh b/lith/deploy-lock.sh
new file mode 100644
index 0000000000..90b9d36075
--- /dev/null
+++ b/lith/deploy-lock.sh
@@ -0,0 +1,29 @@
+#!/usr/bin/env bash
+# Shared by CLI and webhook. The token owns the entire deploy, including rollback.
+# A stranded lock is inspected by an operator; it is never stolen on a timeout.
+lith_lock() {
+ local action="${1:-}" token="${2:-}" lock="${AC_LITH_LOCK_DIR:-/var/lock/ac-lith-deploy}"
+ if ! [[ "$token" =~ ^[A-Za-z0-9-]+$ ]]; then echo "Invalid deploy lock token" >&2; return 2; fi
+ case "$action" in
+ acquire)
+ if ! mkdir "$lock" 2>/dev/null; then
+ echo "Lith deploy locked: $lock (owner: $(cat "$lock/owner" 2>/dev/null || echo unknown))" >&2
+ return 1
+ fi
+ if ! printf '%s\n' "$token" > "$lock/owner"; then
+ rmdir "$lock" 2>/dev/null
+ return 1
+ fi
+ ;;
+ release)
+ if [ "$(cat "$lock/owner" 2>/dev/null)" != "$token" ]; then
+ echo 'Refusing to release another Lith deployment lock' >&2
+ return 1
+ fi
+ rm "$lock/owner" && rmdir "$lock"
+ ;;
+ *) return 2 ;;
+ esac
+}
+
+if [ -z "${BASH_SOURCE[0]:-}" ] || [ "${BASH_SOURCE[0]}" = "$0" ]; then lith_lock "$@"; fi
diff --git a/lith/deploy.fish b/lith/deploy.fish
index 84585e3031..0f275468b1 100644
--- a/lith/deploy.fish
+++ b/lith/deploy.fish
@@ -19,7 +19,8 @@ set DEFAULT_LITH_DROPLET_NAME "ac-lith"
set TARGET_HOST $DEFAULT_LITH_HOST
set TARGET_DROPLET_NAME $DEFAULT_LITH_DROPLET_NAME
set LOCAL_BRANCH (git -C $REPO_ROOT branch --show-current 2>/dev/null)
-set TARGET_BRANCH $LOCAL_BRANCH
+set TARGET_BRANCH main
+set DEPLOY_LOCK_TOKEN ""
if set -q LITH_HOST
set TARGET_HOST $LITH_HOST
@@ -37,6 +38,11 @@ if test -z "$TARGET_BRANCH"
set TARGET_BRANCH main
end
+if not string match -qr '^[A-Za-z0-9][A-Za-z0-9._/-]*$' -- "$TARGET_BRANCH"; or not git check-ref-format --branch "$TARGET_BRANCH" >/dev/null 2>&1
+ echo "Invalid deployment branch"
+ exit 1
+end
+
function ssh_ok --argument host
ssh -i $SSH_KEY -o StrictHostKeyChecking=no -o ConnectTimeout=10 $LITH_USER@$host "echo ok" &>/dev/null
end
@@ -104,6 +110,9 @@ end
# decrypt to a tempfile we use for this run only.
set DECRYPTED_KEY ""
function cleanup_decrypted_key --on-event fish_exit
+ if test -n "$DEPLOY_LOCK_TOKEN"
+ ssh -i $SSH_KEY -o ConnectTimeout=10 $LITH_USER@$TARGET_HOST "bash -s -- release $DEPLOY_LOCK_TOKEN" < "$SCRIPT_DIR/deploy-lock.sh"
+ end
if test -n "$DECRYPTED_KEY"; and test -f $DECRYPTED_KEY
rm -f $DECRYPTED_KEY
end
@@ -174,19 +183,39 @@ echo -e "$GREEN-> Connected to $TARGET_HOST.$NC"
# Deploy from pushed git state only. This avoids production drift from local rsync overlays.
echo -e "$GREEN-> Verifying origin/$TARGET_BRANCH...$NC"
-git -C $REPO_ROOT fetch origin $TARGET_BRANCH --quiet
-set ORIGIN_HEAD (git -C $REPO_ROOT rev-parse origin/$TARGET_BRANCH)
+if not git -C $REPO_ROOT fetch origin $TARGET_BRANCH --quiet
+ echo "Failed to fetch deployment branch"
+ exit 1
+end
+set ORIGIN_HEAD (git -C $REPO_ROOT rev-parse --verify origin/$TARGET_BRANCH)
+if not string match -qr '^[a-f0-9]{40}$' -- "$ORIGIN_HEAD"
+ echo "Could not resolve deployment revision"
+ exit 1
+end
+if set -q EXPECTED_COMMIT; and test "$EXPECTED_COMMIT" != "$ORIGIN_HEAD"
+ echo "Refusing deploy: branch is $ORIGIN_HEAD, expected $EXPECTED_COMMIT"
+ exit 1
+end
if test "$LOCAL_BRANCH" = "$TARGET_BRANCH"
set LOCAL_HEAD (git -C $REPO_ROOT rev-parse HEAD)
if test "$LOCAL_HEAD" != "$ORIGIN_HEAD"
- echo -e "$RED x Local $TARGET_BRANCH is ahead of origin/$TARGET_BRANCH.$NC"
+ echo -e "$RED x Local $TARGET_BRANCH differs from origin/$TARGET_BRANCH.$NC"
echo -e "$YELLOW Push first. This deploy script no longer rsyncs uncommitted or unpushed code into production.$NC"
exit 1
end
end
echo -e "$GREEN-> Deploying branch $TARGET_BRANCH at $ORIGIN_HEAD...$NC"
+set lock_candidate (uuidgen | string lower)
+if not string match -qr '^[a-f0-9-]{36}$' -- "$lock_candidate"
+ echo "Could not generate deployment lock token"
+ exit 1
+end
+if not ssh -i $SSH_KEY $LITH_USER@$TARGET_HOST "bash -s -- acquire $lock_candidate" < "$SCRIPT_DIR/deploy-lock.sh"
+ exit 1
+end
+set DEPLOY_LOCK_TOKEN $lock_candidate
set PREVIOUS_HEAD (ssh -i $SSH_KEY $LITH_USER@$TARGET_HOST "cd $REMOTE_DIR && git rev-parse HEAD")
if test -z "$PREVIOUS_HEAD"
echo -e "$RED x Could not resolve the currently deployed commit.$NC"
@@ -196,12 +225,13 @@ end
if not ssh -i $SSH_KEY $LITH_USER@$TARGET_HOST "\
cd $REMOTE_DIR && \
git fetch origin $TARGET_BRANCH --quiet && \
+test \"\$(git rev-parse origin/$TARGET_BRANCH)\" = $ORIGIN_HEAD && \
if git show-ref --verify --quiet refs/heads/$TARGET_BRANCH; then \
git checkout $TARGET_BRANCH --quiet; \
else \
git checkout -B $TARGET_BRANCH origin/$TARGET_BRANCH --quiet; \
fi && \
-git reset --hard origin/$TARGET_BRANCH --quiet && \
+git reset --hard $ORIGIN_HEAD --quiet && \
git rev-parse HEAD > system/public/.commit-ref && \
sh xbox/tools/precompress-live.sh"
echo -e "$RED x Failed to check out origin/$TARGET_BRANCH on $TARGET_HOST.$NC"
@@ -399,7 +429,7 @@ else
end
end
-echo -e "$GREEN-> Done. lith deployed to $TARGET_HOST$NC"
+echo -e "$GREEN-> Checking deployed revision and health on $TARGET_HOST$NC"
# Mirror slab/menuband/ to its standalone GitHub repo. Runs after a
# successful site deploy so the mirror's release pace matches what's
@@ -450,3 +480,22 @@ else
echo -e "$YELLOW $SERVICE_ENV, then: ssh $LITH_USER@$TARGET_HOST journalctl -u lith -n 50$NC"
exit 1
end
+
+# Verify the pinned checkout and the marker read by version reporting while
+# still holding the same lock as the webhook.
+if not ssh -i $SSH_KEY $LITH_USER@$TARGET_HOST "cd $REMOTE_DIR && test \"\$(git rev-parse HEAD)\" = $ORIGIN_HEAD && test \"\$(cat system/public/.commit-ref)\" = $ORIGIN_HEAD"
+ echo "Deployed revision verification failed"
+ exit 1
+end
+if not node "$SCRIPT_DIR/verify-release.mjs" "$ORIGIN_HEAD" "https://$DB_PROBE_HOST"
+ exit 1
+end
+# Arena's client and authority are one release. Retain the Lith lock through
+# mirror catch-up and the server's exact-SHA health gate.
+if test "$AC_DEPLOY_ARENA" = "1"
+ sleep 75
+ if not fish "$REPO_ROOT/session-server/deploy.fish" "$ORIGIN_HEAD"
+ exit 1
+ end
+end
+echo "Verified deployment: $TARGET_BRANCH $ORIGIN_HEAD on $TARGET_HOST"
diff --git a/lith/verify-release.mjs b/lith/verify-release.mjs
new file mode 100644
index 0000000000..6c807f51ea
--- /dev/null
+++ b/lith/verify-release.mjs
@@ -0,0 +1,27 @@
+#!/usr/bin/env node
+import { resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+export async function verifyRelease(commit, origin, { fetcher = fetch, attempts = 6, pause = ms => new Promise(r => setTimeout(r, ms)) } = {}) {
+ if (!/^[a-f0-9]{40}$/.test(commit)) throw new Error('expected a full commit SHA');
+ let reason = 'no response';
+ for (let attempt = 0; attempt < attempts; attempt++) {
+ try {
+ const url = new URL('/api/version', origin);
+ url.searchParams.set('release-check', `${commit}-${Date.now()}`);
+ const response = await fetcher(url, { cache: 'no-store', signal: AbortSignal.timeout(15000) });
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ const body = await response.json();
+ if (body.deployed === commit.slice(0, 7)) return;
+ reason = `served ${body.deployed}, expected ${commit.slice(0, 7)}`;
+ } catch (error) { reason = error.message; }
+ if (attempt + 1 < attempts) await pause(6000);
+ }
+ throw new Error(`Public release verification failed: ${reason}`);
+}
+
+if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+ verifyRelease(process.argv[2], process.argv[3] || 'https://aesthetic.computer')
+ .then(() => console.log(`Public version verified: ${process.argv[2]}`))
+ .catch(error => { console.error(error.message); process.exitCode = 1; });
+}
diff --git a/lith/webhook.sh b/lith/webhook.sh
index 8ba52ee832..206831ccaf 100755
--- a/lith/webhook.sh
+++ b/lith/webhook.sh
@@ -25,6 +25,10 @@ if ! [[ "$DEPLOY_BRANCH" =~ ^[A-Za-z0-9._/-]+$ ]]; then
fi
cd "$REMOTE_DIR"
+source "$REMOTE_DIR/lith/deploy-lock.sh"
+LOCK_TOKEN="webhook-$$-$(date +%s)"
+lith_lock acquire "$LOCK_TOKEN"
+trap 'lith_lock release "$LOCK_TOKEN"' EXIT
# Record HEAD before pull
OLD_HEAD=$(git rev-parse HEAD)
@@ -49,12 +53,15 @@ else
git checkout -B "$DEPLOY_BRANCH" "origin/$DEPLOY_BRANCH" --quiet
fi
-git reset --hard "origin/$DEPLOY_BRANCH" --quiet
+git reset --hard "$REMOTE_HEAD" --quiet
NEW_HEAD=$(git rev-parse HEAD)
NEW_BRANCH=$(git branch --show-current)
if [ "$OLD_HEAD" = "$NEW_HEAD" ]; then
+ printf '%s\n' "$NEW_HEAD" > system/public/.commit-ref
+ sh xbox/tools/precompress-live.sh
+ node lith/verify-release.mjs "$NEW_HEAD" "https://${LITH_PROBE_HOST:-aesthetic.computer}"
log "already up to date on $NEW_BRANCH ($NEW_HEAD)"
exit 0
fi
@@ -62,6 +69,7 @@ fi
if ! node xbox/live/render-social-preview.mjs --check; then
log "oskiewar social preview is stale; restoring $OLD_HEAD"
git reset --hard "$OLD_HEAD" --quiet
+ printf '%s\n' "$OLD_HEAD" > system/public/.commit-ref
sh xbox/tools/precompress-live.sh
exit 1
fi
@@ -216,3 +224,8 @@ if [ ${#PURGE_URLS[@]} -gt 0 ]; then
fi
log "done"
+
+[ "$(git rev-parse HEAD)" = "$REMOTE_HEAD" ]
+[ "$(cat system/public/.commit-ref)" = "$REMOTE_HEAD" ]
+node lith/verify-release.mjs "$REMOTE_HEAD" "https://${LITH_PROBE_HOST:-aesthetic.computer}"
+log "verified revision $REMOTE_HEAD"
diff --git a/slab/bin/prox-mcp.mjs b/slab/bin/prox-mcp.mjs
index bf23534f48..96d6f1c1fd 100755
--- a/slab/bin/prox-mcp.mjs
+++ b/slab/bin/prox-mcp.mjs
@@ -27,6 +27,8 @@ import { join } from "node:path";
import { homedir, hostname } from "node:os";
import { httpPort, serveHttp, serveStdio } from "../../toolchain/mcp/http-front.mjs";
+import { snapshot } from "../lib/prox-handoff.mjs";
+
const pexec = promisify(execFile);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -272,7 +274,7 @@ async function toolPoke({ handle, by }) {
return [{ type: "text", text: `poked ${r.host}:${r.name} as Β«${poker}Β» β its rock should blink + rattle (HTTP ${res.status}).` }];
}
-async function toolDump({ handle, destination } = {}) {
+async function toolDump({ handle, destination, handoff = {} } = {}) {
if (!handle) throw new Error("`handle` is required (a local `host:name` or fuzzy name; see prox_find).");
const hits = resolve(await allRocks(), handle);
if (!hits.length) throw new Error(`no rock resolves Β«${handle}Β» to dump.`);
@@ -300,48 +302,21 @@ async function toolDump({ handle, destination } = {}) {
host: r.host, name: r.name, sessionId: r.id, providerSessionId: providerId,
agent, cwd: marker.cwd || r.cwd || "", subject: marker.subject || r.subject || "",
seed: r.seed || "", status: r.status || marker.state || "",
+ checkout: snapshot(marker.cwd || r.cwd || homedir()),
+ handoff: Object.fromEntries(["objective", "corrections", "evidence", "pending", "next"].map(key => [key, String(handoff[key] || "").slice(0, 8000)])),
};
await writeFile(join(out, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n", { mode: 0o600 });
- let resume;
- if (agent === "easel") {
- resume = `#!/bin/sh
-set -eu
-bundle=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
-day=$(date +%Y/%m/%d)
-store="$HOME/.codex/sessions/$day"
-mkdir -p "$store"
-cp "$bundle/transcript.jsonl" "$store/rollout-prox-${providerId}.jsonl"
-cd ${shellQuote(marker.cwd || r.cwd || homedir())} 2>/dev/null || cd "$HOME"
-exec aesthetic --resume ${shellQuote(providerId)}
-`;
- } else if (agent === "codex") {
- resume = `#!/bin/sh
+ await copyFile(join(import.meta.dirname, "../lib/prox-handoff.mjs"), join(out, "handoff.mjs"));
+ const resume = `#!/bin/sh
set -eu
bundle=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
-day=$(date +%Y/%m/%d)
-store="$HOME/.codex/sessions/$day"
-mkdir -p "$store"
-cp "$bundle/transcript.jsonl" "$store/rollout-prox-${providerId}.jsonl"
-cd ${shellQuote(marker.cwd || r.cwd || homedir())} 2>/dev/null || cd "$HOME"
-exec codex resume ${shellQuote(providerId)}
+exec node "$bundle/handoff.mjs" "$@"
`;
- } else {
- resume = `#!/bin/sh
-set -eu
-bundle=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
-project=${shellQuote((marker.cwd || r.cwd || homedir()).replaceAll("/", "-") || "-")}
-store="$HOME/.claude/projects/$project"
-mkdir -p "$store"
-cp "$bundle/transcript.jsonl" "$store/${r.id}.jsonl"
-cd ${shellQuote(marker.cwd || r.cwd || homedir())} 2>/dev/null || cd "$HOME"
-exec claude --resume ${shellQuote(r.id)}
-`;
- }
await writeFile(join(out, "resume.sh"), resume, { mode: 0o700 });
await chmod(join(out, "resume.sh"), 0o700);
await writeFile(join(out, "README.txt"),
- `Portable prox state for ${r.host}:${r.name}\n\nRun ./resume.sh to install the transcript into ${agent}'s native session store and resume it.\nThe animated sigil.gif is rendered from the prompt rock's exact seeded 3D model.\nThis bundle contains raw private agent history, including tool results and local paths. Do not publish it.\n`,
+ `Portable prox state for ${r.host}:${r.name}\n\nRun ./resume.sh [cwd] to refresh checkout state and install the transcript into ${agent}'s native session store and resume it.\nThe animated sigil.gif is rendered from the prompt rock's exact seeded 3D model.\nThis bundle contains raw private agent history, including tool results and local paths. Do not publish it.\n`,
{ mode: 0o600 });
const rendered = await renderRockBundle(r.seed, out);
return [{ type: "text", text: `dumped ${r.host}:${r.name} β ${out}\nagent: ${agent}\nresume id: ${providerId}\nrock: ${rendered ? "animated exact-model sigil.gif + Finder icon" : "renderer unavailable; state bundle is still complete"}\nprivate raw transcript included; move the .prox folder as one bundle.` }];
@@ -593,12 +568,17 @@ const TOOLS = [
{
name: "prox_dump",
description:
- "Export one local prompt rock as a portable, resumable private bundle. Copies the raw native transcript plus session/cwd metadata and writes a resume.sh installer. Defaults to ~/Desktop/-.prox. Raw transcripts can contain tool output and local paths, so the bundle must remain private. Local machine only; no transcript data is sent over the fleet ledger.",
+ "Export one local prompt rock as a portable, resumable private bundle. Copies the raw native transcript, checkout/instruction snapshot and optional handoff; resume.sh refreshes destination state before resuming. Defaults to ~/Desktop/-.prox. Raw transcripts can contain tool output and local paths, so the bundle must remain private. Local machine only; no transcript data is sent over the fleet ledger.",
inputSchema: {
type: "object",
properties: {
handle: { type: "string", description: "A local host:name, session id, or unambiguous fuzzy name." },
destination: { type: "string", description: "Optional existing destination directory. Defaults to ~/Desktop." },
+ handoff: {
+ type: "object", additionalProperties: false,
+ description: "Optional concise continuity record. Never infer completion from the session subject.",
+ properties: Object.fromEntries(["objective", "corrections", "evidence", "pending", "next"].map(key => [key, { type: "string", maxLength: 8000 }])),
+ },
},
required: ["handle"],
},
diff --git a/slab/lib/prox-handoff.mjs b/slab/lib/prox-handoff.mjs
new file mode 100644
index 0000000000..fd6789eea0
--- /dev/null
+++ b/slab/lib/prox-handoff.mjs
@@ -0,0 +1,82 @@
+// Also copied into private .prox bundles: builtins only, so a moved bundle can resume.
+import { readFileSync, realpathSync, writeFileSync, mkdirSync, readdirSync, statSync, existsSync } from 'node:fs';
+import { execFileSync, spawnSync } from 'node:child_process';
+import { createHash } from 'node:crypto';
+import { hostname, homedir } from 'node:os';
+import { resolve, dirname, join, relative } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+export function snapshot(cwd) {
+ cwd = realpathSync(cwd);
+ if (!statSync(cwd).isDirectory()) throw new Error(`not a directory: ${cwd}`);
+ const git = (...args) => {
+ try { return execFileSync('git', ['-C', cwd, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); }
+ catch { return null; }
+ };
+ const root = git('rev-parse', '--show-toplevel');
+ const instructions = {};
+ if (root) {
+ let dir = cwd;
+ for (;;) {
+ for (const name of ['SCORE.md', 'AGENTS.md', 'CLAUDE.md', 'ENVIRONMENT.md', 'WORKFLOWS.md']) {
+ const file = join(dir, name);
+ if (existsSync(file)) instructions[relative(root, file)] = createHash('sha256').update(readFileSync(file)).digest('hex');
+ }
+ if (dir === root || dirname(dir) === dir) break;
+ dir = dirname(dir);
+ }
+ }
+ return {
+ capturedAt: new Date().toISOString(), host: hostname(), platform: process.platform,
+ cwd, environment: process.env.AC_AGENT_ENV || null, shell: process.env.SHELL || null,
+ root, branch: git('branch', '--show-current'), commit: git('rev-parse', 'HEAD'),
+ dirty: root ? Boolean(git('status', '--porcelain')) : null, instructions,
+ };
+}
+
+export function resumeContext(manifest, current) {
+ const old = manifest.checkout || {};
+ const changed = ['host', 'cwd', 'root', 'branch', 'commit', 'environment', 'shell']
+ .filter(key => old[key] !== current[key]);
+ if (JSON.stringify(old.instructions) !== JSON.stringify(current.instructions)) changed.push('instructions');
+ return [
+ 'Resume the existing objective and accepted corrections from this session.',
+ `Prox checkout refresh: ${JSON.stringify(current)}`,
+ `Changed since export: ${changed.join(', ') || 'none detected'}.`,
+ `Author-supplied handoff (may be incomplete): ${JSON.stringify(manifest.handoff || {})}`,
+ 'Before continuing, reconcile this snapshot with the session history. Read current ENVIRONMENT.md and SCORE.md if present, then guides for the task and touched paths. Check dirty files and currently exposed tools; old host permissions and capabilities are not transferred by this bundle. Keep prior user authorization where it still applies. Do not repeat completed work without a reason.',
+ ].join('\n');
+}
+
+export function resumeBundle(bundle, target) {
+ const manifest = JSON.parse(readFileSync(join(bundle, 'manifest.json'), 'utf8'));
+ const cwd = target ? resolve(target) : manifest.cwd;
+ if (!cwd) throw new Error('bundle has no cwd; run resume.sh ');
+ // Check first: no transcript import or process launch on a missing destination.
+ const current = snapshot(cwd);
+ const context = resumeContext(manifest, current);
+ writeFileSync(join(bundle, 'resume-context.json'), JSON.stringify(current, null, 2) + '\n', { mode: 0o600 });
+ const codex = manifest.agent === 'codex' || manifest.agent === 'easel';
+ const id = codex ? manifest.providerSessionId : manifest.sessionId;
+ if (!/^[A-Za-z0-9_-]+$/.test(id)) throw new Error('invalid native session id');
+ const day = new Date().toISOString().slice(0, 10).replaceAll('-', '/');
+ const store = codex ? join(homedir(), '.codex', 'sessions', day)
+ : join(homedir(), '.claude', 'projects', resolve(cwd).replaceAll('/', '-'));
+ mkdirSync(store, { recursive: true, mode: 0o700 });
+ const transcript = join(store, codex ? `rollout-prox-${id}.jsonl` : `${id}.jsonl`);
+ // Reusing an existing native store must never replace its newer conversation.
+ const alreadyStored = (dir) => existsSync(dir) && readdirSync(dir, { withFileTypes: true }).some(entry =>
+ entry.isDirectory() ? alreadyStored(join(dir, entry.name)) : entry.name.endsWith(`${id}.jsonl`));
+ if (!(codex ? alreadyStored(join(homedir(), '.codex', 'sessions')) : existsSync(transcript))) writeFileSync(transcript, readFileSync(join(bundle, 'transcript.jsonl')), { mode: 0o600, flag: 'wx' });
+ const command = manifest.agent === 'easel' ? 'aesthetic' : codex ? 'codex' : 'claude';
+ const args = manifest.agent === 'easel' ? ['--resume', id, '--prompt', context]
+ : codex ? ['resume', id, context] : ['--resume', id, context];
+ const result = spawnSync(command, args, { cwd, stdio: 'inherit' });
+ if (result.error) throw result.error;
+ return result.status ?? 1;
+}
+
+if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
+ try { process.exitCode = resumeBundle(dirname(fileURLToPath(import.meta.url)), process.argv[2]); }
+ catch (error) { console.error(`Prox resume stopped: ${error.message}. Supply an existing checkout with ./resume.sh .`); process.exitCode = 1; }
+}
diff --git a/slab/test/prox-handoff.test.mjs b/slab/test/prox-handoff.test.mjs
new file mode 100644
index 0000000000..2d58daf6b2
--- /dev/null
+++ b/slab/test/prox-handoff.test.mjs
@@ -0,0 +1,79 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { mkdtempSync, realpathSync, mkdirSync, writeFileSync, readFileSync, existsSync, copyFileSync, rmSync } from 'node:fs';
+import { spawnSync, execFileSync } from 'node:child_process';
+import { join } from 'node:path';
+import { tmpdir } from 'node:os';
+import { snapshot, resumeContext } from '../lib/prox-handoff.mjs';
+
+function fixture(t) {
+ const root = realpathSync(mkdtempSync(join(tmpdir(), 'prox-handoff-')));
+ t.after(() => rmSync(root, { recursive: true, force: true }));
+ const cwd = join(root, 'checkout'), bundle = join(root, 'bundle'), home = join(root, 'home');
+ for (const dir of [cwd, bundle, home]) mkdirSync(dir);
+ execFileSync('git', ['init', '-q', '-b', 'main', cwd]);
+ writeFileSync(join(cwd, 'SCORE.md'), 'private fixture rules');
+ execFileSync('git', ['-C', cwd, 'add', '.']);
+ execFileSync('git', ['-C', cwd, '-c', 'user.name=Test', '-c', 'user.email=test@example.invalid', '-c', 'commit.gpgsign=false', '-c', 'core.hooksPath=/dev/null', 'commit', '-qm', 'fixture']);
+ copyFileSync(new URL('../lib/prox-handoff.mjs', import.meta.url), join(bundle, 'handoff.mjs'));
+ const manifest = { cwd, agent: 'codex', providerSessionId: 'fixture-session', checkout: snapshot(cwd), handoff: { objective: 'continue', corrections: 'keep blue' } };
+ writeFileSync(join(bundle, 'manifest.json'), JSON.stringify(manifest));
+ writeFileSync(join(bundle, 'transcript.jsonl'), 'old fixture history');
+ return { root, cwd, bundle, home, manifest };
+}
+
+test('handoff records hashes rather than guide contents and notices drift', t => {
+ const { cwd, manifest } = fixture(t);
+ assert.equal(manifest.checkout.dirty, false);
+ assert.equal(manifest.checkout.instructions['SCORE.md'].length, 64);
+ assert.ok(!JSON.stringify(manifest.checkout).includes('private fixture rules'));
+ writeFileSync(join(cwd, 'SCORE.md'), 'new instructions');
+ const context = resumeContext(manifest, snapshot(cwd));
+ assert.match(context, /Changed since export: instructions/);
+ assert.match(context, /keep blue/);
+ assert.match(context, /"dirty":true/);
+});
+
+test('missing checkout stops before native transcript import', t => {
+ const { root, bundle, home } = fixture(t);
+ const result = spawnSync(process.execPath, [join(bundle, 'handoff.mjs'), join(root, 'missing')], { env: { ...process.env, HOME: home }, encoding: 'utf8' });
+ assert.equal(result.status, 1);
+ assert.ok(!existsSync(join(home, '.codex')));
+});
+
+test('explicit destination is refreshed and newer native history is preserved', t => {
+ const { root, cwd, bundle, home } = fixture(t);
+ const bin = join(root, 'bin'), store = join(home, '.codex/sessions/2000/01/01');
+ mkdirSync(bin);
+ mkdirSync(store, { recursive: true });
+ const existing = join(store, 'original-fixture-session.jsonl');
+ writeFileSync(existing, 'newer native history');
+ writeFileSync(join(bin, 'codex'), '#!/bin/sh\nprintf "%s\\n" "$@" > "$CAPTURE"\n', { mode: 0o755 });
+ const capture = join(root, 'args');
+ const result = spawnSync(process.execPath, [join(bundle, 'handoff.mjs'), cwd], { env: { ...process.env, HOME: home, PATH: `${bin}:${process.env.PATH}`, CAPTURE: capture }, encoding: 'utf8' });
+ assert.equal(result.status, 0, result.stderr);
+ assert.equal(readFileSync(existing, 'utf8'), 'newer native history');
+ assert.match(readFileSync(capture, 'utf8'), /resume\nfixture-session\nResume the existing objective/);
+ assert.equal(JSON.parse(readFileSync(join(bundle, 'resume-context.json'))).cwd, cwd);
+});
+
+test('prox_dump includes the handoff and executable refresh path through MCP', t => {
+ const { cwd, root, home } = fixture(t);
+ const ledger = join(home, '.config/slab/ledger');
+ const slabHome = join(home, '.local/share/slab');
+ const markers = join(slabHome, 'state/active-prompts');
+ mkdirSync(ledger, { recursive: true }); mkdirSync(markers, { recursive: true });
+ const id = 'fixture-session';
+ const transcript = join(root, 'native.jsonl'); writeFileSync(transcript, 'fixture history');
+ writeFileSync(join(ledger, 'local.json'), JSON.stringify({ host: 'fixture', entries: [{ id, host: 'fixture', name: 'rock', cwd, status: 'working', kind: 'session' }] }));
+ writeFileSync(join(markers, id), JSON.stringify({ cwd, agent_type: 'codex', provider_session_id: id, transcript_path: transcript }));
+ const request = { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'prox_dump', arguments: { handle: 'fixture:rock', destination: root, handoff: { objective: 'keep going', next: 'verify' } } } };
+ const result = spawnSync(process.execPath, [new URL('../bin/prox-mcp.mjs', import.meta.url).pathname], { input: JSON.stringify(request) + '\n', env: { ...process.env, HOME: home, SLAB_HOME: slabHome }, encoding: 'utf8' });
+ assert.equal(result.status, 0, result.stderr);
+ assert.match(JSON.parse(result.stdout).result.content[0].text, /dumped fixture:rock/);
+ const exported = join(root, 'fixture-rock.prox');
+ const manifest = JSON.parse(readFileSync(join(exported, 'manifest.json')));
+ assert.equal(manifest.handoff.next, 'verify');
+ assert.equal(manifest.checkout.commit, snapshot(cwd).commit);
+ assert.match(readFileSync(join(exported, 'resume.sh'), 'utf8'), /exec node "\$bundle\/handoff.mjs" "\$@"/);
+});
diff --git a/toolchain/instructions/README.md b/toolchain/instructions/README.md
new file mode 100644
index 0000000000..fbb65e7e73
--- /dev/null
+++ b/toolchain/instructions/README.md
@@ -0,0 +1,64 @@
+# Instruction flow
+
+`SCORE.md` owns shared rules, `WORKFLOWS.md` owns shorthand definitions,
+and `OPERATIONS.md` holds the longer architecture and command recipes.
+`AGENTS.md` remains a symlink; root `CLAUDE.md` imports SCORE.
+
+List or preload a task's guides:
+
+```sh
+node toolchain/instructions/context.mjs --route piece --path system/public/aesthetic.computer/disks/example.mjs
+node toolchain/instructions/context.mjs --route piece --print
+node toolchain/instructions/context.mjs --doctor
+```
+
+Routes are explicit task selections plus touched-path matches. Ancestor
+SCORE/AGENTS/CLAUDE guides are discovered for touched paths; unrelated domains
+are not recursively loaded. Ant colony rules require the explicit `ants`
+route. The doctor reports installed hook paths and whether their bytes match
+this checkout. It does not install hooks or enforce a new approval step.
+
+Easel's portable authoring manifest lives in `easel/src/context.mjs`; its
+bundle generator and runtime share it. JavaScript, KidLisp and Lua select
+appropriate guides. A nested checkout uses local guides with per-file bundle
+fallback. Every user turn carries current publish state; toggles during an
+active turn reach the model on its next turn.
+
+Prox exports carry optional objective/corrections/evidence/pending/next fields,
+checkout state, and digests of root/ancestor instruction files. These digests
+are an initial scope snapshot, not a claim that every task-specific guide was
+read. Resume refreshes the destination and asks the agent to resolve current
+guides and exposed capabilities. Missing destinations stop before native-store
+import, and existing native history is preserved. Bundles remain private.
+
+Release writers use a shared remote Lith lock from checkout through rollback,
+artifact refresh, restart and verification. `AC_DEPLOY_ARENA=1` retains that
+lock through the exact-SHA session-server deployment. Oskiewar additionally
+uses a common-Git-directory lock around receipt reads/writes. Dry runs do not
+write receipts. A crashed owner can leave a lock; inspect the recorded owner
+before removing it. No timer steals ownership.
+
+Lith compares the full remote checkout/marker and public `/api/version`'s
+seven-character deployed revision. Oskiewar also verifies decoded identity
+and Brotli source bytes. A receipt records observations at verification time;
+a later authorized release can replace production. Webhooks without an
+`EXPECTED_COMMIT` still select the latest branch head when their transaction
+starts. Both release entry points must be updated before their shared lock
+can protect them; older deployed scripts do not acquire it.
+
+Focused validation (no deployment):
+
+```sh
+node --test toolchain/instructions/context.test.mjs toolchain/release-safety.test.mjs slab/test/prox-handoff.test.mjs easel/test/authoring-context.test.mjs easel/test/context.test.mjs xbox/tools/tests/oskiewar-release.test.mjs xbox/tools/oskiewar-release.test.mjs
+node easel/bin/sync-context.mjs --check
+fish -n lith/deploy.fish
+bash -n lith/deploy-lock.sh
+bash -n lith/webhook.sh
+bash -n .githooks/post-commit
+git diff --check
+```
+
+Validation baseline: `slab/test/prox-mcp.test.mjs` has seven resolver/Loopboy
+failures at base commit `799caa12df`. All seven were reproduced using the
+untouched `slab/bin/prox-mcp.mjs` from that commit. The new handoff tests include
+an MCP export fixture and run separately from those preexisting failures.
diff --git a/toolchain/instructions/context.mjs b/toolchain/instructions/context.mjs
new file mode 100644
index 0000000000..f04d59d635
--- /dev/null
+++ b/toolchain/instructions/context.mjs
@@ -0,0 +1,92 @@
+#!/usr/bin/env node
+// Explicit task context. No recursive scan, network request, or hook installation.
+import { readFileSync, existsSync, readlinkSync, statSync } from 'node:fs';
+import { resolve, dirname, relative, join } from 'node:path';
+import { createHash } from 'node:crypto';
+import { execFileSync } from 'node:child_process';
+import { fileURLToPath } from 'node:url';
+
+export const repoRoot = resolve(import.meta.dirname, '../..');
+export const routes = JSON.parse(readFileSync(new URL('./routes.json', import.meta.url)));
+export const digest = (bytes) => createHash('sha256').update(bytes).digest('hex');
+
+export function guidePaths({ root = repoRoot, names = [], paths = [] } = {}) {
+ const guides = new Set(['SCORE.md', 'WORKFLOWS.md', 'ENVIRONMENT.md']);
+ const selected = new Set(names);
+ for (const path of paths) {
+ const absolute = resolve(root, path);
+ const rel = relative(root, absolute);
+ if (rel === '..' || rel.startsWith('../')) throw new Error(`path outside checkout: ${path}`);
+ for (const [name, route] of Object.entries(routes)) {
+ if (route.paths.some(prefix => rel.startsWith(prefix) || `${rel}/` === prefix)) selected.add(name);
+ }
+ // Explicitly discover scoped guides along the touched path, including new subsystems.
+ let dir = existsSync(absolute) && statSync(absolute).isDirectory() ? absolute : dirname(absolute);
+ while (dir !== root) {
+ for (const name of ['SCORE.md', 'AGENTS.md', 'CLAUDE.md']) {
+ const guide = join(dir, name);
+ if (existsSync(guide)) guides.add(relative(root, guide));
+ }
+ dir = dirname(dir);
+ }
+ }
+ for (const name of selected) {
+ if (!routes[name]) throw new Error(`unknown route: ${name}`);
+ for (const guide of routes[name].guides) guides.add(guide);
+ }
+ return [...guides];
+}
+
+export function bundle(options = {}) {
+ const root = options.root || repoRoot;
+ return guidePaths(options).map(path => {
+ const body = readFileSync(resolve(root, path), 'utf8');
+ return { path, bytes: Buffer.byteLength(body), sha256: digest(body), body };
+ });
+}
+
+export function doctor(root = repoRoot) {
+ const failures = [];
+ const scoreBytes = Buffer.byteLength(readFileSync(join(root, 'SCORE.md')));
+ if (scoreBytes > 32768) failures.push(`SCORE.md exceeds 32768 bytes (${scoreBytes})`);
+ try { if (readlinkSync(join(root, 'AGENTS.md')) !== 'SCORE.md') failures.push('AGENTS.md must point to SCORE.md'); }
+ catch { failures.push('AGENTS.md must be a symlink to SCORE.md'); }
+ if (!/^@SCORE\.md$/m.test(readFileSync(join(root, 'CLAUDE.md'), 'utf8'))) failures.push('CLAUDE.md must import SCORE.md');
+ for (const guide of guidePaths({ root, names: Object.keys(routes) })) {
+ if (!existsSync(join(root, guide))) failures.push(`missing guide: ${guide}`);
+ }
+ const hooks = {};
+ for (const name of ['pre-commit', 'post-commit']) {
+ try {
+ const path = execFileSync('git', ['rev-parse', '--git-path', `hooks/${name}`], { cwd: root, encoding: 'utf8' }).trim();
+ const installed = existsSync(resolve(root, path));
+ const tracked = join(root, '.githooks', name);
+ hooks[name] = { path: resolve(root, path), installed,
+ matchesCheckout: installed && existsSync(tracked) ? digest(readFileSync(resolve(root, path))) === digest(readFileSync(tracked)) : false };
+ } catch { hooks[name] = { installed: false }; }
+ }
+ return { scoreBytes, failures, hooks };
+}
+
+if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+ try {
+ const args = process.argv.slice(2), names = [], paths = [];
+ for (let i = 0; i < args.length; i++) {
+ if (args[i] === '--route' || args[i] === '--path') {
+ const flag = args[i], value = args[++i];
+ if (!value || value.startsWith('--')) throw new Error(`${flag} needs a value`);
+ (flag === '--route' ? names : paths).push(value);
+ } else if (!['--print', '--doctor'].includes(args[i])) throw new Error(`unknown option: ${args[i]}`);
+ }
+ if (args.includes('--doctor')) {
+ const result = doctor();
+ console.log(JSON.stringify(result, null, 2));
+ if (result.failures.length) process.exitCode = 1;
+ } else {
+ const guides = bundle({ names, paths });
+ console.log(args.includes('--print')
+ ? guides.map(g => `\n${g.body}`).join('\n')
+ : JSON.stringify(guides.map(({ body, ...metadata }) => metadata), null, 2));
+ }
+ } catch (error) { console.error(error.message); process.exitCode = 1; }
+}
diff --git a/toolchain/instructions/context.test.mjs b/toolchain/instructions/context.test.mjs
new file mode 100644
index 0000000000..dfa5b6b2fc
--- /dev/null
+++ b/toolchain/instructions/context.test.mjs
@@ -0,0 +1,32 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { guidePaths, bundle, doctor } from './context.mjs';
+
+test('task routes deduplicate guides and discover newly scoped scores', t => {
+ const root = mkdtempSync(join(tmpdir(), 'instruction-context-'));
+ t.after(() => rmSync(root, { recursive: true, force: true }));
+ mkdirSync(join(root, 'new/subsystem'), { recursive: true });
+ writeFileSync(join(root, 'new/SCORE.md'), 'local rules');
+ const guides = guidePaths({ root, names: ['piece', 'code'], paths: ['new/subsystem/app.mjs'] });
+ assert.equal(guides.filter(p => p === 'HAND.md').length, 1);
+ assert.ok(guides.includes('new/SCORE.md'));
+ assert.ok(guides.includes('system/public/aesthetic.computer/disks/CLAUDE.md'));
+ assert.throws(() => guidePaths({ root, paths: ['../outside'] }), /outside checkout/);
+ assert.throws(() => guidePaths({ names: ['missing'] }), /unknown route/);
+});
+
+test('ant automation policy is an explicit route, not every ants tool edit', () => {
+ assert.ok(!guidePaths({ paths: ['ants/mail-mcp/server.mjs'] }).includes('ants/mindset-and-rules.md'));
+ assert.ok(guidePaths({ names: ['ants'] }).includes('ants/mindset-and-rules.md'));
+});
+
+test('root adapters and all routed guides remain loadable', () => {
+ assert.deepEqual(doctor().failures, []);
+ for (const guide of bundle({ names: ['piece'] })) {
+ assert.equal(guide.sha256.length, 64);
+ assert.equal(guide.bytes, Buffer.byteLength(guide.body));
+ }
+});
diff --git a/toolchain/instructions/routes.json b/toolchain/instructions/routes.json
new file mode 100644
index 0000000000..55f7743916
--- /dev/null
+++ b/toolchain/instructions/routes.json
@@ -0,0 +1,11 @@
+{
+ "code": {"paths": ["system/public/aesthetic.computer/lib/", "shared/"], "guides": ["HAND.md"]},
+ "piece": {"paths": ["system/public/aesthetic.computer/disks/"], "guides": ["HAND.md", "SCREEN.md", "system/public/aesthetic.computer/disks/CLAUDE.md"]},
+ "kidlisp": {"paths": ["kidlisp/"], "guides": ["HAND.md", "kidlisp/SCORE.md", "kidlisp/README.md"]},
+ "papers": {"paths": ["papers/"], "guides": ["papers/SCORE.md", "papers/VOICE.md"]},
+ "release": {"paths": ["lith/", "session-server/", ".githooks/", "xbox/tools/oskiewar-release.mjs"], "guides": ["HAND.md", "WORKFLOWS.md", "lith/README.md"]},
+ "native": {"paths": ["fedac/native/"], "guides": ["HAND.md", "fedac/SCORE.md", "fedac/native/SCORE.md"]},
+ "fleet": {"paths": ["toolchain/macos/"], "guides": ["HAND.md", "toolchain/macos/SCORE.md"]},
+ "prox": {"paths": ["slab/bin/prox", "slab/lib/prox"], "guides": ["HAND.md", "WORKFLOWS.md", "ENVIRONMENT.md"]},
+ "ants": {"paths": [], "guides": ["ants/mindset-and-rules.md"]}
+}
diff --git a/toolchain/release-lock.mjs b/toolchain/release-lock.mjs
new file mode 100644
index 0000000000..d6bd823373
--- /dev/null
+++ b/toolchain/release-lock.mjs
@@ -0,0 +1,23 @@
+// One writer across worktrees. A crashed owner leaves evidence for manual recovery.
+import { openSync, closeSync, writeFileSync, readFileSync, unlinkSync } from 'node:fs';
+import { randomUUID } from 'node:crypto';
+import { hostname } from 'node:os';
+
+export async function withReleaseLock(path, action) {
+ const token = randomUUID();
+ let fd;
+ try { fd = openSync(path, 'wx', 0o600); }
+ catch (error) {
+ if (error.code !== 'EEXIST') throw error;
+ throw new Error(`Release already locked at ${path}; inspect the owner before recovery`);
+ }
+ try {
+ writeFileSync(fd, JSON.stringify({ token, pid: process.pid, host: hostname(), startedAt: new Date().toISOString() }));
+ } finally { closeSync(fd); }
+ try { return await action(); }
+ finally {
+ const owner = JSON.parse(readFileSync(path, 'utf8'));
+ if (owner.token !== token) throw new Error(`Release lock owner changed: ${path}`);
+ unlinkSync(path);
+ }
+}
diff --git a/toolchain/release-safety.test.mjs b/toolchain/release-safety.test.mjs
new file mode 100644
index 0000000000..e24d9383bf
--- /dev/null
+++ b/toolchain/release-safety.test.mjs
@@ -0,0 +1,277 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { mkdtempSync, realpathSync, mkdirSync, writeFileSync, readFileSync, copyFileSync, existsSync, rmSync } from 'node:fs';
+import { spawnSync, execFileSync } from 'node:child_process';
+import { tmpdir } from 'node:os';
+import { join, dirname } from 'node:path';
+import { createServer } from 'node:http';
+import { brotliCompressSync } from 'node:zlib';
+import { withReleaseLock } from './release-lock.mjs';
+import { verifyRelease } from '../lith/verify-release.mjs';
+import { verifyWeb, sha256, newRelease } from '../xbox/tools/oskiewar-release.mjs';
+
+const repo = dirname(import.meta.dirname);
+function temp(t) {
+ const root = realpathSync(mkdtempSync(join(tmpdir(), 'release-fixture-')));
+ t.after(() => rmSync(root, { recursive: true, force: true }));
+ return root;
+}
+function copy(root, file) {
+ mkdirSync(dirname(join(root, file)), { recursive: true });
+ copyFileSync(join(repo, file), join(root, file));
+}
+function git(root, ...args) {
+ return execFileSync('git', ['-C', root, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
+}
+function commit(root) {
+ git(root, 'add', '.');
+ git(root, '-c', 'commit.gpgsign=false', '-c', 'core.hooksPath=/dev/null', '-c', 'user.name=Test', '-c', 'user.email=test@example.invalid', 'commit', '-qm', 'fixture');
+}
+function releaseFixture(t, version = 1) {
+ const root = temp(t);
+ for (const file of ['xbox/tools/oskiewar-release.mjs', 'toolchain/release-lock.mjs', 'lith/verify-release.mjs']) copy(root, file);
+ mkdirSync(join(root, 'xbox/live/social'), { recursive: true });
+ writeFileSync(join(root, 'xbox/live/oskiewar.js'), `const buildVersion = ${version};\n`);
+ writeFileSync(join(root, 'xbox/live/social/manifest.json'), '{}');
+ git(root, 'init', '-q', '-b', 'main');
+ commit(root);
+ return root;
+}
+function release(root, ...args) {
+ return spawnSync(process.execPath, [join(root, 'xbox/tools/oskiewar-release.mjs'), ...args], {
+ cwd: root, env: { ...process.env, DEPLOY_BRANCH: 'main' }, encoding: 'utf8', timeout: 10000,
+ });
+}
+
+test('common receipt lock excludes overlapping writers and releases after rejection', async t => {
+ const path = join(temp(t), 'receipt.lock');
+ let unblock;
+ const pending = withReleaseLock(path, () => new Promise(r => { unblock = r; }));
+ await assert.rejects(withReleaseLock(path, () => assert.fail('second writer entered')), /already locked/);
+ unblock(); await pending;
+ assert.ok(!existsSync(path));
+ await assert.rejects(withReleaseLock(path, () => { throw new Error('failed operation'); }), /failed operation/);
+ assert.ok(!existsSync(path));
+});
+
+test('streamed CLI and sourced webhook share lock ownership', t => {
+ const root = temp(t), lock = join(root, 'remote-lock');
+ const source = readFileSync(join(repo, 'lith/deploy-lock.sh'), 'utf8');
+ const env = { ...process.env, AC_LITH_LOCK_DIR: lock };
+ const stream = (...args) => spawnSync('bash', ['-s', '--', ...args], { input: source, env, encoding: 'utf8' });
+ assert.equal(stream('acquire', '').status, 2);
+ assert.equal(stream('acquire', 'cli-a').status, 0);
+ const webhook = spawnSync('bash', ['-c', 'source "$1"; lith_lock acquire webhook-b', 'test', join(repo, 'lith/deploy-lock.sh')], { env, encoding: 'utf8' });
+ assert.equal(webhook.status, 1);
+ assert.equal(stream('release', 'webhook-b').status, 1);
+ assert.equal(readFileSync(join(lock, 'owner'), 'utf8').trim(), 'cli-a');
+ assert.equal(stream('release', 'cli-a').status, 0);
+ assert.equal(stream('acquire', 'webhook-b').status, 0);
+ assert.equal(stream('release', 'webhook-b').status, 0);
+});
+
+test('public revision verification rejects old production and tolerates a retiring cache', async () => {
+ const commit = 'a'.repeat(40);
+ await assert.rejects(verifyRelease(commit, 'https://example.invalid', { attempts: 1, fetcher: async () => Response.json({ deployed: 'bbbbbbb' }) }), /served bbbbbbb/);
+ let calls = 0;
+ await verifyRelease(commit, 'https://example.invalid', { attempts: 2, pause: async () => {}, fetcher: async () => Response.json({ deployed: ++calls === 1 ? 'bbbbbbb' : 'aaaaaaa' }) });
+ assert.equal(calls, 2);
+});
+
+test('browser-decoded Brotli must match identity bytes', async t => {
+ let stale = false;
+ const seen = [];
+ const server = createServer((req, res) => {
+ const encoding = req.headers['accept-encoding']; seen.push(encoding);
+ if (encoding === 'br') {
+ res.setHeader('Content-Encoding', 'br');
+ res.end(brotliCompressSync(Buffer.from(stale ? 'old code' : 'new code')));
+ } else res.end('new code');
+ });
+ await new Promise(r => server.listen(0, '127.0.0.1', r));
+ t.after(() => new Promise(r => server.close(r)));
+ const fetcher = (_, options) => fetch(`http://127.0.0.1:${server.address().port}/source`, options);
+ await verifyWeb(sha256('new code'), fetcher);
+ stale = true;
+ await assert.rejects(verifyWeb(sha256('new code'), fetcher), /web br hash/);
+ assert.deepEqual(seen, ['identity', 'br', 'identity', 'br']);
+});
+
+test('a new UI commit requires web deployment even with identical game JS', () => {
+ const previous = { desired: { commit: 'old' }, channels: { web: { hash: 'same' } } };
+ assert.equal(newRelease('same', 'new', 'live', previous).channels.web.status, 'pending');
+});
+
+test('dry deploy and reconcile preserve the shared receipt and source', t => {
+ const root = releaseFixture(t);
+ const source = readFileSync(join(root, 'xbox/live/oskiewar.js'));
+ const receiptPath = join(root, '.git/oskiewar-parity.json');
+ const receipt = newRelease(sha256(source), git(root, 'rev-parse', 'HEAD'), 'live');
+ writeFileSync(receiptPath, JSON.stringify(receipt));
+ const before = readFileSync(receiptPath, 'utf8');
+ for (const command of ['deploy', 'reconcile', 'deploy-xbox-dev']) {
+ const result = release(root, command, '--dry-run');
+ assert.equal(result.status, 0, result.stderr);
+ assert.equal(readFileSync(receiptPath, 'utf8'), before);
+ assert.deepEqual(readFileSync(join(root, 'xbox/live/oskiewar.js')), source);
+ assert.ok(!existsSync(`${receiptPath}.lock`));
+ }
+});
+
+test('stamping preserves staged files and preexisting social edits', t => {
+ const root = releaseFixture(t, 0);
+ const before = git(root, 'rev-parse', 'HEAD');
+ writeFileSync(join(root, 'unrelated'), 'keep this');
+ git(root, 'add', 'unrelated');
+ let result = release(root, 'deploy');
+ assert.equal(result.status, 1);
+ assert.match(result.stderr, /empty index/);
+ assert.equal(git(root, 'diff', '--cached', '--name-only'), 'unrelated');
+ git(root, 'reset', '-q', 'HEAD', '--', 'unrelated');
+ writeFileSync(join(root, 'xbox/live/social/manifest.json'), '{"keep":true}');
+ result = release(root, 'deploy');
+ assert.equal(result.status, 1);
+ assert.match(result.stderr, /clean social-preview/);
+ assert.equal(git(root, 'rev-parse', 'HEAD'), before);
+ assert.ok(!existsSync(join(root, '.git/oskiewar-parity.json.lock')));
+});
+
+test('Lith refuses a remote branch advancing after the local pin, and releases its lock', t => {
+ const root = releaseFixture(t);
+ for (const file of ['lith/deploy.fish', 'lith/deploy-lock.sh']) copy(root, file);
+ const remote = join(root, 'bare');
+ git(root, 'init', '-q', '--bare', remote);
+ git(root, 'remote', 'add', 'origin', remote);
+ git(root, 'push', '-q', 'origin', 'main');
+ const key = join(root, 'aesthetic-computer-vault/home/.ssh/id_rsa');
+ mkdirSync(dirname(key), { recursive: true }); writeFileSync(key, 'fixture');
+ const bin = join(root, 'bin'); mkdirSync(bin);
+ const realGit = execFileSync('which', ['git'], { encoding: 'utf8' }).trim();
+ writeFileSync(join(bin, 'git'), `#!/bin/bash
+printf '%s\\n' "$*" >> "$COMMANDS"
+if [ "$RACING_REMOTE" = 1 ] && [ "$*" = 'rev-parse origin/main' ]; then
+ printf '%s\\n' bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
+ exit 0
+fi
+exec "$REAL_GIT" "$@"
+`, { mode: 0o755 });
+ writeFileSync(join(bin, 'ssh'), `#!/bin/bash
+command="\${@: -1}"
+if [[ "$command" == *'git fetch origin'* ]]; then export RACING_REMOTE=1; fi
+exec bash -c "$command"
+`, { mode: 0o755 });
+ const script = join(root, 'lith/deploy.fish');
+ writeFileSync(script, readFileSync(script, 'utf8').replace('set REMOTE_DIR "/opt/ac"', `set REMOTE_DIR "${root}"`));
+ const log = join(root, 'commands');
+ const lock = join(root, 'remote-lock');
+ const result = spawnSync('fish', ['--no-config', script], { cwd: root, encoding: 'utf8', timeout: 15000, env: {
+ ...process.env, PATH: `${bin}:${process.env.PATH}`, REAL_GIT: realGit, COMMANDS: log,
+ AC_LITH_LOCK_DIR: lock, DEPLOY_BRANCH: 'main', EXPECTED_COMMIT: git(root, 'rev-parse', 'HEAD'),
+ } });
+ assert.equal(result.status, 1, result.stdout + result.stderr);
+ assert.match(result.stdout, /Failed to check out/);
+ assert.doesNotMatch(readFileSync(log, 'utf8'), /^reset --hard/m);
+ assert.ok(!existsSync(lock), result.stderr);
+});
+
+test('arena hook stops after push or Lith failure and pins its triggering commit', t => {
+ const root = temp(t), bin = join(root, 'bin'); mkdirSync(bin);
+ mkdirSync(join(root, '.git'));
+ copy(root, '.githooks/post-commit');
+ const log = join(root, 'calls'), sha = 'a'.repeat(40);
+ writeFileSync(join(bin, 'git'), `#!/bin/bash
+case "$*" in
+ *--show-toplevel*) printf '%s\\n' "$FIXTURE_ROOT" ;;
+ *symbolic-ref*) echo main ;;
+ *diff-tree*) echo shared/physics.mjs ;;
+ *--git-common-dir*) printf '%s/.git\\n' "$FIXTURE_ROOT" ;;
+ *rev-parse*) echo ${sha} ;;
+ *push*) printf 'push %s\\n' "$*" >> "$CALLS"; exit "$PUSH_STATUS" ;;
+esac
+`, { mode: 0o755 });
+ writeFileSync(join(bin, 'fish'), `#!/bin/bash
+printf 'fish %s %s %s\\n' "$*" "$EXPECTED_COMMIT" "$AC_DEPLOY_ARENA" >> "$CALLS"
+exit "$LITH_STATUS"
+`, { mode: 0o755 });
+ writeFileSync(join(bin, 'node'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
+ for (const [push, lith] of [[1, 0], [0, 1], [0, 0]]) {
+ writeFileSync(log, '');
+ const result = spawnSync('bash', [join(root, '.githooks/post-commit')], { cwd: root, encoding: 'utf8', timeout: 5000, env: {
+ ...process.env, AC_NO_AUTO_DEPLOY: '', NODE_BIN: 'node', PATH: `${bin}:${process.env.PATH}`,
+ FIXTURE_ROOT: root, CALLS: log, PUSH_STATUS: String(push), LITH_STATUS: String(lith),
+ } });
+ assert.equal(result.status, 0, result.stderr);
+ const calls = readFileSync(log, 'utf8');
+ assert.match(calls, new RegExp(`${sha}:refs/heads/main`));
+ if (push) assert.doesNotMatch(calls, /fish/);
+ else assert.match(calls, new RegExp(`lith/deploy.fish ${sha} 1`));
+ // The paired server work is owned by Lith under the same lock.
+ assert.doesNotMatch(calls, /session-server/);
+ }
+});
+
+test('webhook rollback restores the commit marker and sidecars before releasing ownership', t => {
+ const root = releaseFixture(t);
+ for (const file of ['lith/webhook.sh', 'lith/deploy-lock.sh']) copy(root, file);
+ mkdirSync(join(root, 'system/public'), { recursive: true });
+ const old = git(root, 'rev-parse', 'HEAD');
+ writeFileSync(join(root, 'ui.html'), 'new UI'); commit(root);
+ const next = git(root, 'rev-parse', 'HEAD');
+ const bare = join(temp(t), 'bare'); git(root, 'init', '-q', '--bare', bare);
+ git(root, 'remote', 'add', 'origin', bare); git(root, 'push', '-q', 'origin', 'main');
+ git(root, 'reset', '--hard', old);
+ // The fixture's deploy script stays outside the checkout it changes.
+ const script = join(temp(t), 'webhook.sh');
+ writeFileSync(script, readFileSync(join(repo, 'lith/webhook.sh'), 'utf8').replace('REMOTE_DIR="/opt/ac"', `REMOTE_DIR="${root}"`));
+ copy(root, 'lith/deploy-lock.sh');
+ const bin = join(root, 'bin'); mkdirSync(bin);
+ writeFileSync(join(bin, 'node'), '#!/bin/sh\nexit 1\n', { mode: 0o755 });
+ writeFileSync(join(root, 'xbox/tools/precompress-live.sh'), '#!/bin/sh\ntest -f "$AC_LITH_LOCK_DIR/owner" || exit 2\ngit rev-parse HEAD > "$REBUILT"\n');
+ const rebuilt = join(root, 'rebuilt'), lock = join(root, 'deploy-lock');
+ const result = spawnSync('bash', [script], { cwd: root, encoding: 'utf8', timeout: 5000, env: {
+ ...process.env, PATH: `${bin}:${process.env.PATH}`, AC_LITH_LOCK_DIR: lock, REBUILT: rebuilt,
+ DEPLOY_BRANCH: 'main', EXPECTED_COMMIT: next,
+ } });
+ assert.equal(result.status, 1, result.stdout + result.stderr);
+ assert.equal(git(root, 'rev-parse', 'HEAD'), old);
+ assert.equal(readFileSync(join(root, 'system/public/.commit-ref'), 'utf8').trim(), old);
+ assert.equal(readFileSync(rebuilt, 'utf8').trim(), old);
+ assert.ok(!existsSync(lock));
+});
+
+test('paired arena deployment retains the Lith lock through the server command', t => {
+ const root = releaseFixture(t);
+ for (const file of ['lith/deploy.fish', 'lith/deploy-lock.sh']) copy(root, file);
+ const bare = join(temp(t), 'bare'); git(root, 'init', '-q', '--bare', bare);
+ git(root, 'remote', 'add', 'origin', bare); git(root, 'push', '-q', 'origin', 'main');
+ const key = join(root, 'aesthetic-computer-vault/home/.ssh/id_rsa');
+ mkdirSync(dirname(key), { recursive: true }); writeFileSync(key, 'fixture');
+ const bin = join(root, 'bin'); mkdirSync(bin);
+ const sha = git(root, 'rev-parse', 'HEAD');
+ writeFileSync(join(bin, 'ssh'), `#!/bin/bash
+command="\${@: -1}"
+case "$command" in
+ 'bash -s -- '*) exec bash -c "$command" ;;
+ *'&& git rev-parse HEAD') echo "$FIXTURE_SHA" ;;
+ 'echo ok') echo ok ;;
+ *) exit 0 ;;
+esac
+`, { mode: 0o755 });
+ for (const command of ['scp', 'sleep', 'node']) writeFileSync(join(bin, command), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
+ writeFileSync(join(bin, 'curl'), '#!/bin/sh\nprintf 200\n', { mode: 0o755 });
+ writeFileSync(join(bin, 'fish'), `#!/bin/sh
+[ -f "$AC_LITH_LOCK_DIR/owner" ] || exit 2
+printf '%s\\n' "$@" > "$SERVER_ARGS"
+bash "$LOCK_HELPER" acquire competitor > /dev/null 2>&1 && exit 3
+exit 0
+`, { mode: 0o755 });
+ const actualFish = execFileSync('which', ['fish'], { encoding: 'utf8' }).trim();
+ const lock = join(root, 'deploy-lock'), args = join(root, 'server-args');
+ const result = spawnSync(actualFish, ['--no-config', join(root, 'lith/deploy.fish')], { cwd: root, encoding: 'utf8', timeout: 10000, env: {
+ ...process.env, PATH: `${bin}:${process.env.PATH}`, DEPLOY_BRANCH: 'main', EXPECTED_COMMIT: sha,
+ AC_DEPLOY_ARENA: '1', FIXTURE_SHA: sha, AC_LITH_LOCK_DIR: lock, SERVER_ARGS: args, LOCK_HELPER: join(root, 'lith/deploy-lock.sh'),
+ } });
+ assert.equal(result.status, 0, result.stdout + result.stderr);
+ assert.equal(readFileSync(args, 'utf8'), `${root}/session-server/deploy.fish\n${sha}\n`);
+ assert.ok(!existsSync(lock));
+});
diff --git a/xbox/tools/oskiewar-release.mjs b/xbox/tools/oskiewar-release.mjs
index 05ec656fd1..030925f879 100644
--- a/xbox/tools/oskiewar-release.mjs
+++ b/xbox/tools/oskiewar-release.mjs
@@ -1,6 +1,8 @@
#!/usr/bin/env node
// One release receipt for Oskiewar's web, iOS-web, and Xbox live surfaces.
+import { verifyRelease } from "../../lith/verify-release.mjs";
+import { withReleaseLock } from "../../toolchain/release-lock.mjs";
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
@@ -43,7 +45,8 @@ export function newRelease(hash, commit, severity, previous = null) {
format: "computer.aesthetic.oskiewar-parity", version: 1,
desired: { hash, commit, severity, createdAt: at },
channels: Object.fromEntries(channels.map((name) => [name, {
- status: previous?.channels?.[name]?.hash === hash ? "current" : "pending",
+ status: previous?.channels?.[name]?.status === "current" && previous?.channels?.[name]?.hash === hash &&
+ (name !== "web" || previous?.desired?.commit === commit) ? "current" : "pending",
hash: previous?.channels?.[name]?.hash || null, updatedAt: at,
}])),
};
@@ -153,12 +156,19 @@ function sourceState(previous = null) {
build, expectedBuild };
}
-async function verifyWeb(hash) {
- const response = await fetch(`https://oskiewar.com/oskiewar.js?parity=${Date.now()}`,
- { cache: "no-store" });
- if (!response.ok) throw new Error(`web returned HTTP ${response.status}`);
- const actual = sha256(Buffer.from(await response.arrayBuffer()));
- if (actual !== hash) throw new Error(`web hash ${actual.slice(0, 12)} != ${hash.slice(0, 12)}`);
+export async function verifyWeb(hash, fetcher = fetch) {
+ const representations = [];
+ for (const encoding of ["identity", "br"]) {
+ const response = await fetcher(`https://oskiewar.com/oskiewar.js?parity=${Date.now()}`, {
+ cache: "no-store", headers: { "accept-encoding": encoding }, signal: AbortSignal.timeout(15000),
+ });
+ if (!response.ok) throw new Error(`web ${encoding} returned HTTP ${response.status}`);
+ // fetch decodes Content-Encoding before exposing arrayBuffer().
+ const actual = sha256(Buffer.from(await response.arrayBuffer()));
+ if (actual !== hash) throw new Error(`web ${encoding} hash ${actual.slice(0, 12)} != ${hash.slice(0, 12)}`);
+ representations.push(response.headers.get("content-encoding") || "identity");
+ }
+ return [...new Set(representations)].join(", ");
}
// The version stamp, stamped.
@@ -193,6 +203,10 @@ function stampBuildVersion(current, previous = null) {
// Counted from the clean tree, before the write below dirties it: the
// stamp's own commit is the one that takes the count to this number.
+ if (git("diff", "--cached", "--name-only"))
+ throw new Error("Release stamping requires an empty index; preserve staged work first");
+ if (git("status", "--porcelain", "--", "xbox/live/social"))
+ throw new Error("Release stamping requires clean social-preview files");
const next = current.expectedBuild + 1;
console.log(`β stamping oskiewar v${current.build ?? "?"} β v${next}`);
const bytes = readFileSync(sourcePath, "utf8");
@@ -213,7 +227,7 @@ function stampBuildVersion(current, previous = null) {
"by remembering, so the corner of the screen cannot disagree with the " +
"code behind it.\n\n" +
"Co-Authored-By: Claude Opus 5 (1M context) "]);
- run("git", ["push"]);
+ run("git", ["push", "origin", `HEAD:refs/heads/${process.env.DEPLOY_BRANCH || "main"}`]);
// Restated against the SAME baseline, so the stamp commit cannot quietly
// change the severity the release was classified at.
@@ -226,14 +240,21 @@ function stampBuildVersion(current, previous = null) {
async function reconcile(receipt, { dryRun = false } = {}) {
const hash = receipt.desired.hash;
- if (receipt.channels.web.status !== "current") {
- if (dryRun) console.log("would deploy web");
- else try {
- run("fish", ["lith/deploy.fish"]);
- await verifyWeb(hash);
- mark(receipt, "web", "current", "verified production bytes");
- } catch (error) { mark(receipt, "web", "failed", error.message); }
+ if (dryRun) {
+ console.log("would verify web and reconcile pending channels");
+ return receipt;
}
+ // A saved current flag is past evidence. Check the public bytes again.
+ try {
+ if (receipt.channels.web.status !== "current") {
+ run("fish", ["lith/deploy.fish"], { env: {
+ DEPLOY_BRANCH: receipt.desired.branch || "main", EXPECTED_COMMIT: receipt.desired.commit,
+ } });
+ }
+ await verifyRelease(receipt.desired.commit, "https://aesthetic.computer");
+ const encodings = await verifyWeb(hash);
+ mark(receipt, "web", "current", `verified production bytes (${encodings})`);
+ } catch (error) { mark(receipt, "web", "failed", error.message); }
// iOS game code is the production web channel; its bundled copy remains the
// offline fallback. A native-shell change is deliberately not called live.
if (receipt.channels.web.status === "current" &&
@@ -274,6 +295,7 @@ function print(receipt, current = null) {
console.log(JSON.stringify({ current, receipt,
parity: receipt && channels.every((name) => status(name) === "current"),
blocked, offline }, null, 2));
+ if (blocked.length) process.exitCode = 1;
if (offline.length && !blocked.length)
console.log(`\nNothing failed. ${offline.join(", ")} ` +
`${offline.length === 1 ? "is" : "are"} offline β ` +
@@ -287,6 +309,9 @@ async function main() {
let current = sourceState(previous);
if (command === "status") return print(readReceipt(), current);
if (command === "deploy") {
+ const branch = process.env.DEPLOY_BRANCH || "main";
+ if (git("branch", "--show-current") !== branch)
+ throw new Error(`Release from the intended deployment branch ${branch}`);
if (!current.tracked || current.dirty)
throw new Error("Oskiewar source must be tracked and committed before a unified release");
// `--no-bump` keeps the old behaviour: refuse, and let a person decide.
@@ -298,7 +323,8 @@ async function main() {
(args.includes("--no-bump") ? " (drop --no-bump to stamp it)" : ""));
current = stamped;
const receipt = newRelease(current.hash, current.commit, current.severity, previous);
- save(receipt);
+ receipt.desired.branch = branch;
+ if (!dryRun) save(receipt);
await reconcile(receipt, { dryRun });
return print(receipt, current);
}
@@ -306,6 +332,7 @@ async function main() {
const receipt = newRelease(current.hash, current.commit,
current.severity, previous);
receipt.desired.development = true;
+ if (dryRun) return print(receipt, current);
save(receipt);
// Asking for the Xbox explicitly and finding it asleep IS a failure of
// what you asked for β unlike the unified deploy, there is no other
@@ -338,4 +365,5 @@ async function main() {
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url))
- main().catch((error) => { console.error(error.message); process.exitCode = 1; });
+ (process.argv[2] === "status" || !process.argv[2] || process.argv.includes("--dry-run")
+ ? main() : withReleaseLock(`${receiptPath}.lock`, main)).catch((error) => { console.error(error.message); process.exitCode = 1; });
diff --git a/xbox/tools/oskiewar-release.test.mjs b/xbox/tools/oskiewar-release.test.mjs
index 1cdc83a025..5743a21fa4 100644
--- a/xbox/tools/oskiewar-release.test.mjs
+++ b/xbox/tools/oskiewar-release.test.mjs
@@ -119,7 +119,7 @@ test("the stamp carries the hash-bound social preview with it", () => {
test("the stamp pushes, because lith deploys pushed state only", () => {
const stamp = source.match(/function stampBuildVersion[\s\S]*?\n}\n/)[0];
- assert.match(stamp, /run\("git", \["push"\]\)/);
+ assert.match(stamp, /run\("git", \["push", "origin", `HEAD:refs\/heads\//);
// A new commit, never an amend: HEAD here is usually already pushed, and
// another session commits into this same checkout.
assert.doesNotMatch(stamp, /--amend/);
diff --git a/xbox/tools/tests/oskiewar-release.test.mjs b/xbox/tools/tests/oskiewar-release.test.mjs
index 9c60c821a4..e373e0f782 100644
--- a/xbox/tools/tests/oskiewar-release.test.mjs
+++ b/xbox/tools/tests/oskiewar-release.test.mjs
@@ -15,7 +15,7 @@ test("native changes escalate the release", () => {
test("a new fingerprint leaves every channel with a durable obligation", () => {
const receipt = newRelease("next", "commit", "live", {
- channels: { web: { hash: "old" }, ios: { hash: "next" }, xbox: { hash: "old" } },
+ channels: { web: { hash: "old" }, ios: { hash: "next", status: "current" }, xbox: { hash: "old" } },
});
assert.equal(receipt.channels.web.status, "pending");
assert.equal(receipt.channels.ios.status, "current");