17 Commits
Author SHA1 Message Date
Gabriel Brown 8316c8a1bf Drop the rule that left starting dev servers to the user 2026-11-05 16:51:31 -05:00
gibandClaude Opus 5.5 86ea1a3374 Wait up to 6 hours for Nextcloud's borg lock before deferring the weekly media backup
AIO's borg now runs daily from 07:30 UTC for about 2 hours, so the Saturday
05:00 fire can find the lock held and skip the whole week, which trips Uptime
Kuma's 8-day push window. Poll the lock every 5 minutes for up to 6 hours and
defer only if it is still held after that.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 15:31:24 -05:00
Gabriel Brown c6d299b675 Seat the ticket review council on Claude Opus 5.5 and Codex GPT-6.1 Sol
MiMo leaves the council. Codex is now installed and signed in through T3,
so the second model family comes from GPT-6.1 Sol instead.
2026-11-05 15:30:54 -05:00
Gabriel Brown 6c8db4e845 Fix: Install the whole Codex package so codex-code-mode-host sits beside bin/codex
Codex 0.165.0 ships as a package tree and runs bin/codex-code-mode-host,
codex-path/ and codex-resources/ relative to itself. The installer verified
the full archive but kept only bin/codex, so code mode failed. Keep the whole
verified tree, link ~/.local/bin/codex to its bin/codex, and replace the
receipted flat install earlier runs left behind once the new tree verifies.
2026-11-05 15:14:47 -05:00
Gabriel Brown 86f6e391b7 Run the ticket skills through T3 Code, with a review council before pre-mr-review
Agents now start pre-mr-review on the work account themselves, through a Klaude
delegate, and continue from its verdict. A council of models on my own accounts
replaces the single reviewer before it, and adding a seat is one table row.

Proof comes from T3's browser, with mp4 recordings, and capture-proof stays as
the fallback. Ticket pages carry their own data, so index.html renders alone in
T3's file viewer, and worktree threads share the main checkout's docs.

A PreToolUse hook sends subagents in T3 threads to delegate_task. Blocker, proof,
review and MR summary rules come from ideas in t3dotgg/tstack.
2026-11-04 18:04:18 -05:00
gib 968e0a1045 VPS: add the WristCode gateway stack 2026-11-01 13:05:24 -05:00
Gabriel Brown d5ef335d61 Link the agent half of user/ on the Mac mini
Manifest lines can now name an OS. The SSH config, pwswitch and espanso are
Linux only, so link-user on the Mac links CLAUDE.md, rules, skills and the
opencode commands and nothing else. link-mcp no longer aborts under the Mac's
bash 3.2 when a server has no token.
2026-11-01 12:41:58 -05:00
Gabriel Brown 27e28b0dd1 Show mocks in T3 Code threads, and drop the t3 MCP servers that T3 already provides 2026-10-30 14:58:46 -04:00
Gabriel Brown 4e39be860c Make ticket pages open in T3 Code's file viewer, and show plans, mocks and progress inline in the thread 2026-10-30 14:58:46 -04:00
Gabriel Brown e21a4c24f0 Offer T3 Code as the preferred agent, opening a thread in the Panama project, and install it with the desktop 2026-10-30 14:58:46 -04:00
Gabriel Brown 5af5d7a96b Pin Terra on a server that already has it, before its package update 2026-10-30 14:19:14 -04:00
Gabriel Brown c6fb2fa767 Pin Terra through repos.override.d so terra-release updates stop undoing it 2026-10-30 14:04:31 -04:00
Gabriel Brown 042885f113 Keep the home server's root backup scripts with the rest of its tooling 2026-10-30 13:59:29 -04:00
Gabriel Brown 7cc98cd318 Make pwswitch off cover sudo -v, so Panama's installer runs without a password 2026-10-30 13:57:39 -04:00
Gabriel Brown cb089b39ea Sync both server snapshots with what the live machines run, and keep their backup tooling 2026-10-30 13:44:29 -04:00
Gabriel Brown 95fa480469 Let a server service carry helper scripts and timers, starting with UseSend's cert refresh 2026-10-30 13:44:29 -04:00
Gabriel Brown a987cbab66 Generate opencode's config from a tracked template, with matching commands, agents and pwswitch 2026-10-30 13:44:29 -04:00
102 changed files with 5297 additions and 424 deletions

No files matched your search

+1 -1
View File
@@ -250,7 +250,7 @@ docs/ Settings reference, and the design specs behind the work
## Tests
192 of them, under `tests/`. `tests/contracts.manifest` classifies every
193 of them, under `tests/`. `tests/contracts.manifest` classifies every
contract by the capabilities it needs. Run the hermetic set, or grant a
specific external capability when automation needs it:
+40 -5
View File
@@ -10,9 +10,16 @@
# start time, so choosing an agent in Settings takes effect on the next crash
# without restarting anything:
#
# preferredAgent none | claude | codex ("none" is the default: silence)
# preferredAgent none | claude | codex | t3 ("none" is the default: silence)
# agentAutoApprove true -> the agent starts in its own "don't stop to ask"
# mode; false -> its normal prompting mode, untouched.
# T3 Code ignores it: a T3 thread runs in the mode its
# project is set to, which is a choice made inside T3.
#
# claude and codex open in a kitty window. t3 starts a thread in the Panama
# project on the running T3 Code server and brings it up in the desktop app,
# so the investigation lives beside every other thread instead of in a
# terminal of its own.
#
# "none" exits 0 without a word. It is not an error to have no agent; it is the
# shipped state, and a rung that shouted about it would be a rung that gets
@@ -95,8 +102,9 @@ usage() {
cat <<'EOF'
Usage: panama-agent [--prompt "text"]
Opens the agent named by preferredAgent in a terminal, in the Panama checkout.
With no agent chosen, exits silently: choose one on Settings > System > Agents.
Opens the agent named by preferredAgent in the Panama checkout: a terminal for
claude and codex, a thread in the T3 Code app for t3. With no agent chosen,
exits silently: choose one on Settings > System > Agents.
EOF
}
@@ -139,7 +147,7 @@ agent="$(read_setting preferredAgent none)"
auto_approve="$(read_setting agentAutoApprove true)"
case "$agent" in
claude | codex) ;;
claude | codex | t3) ;;
*)
printf 'panama-agent: unsupported preferredAgent: %s\n' "$agent" >&2
printf 'Choose one on Settings > System > Agents.\n' >&2
@@ -148,7 +156,8 @@ case "$agent" in
esac
# Resolved to a path before argv is built, so kitty is never asked to repeat a
# PATH lookup this script has already done more carefully than kitty could.
# PATH lookup this script has already done more carefully than kitty could. T3
# Code's CLI is `t3`, which the desktop app writes into ~/.local/bin.
if ! agent_bin="$(resolve_agent "$agent")"; then
printf 'panama-agent: %s is not installed.\n' "$agent" >&2
printf 'Looked on PATH and in %s.\n' "$HOME/.local/bin" >&2
@@ -156,6 +165,32 @@ if ! agent_bin="$(resolve_agent "$agent")"; then
exit 1
fi
# T3 Code: no terminal. The thread starts on the running server in the
# project whose root is the checkout, with the project's default model, and
# the t3code:// link opens it in the app. `project add` refuses a root that is
# already a project, which is the common case, so its failure is not one. The
# prompt goes over stdin ("-"), so a prompt that begins with a dash is never
# read as a flag. With no prompt there is nothing to start, so the app just
# opens on the project.
if [[ "$agent" == "t3" ]]; then
repair_path
"$agent_bin" project add "$PANAMA_PATH" >/dev/null 2>&1 || true
if [[ -z "$prompt" ]]; then
exec setsid "$agent_bin" app "$PANAMA_PATH"
fi
if ! thread="$(printf '%s' "$prompt" | "$agent_bin" thread start "$PANAMA_PATH" -)"; then
printf 'panama-agent: T3 Code could not start a thread.\n' >&2
printf 'Is the T3 Code server running? Check: systemctl --user status t3code\n' >&2
exit 1
fi
environment_file="${T3CODE_HOME:-$HOME/.t3}/userdata/environment-id"
environment="$(cat "$environment_file" 2>/dev/null || true)"
if [[ -n "$environment" ]]; then
exec setsid xdg-open "t3code://threads/$environment/$thread"
fi
exec setsid "$agent_bin" app "$PANAMA_PATH"
fi
# The per-agent launch table. VERIFIED against the installed binaries' --help on
# 2026-08-25 (claude 2.1.245, codex-cli 0.149.1) -- these flags are not the same
# from release to release, so re-run --help before changing them.
+65 -15
View File
@@ -4,8 +4,10 @@
#
# The repository carries the catalog: server/vps/<Name>/ holds a
# compose.yml, a podman-<name>.service user unit, and an .env.example naming
# what the service needs told. The machine carries the rest: ~/Server/<Name>/
# is a real directory owning the .env (secrets never sit inside the checkout,
# what the service needs told. A service may also carry helper units (any
# other *.service or *.timer, e.g. a cert refresh) and helper files (scripts,
# a README) beside them. The machine carries the rest: ~/Server/<Name>/ is a
# real directory owning the .env (secrets never sit inside the checkout,
# gitignored or not) and the bind-mounted data. Only the tracked files are
# symlinked, so a `git clean` in the repo can never reach a database.
#
@@ -83,12 +85,12 @@ require_service() {
fi
}
# The one unit file a service directory carries. Its filename is its identity
# -- podman-<name>.service, exactly what lands in the systemd user directory --
# so nothing here invents a name that could drift from the file's.
# The one compose unit a service directory carries. Its filename is its
# identity -- podman-<name>.service, exactly what lands in the systemd user
# directory -- so nothing here invents a name that could drift from the file's.
unit_path_for() {
local name="$1" unit
for unit in "$SERVER_DIR/$name"/*.service; do
for unit in "$SERVER_DIR/$name"/podman-*.service; do
[[ -e "$unit" ]] || continue
printf '%s' "$unit"
return 0
@@ -96,6 +98,44 @@ unit_path_for() {
return 1
}
# Every other unit in the directory: helper services and the timers that fire
# them. They are linked beside the compose unit, and each timer is enabled.
helper_units_for() {
local name="$1" unit
for unit in "$SERVER_DIR/$name"/*.service "$SERVER_DIR/$name"/*.timer; do
[[ -e "$unit" ]] || continue
[[ "$(basename "$unit")" == podman-*.service ]] && continue
printf '%s\n' "$unit"
done
}
# The files linked into ~/Server/<Name>/: every visible top-level file that is
# not a unit. That is compose.yml plus any helper script or README; dotfiles
# are skipped so .env.example (and a stray .env) never become links.
live_files_for() {
local name="$1" file
for file in "$SERVER_DIR/$name"/*; do
[[ -f "$file" ]] || continue
case "$file" in *.service|*.timer) continue ;; esac
printf '%s\n' "$file"
done
}
link_service_files() {
local name="$1" file unit
mkdir -p "$TARGET_DIR/$name" "$UNIT_DIR"
while IFS= read -r file; do
link_file "$file" "$TARGET_DIR/$name/$(basename "$file")"
done < <(live_files_for "$name")
while IFS= read -r unit; do
link_file "$unit" "$UNIT_DIR/$(basename "$unit")"
done < <(helper_units_for "$name")
}
helper_timers_for() {
helper_units_for "$1" | grep '\.timer$' | xargs -r -n1 basename
}
unit_name_for() {
local unit
unit="$(unit_path_for "$1")" || return 1
@@ -111,10 +151,12 @@ unit_installed() {
[[ -L "$UNIT_DIR/$unit_name" ]]
}
# Everything this machine links for the service, so a change to a helper
# script or timer is reported by relink just like a compose change.
definition_hash() {
local name="$1" unit
unit="$(unit_path_for "$name")" || unit=/dev/null
cat "$SERVER_DIR/$name/compose.yml" "$unit" 2>/dev/null | sha256sum | cut -d' ' -f1
local name="$1"
{ live_files_for "$name"; unit_path_for "$name" && echo; helper_units_for "$name"; } \
| sort | xargs -r -d '\n' cat 2>/dev/null | sha256sum | cut -d' ' -f1
}
recorded_hash() {
@@ -198,6 +240,7 @@ cmd_enable() {
fi
mkdir -p "$UNIT_DIR"
link_service_files "$name"
link_file "$unit_path" "$UNIT_DIR/$unit_name"
systemctl --user daemon-reload
if systemctl --user enable --now "$unit_name"; then
@@ -207,6 +250,10 @@ cmd_enable() {
err "$unit_name did not start; see: systemctl --user status $unit_name"
exit 1
fi
local timer
for timer in $(helper_timers_for "$name"); do
systemctl --user enable --now "$timer" && ok "Enabled $timer"
done
}
cmd_disable() {
@@ -216,11 +263,15 @@ cmd_disable() {
local unit_name
unit_name="$(unit_name_for "$name")" || { err "$name has no unit file in the catalog"; exit 1; }
local timer unit
for timer in $(helper_timers_for "$name"); do
systemctl --user disable --now "$timer" 2>/dev/null || true
done
systemctl --user disable --now "$unit_name" 2>/dev/null || true
if [[ -L "$UNIT_DIR/$unit_name" ]]; then
rm "$UNIT_DIR/$unit_name"
systemctl --user daemon-reload
fi
for unit in "$UNIT_DIR/$unit_name" $(helper_units_for "$name" | xargs -r -n1 basename | sed "s|^|$UNIT_DIR/|"); do
[[ -L "$unit" ]] && rm "$unit"
done
systemctl --user daemon-reload
ok "$name disabled. ~/Server/$name, its .env and its data were not touched."
}
@@ -267,8 +318,7 @@ cmd_relink() {
unit_installed "$name" || continue
link_file "$unit_path" "$UNIT_DIR/$unit_name"
mkdir -p "$TARGET_DIR/$name"
link_file "$SERVER_DIR/$name/compose.yml" "$TARGET_DIR/$name/compose.yml"
link_service_files "$name"
refreshed=$((refreshed + 1))
# Changed since the last time this machine looked, which is what makes a
@@ -184,7 +184,8 @@ Singleton {
options: [
{ value: "none", label: "None" },
{ value: "claude", label: "Claude Code" },
{ value: "codex", label: "Codex" }
{ value: "codex", label: "Codex" },
{ value: "t3", label: "T3 Code" }
]
},
{
@@ -205,7 +206,7 @@ Singleton {
{
key: "agentAutoApprove", type: "bool", def: true, group: "agents",
label: "Launched agents approve their own tools",
detail: "Investigations run without permission prompts. The diagnose skill still holds agents to reading rather than fixing, and root still goes through panama-sudo, reason and all"
detail: "Investigations run without permission prompts. T3 Code threads use the mode set on the Panama project in T3 instead. The diagnose skill still holds agents to reading rather than fixing, and root still goes through panama-sudo, reason and all"
},
{
key: "agentUsageClaude", type: "bool", def: true, group: "agents",
@@ -37,8 +37,8 @@ SettingsPage {
// them not to believe the page.
//
// Probed through a LOGIN shell rather than this one. Quickshell is started
// by systemd, whose PATH does not include ~/.local/bin -- where both of
// these usually land -- and a login shell is the environment the launcher
// by systemd, whose PATH does not include ~/.local/bin -- where all three of
// these usually land (T3 Code's CLI is `t3`) -- and a login shell is the environment the launcher
// hands the agent when it spawns a terminal. Asking with the shell's own
// PATH would report "not installed" for an agent that starts perfectly.
property var installed: ({})
@@ -60,7 +60,7 @@ SettingsPage {
running: true
command: ["bash", "-lc",
"for agent in claude codex; do command -v \"$agent\" >/dev/null 2>&1 && printf '%s\\n' \"$agent\"; done"]
"for agent in claude codex t3; do command -v \"$agent\" >/dev/null 2>&1 && printf '%s\\n' \"$agent\"; done"]
stdout: StdioCollector {
onStreamFinished: root.absorbProbe(this.text)
}
@@ -70,6 +70,7 @@ SettingsPage {
switch (value) {
case "claude": return "starred-symbolic";
case "codex": return "utilities-terminal-symbolic";
case "t3": return "view-dual-symbolic";
default: return "notifications-disabled-symbolic";
}
}
@@ -356,6 +356,7 @@ Singleton {
{ label: "AI assistant", detail: "Choose the agent this desktop hands crashes, failed reloads and red health checks to", page: "agents" },
{ label: "Claude Code", detail: "Use Claude Code as the agent the desktop escalates to, and watch its usage", page: "agents" },
{ label: "Codex", detail: "Use Codex as the agent the desktop escalates to, and watch its usage", page: "agents" },
{ label: "T3 Code", detail: "Open a T3 Code thread in the Panama project when the desktop escalates something", page: "agents" },
{ label: "Diagnose crashes", detail: "When a program dumps core, the notification carries a click that opens your AI assistant with the crash details", page: "agents" },
{ label: "Ask the agent", detail: "A red System Health check with no repair left hands its snapshot to your AI assistant", page: "agents" },
{ label: "Agent usage panel", detail: "Limits, resets and tokens for each agent, opened from the bar", page: "agents" },
@@ -5,6 +5,6 @@
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Open Agents in Settings.
# @vicinae.keywords ["settings", "preferred agent", "offer to diagnose crashes", "offer help when the shell fails to reload", "system health hands off unrepairable checks", "launched agents approve their own tools", "collect claude code usage", "collect codex usage", "refresh interval", "ai assistant", "claude code", "codex", "diagnose crashes"]
# @vicinae.keywords ["settings", "preferred agent", "offer to diagnose crashes", "offer help when the shell fails to reload", "system health hands off unrepairable checks", "launched agents approve their own tools", "collect claude code usage", "collect codex usage", "refresh interval", "ai assistant", "claude code", "codex", "t3 code"]
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page agents
+2 -2
View File
@@ -28,11 +28,11 @@ Found on **System › Agents**.
| Setting | Default | What it does |
|---|---|---|
| **Preferred agent**<br>`preferredAgent` | none | Who answers when the desktop offers to investigate something Choices: None, Claude Code, Codex. |
| **Preferred agent**<br>`preferredAgent` | none | Who answers when the desktop offers to investigate something Choices: None, Claude Code, Codex, T3 Code. |
| **Offer to diagnose crashes**<br>`crashDiagnoseOffer` | true | When a program dumps core, the notification carries a click that opens the preferred agent mid-investigation with the crash details in hand |
| **Offer help when the shell fails to reload**<br>`reloadFailureOffer` | true | A broken change to the shell's own configuration offers the failing log to the agent |
| **System Health hands off unrepairable checks**<br>`healthAgentHandoff` | true | A red check with no repair, or whose repair failed, grows an Ask-the-agent button carrying the check's snapshot |
| **Launched agents approve their own tools**<br>`agentAutoApprove` | true | Investigations run without permission prompts. The diagnose skill still holds agents to reading rather than fixing, and root still goes through panama-sudo, reason and all |
| **Launched agents approve their own tools**<br>`agentAutoApprove` | true | Investigations run without permission prompts. T3 Code threads use the mode set on the Panama project in T3 instead. The diagnose skill still holds agents to reading rather than fixing, and root still goes through panama-sudo, reason and all |
| **Collect Claude Code usage**<br>`agentUsageClaude` | true | Limits from Anthropic's usage endpoint, tokens from the local transcripts |
| **Collect Codex usage**<br>`agentUsageCodex` | true | Limits over the Codex app-server, sessions from its local files |
| **Refresh interval**<br>`agentUsageRefreshMinutes` | 15 min | How often the usage collectors ask for fresh numbers, in minutes. Range 5–60. |
+42 -2
View File
@@ -8,7 +8,9 @@ screen, plus rootless podman compose services managed as systemd user units.
server/
vps/ The catalog panama-server installs from, one directory
per service: compose.yml, the podman-<name>.service user
unit, and an .env.example naming what the service needs told
unit, and an .env.example naming what the service needs told.
Loose files beside the directories are the VPS's own
host tooling (see "Host tooling" below)
home/ The home server's services in the same shape (see below)
scripts/ update-containers, the nightly image updater
systemd/ The units behind it, linked by setup-server
@@ -25,6 +27,8 @@ installable: panama-server reads `server/vps/` only, so a service moves into
the catalog when it actually moves to podman.
Paths keep their nesting (`server/home/Agents/Gitea` is `~/Server/Agents/Gitea`).
`Media/compact-tv/` is the nightly TV re-encode job that runs beside the Media
stack, with its own README.
Three services are checkouts of upstream repositories. For those, only the
local files are kept, and an `UPSTREAM` file names the repository and the exact
commit the checkout sits at: `PostHog`, `Sentry` and `Plausible`.
@@ -38,7 +42,8 @@ compose and its copy here can differ on those lines alone.
The repository carries the definitions; the machine carries the state.
`panama server enable <Name>` creates `~/Server/<Name>/` as a **real
directory** and symlinks only `compose.yml` into it; the unit symlinks into
directory** and symlinks the service's visible top-level files into it:
`compose.yml`, plus any helper script or README. The units symlink into
`~/.config/systemd/user/`. The `.env` (seeded once from `.env.example`, then
yours) and the bind-mounted `data/` live in `~/Server/<Name>/` and are never
inside the checkout — so nothing git can do, `clean -fdx` included, can reach
@@ -99,6 +104,41 @@ existing service. The unit is the standard oneshot `podman compose up -d`
shape (`WorkingDirectory=%h/Server/<Name>`); the shape contract pins the
details. Secrets go in `.env.example` as `KEY=CHANGE_ME` lines.
A service that needs a scheduled job of its own carries it beside the
compose unit: a helper `<job>.service` and a `<job>.timer` with the same
name, plus the script it runs. Enable links all three and enables the timer.
Disable stops the timer and unlinks the units. `UseSend` is the example: its
`usesend-cert-refresh` timer keeps the SMTP proxy's certificate in step with
Nginx Proxy Manager's.
## Host tooling
Each server's own scripts and runbooks sit as loose files at the top of its
tree. They are reference copies for rebuilding a machine. Nothing installs
them.
- `vps/backup`, `vps/server-backup.service`, `vps/server-backup.timer`: the
VPS's nightly backup, run as a user unit. It dumps every database into
`~/Server/_backup_state/`, then rsyncs `~/Server` to the home server's ZFS
pool with `--fake-super`, so subuid ownership survives the trip.
- `vps/verify-rootless`: the post-reboot health check for the rootless
stack. It covers linger, the unprivileged port floor, unit state and boot
enablement, postgres, endpoints, and admin ports bound to the WireGuard
address.
- `vps/AGENTS.md`: the VPS runbook, with both public IPs redacted.
- `home/RESTORE.md`: how to restore the home server's `~/Server` from the
ZFS backup.
- `home/backup` and `home/systemd/`: the home server's backup trigger and its
root system units (`server-backup` nightly, `media-backup` weekly).
- `home/server-backup` and `home/media-backup`: the scripts those units run.
On that machine they live in `/usr/local/sbin`, root-owned, because a
root-run script must not sit in a directory the user can write to. Restore
them there, not into `~/Server`.
The VPS units carry `RequiresMountsFor=/home/gib/Media` because that host
keeps its podman graphroot on an attached volume mounted there. The setting
is harmless on a machine without the mount.
## Nightly updates
`server/scripts/update-containers` pulls images and restarts changed
+1 -1
View File
@@ -19,7 +19,7 @@ x-runner-env: &runner-env
TZ: America/New_York
GITEA_INSTANCE_URL: https://git.gbrown.org
GITEA_RUNNER_REGISTRATION_TOKEN: ${GITEA_RUNNER_REGISTRATION_TOKEN}
GITEA_RUNNER_LABELS: "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest,ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04,ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04"
GITEA_RUNNER_LABELS: "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest,ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04,ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04,build:docker://docker.gitea.com/runner-images:ubuntu-latest"
DOCKER_TLS_VERIFY: "1"
DOCKER_CERT_PATH: /certs/client
CONFIG_FILE: /data/config.yml
+1 -1
View File
@@ -18,7 +18,7 @@ services:
# restart: unless-stopped
portainer-agent:
image: portainer/agent:2.33.2
image: portainer/agent:latest
container_name: portainer-agent
hostname: portainer-agent
networks:
+3 -3
View File
@@ -43,8 +43,8 @@ volumes:
services:
gbrown-backend:
# Pinned to the build the VPS Convex stacks already run. Bump on purpose.
image: ghcr.io/get-convex/convex-backend@sha256:d715e9ec088784407ca4ba2d3db592702cd328d02c76cdca3852c0018f2a76b4
# Pinned to the build the VPS Convex stacks run (bumped 2026-10-28). Bump on purpose.
image: ghcr.io/convex/convex-backend@sha256:207cf95def42f01614b6e3af0af5c20d0dcaa5ff2adf258ee7bc9555576644fa
container_name: gbrown-backend
hostname: gbrown-backend
networks: ['nginx-bridge']
@@ -70,7 +70,7 @@ services:
gbrown-dashboard:
# Released alongside the backend; pinned with it.
image: ghcr.io/get-convex/convex-dashboard@sha256:f85cf0d0448b9c835ae3df5c7c1c6f0145dd4c8f0f92eb0245daf304efdd9f1a
image: ghcr.io/convex/convex-dashboard@sha256:a55bf8abca1332f787bc4d4a79850182175027be7b70aa230be4389a1d3268e2
container_name: gbrown-dashboard
hostname: gbrown-dashboard
networks: ['nginx-bridge']
+109
View File
@@ -0,0 +1,109 @@
# compact-tv
Sonarr and Radarr each have exactly four quality profiles. Two of them (**Standard** and
**Mini**) also have their files shrunk by `compact-tv.py`, which runs every night.
| Profile | Used for | What gets kept |
|---|---|---|
| **Mini** | shows I demote hard | TV: 720p at 800 kbps (~0.42 GB per hour). Movies: 720p at 1500 kbps |
| **Standard** | **every request from other users (Seerr default)**, and anything I demote | TV: 1080p at 1200 kbps (~0.6 GB per hour). Movies: 1080p at 2500 kbps (~1.2 GB per hour) |
| **HD** | my own library, unchanged | whatever downloads, up to 1080p |
| **UHD** | only me, through Seerr's 4K servers | 4K |
SD sources keep their resolution, at 600 kbps for TV and 900 kbps for movies. Movies get
higher rates because dark scenes, grain and action break down first at low bitrates. Nothing is ever
upscaled. Audio becomes Opus: 96 kbps for stereo, 160 for 5.1, 192 for 7.1. Subtitles,
chapters and attachments are copied as they are. I picked the rates from sample encodes of
Shameless S05E06 (VMAF ~91 for Standard, ~86 for Mini).
## How the profiles work
- **Standard and Mini** are copies of HD that upgrade only toward small releases (see below),
so Sonarr and Radarr never replace a file with a bigger one.
- **Small downloads first.** Both profiles prefer small releases when they grab:
- The "Small Encode" format (MeGusta, PSA, ELiTE, GalaxyTV/RG, YTS/YIFY) scores +3000.
- LQ scores 0. In HD and UHD it scores -10000, which keeps those groups out of HD.
- Standard merges HDTV/WEB/Bluray at each resolution, so the format score decides between
sources rather than the source type.
- Mini merges 720p and 1080p into one group and adds +1000 for 720p.
- Many files therefore arrive already small, and the job skips them.
- **Minimum sizes are shared by every profile.** For 720p I lowered them to 5 MB per minute
and for 1080p to 6 MB per minute (8 for Bluray-1080p), so small encodes are not rejected.
HD still never takes the LQ groups.
- **Seerr:**
- The "Sonarr - HD" and "Radarr" servers default to Standard. Only my admin account sees
the profile picker, so other users always get Standard. I pick HD or UHD per request.
- Other users no longer have "Request 4K".
- **Configarr is opt-in** (`docker compose --profile configarr up configarr`). Its templates
would recreate the old TRaSH profiles and size limits.
- Backups from before all this: `backups/pre-four-profiles-20261028/`. That covers both
apps' profiles, custom formats and size definitions, which title was on which profile,
and Seerr's settings. `setup-small-profiles.py` reapplies the Standard and Mini settings.
## How the job works
The nightly run (00:30 to 08:00) has two phases.
**1. Search first.** It asks Sonarr and Radarr to search titles on Standard or Mini that it
hasn't searched before, biggest first: 5 shows and 20 movies a night, so downloads and
indexer use stay spread out. Standard and Mini upgrade only toward small releases:
- Every allowed quality sits in one group, so no resolution or source upgrade can happen.
- An upgrade needs a custom-format score jump of at least 2500, and upgrades stop at 3000.
Only gaining "Small Encode" clears that bar.
So a search replaces a big file only with a small encode, never with a big HEVC release.
After that, the apps' regular RSS checks keep picking up small releases on their own.
**2. Encode what's left.** A title becomes eligible for encoding 7 days after its search,
and only its files that are still big get encoded. Files that arrive after the title's
search work differently:
- This covers new episodes of ongoing shows, and fresh requests.
- The first night they're seen, they get their own search (one episode or movie at a time).
- They're encoded only if they're still big 3 days later.
Downloads never wait for any of this, so new episodes and requests land in the library as
soon as they download. Only encoding waits. For each file, the job encodes to
`~/.cache/compact-tv/work.mkv` on the NVMe and then checks the result:
- the duration matches the original
- the audio and subtitle track counts match
- the file is smaller
It then renames the new file over the original, keeping the same name with `.mkv`, and asks
Sonarr or Radarr to rescan. Plex picks the change up through its folder watcher, and the
episode keeps its Plex ID, so watch history stays. If a small release shows up later, the
app may still replace an encoded file with it, which costs bandwidth, not GPU.
Other details:
- Each output carries a `COMPACT_TIER` tag. A file is skipped if re-encoding would save less
than 30%. Ruled-out files are cached by size in `state.json`, so they aren't probed every
night. Search dates and failures are kept there too.
- A downloaded HEVC file no bigger than twice the target size counts as done. That is the
small release a search brought in (a MeGusta 1080p episode runs about 2.3 Mbps against
Standard's 1.2), and it is neither re-encoded nor searched again. The job's own tagged
outputs are exempt, so Standard to Mini still works.
- **Skipped:** HDR and Dolby Vision sources (they need tone mapping), subtitle tracks ffprobe
can't identify, and files that have failed twice.
- Speed is about 0.19 hours of GPU per hour of 1080p video (NVENC is the bottleneck). The job
stops starting new encodes at 08:00.
- `cost-report.py` estimates, per title, the GPU hours the job would need against the size of
a fresh small download.
- A `PAUSE` file in this folder makes the nightly run do nothing.
## Going back up
Moving down works: Standard to Mini re-encodes the Standard copy. Moving up does not
restore quality. To get quality back, I set the title to HD and search, which redownloads
it at full size.
## Running it
./compact-tv.py --dry-run # what tonight would do
./compact-tv.py --search-all --limit 0 # search every unsearched title now, encode nothing
touch PAUSE # skip nightly runs until PAUSE is deleted
./compact-tv.py --series "Shameless (US)" --limit 1
systemctl status compact-tv.timer compact-tv.service
tail -f compact-tv.log
The installed units are copies of `compact-tv.service` and `compact-tv.timer` from this
folder. `ExecStart` goes through `/usr/bin/python3` because SELinux won't let systemd
execute a file labeled as home content.
+348
View File
@@ -0,0 +1,348 @@
#!/usr/bin/env python3
"""Shrink shows and movies that Sonarr/Radarr have on the "Standard" or "Mini" profile.
Demoting something is just picking one of those profiles. Each night this job first asks
Sonarr/Radarr to search a few titles it hasn't searched before. Both profiles upgrade only
toward small releases, so the search swaps whatever big files have a small release for
bandwidth instead of GPU. A week after a title's search, the job re-encodes whatever is
still big to HEVC on the GPU (NVENC) at the tier's bitrate, verifies the result, swaps it in
place of the original, and has Sonarr/Radarr rescan. Plex and Jellyfin pick up changes
through their own folder watchers.
Moving something back up means switching it to HD and searching, which redownloads it at
full size. See README.md.
Usage:
compact-tv.py [--until HH:MM] [--series TITLE] [--limit N] [--dry-run]
--series matches a show or movie title. Runs as gib (files are gib-owned) from
compact-tv.service, nightly.
"""
import argparse, datetime, fcntl, json, os, pathlib, re, shutil, subprocess, sys, time, urllib.request
HERE = pathlib.Path(__file__).resolve().parent
STATE = HERE / "state.json"
LOG = HERE / "compact-tv.log"
WORK = pathlib.Path.home() / ".cache" / "compact-tv" # NVMe, so half-done encodes never land in a ZFS snapshot
VOLUMES = pathlib.Path.home() / "Server/Media/volumes"
# Per app: API port, container path -> host path, and how to list one title's files.
APPS = {
"sonarr": {"port": 8989, "paths": ("/tv/", "/home/gib/Public/TV/"), "titles": "/series",
"files": lambda call, t: call(f"/episodefile?seriesId={t['id']}"),
"rescan": lambda t: {"name": "RescanSeries", "seriesId": t["id"]}},
"radarr": {"port": 7878, "paths": ("/movies/", "/home/gib/Public/Movies/"), "titles": "/movie",
"files": lambda call, t: [t["movieFile"]] if t.get("movieFile") else [],
"rescan": lambda t: {"name": "RescanMovie", "movieId": t["id"]}},
}
# Video bitrate (kbps) per output class, and the widest output each tier allows.
# TV floors come from watching samples: 1200 kbps at 1080p, 800 at 720p, 600 at SD.
# Movies get roughly double: dark scenes, grain and action break down first at low rates,
# and a 2-hour movie at 2500 kbps is still ~2.3 GB (a quarter of a typical 1080p file).
# That also leaves already-small releases (YTS-style, ~1.5-2 GB) under the 30% bar.
# Nothing is ever upscaled: a 720p or SD source keeps its size and uses that class's rate.
TIERS = {
"Standard": {"max_width": 1920, "kbps": {"sonarr": {"1080": 1200, "720": 1200, "sd": 600},
"radarr": {"1080": 2500, "720": 1800, "sd": 900}}},
"Mini": {"max_width": 1280, "kbps": {"sonarr": {"1080": 800, "720": 800, "sd": 600},
"radarr": {"1080": 1500, "720": 1500, "sd": 900}}},
}
MIN_SAVING = 0.30 # leave a file alone unless re-encoding saves at least this much
MAX_FAILURES = 2 # after this many failed attempts a file is skipped for good
# Search first, encode later. Standard/Mini upgrade only toward small releases (see
# setup-small-profiles.py), so asking the *arr to search a title swaps whatever big files
# have a small release, for bandwidth instead of GPU. Each night searches a few titles not
# searched before, biggest first, so downloads and indexer use stay spread out. A title's
# files are only encoded once its search is SEARCH_GRACE_DAYS old.
#
# Files that arrive after their title's search (new episodes, fresh requests) were never
# searched for a small copy, so the job searches that episode or movie on its own first,
# and encodes it only if it is still big FILE_SEARCH_GRACE_DAYS later. Downloads are never
# delayed by any of this; only encoding waits.
SEARCHES_PER_NIGHT = {"sonarr": 5, "radarr": 20}
SEARCH_GRACE_DAYS = 7
FILE_SEARCH_GRACE_DAYS = 3 # small encodes usually appear within hours to a day; 3 leaves room
HDR_TRANSFERS = {"smpte2084", "arib-std-b67"}
def log(msg):
line = f"{datetime.datetime.now():%Y-%m-%d %H:%M:%S} {msg}"
print(line, flush=True)
with open(LOG, "a") as f:
f.write(line + "\n")
# ── Sonarr / Radarr ─────────────────────────────────────────────────────────
def arr(app):
"""Return a call(path, body=None) for the app's v3 API."""
key = re.search(r"<ApiKey>([^<]+)", (VOLUMES / app / "config.xml").read_text()).group(1)
ip = subprocess.run(["docker", "inspect", app, "--format",
"{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}"],
capture_output=True, text=True, check=True).stdout.split()[0]
base = f"http://{ip}:{APPS[app]['port']}/api/v3"
def call(path, body=None):
req = urllib.request.Request(base + path, headers={"X-Api-Key": key, "Content-Type": "application/json"},
data=json.dumps(body).encode() if body is not None else None)
with urllib.request.urlopen(req, timeout=60) as r:
return json.load(r)
return call
# ── probing and planning ────────────────────────────────────────────────────
def probe(path):
out = subprocess.run(["ffprobe", "-v", "error", "-print_format", "json", "-show_format", "-show_streams", str(path)],
capture_output=True, text=True)
return json.loads(out.stdout) if out.returncode == 0 else None
def subtitles(info):
"""Subtitle tracks ffmpeg can carry over. Tracks ffprobe can't identify are dropped."""
return [s for s in info["streams"] if s["codec_type"] == "subtitle" and s.get("codec_name")]
def audio_kbps(channels):
return 96 if channels <= 2 else 160 if channels <= 6 else 192
def plan(info, tier, app):
"""Return (ffmpeg args, estimated output bytes) for one file, or (None, reason) to skip it."""
t = TIERS[tier]
video = next((s for s in info["streams"] if s["codec_type"] == "video"
and not s.get("disposition", {}).get("attached_pic")), None)
if not video:
return None, "no video stream"
if video.get("color_transfer") in HDR_TRANSFERS or any("Dolby Vision" in str(sd) for sd in video.get("side_data_list", [])):
return None, "HDR/Dolby Vision source (not handled yet)"
if (info["format"].get("tags") or {}).get("COMPACT_TIER") == tier:
return None, f"already {tier}"
src_w = int(video["width"])
out_w = min(src_w, t["max_width"])
cls = "1080" if out_w >= 1700 else "720" if out_w >= 1200 else "sd"
vkbps = t["kbps"][app][cls]
duration = float(info["format"]["duration"])
audios = [s for s in info["streams"] if s["codec_type"] == "audio"]
est = int((vkbps + sum(audio_kbps(int(a.get("channels") or 2)) for a in audios)) * 1000 / 8 * duration * 1.03)
if int(info["format"]["size"]) * (1 - MIN_SAVING) < est:
return None, f"already small ({int(info['format']['size']) / 1e9 * 3600 / duration:.2f} GB/h)"
# A downloaded HEVC file within 2x the target is a small release that already replaced a
# big one. Re-encoding it would cost GPU and quality for little space. Our own outputs
# (tagged) still go through, so moving a title from Standard to Mini works.
tags = info["format"].get("tags") or {}
if video.get("codec_name") == "hevc" and "COMPACT_TIER" not in tags and int(info["format"]["size"]) <= 2 * est:
return None, f"small HEVC release ({int(info['format']['size']) / 1e9 * 3600 / duration:.2f} GB/h)"
vf = []
if video.get("field_order") not in (None, "progressive", "unknown"):
vf.append("bwdif")
if out_w < src_w:
vf.append(f"scale={out_w}:-2:flags=lanczos")
args = ["-map", f"0:{video['index']}"]
if vf:
args += ["-filter:v", ",".join(vf)]
args += ["-c:v", "hevc_nvenc", "-preset", "p7", "-tune", "hq", "-profile:v", "main10", "-pix_fmt", "p010le",
"-rc", "vbr", "-multipass", "fullres", "-spatial-aq", "1", "-temporal-aq", "1", "-rc-lookahead", "32",
"-bf", "4", "-b_ref_mode", "middle",
"-b:v", f"{vkbps}k", "-maxrate", f"{vkbps * 2}k", "-bufsize", f"{vkbps * 4}k"]
for i, a in enumerate(audios):
ch = int(a.get("channels") or 2)
layout = {1: "mono", 2: "stereo", 6: "5.1", 8: "7.1"}.get(ch)
args += ["-map", f"0:{a['index']}", f"-c:a:{i}", "libopus", f"-b:a:{i}", f"{audio_kbps(ch)}k",
f"-filter:a:{i}", f"aformat=channel_layouts={layout or 'stereo'}"]
args += ["-mapping_family", "1"]
for i, s in enumerate(subtitles(info)):
args += ["-map", f"0:{s['index']}", f"-c:s:{i}", "srt" if s["codec_name"] == "mov_text" else "copy"]
args += ["-map", "0:t?", "-c:t", "copy", "-metadata", f"COMPACT_TIER={tier}"]
return args, est
# ── encode, verify, swap ────────────────────────────────────────────────────
def encode(src, args, tier):
WORK.mkdir(parents=True, exist_ok=True)
tmp = WORK / "work.mkv"
tmp.unlink(missing_ok=True)
cmd = ["ffmpeg", "-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-hwaccel", "cuda", "-i", str(src),
*args, "-metadata", f"COMPACT_SOURCE={src.name} ({src.stat().st_size} bytes)", str(tmp)]
r = subprocess.run(cmd, capture_output=True, text=True)
if r.returncode != 0:
tmp.unlink(missing_ok=True)
raise RuntimeError("ffmpeg failed: " + r.stderr.strip()[-300:])
return tmp
def verify(src_info, tmp):
new = probe(tmp)
if not new:
raise RuntimeError("output does not probe")
a, b = float(src_info["format"]["duration"]), float(new["format"]["duration"])
if abs(a - b) > max(2.0, a * 0.005):
raise RuntimeError(f"duration mismatch {a:.1f}s vs {b:.1f}s")
counts = lambda i: (sum(1 for s in i["streams"] if s["codec_type"] == "audio"), len(subtitles(i)))
if counts(src_info) != counts(new):
raise RuntimeError(f"audio/subtitle track counts {counts(src_info)} -> {counts(new)}")
if int(new["format"]["size"]) >= int(src_info["format"]["size"]):
raise RuntimeError("output is not smaller")
return int(new["format"]["size"])
def swap(src, tmp):
"""Copy the encode next to the original, then rename it over the original in one step."""
final = src.with_suffix(".mkv")
staged = src.with_name(f".{final.name}.compact-tmp")
shutil.copyfile(tmp, staged)
os.replace(staged, final)
if final != src:
src.unlink()
tmp.unlink(missing_ok=True)
return final
# ── main ────────────────────────────────────────────────────────────────────
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--until", help="stop starting new encodes after this local time (HH:MM)")
ap.add_argument("--series", help="only this series title")
ap.add_argument("--limit", type=int, help="encode at most N files")
ap.add_argument("--dry-run", action="store_true", help="plan only, change nothing")
ap.add_argument("--search-all", action="store_true",
help="search every not-yet-searched title now instead of a few per night")
opt = ap.parse_args()
if (HERE / "PAUSE").exists() and not opt.dry_run:
log("PAUSE file present, not encoding anything (delete it to resume)")
return
lock = open(HERE / ".lock", "w")
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
sys.exit("another compact-tv run is active")
deadline = None
if opt.until:
h, m = map(int, opt.until.split(":"))
now = datetime.datetime.now()
deadline = now.replace(hour=h, minute=m, second=0, microsecond=0)
if deadline <= now:
deadline += datetime.timedelta(days=1)
state = json.loads(STATE.read_text()) if STATE.exists() else {}
for k in ("failures", "searched", "skip"):
state.setdefault(k, {})
save = lambda: STATE.write_text(json.dumps(state, indent=1))
today = datetime.date.today()
work = [] # (app, call, title, tier); TV first, then movies
for app, a in APPS.items():
call = arr(app)
profiles = {p["id"]: p["name"] for p in call("/qualityprofile")}
work += [(app, call, t, profiles[t["qualityProfileId"]]) for t in call(a["titles"])
if profiles.get(t["qualityProfileId"]) in TIERS and (not opt.series or t["title"] == opt.series)]
log(f"=== run start{' [dry run]' if opt.dry_run else ''}: {len(work)} title(s) on "
f"{'/'.join(TIERS)}{f', until {deadline:%H:%M}' if deadline else ''} ===")
# ── 1. search: ask the *arr for small replacements before spending GPU ──
size_of = lambda t: (t.get("statistics") or {}).get("sizeOnDisk") or t.get("sizeOnDisk") or 0
for app in APPS:
fresh = sorted((w for w in work if w[0] == app and f"{app}:{w[2]['id']}" not in state["searched"]
and size_of(w[2]) > 0), key=lambda w: -size_of(w[2]))
fresh = fresh if opt.search_all else fresh[:SEARCHES_PER_NIGHT[app]]
for _, call, t, tier in fresh:
log(f" {'would search' if opt.dry_run else 'search'} {t['title']} ({size_of(t) / 1e9:.0f} GB, {tier})")
if fresh and not opt.dry_run:
call = fresh[0][1]
if app == "sonarr":
for _, _, t, _ in fresh:
call("/command", {"name": "SeriesSearch", "seriesId": t["id"]})
else:
call("/command", {"name": "MoviesSearch", "movieIds": [t["id"] for _, _, t, _ in fresh]})
for _, _, t, _ in fresh:
state["searched"][f"{app}:{t['id']}"] = today.isoformat()
save()
# ── 2. encode what is still big after its search ────────────────────────
age = lambda iso: (today - datetime.date.fromisoformat(iso)).days
def ready(app, ef, title_searched, to_search):
"""True if this big file has had its chance to be replaced by a small download."""
if (ef.get("dateAdded") or "")[:10] <= title_searched: # was on disk when the title was searched
return age(title_searched) >= SEARCH_GRACE_DAYS
key = f"{app}:file:{ef['id']}" # arrived later: search it on its own first
if key not in state["searched"]:
to_search.append(ef)
return False
return age(state["searched"][key]) >= FILE_SEARCH_GRACE_DAYS
def search_files(app, call, show, files):
if opt.dry_run:
log(f" would search {len(files)} newer file(s) of {show['title']}"); return
if app == "sonarr":
ids = {f["id"] for f in files}
eps = [e["id"] for e in call(f"/episode?seriesId={show['id']}") if e.get("episodeFileId") in ids]
call("/command", {"name": "EpisodeSearch", "episodeIds": eps})
else:
call("/command", {"name": "MoviesSearch", "movieIds": [show["id"]]})
for f in files:
state["searched"][f"{app}:file:{f['id']}"] = today.isoformat()
log(f" search {len(files)} newer file(s) of {show['title']} before encoding"); save()
done = saved = 0
for app, call, show, tier in work:
title_searched = state["searched"].get(f"{app}:{show['id']}")
if not title_searched:
continue # its title search comes first, on a later night
container, host = APPS[app]["paths"]
files = sorted(APPS[app]["files"](call, show), key=lambda f: f["path"])
changed, to_search = False, []
for ef in files:
if deadline and datetime.datetime.now() >= deadline:
log("deadline reached, stopping"); break
if opt.limit is not None and done >= opt.limit:
break
src = pathlib.Path(ef["path"].replace(container, host, 1))
if state["failures"].get(str(src), 0) >= MAX_FAILURES or not src.exists():
continue
# Files already ruled out at this tier and size need no re-probe (most of them, most nights).
seen = f"{tier}:{src.stat().st_size}"
if state["skip"].get(str(src)) == seen:
continue
info = probe(src)
if not info:
log(f" SKIP {src.name}: does not probe"); continue
args, est = plan(info, tier, app)
if args is None:
state["skip"][str(src)] = seen # reason in `est`; quiet, since most nights most files are done
continue
if not ready(app, ef, title_searched, to_search):
continue
old = int(info["format"]["size"])
if opt.dry_run:
log(f" would encode {src.name}: {old / 1e6:.0f} MB -> ~{est / 1e6:.0f} MB ({tier})")
done += 1; continue
t0 = time.monotonic()
try:
tmp = encode(src, args, tier)
new = verify(info, tmp)
final = swap(src, tmp)
except Exception as e:
state["failures"][str(src)] = state["failures"].get(str(src), 0) + 1
save()
log(f" FAIL {src.name}: {e}"); continue
done += 1; saved += old - new; changed = True
log(f" {final.name}: {old / 1e6:.0f} MB -> {new / 1e6:.0f} MB "
f"(-{(1 - new / old) * 100:.0f}%, {time.monotonic() - t0:.0f}s, {tier})")
if to_search:
search_files(app, call, show, to_search)
if changed:
call("/command", APPS[app]["rescan"](show))
if (deadline and datetime.datetime.now() >= deadline) or (opt.limit is not None and done >= opt.limit):
break
if not opt.dry_run:
save()
log(f"=== run end: {done} file(s){' planned' if opt.dry_run else ''}, saved {saved / 1e9:.1f} GB ===")
if __name__ == "__main__":
main()
@@ -0,0 +1,17 @@
# Installed to /etc/systemd/system/. Runs as gib because the TV files are gib-owned,
# so the job needs no root. A system unit (rather than a user unit) because gib has
# no lingering session on this headless box. ExecStart goes through python3 because
# SELinux will not let systemd execute a file labeled as home content (user_home_t).
[Unit]
Description=Re-encode TV shows on Sonarr's Compact/Supercompact profiles
Documentation=file:///home/gib/Server/Media/compact-tv/README.md
After=docker.service
Requires=docker.service
[Service]
Type=oneshot
User=gib
Group=gib
ExecStart=/usr/bin/python3 /home/gib/Server/Media/compact-tv/compact-tv.py --until 08:00
Nice=15
IOSchedulingClass=idle
@@ -0,0 +1,11 @@
# Starts after the 00:00 hour and stops starting new encodes at 08:00 (see --until in
# the service), so Plex and Frigate keep the GPU's encoder during the day.
[Unit]
Description=Nightly TV re-encode for Compact/Supercompact shows
[Timer]
OnCalendar=*-*-* 00:30:00
Persistent=false
[Install]
WantedBy=timers.target
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""Per-title cost of shrinking everything on Standard/Mini: re-encode vs redownload.
For each show or movie on those profiles, probes every file with compact-tv's own planner
and reports what the nightly job would do (encode, skip as already small, skip as HDR),
the space it would save, and the GPU time it would take. Alongside that it estimates what a
fresh small download would cost instead, using typical small-encode sizes.
GPU time is measured, not guessed: the Shameless pilot took 669 s for 57.5 minutes of
1080p (about 0.19 h of GPU per hour of video). 4K sources decode slower; the 1.5x for them
is an estimate until the first 4K night is logged.
Usage: cost-report.py > report.tsv (also prints a summary to stderr)
"""
import importlib.util, json, pathlib, sys
HERE = pathlib.Path(__file__).resolve().parent
spec = importlib.util.spec_from_file_location("ct", HERE / "compact-tv.py")
ct = importlib.util.module_from_spec(spec); spec.loader.exec_module(ct)
GPU_H_PER_H = 669 / 3450 # pilot: hours of GPU per hour of 1080p video
UHD_FACTOR = 1.5 # estimate for 2160p sources
SMALL_GB_PER_H = {"Standard": 0.85, "Mini": 0.57} # MeGusta-class 1080p / 720p HEVC, measured on S.W.A.T.
rows = []
for app, a in ct.APPS.items():
call = ct.arr(app)
profiles = {p["id"]: p["name"] for p in call("/qualityprofile")}
container, host = a["paths"]
for t in call(a["titles"]):
tier = profiles.get(t["qualityProfileId"])
if tier not in ct.TIERS:
continue
r = {"app": app, "title": t["title"], "tier": tier, "files": 0, "hours": 0.0, "size": 0,
"enc_files": 0, "enc_size": 0, "enc_est": 0, "gpu_h": 0.0, "hdr_size": 0, "small_size": 0}
for f in a["files"](call, t):
p = pathlib.Path(f["path"].replace(container, host, 1))
info = ct.probe(p) if p.exists() else None
if not info:
continue
size, dur = int(info["format"]["size"]), float(info["format"]["duration"]) / 3600
r["files"] += 1; r["hours"] += dur; r["size"] += size
args, why = ct.plan(info, tier, app)
if args:
w = max((s.get("width") or 0) for s in info["streams"] if s["codec_type"] == "video")
r["enc_files"] += 1; r["enc_size"] += size; r["enc_est"] += why
r["gpu_h"] += dur * GPU_H_PER_H * (UHD_FACTOR if w > 1920 else 1)
elif "HDR" in str(why):
r["hdr_size"] += size
else:
r["small_size"] += size
if r["files"]:
r["redownload_gb"] = round(r["hours"] * SMALL_GB_PER_H[tier], 1)
rows.append(r)
rows.sort(key=lambda r: -(r["enc_size"] - r["enc_est"]))
cols = ["app", "title", "tier", "files", "hours", "size", "enc_files", "enc_size", "enc_est", "gpu_h", "hdr_size",
"small_size", "redownload_gb"]
print("\t".join(cols))
for r in rows:
print("\t".join(str(round(r[c], 2) if isinstance(r[c], float) else r[c]) for c in cols))
tot = lambda k: sum(r[k] for r in rows)
print(f"{len(rows)} titles; encode {tot('enc_files')} files {tot('enc_size')/1e12:.2f} TB -> ~{tot('enc_est')/1e12:.2f} TB, "
f"{tot('gpu_h'):.0f} GPU hours; HDR skipped {tot('hdr_size')/1e12:.2f} TB; already small {tot('small_size')/1e12:.2f} TB",
file=sys.stderr)
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Make Standard and Mini prefer small releases, and upgrade only toward small ones.
Run once per app; it is idempotent, so re-running updates everything in place.
- Adds "Small Encode" (release groups known for small HEVC/x264 encodes) and "720p" /
"1080p" resolution formats. They score 0 in every profile except Standard and Mini.
- HD and UHD are otherwise untouched: they keep LQ at -10000, so they never pick these.
- Standard and Mini:
- All allowed qualities share ONE group, so no "better resolution" or "better source"
upgrade can ever download a big file. Only the custom-format score decides.
- Scores: Small Encode +3000, x265 +2000 (so first grabs still prefer HEVC), LQ 0,
release-tier formats 0 (one big release must never "beat" another big one).
Standard adds +500 for 1080p and +250 for 720p; Mini adds +1000 for 720p and +500 for
1080p. Those preferences stay below the upgrade threshold, so they never cause churn.
- Upgrades are on, but an upgrade needs a score jump of at least 2500, and upgrading
stops at 3000. In practice only gaining "Small Encode" clears that bar: a big H.264
file gets replaced by a small encode, but never by a big HEVC one.
Usage: setup-small-profiles.py <sonarr|radarr>
"""
import json, re, subprocess, sys, urllib.request, pathlib
APP = sys.argv[1]
cfg = pathlib.Path.home() / f"Server/Media/volumes/{APP}/config.xml"
key = re.search(r"<ApiKey>([^<]+)", cfg.read_text()).group(1)
ip = subprocess.run(["docker", "inspect", APP, "--format", "{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}"],
capture_output=True, text=True, check=True).stdout.split()[0]
base = f"http://{ip}:{8989 if APP == 'sonarr' else 7878}/api/v3"
def call(path, body=None, method=None):
req = urllib.request.Request(base + path, method=method, headers={"X-Api-Key": key, "Content-Type": "application/json"},
data=json.dumps(body).encode() if body is not None else None)
with urllib.request.urlopen(req, timeout=60) as r:
return json.load(r)
GROUPS = r"^(MeGusta|PSA|ELiTE|GalaxyTV|GalaxyRG|GalaxyRG265|YIFY|YTS(\.(MX|LT|AG))?)$"
resolution = lambda r: [{"name": f"{r}p", "implementation": "ResolutionSpecification",
"negate": False, "required": True, "fields": [{"name": "value", "value": r}]}]
WANTED = {
"Small Encode": [{"name": "Small encode groups", "implementation": "ReleaseGroupSpecification",
"negate": False, "required": False, "fields": [{"name": "value", "value": GROUPS}]}],
"720p": resolution(720),
"1080p": resolution(1080),
}
TIERS = {
"Standard": {"Small Encode": 3000, "x265 (HD)": 2000, "LQ": 0, "LQ (Release Title)": 0, "1080p": 500, "720p": 250},
"Mini": {"Small Encode": 3000, "x265 (HD)": 2000, "LQ": 0, "LQ (Release Title)": 0, "720p": 1000, "1080p": 500},
}
RELEASE_TIER = re.compile(r"Tier \d+|WEB Scene")
MIN_UPGRADE_JUMP, UPGRADE_UNTIL = 2500, 3000
cfs = {c["name"]: c for c in call("/customformat")}
for name, specs in WANTED.items():
body = {"name": name, "includeCustomFormatWhenRenaming": False, "specifications": specs}
if name in cfs:
body["id"] = cfs[name]["id"]; call(f"/customformat/{body['id']}", body, "PUT")
else:
call("/customformat", body)
cfs = {c["name"]: c["id"] for c in call("/customformat")}
def flatten(items):
"""Every quality in the profile with whether it is allowed, groups expanded, lowest rank first."""
for it in items:
for sub in (it["items"] or [it]):
yield sub["quality"], it["allowed"]
def one_group(profile):
"""Put every allowed quality into a single top-ranked group; leave disallowed ones as they are."""
flat = list(flatten(profile["items"]))
group = {"id": 1100, "name": "Any allowed", "allowed": True,
"items": [{"quality": q, "items": [], "allowed": True} for q, ok in flat if ok]}
profile["items"] = [{"quality": q, "items": [], "allowed": False} for q, ok in flat if not ok] + [group]
profile["cutoff"] = group["id"]
return profile
for prof in call("/qualityprofile"):
have = {f["format"] for f in prof["formatItems"]}
for name in WANTED:
if cfs[name] not in have:
prof["formatItems"].append({"format": cfs[name], "name": name, "score": 0})
scores = TIERS.get(prof["name"])
if scores:
prof = one_group(prof)
prof.update(upgradeAllowed=True, minUpgradeFormatScore=MIN_UPGRADE_JUMP, cutoffFormatScore=UPGRADE_UNTIL)
for f in prof["formatItems"]:
if f["name"] in scores:
f["score"] = scores[f["name"]]
elif RELEASE_TIER.search(f["name"]):
f["score"] = 0
call(f"/qualityprofile/{prof['id']}", prof, "PUT")
if scores:
allowed = [q["quality"]["name"] for it in prof["items"] if it["allowed"] for q in it["items"]]
print(f"{APP} {prof['name']}: one group of {len(allowed)} qualities ({allowed[0]} .. {allowed[-1]}); "
f"upgrades on, jump >= {MIN_UPGRADE_JUMP}, until {UPGRADE_UNTIL}")
print(" nonzero scores: " + ", ".join(f"{f['name']}={f['score']}" for f in prof["formatItems"] if f["score"]))
+5
View File
@@ -427,3 +427,8 @@ services:
# meant a completed sync was immediately restarted -- a hot loop of ~9
# full syncs every 3 minutes. `no` lets it run once per `up` and stop.
restart: "no"
# Opt-in only (`docker compose --profile configarr up configarr`). Its templates
# create the TRaSH profiles and size limits that were removed on 2026-10-28 in
# favour of four hand-managed profiles (Mini/Standard/HD/UHD), so a plain `up`
# must never run it. See Media/compact-tv/README.md.
profiles: [configarr]
+30 -2
View File
@@ -41,8 +41,36 @@ services:
# world is in backups/; upgrades rewrite the world and cannot be undone.
- VERSION=26.1.2
- DIFFICULTY=normal
- MAX_PLAYERS=10
- TYPE=VANILLA
- MAX_PLAYERS=30
# Paper since 2026-10-13. The last vanilla state is backups/gib-26.1.2-vanilla-*.
- TYPE=PAPER
# DonutSMP-style plugin set, pulled from Modrinth on each start. Every entry
# needs a 26.1.2 Paper build; check Modrinth before adding or bumping VERSION.
# Config lives in servers/gib/plugins/<Plugin>/. GrimAC only publishes alpha builds.
- MODRINTH_DOWNLOAD_DEPENDENCIES=required
- |
MODRINTH_PROJECTS=
luckperms
vaultunlocked
essentialsx
essentialsx-spawn
placeholderapi
playerpoints
zmenu
zauctionhouse
ultimateshop
smartspawner
wildrtp
ultimate-teams
asbounty
axafkzone
tab-was-taken
deluxemenus
coreprotect
worldedit
worldguard
grimac:alpha
chunky
- MEMORY=12G
- SIMULATION_DISTANCE=16
- VIEW_DISTANCE=16
+1
View File
@@ -38,6 +38,7 @@ services:
- ENABLE_OAUTH_SIGNUP=true
- OAUTH_MERGE_ACCOUNTS_BY_EMAIL=true
#- ENABLE_OAUTH_ROLE_MANAGEMENT=true
#- OAUTH_ROLES_CLAIM=roles
#- OAUTH_ADMIN_ROLES=admin,internal,superuser
- OAUTH_CLIENT_ID=${OAUTH_CLIENT_ID}
- OAUTH_CLIENT_SECRET=${OAUTH_CLIENT_SECRET}
+140
View File
@@ -0,0 +1,140 @@
# Restoring ~/Server on server.gib from the ZFS-pool backup
Written 2026-09-17. Read this *before* you need it.
## What this protects against
The NVMe/LUKS btrfs volume (`/`, `/home`, and `/var/lib/docker`) dying. The backup lives on
the ZFS pool in the same machine, so it does **not** protect against fire, theft, or the
whole box failing. It is a disk-failure backup, not an offsite one.
## Layout
/home/gib/Public/Server_Backup/Containers/server.gib/current/
**One directory, overwritten daily at 02:00** by `server-backup.timer`. History lives in ZFS:
`Media/Server_Backup` has `zfs-auto-snapshot` keeping **14 dailies**, taken by `cron.daily`
between roughly 03:22 and 03:52. The 02:00 schedule is deliberate — it must finish before
that window, or a snapshot captures a half-written backup as that day's history. It also
staggers against the VPS backup, which pushes into this same pool at 01:00.
Older copies are under the hidden snapshot dir (`snapdir=hidden`, so `ls` will not show it,
but you can `cd` in):
ls /home/gib/Public/Server_Backup/.zfs/snapshot/
ls /home/gib/Public/Server_Backup/.zfs/snapshot/<snap>/Containers/server.gib/current/
The `03-16-2026` and `03-31-2026` directories are leftovers from the old manual script.
## Why this runs as root
This box runs Docker, so most container data under `~/Server` is root-owned. The old script
used `sudo rsync`, which worked but meant it could never be automated without a sudoers
rule. The replacement runs as root via `server-backup.service`, a systemd **system** unit —
already root, so ownership is preserved natively and **no sudoers entry is needed at
runtime**. If you see `/etc/sudoers.d/99-claude-setup` on this machine, it was temporary
setup scaffolding and should be deleted.
(This differs from the VPS, which is rootless podman and needs `rsync --fake-super` to carry
subuid ownership through. Nothing like that is needed here.)
The script lives at `/usr/local/sbin/server-backup`, deliberately **not** in `~/Server` — a
root-executed script inside a user-writable directory is a footgun. `~/Server/backup` is a
thin wrapper that triggers the systemd unit so manual runs behave identically to nightly ones.
## What is in a backup
| Path | What it is |
|---|---|
| `Media/`, `Frigate/`, `Home/`, `OpenWebUI/`, ... | Raw bind-mount data per service |
| `_backup_state/sqlite/<original path>` | Online `.backup` of every SQLite DB — **authoritative** |
| `_backup_state/convex/<container>.zip` | `npx convex export` snapshot of each SQLite-backed Convex deployment (gbrown) — **authoritative** |
> **Use `_backup_state/` for databases, not the raw copies.** Containers keep running during
> the backup, so the raw files are crash-consistent at best. The dumps use SQLite's online
> backup API and are what will actually restore cleanly. That covers the ones that matter:
> Plex's `com.plexapp.plugins.library.db` (watch history, ratings), Jellyfin, Frigate, Home
> Assistant, OpenWebUI, Audiobookshelf, and every *arr config.
>
> **Convex is the exception.** It writes to its SQLite file constantly, so a raw `.backup`
> never completes. Its `_backup_state/convex/` export is the restore source, and the raw
> `db.sqlite3` is not dumped at all.
## What is deliberately NOT backed up
- **`Agents/Gitea/volumes/runner-{1,2,3}/dind` — ~203 GB.** Docker-in-Docker containerd
image layers for the CI runners. Pure cache that churns constantly; a rebuilt runner just
re-pulls it. The parts that matter — `runner-N/data/.runner` (the 648-byte registration
token) and `certs/` — **are** backed up.
> Measured as root. A plain `du` as `gib` reports these as ~13 MB because gib cannot
> descend into them. Do not trust non-root sizes here.
- Docker **named volumes** (`/var/lib/docker/volumes`, 47 in use, ~61 GB): Sentry (13) and
Plausible (4) were consciously excluded — Sentry's event data is not worth restoring and
Plausible had nothing in it. Romm looked like a gap but is not: its anonymous `/romm`
volume is only a parent directory, with `assets/`, `config/` and `resources/` bind-mounted
from `~/Server` and `library/` from the pool.
- **Nextcloud AIO** — has its own borg backup at `~/Public/Nextcloud/Backups`, run by
hand from the AIO admin UI. Last run 2026-09-17 (finished 09-18). Runs are slow and
AIO stops the Nextcloud containers while one is going.
- `node_modules`, build output, `*.log`, `cache`/`tmp` dirs, `*.mp4`/`*.mkv`.
## Restoring
Stop the stack first so nothing overwrites what you restore:
cd ~/Server/<service> && docker compose down # per service, or stop docker entirely
Pull it back — as root, so ownership is restored:
sudo rsync -a --numeric-ids --delete \
/home/gib/Public/Server_Backup/Containers/server.gib/current/ \
/home/gib/Server/
For an older day, use a snapshot path instead:
/home/gib/Public/Server_Backup/.zfs/snapshot/<snap>/Containers/server.gib/current/
Then put the databases back from the dumps. For each one, stop the owning container, copy the
dump over the live file, and match the original ownership. Plex, for example:
docker compose -f ~/Server/Media/compose.yml stop plex
D=~/Server/_backup_state/sqlite/Media/volumes/plex/config/Library/Application\ Support/Plex\ Media\ Server/Plug-in\ Support/Databases
L=~/Server/Media/volumes/plex/config/Library/Application\ Support/Plex\ Media\ Server/Plug-in\ Support/Databases
sudo cp "$D/com.plexapp.plugins.library.db" "$L/"
sudo chown --reference="$L/com.plexapp.dlna.db" "$L/com.plexapp.plugins.library.db"
docker compose -f ~/Server/Media/compose.yml start plex
`chown --reference` copies ownership from a neighbouring file, which saves looking up what
uid each container expects.
For Convex, start the backend (it can be empty) and import the export through it. This
replaces every table with the snapshot:
Z=~/Server/_backup_state/convex/gbrown-backend.zip
KEY=$(docker exec gbrown-backend ./generate_admin_key.sh | tail -1)
docker run --rm -it --network nginx-bridge -v "$Z:/backup.zip:ro" \
-e CONVEX_SELF_HOSTED_URL=http://gbrown-backend:3210 -e CONVEX_SELF_HOSTED_ADMIN_KEY="$KEY" \
docker.io/library/node:22-alpine sh -ec 'mkdir /w && cd /w && echo "{}" > package.json &&
npm install --silent convex && npx convex import --replace-all /backup.zip'
Functions and schema are not in the export. Redeploy them from the app's repo as usual.
After a full restore the CI runners will have no `dind` storage. That is fine — they re-pull
images on the next job. The registration in `runner-N/data/.runner` is intact, so they
reconnect to Gitea without re-registering.
## Checking it is working
systemctl list-timers server-backup.timer
systemctl status server-backup.service
tail -40 ~/Server/logs/backup.log
A healthy run ends with `=== backup complete ===`. Watch for:
- `ZFS history OK` — confirms snapshots are still being taken. If this ever says **WARNING**,
versioning is broken and `current/` is just a mirror: a mistake would propagate with
nothing to roll back to. Fix `zfs-auto-snapshot` before anything else.
- `rsync exit 24` is a NOTE, not a failure — a file changed mid-transfer, normal on a live box.
Optional: put an uptime-kuma push URL in `~/Server/.backup-heartbeat-url` and successful runs
will ping it, turning a silent failure into a down monitor.
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
# Manual trigger for the daily backup.
#
# The real script lives at /usr/local/sbin/server-backup and runs as ROOT via
# server-backup.service. It is kept out of ~/Server on purpose: ~/Server is
# user-writable, and a root-executed script sitting in a user-writable directory
# is an obvious footgun. Run it through systemd so a manual run behaves exactly
# like the nightly one (same environment, same logging, same lock).
#
# status: systemctl status server-backup.service
# log: tail -f ~/Server/logs/backup.log
# timer: systemctl list-timers server-backup.timer
# restore: see ~/Server/RESTORE.md
exec sudo systemctl start --wait server-backup.service
+152
View File
@@ -0,0 +1,152 @@
#!/usr/bin/env bash
#
# Weekly copy of the Media pool -> the Backup pool, both on this machine.
#
# Purpose: survive losing the Media pool itself (or a bad delete on it). Both pools are
# in this one box, so this is NOT an offsite backup and never will be.
#
# source /home/gib/Public (Media, 50.9 T raidz)
# dest /home/gib/.local/share/Backup/Public (Backup, 14.5 T raidz1)
#
# Runs as ROOT via media-backup.service. Not optional: /home/gib/Public/Nextcloud/Server is
# uid 33 mode 0750 and the borg repo is root:root mode 0600. The historical hand-run rsync
# ran as gib, could not read those, skipped them -- AND EXITED 0. That is how 442 G of
# Nextcloud ended up in the Backup pool while nobody could tell it was the wrong 442 G.
#
# HISTORY IS TAKEN BY THIS SCRIPT, NOT BY A TIMER. zfs-auto-snapshot is disabled on the
# Backup pool (com.sun:auto-snapshot=false) because a timer-driven snapshot can land
# mid-rsync and record a half-written backup as that week's restore point. Here the snapshot
# is taken only AFTER a successful sync, so every snapshot on that pool is, by construction,
# a complete backup.
set -uo pipefail
SOURCE="/home/gib/Public/"
DEST="/home/gib/.local/share/Backup/Public"
POOL="Backup"
BORG_LOCK="/home/gib/Public/Nextcloud/Backups/borg/lock.exclusive"
KEEP_SNAPSHOTS=8 # ~2 months at weekly
MIN_FREE_GB=200 # refuse to start if the pool is already this tight
LOG="/var/log/media-backup.log"
LOCK="/run/media-backup.lock"
log() { printf '%s %s\n' "$(date +'%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG"; }
die() { log "FATAL: $*"; exit 1; }
exec 9>"$LOCK" || die "cannot open lock $LOCK"
flock -n 9 || die "another run is already in progress"
EXCLUDES=(
# --- too big, deliberately not backed up -------------------------------
# 23.2 T. Larger on its own than the entire Backup pool. gib's explicit decision:
# if Media is lost, the TV library gets rebuilt.
"/TV"
# Torrent scratch. qBittorrent manages it (ratio 2, ~14.7 d inactive, then
# RemoveWithContent), and anything worth keeping has already been imported.
"/Downloads"
# --- already self-versioning, so a second copy buys nothing ------------
# TimeMachine is its own backup format with its own history.
"/Mac"
# 586 G of raw Nextcloud data. Covered by AIO's borg repo below, which is
# deduplicated, encrypted, and carries 7d/4w/6m of history instead of one stale
# point-in-time. Verified: the borgbackup container mounts this exact path as
# /nextcloud_aio_volumes/nextcloud_aio_nextcloud_data and borg walks the whole tree.
# We keep Nextcloud/Backups (the repo). We do NOT keep Nextcloud/Server.
"/Nextcloud/Server"
# --- regenerable ------------------------------------------------------
"/Server_Backup/Ollama_Models" # re-pullable model weights
"/Server_Backup/ComfyUI" # re-downloadable checkpoints
"/Server_Backup/frigate" # camera footage; container is decommissioned
"/Server_Backup/.Trash-1000" # desktop trash
)
# Kept on purpose, so nobody "tidies" them later: Server_Backup/Containers (the only copy
# of both machines' ~/Server backups outside the Media pool), immich (irreplaceable photos),
# Games and ROMs (gib: "they can be hard to get").
EXCLUDE_ARGS=()
for e in "${EXCLUDES[@]}"; do EXCLUDE_ARGS+=( "--exclude=$e" ); done
log "=== media-backup start ==="
# --- preflight ------------------------------------------------------------
mountpoint -q /home/gib/Public \
|| die "/home/gib/Public is not a mountpoint -- Media pool not imported, refusing to run"
mountpoint -q /home/gib/.local/share/Backup \
|| die "Backup pool is not mounted -- refusing to write into a plain directory on the NVMe"
mkdir -p "$DEST"
# A borg run holds this lock for 2-4 hours and rewrites index/hints/integrity at the END.
# Copying mid-run pairs new data chunks with a stale index: a repo that looks fine and
# fails at restore. AIO's borg runs daily from 07:30 UTC for about 2 hours, so it can
# still be going when the timer fires. Wait for it to finish, checking every
# BORG_WAIT_INTERVAL, and defer only if it is still held after BORG_WAIT_MAX. The unit's
# TimeoutStartSec=24h covers the wait plus the ~50 min sync.
BORG_WAIT_INTERVAL=300 # 5 min
BORG_WAIT_MAX=21600 # 6 h
waited=0
while [ -e "$BORG_LOCK" ] && [ "$waited" -lt "$BORG_WAIT_MAX" ]; do
log "borg is mid-run (lock held at $BORG_LOCK). Waited $((waited / 60)) of $((BORG_WAIT_MAX / 60)) min, checking again in $((BORG_WAIT_INTERVAL / 60)) min"
sleep "$BORG_WAIT_INTERVAL"
waited=$(( waited + BORG_WAIT_INTERVAL ))
done
if [ -e "$BORG_LOCK" ]; then
log "borg is mid-run (lock held at $BORG_LOCK)"
log "DEFERRING this week's run rather than copying a torn repo. Next fire: next Saturday."
log "=== media-backup deferred ==="
exit 0
fi
[ "$waited" -gt 0 ] && log "borg lock released after waiting $((waited / 60)) min, continuing"
avail_gb=$(zfs get -Hp -o value available "$POOL" 2>/dev/null)
avail_gb=$(( ${avail_gb:-0} / 1024 / 1024 / 1024 ))
log "Backup pool free: ${avail_gb} GB"
[ "$avail_gb" -lt "$MIN_FREE_GB" ] \
&& die "only ${avail_gb} GB free (floor ${MIN_FREE_GB} GB) -- refusing to start"
# --- sync -----------------------------------------------------------------
# --numeric-ids is mandatory: `getent passwd 33` returns nothing on this host (uid 33 is
# www-data inside a container), so rsync has no name to map ownership by.
# --delete/--delete-excluded keep the copy exact; anything wrongly removed is still in
# last week's snapshot.
log "Syncing ${SOURCE} -> ${DEST}/ ..."
rsync -a --numeric-ids --delete --delete-excluded --human-readable --stats \
"${EXCLUDE_ARGS[@]}" "$SOURCE" "$DEST/" 2>&1 | tee -a "$LOG"
status=${PIPESTATUS[0]}
# 24 = "source files vanished during transfer". Normal on a live system.
if [ "$status" -ne 0 ] && [ "$status" -ne 24 ]; then
die "rsync exit $status -- NOT snapshotting. Last good snapshot is still the newest."
fi
[ "$status" -eq 24 ] && log "NOTE: rsync exit 24 (files vanished mid-run) -- expected"
# --- snapshot, only now that the sync succeeded ---------------------------
SNAP="${POOL}@backup-$(date +%Y-%m-%d)"
if zfs list -t snapshot -H "$SNAP" >/dev/null 2>&1; then
log "snapshot $SNAP already exists (second run today), skipping"
else
zfs snapshot "$SNAP" && log "snapshot taken: $SNAP" || log "WARNING: snapshot failed"
fi
# Keep the newest N. These are ours, so nothing else prunes them.
mapfile -t snaps < <(zfs list -t snapshot -H -o name -s creation -d 1 "$POOL" 2>/dev/null | grep "@backup-")
if [ "${#snaps[@]}" -gt "$KEEP_SNAPSHOTS" ]; then
drop=$(( ${#snaps[@]} - KEEP_SNAPSHOTS ))
for s in "${snaps[@]:0:$drop}"; do
zfs destroy "$s" && log "pruned old snapshot: $s"
done
fi
log "snapshots retained: $(zfs list -t snapshot -H -o name -d 1 "$POOL" 2>/dev/null | grep -c '@backup-')"
# Heartbeat for Uptime Kuma ("Media Backup (server.gib)"), sent only after a completed
# sync and snapshot. A deferred week exits before this, so it does not ping.
if [ -r /home/gib/Server/.media-backup-heartbeat-url ]; then
url=$(< /home/gib/Server/.media-backup-heartbeat-url)
curl -fsS -m 20 "$url" >/dev/null 2>&1 \
&& log "Heartbeat sent" || log "NOTE: heartbeat ping failed (backup itself was fine)"
fi
log "=== media-backup complete ==="
+181
View File
@@ -0,0 +1,181 @@
#!/usr/bin/env bash
#
# Daily backup of ~/Server (NVMe) to the ZFS pool on this same machine.
# Purpose: survive the NVMe/LUKS volume dying. Not an offsite backup.
#
# Runs as ROOT via server-backup.service. That is deliberate: this box runs Docker,
# so most container data under ~/Server is root-owned. The old script used `sudo rsync`,
# which worked but could never be automated without a sudoers rule. A systemd system
# unit is already root, so ownership is preserved natively and no sudoers entry is
# needed at runtime. (Note this differs from the VPS, which is rootless podman and
# needs rsync --fake-super to carry subuid ownership; nothing like that applies here.)
#
# History is ZFS's job: Media/Server_Backup has zfs-auto-snapshot keeping 14 dailies,
# so this writes ONE directory and never makes dated copies. Snapshots are block-level,
# so a 1.6 GB database that changes a few pages costs a few pages.
#
# See RESTORE.md for how to bring this back.
set -uo pipefail
SOURCE="/home/gib/Server/"
DEST="/home/gib/Public/Server_Backup/Containers/server.gib/current"
STATE_DIR="/home/gib/Server/_backup_state"
LOG_DIR="/home/gib/Server/logs"
LOG="$LOG_DIR/backup.log"
LOCK="/run/server-backup.lock"
REMOTE_DATASET="Media/Server_Backup"
SNAPSHOT_MAX_AGE_HOURS=48
mkdir -p "$LOG_DIR"
log() { printf '%s %s\n' "$(date +'%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG"; }
die() { log "FATAL: $*"; exit 1; }
exec 9>"$LOCK" || die "cannot open lock $LOCK"
flock -n 9 || die "another backup is already running"
EXCLUSIONS=(
# --- build noise (inherited from the original script) -------------------
"node_modules" ".git" "*.log" "*.tmp" ".cache" "cache" "tmp" "temp"
".npm" ".yarn" "dist" "build" "target" ".venv" "venv" "__pycache__"
"*.pyc" ".DS_Store" "Thumbs.db" "vendor" "*.mp4" "*.mkv"
# --- the big one: Gitea CI runner Docker-in-Docker storage --------------
# 203 GB across runner-1/2/3 (72 GB of containerd image layers each), and it
# churns constantly as CI builds run. It is pure cache: a rebuilt runner just
# re-pulls everything. Excluding dind/ specifically KEEPS runner-N/data/.runner
# (the 648-byte registration token) and certs/, which are the only parts that
# would actually be painful to recreate.
#
# Measured as root on 2026-09-17 -- a plain `du` as gib reports these as ~13 MB
# because it cannot descend into root-owned dirs. Do not trust non-root sizes here.
"/Agents/Gitea/volumes/*/dind"
# Regenerated every run; no reason to ship the previous run's copy.
"/_backup_state/*.partial"
)
EXCLUDE_ARGS=()
for e in "${EXCLUSIONS[@]}"; do EXCLUDE_ARGS+=( "--exclude=$e" ); done
# Convex deployments backed by SQLite, as "container:sqlite path under ~/Server".
# Convex keeps that file in rollback-journal mode and writes to it constantly, so
# sqlite3 .backup either hits "database is locked" or restarts forever. Instead,
# dump_convex takes Convex's own snapshot export (`npx convex export`, restored with
# `npx convex import`), and dump_state skips the raw file.
CONVEX_EXPORTS=(
"gbrown-backend:Gbrown/data/convex/db.sqlite3"
)
# ---------------------------------------------------------------------------
# Consistent dumps. Containers keep running; these are what make that safe.
# ---------------------------------------------------------------------------
dump_state() {
log "Dumping databases to _backup_state/ ..."
rm -rf "$STATE_DIR"
mkdir -p "$STATE_DIR/sqlite"
printf 'Generated %s by /usr/local/sbin/server-backup. Authoritative restore source.\n' \
"$(date -Is)" > "$STATE_DIR/README"
local n=0 skipped=0
while IFS= read -r db; do
# Only real SQLite. Docker/containerd/buildkit scatter BoltDB files named *.db
# (portainer.db, metadata_v2.db, meta.db ...) which sqlite3 cannot read.
read -r -n 15 magic < "$db" 2>/dev/null
[ "$magic" = "SQLite format 3" ] || { skipped=$((skipped+1)); continue; }
local rel="${db#/home/gib/Server/}" out
[[ " ${CONVEX_EXPORTS[*]#*:} " == *" $rel "* ]] && continue # dump_convex covers it
out="$STATE_DIR/sqlite/$rel"
mkdir -p "$(dirname "$out")"
# .timeout: wait up to 30s for a writer's lock instead of failing on first contact.
if sqlite3 -cmd ".timeout 30000" "$db" ".backup '$out'" 2>>"$LOG"; then
n=$((n+1))
else
log " WARNING: sqlite dump failed: $rel"
fi
done < <(find /home/gib/Server \
-path "$STATE_DIR" -prune -o \
-path '*/dind' -prune -o \
-path '*/other/firefox' -prune -o \
-path '*/node_modules' -prune -o \
-type f \( -name '*.db' -o -name '*.sqlite' -o -name '*.sqlite3' \) \
! -name '*-wal' ! -name '*-shm' \
! -name '*logs.db' ! -name '*cache*.db' -print 2>/dev/null)
log " sqlite: $n dumped, $skipped skipped (not SQLite)"
dump_convex
}
# Snapshot-exports each CONVEX_EXPORTS deployment to _backup_state/convex/<container>.zip
# through a throwaway node container, so nothing is installed on the host. The admin key
# is derived from the instance secret on every run rather than stored. Takes ~8 min.
dump_convex() {
mkdir -p "$STATE_DIR/convex"
local entry c key
for entry in "${CONVEX_EXPORTS[@]}"; do
c="${entry%%:*}"
key=$(docker exec "$c" ./generate_admin_key.sh 2>/dev/null | tail -1)
if [ -z "$key" ]; then
log " WARNING: convex export failed: $c (could not generate admin key -- is it running?)"
continue
fi
if CONVEX_SELF_HOSTED_ADMIN_KEY="$key" timeout 1800 docker run --rm \
--network nginx-bridge -v "$STATE_DIR/convex:/out" \
-e CONVEX_SELF_HOSTED_URL="http://$c:3210" -e CONVEX_SELF_HOSTED_ADMIN_KEY \
-e npm_config_update_notifier=false -e npm_config_fund=false -e npm_config_audit=false \
docker.io/library/node:22-alpine sh -ec '
# The CLI only runs inside a project that depends on convex.
mkdir /work && cd /work && echo "{}" > package.json
npm install --silent convex@latest
npx convex export --include-file-storage --path "/out/'"$c"'.zip"' \
>>"$LOG" 2>&1; then
log " convex: exported $c ($(du -h "$STATE_DIR/convex/$c.zip" | cut -f1))"
else
log " WARNING: convex export failed: $c (see npx output above)"
fi
done
}
# ---------------------------------------------------------------------------
# ZFS is the only thing giving us history, so make its absence loud.
# ---------------------------------------------------------------------------
check_zfs_history() {
local newest now age_h
newest=$(zfs list -H -p -o creation -t snapshot -s creation -d 1 "$REMOTE_DATASET" 2>/dev/null | tail -1)
if [ -z "$newest" ]; then
log " ⚠️ WARNING: no ZFS snapshots on $REMOTE_DATASET -- this backup has NO history."
log " It is a plain mirror right now. Check zfs-auto-snapshot."
return
fi
now=$(date +%s); age_h=$(( (now - newest) / 3600 ))
if [ "$age_h" -gt "$SNAPSHOT_MAX_AGE_HOURS" ]; then
log " ⚠️ WARNING: newest ZFS snapshot is ${age_h}h old (limit ${SNAPSHOT_MAX_AGE_HOURS}h) -- versioning may be broken."
else
log " ZFS history OK (newest snapshot ${age_h}h old, 14 dailies retained)"
fi
}
log "=== backup start -> $DEST ==="
[ -d "$(dirname "$DEST")" ] || die "destination parent missing -- is the ZFS pool mounted?"
mountpoint -q /home/gib/Public || die "/home/gib/Public is not a mountpoint -- refusing to write into an unmounted path"
mkdir -p "$DEST"
dump_state
check_zfs_history
rsync -a --numeric-ids --delete --delete-excluded --human-readable --stats \
"${EXCLUDE_ARGS[@]}" "$SOURCE" "$DEST/" 2>&1 | tee -a "$LOG"
status=${PIPESTATUS[0]}
if [ "$status" -ne 0 ] && [ "$status" -ne 24 ]; then
die "rsync failed with exit $status -- 'current' may be half-updated; yesterday's ZFS snapshot is still good"
fi
[ "$status" -eq 24 ] && log "NOTE: rsync exit 24 (files vanished mid-run) -- expected on a live system"
if [ -r /home/gib/Server/.backup-heartbeat-url ]; then
url=$(< /home/gib/Server/.backup-heartbeat-url)
curl -fsS -m 20 "$url" >/dev/null 2>&1 \
&& log "Heartbeat sent" || log "NOTE: heartbeat ping failed (backup itself was fine)"
fi
log "=== backup complete ==="
+19
View File
@@ -0,0 +1,19 @@
[Unit]
Description=Weekly copy of the Media pool to the Backup pool
Documentation=file:///home/gib/Server/RESTORE.md
After=zfs-mount.service
Wants=zfs-mount.service
RequiresMountsFor=/home/gib/Public /home/gib/.local/share/Backup
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/media-backup
# 8.6 TB on the first run, deltas after. Generous, but bounded so a hung rsync
# fails loudly instead of overlapping next week's fire.
TimeoutStartSec=24h
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=7
[Install]
WantedBy=multi-user.target
+18
View File
@@ -0,0 +1,18 @@
[Unit]
Description=Weekly Media -> Backup pool copy
[Timer]
# Saturday 05:00. Clear of: the cron.daily zfs-auto-snapshot window (03:22-03:52),
# timeshift (04:00), arr-queue-reconcile (04:31), and both ~/Server backups (01:00, 02:00).
# Saturday specifically because Monday carries the Media scrub and Sunday has
# raid-check (01:00), docker-prune (05:33) and dind-prune (06:03).
# Nextcloud AIO's borg runs daily from 07:30 UTC for about 2 hours, so its lock can
# overlap this fire. The script waits up to 6 h for the lock, checking every 5 min,
# and defers the week only if borg is still running after that.
OnCalendar=Sat *-*-* 05:00:00
RandomizedDelaySec=15m
Persistent=true
Unit=media-backup.service
[Install]
WantedBy=timers.target
+19
View File
@@ -0,0 +1,19 @@
[Unit]
Description=Back up /home/gib/Server to the ZFS pool
Documentation=file:///home/gib/Server/RESTORE.md
# The destination is on ZFS; do not run before the pool is up.
After=zfs-mount.service docker.service
Wants=zfs-mount.service
RequiresMountsFor=/home/gib/Public
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/server-backup
TimeoutStartSec=8h
# This box is a daily driver running 145 containers. Stay out of the way.
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=7
[Install]
WantedBy=multi-user.target
+14
View File
@@ -0,0 +1,14 @@
[Unit]
Description=Daily backup of /home/gib/Server to the ZFS pool
[Timer]
# MUST finish before cron.daily fires zfs-auto-snapshot (~03:22-03:52); a snapshot that
# catches a running rsync stores a partial backup as that day's history.
# 02:00 also staggers against the VPS backup, which pushes into this same pool at 01:00.
OnCalendar=*-*-* 02:00:00
RandomizedDelaySec=10m
Persistent=true
Unit=server-backup.service
[Install]
WantedBy=timers.target
+1 -1
View File
@@ -92,7 +92,7 @@ list_units() {
# services therefore resolve no images and are skipped without a log line.
#
# Image tags frequently reference variables from the service's .env, e.g.
# ghcr.io/get-convex/convex-backend:${BACKEND_TAG:-latest}
# ghcr.io/convex/convex-backend:${BACKEND_TAG:-latest}
# git.gbrown.org/gib/${NEXT_CONTAINER_NAME}:latest
# Those must be expanded or the pull is attempted against a literal "${...}" and
# always fails. We expand in a SUBSHELL so the .env cannot leak into this script's
+1789
View File
File diff suppressed because it is too large. Load diff
@@ -1,4 +1,5 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: Authentik
After=network-online.target podman.socket podman-postgresql.service
Wants=network-online.target podman-postgresql.service
+1
View File
@@ -1,4 +1,5 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: Beszel
After=network-online.target podman.socket
Wants=network-online.target
+2 -2
View File
@@ -32,7 +32,7 @@ services:
restart: unless-stopped
bill-tracker-backend:
image: ${CONVEX_BACKEND_IMAGE:-ghcr.io/get-convex/convex-backend@sha256:1f2044e3eac463ac78973b136c0baf72d4ada602611d853d6f99f280e29e0a98}
image: ${CONVEX_BACKEND_IMAGE:-ghcr.io/convex/convex-backend@sha256:207cf95def42f01614b6e3af0af5c20d0dcaa5ff2adf258ee7bc9555576644fa}
container_name: bill-tracker-backend
hostname: bill-tracker-backend
domainname: api.billtracker.gbrown.org
@@ -56,7 +56,7 @@ services:
stop_signal: SIGINT
bill-tracker-dashboard:
image: ${CONVEX_DASHBOARD_IMAGE:-ghcr.io/get-convex/convex-dashboard@sha256:284a2638e0c1a4ec0c2327d8219776f3a426ca5824b81686ae4d9454dc0ce8ed}
image: ${CONVEX_DASHBOARD_IMAGE:-ghcr.io/convex/convex-dashboard@sha256:a55bf8abca1332f787bc4d4a79850182175027be7b70aa230be4389a1d3268e2}
container_name: bill-tracker-dashboard
hostname: bill-tracker-dashboard
domainname: dashboard.billtracker.gbrown.org
+2 -2
View File
@@ -29,7 +29,7 @@ services:
restart: unless-stopped
convexmonorepo-backend:
image: ghcr.io/get-convex/convex-backend:${BACKEND_TAG:-latest}
image: ghcr.io/convex/convex-backend:${BACKEND_TAG:-latest}
container_name: ${BACKEND_CONTAINER_NAME:-convex-backend}
hostname: ${BACKEND_CONTAINER_NAME:-convex-backend}
domainname: ${BACKEND_DOMAIN:-convex.gbrown.org}
@@ -58,7 +58,7 @@ services:
stop_signal: SIGINT
convexmonorepo-dashboard:
image: ghcr.io/get-convex/convex-dashboard:${DASHBOARD_TAG:-latest}
image: ghcr.io/convex/convex-dashboard:${DASHBOARD_TAG:-latest}
container_name: ${DASHBOARD_CONTAINER_NAME:-convex-dashboard}
hostname: ${DASHBOARD_CONTAINER_NAME:-convex-dashboard}
domainname: ${DASHBOARD_DOMAIN:-dashboard.${BACKEND_DOMAIN:-convex.gbrown.org}}
@@ -1,4 +1,5 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: iSponsorBlockTV
After=network-online.target podman.socket
Wants=network-online.target
@@ -1,4 +1,5 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: Infisical
After=network-online.target podman.socket podman-postgresql.service
Wants=network-online.target podman-postgresql.service
+3 -3
View File
@@ -19,7 +19,7 @@ services:
- NODE_ENV=${NODE_ENV}
- SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN}
- NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL:-http://localhost:${NEXT_PORT:-3000}}
- NEXT_PUBLIC_CONVEX_URL=${NEXT_PUBLIC_CONVEX_URL:-http://${BACKEND_CONTAINER_NAME:-lashaddict-backend}:${BACKEND_PORT:-3210}}
- NEXT_PUBLIC_CONVEX_URL=${CONVEX_SERVER_URL:-http://${BACKEND_CONTAINER_NAME:-lashaddict-backend}:${BACKEND_PORT:-3210}}
- NEXT_PUBLIC_PLAUSIBLE_URL=${NEXT_PUBLIC_PLAUSIBLE_URL:-https://plausible.gbrown.org}
- NEXT_PUBLIC_SENTRY_DSN=${NEXT_PUBLIC_SENTRY_DSN}
- NEXT_PUBLIC_SENTRY_URL=${NEXT_PUBLIC_SENTRY_URL}
@@ -33,7 +33,7 @@ services:
stdin_open: true
restart: unless-stopped
lashaddict-backend:
image: ghcr.io/get-convex/convex-backend:${BACKEND_TAG:-latest}
image: ghcr.io/convex/convex-backend:${BACKEND_TAG:-latest}
container_name: ${BACKEND_CONTAINER_NAME:-lashaddict-backend}
# Same Cloudflare DNS pin as lashaddict-next (see its comment): the backend
# fetches its own auth discovery URL, which Hetzner resolvers failed.
@@ -67,7 +67,7 @@ services:
stop_grace_period: 10s
stop_signal: SIGINT
lashaddict-dashboard:
image: ghcr.io/get-convex/convex-dashboard:${DASHBOARD_TAG:-latest}
image: ghcr.io/convex/convex-dashboard:${DASHBOARD_TAG:-latest}
container_name: ${DASHBOARD_CONTAINER_NAME:-lashaddict-dashboard}
# Same Cloudflare DNS pin as lashaddict-next (see its comment): the backend
# fetches its own auth discovery URL, which Hetzner resolvers failed.
+1
View File
@@ -1,4 +1,5 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: N8n
After=network-online.target podman-postgresql.service
Wants=network-online.target
@@ -1,4 +1,5 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: Nginx Proxy Manager
After=network-online.target podman-postgresql.service
Wants=network-online.target
@@ -1,4 +1,5 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: Portainer
After=network-online.target
Wants=network-online.target
@@ -1,4 +1,5 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: PostgreSQL
After=network-online.target
Wants=network-online.target
@@ -1,4 +1,5 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: Rustdesk
After=network-online.target podman.socket
Wants=network-online.target
+2 -2
View File
@@ -64,7 +64,7 @@ services:
security_opt:
- label:disable
spoon-backend:
image: ghcr.io/get-convex/convex-backend:${BACKEND_TAG:-latest}
image: ghcr.io/convex/convex-backend:${BACKEND_TAG:-latest}
container_name: ${BACKEND_CONTAINER_NAME:-spoon-backend}
hostname: ${BACKEND_CONTAINER_NAME:-spoon-backend}
domainname: ${BACKEND_DOMAIN:-convex.spoon.gbrown.org}
@@ -93,7 +93,7 @@ services:
stop_grace_period: 10s
stop_signal: SIGINT
spoon-dashboard:
image: ghcr.io/get-convex/convex-dashboard:${DASHBOARD_TAG:-latest}
image: ghcr.io/convex/convex-dashboard:${DASHBOARD_TAG:-latest}
container_name: ${DASHBOARD_CONTAINER_NAME:-spoon-dashboard}
hostname: ${DASHBOARD_CONTAINER_NAME:-spoon-dashboard}
domainname: ${DASHBOARD_DOMAIN:-dashboard.${BACKEND_DOMAIN:-spoon.gbrown.org}}
+2 -2
View File
@@ -29,7 +29,7 @@ services:
restart: unless-stopped
stpeteit-backend:
image: ghcr.io/get-convex/convex-backend:${BACKEND_TAG:-latest}
image: ghcr.io/convex/convex-backend:${BACKEND_TAG:-latest}
container_name: ${BACKEND_CONTAINER_NAME:-stpeteit-backend}
hostname: ${BACKEND_CONTAINER_NAME:-stpeteit-backend}
domainname: ${BACKEND_DOMAIN:-convex.stpeteit.com}
@@ -58,7 +58,7 @@ services:
stop_signal: SIGINT
stpeteit-dashboard:
image: ghcr.io/get-convex/convex-dashboard:${DASHBOARD_TAG:-latest}
image: ghcr.io/convex/convex-dashboard:${DASHBOARD_TAG:-latest}
container_name: ${DASHBOARD_CONTAINER_NAME:-stpeteit-dashboard}
hostname: ${DASHBOARD_CONTAINER_NAME:-stpeteit-dashboard}
domainname: ${DASHBOARD_DOMAIN:-dashboard.${BACKEND_DOMAIN:-convex.stpete.com}}
+1
View File
@@ -1,4 +1,5 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: Tools
After=network-online.target podman.socket
Wants=network-online.target
+1
View File
@@ -1,4 +1,5 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: Uptime
After=network-online.target
Wants=network-online.target
+5 -2
View File
@@ -8,14 +8,17 @@ AWS_DEFAULT_REGION=us-east-2
DATABASE_URL=CHANGE_ME
GITHUB_ID=Ov23liz34colyJed9t9S
GITHUB_SECRET=CHANGE_ME
MINIO_ROOT_USER=usesend
MINIO_ROOT_PASSWORD=CHANGE_ME
NEXTAUTH_SECRET=CHANGE_ME
NEXT_PUBLIC_IS_CLOUD=false
POSTGRES_USER=usesend
POSTGRES_PASSWORD=CHANGE_ME
POSTGRES_DB=usesend
REDIS_URL=redis://usesend-redis:6379
RUSTFS_ACCESS_KEY=CHANGE_ME
RUSTFS_SECRET_KEY=CHANGE_ME
S3_COMPATIBLE_API_URL=https://storage.usesend.gbrown.org
S3_COMPATIBLE_PUBLIC_URL=https://storage.usesend.gbrown.org/unsend
S3_COMPATIBLE_BUCKET=unsend
SMTP_HOST=smtp.usesend.gbrown.org
SMTP_USER=usesend
USESEND_PORT=3000
+39
View File
@@ -0,0 +1,39 @@
# TLS certificate for the UseSend SMTP proxy
The `usesend/smtp-proxy` image ships a default certificate that **cannot complete a
TLS 1.3 handshake** — OpenSSL rejects it with `no suitable signature algorithm`.
Because the proxy also refuses `AUTH` until `STARTTLS` has succeeded, that default
cert made *all* inbound SMTP impossible. These files replace it.
## What lives in certs/
`fullchain.pem` and `privkey.pem`, copied from NPM's Let's Encrypt store:
~/Server/Nginx_Proxy_Manager/volumes/letsencrypt/live/npm-43/
That certificate carries `*.usesend.gbrown.org`, which is what lets clients validate
the proxy as `smtp.usesend.gbrown.org`. A wildcard matches exactly one label, so
`*.gbrown.org` alone would not cover it.
Clients reach that name two ways, neither of them public DNS:
- containers on `nginx-bridge` get the network alias set in `compose.yml`
- LAN and home-server clients get a UniFi DNS record pointing it at `192.168.2.2`
## Why copies instead of a bind mount
NPM mounts that letsencrypt tree with `:Z`, which gives it a *private* SELinux MCS
category. A second container mounting the same path would relabel it and the two
would fight over it. Copying keeps the stacks independent.
## Keeping the copies fresh
NPM renews on its own schedule, so `usesend-cert-refresh.timer` runs
`refresh-smtp-certs` daily. It copies the cert when NPM's fingerprint changes,
refuses a half-renewed cert/key pair, and restarts the proxy.
If the copies ever expire, every app sending mail through the proxy fails at once,
with a TLS error rather than anything that mentions certificates. Check with:
openssl x509 -in ~/Server/UseSend/certs/fullchain.pem -noout -enddate
systemctl --user list-timers usesend-cert-refresh.timer
+69 -30
View File
@@ -21,25 +21,69 @@ services:
tty: true
stdin_open: true
restart: unless-stopped
minio:
image: minio/minio
# S3-compatible storage for images uploaded in the email editor, following
# upstream's docker/prod/compose.yml (RustFS replaced MinIO, whose images are
# no longer published). The browser uploads straight to S3_COMPATIBLE_API_URL
# and sent emails link to S3_COMPATIBLE_PUBLIC_URL, so NPM proxies
# storage.usesend.gbrown.org -> http://usesend-storage:9000. No host ports:
# the console (9001) is only reachable from inside nginx-bridge.
# Pinned like upstream. Bump the tag deliberately when upstream does.
rustfs:
image: docker.io/rustfs/rustfs:1.0.4
container_name: usesend-storage
hostname: usesend-storage
networks:
- nginx-bridge
env_file: .env
# RustFS runs as uid 10001; keep-id keeps ./volumes/rustfs gib-owned (AGENTS.md §3.2)
userns_mode: keep-id:uid=10001,gid=10001
security_opt:
- no-new-privileges:true
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
labels:
com.centurylinklabs.watchtower.enable: 'true'
RUSTFS_ACCESS_KEY: ${RUSTFS_ACCESS_KEY:?err}
RUSTFS_SECRET_KEY: ${RUSTFS_SECRET_KEY:?err}
RUSTFS_ADDRESS: 0.0.0.0:9000
RUSTFS_CONSOLE_ADDRESS: 0.0.0.0:9001
RUSTFS_CONSOLE_ENABLE: 'true'
RUSTFS_OBS_LOGGER_LEVEL: error
TZ: America/New_York
volumes:
- ./volumes/minio:/data:Z
entrypoint: sh
command: -c 'mkdir -p /data/unsend && minio server /data --console-address ':9001' --address ':9002''
tty: true
stdin_open: true
- ./volumes/rustfs:/data:Z
command: ["/data"]
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:9000/health"]
interval: 20s
timeout: 10s
retries: 5
start_period: 40s
restart: unless-stopped
# One-shot: creates the bucket, allows browser uploads from the app (CORS) and
# makes objects publicly readable so images load in sent emails. Reruns on
# every unit start, which is harmless.
rustfs-init:
image: docker.io/amazon/aws-cli:2.34.30
container_name: usesend-storage-init
networks:
- nginx-bridge
entrypoint: ["/bin/sh", "-ec"]
environment:
AWS_ACCESS_KEY_ID: ${RUSTFS_ACCESS_KEY:?err}
AWS_SECRET_ACCESS_KEY: ${RUSTFS_SECRET_KEY:?err}
AWS_DEFAULT_REGION: us-east-1
AWS_EC2_METADATA_DISABLED: 'true'
BUCKET: ${S3_COMPATIBLE_BUCKET:-unsend}
APP_ORIGIN: ${USESEND_URL:?err}
depends_on:
rustfs:
condition: service_healthy
command:
- |
s3="aws --endpoint-url http://usesend-storage:9000 s3api"
$$s3 head-bucket --bucket "$$BUCKET" >/dev/null 2>&1 || $$s3 create-bucket --bucket "$$BUCKET"
$$s3 put-bucket-cors --bucket "$$BUCKET" --cors-configuration \
'{"CORSRules":[{"AllowedOrigins":["'"$$APP_ORIGIN"'"],"AllowedMethods":["GET","HEAD","PUT"],"AllowedHeaders":["*"],"ExposeHeaders":["ETag"],"MaxAgeSeconds":3600}]}'
$$s3 put-bucket-policy --bucket "$$BUCKET" --policy \
'{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::'"$$BUCKET"'/*"]}]}'
restart: 'no'
smtp-server:
image: usesend/smtp-proxy:latest
container_name: usesend-smtp-server
@@ -47,26 +91,15 @@ services:
networks:
nginx-bridge:
# The proxy REQUIRES STARTTLS before it will accept AUTH, so clients must
# reach it by a name the certificate actually covers. The wildcard is
# *.gbrown.org, which matches ONE label -- hence smtp-usesend, not
# smtp.usesend.
# reach it by a name the certificate actually covers: smtp.usesend.gbrown.org,
# validated by the *.usesend.gbrown.org SAN on npm-43 (see certs/README.md).
#
# Resolution: podman's aardvark-dns answers this name authoritatively for
# containers ON nginx-bridge, handing back the container IP. NPM is not
# involved at all -- it only reverse-proxies HTTP(S), and SMTP ports are
# never published to the host, so the proxy is unreachable from outside.
#
# NOTE: there IS a wildcard *.gbrown.org record in Cloudflare, so this name
# also resolves publicly (to Cloudflare, which serves nothing for it). The
# consequence: if this container is STOPPED, aardvark-dns stops answering,
# the lookup falls through to that public record, and clients dial
# Cloudflare on 587 and hang until timeout instead of failing fast.
# involved at all -- it only reverse-proxies HTTP(S). Cloudflare is not
# involved either: there is deliberately no public DNS record for this name,
# so if the container is stopped, lookups fail fast with NXDOMAIN.
aliases:
# Both names resolve to this container and BOTH validate against npm-43,
# which carries *.gbrown.org and *.usesend.gbrown.org. smtp.usesend is the
# name UseSend's own UI advertises and is the target to standardise on;
# smtp-usesend stays until every consumer has been moved across.
- smtp-usesend.gbrown.org
- smtp.usesend.gbrown.org
env_file: .env
environment:
@@ -83,8 +116,8 @@ services:
# brute-force it, and contradicts the Cloudflare-only posture in §7.
# The `Home` wg interface sits in firewalld's `trusted` zone, so no rule is needed.
# Rootless can bind these because net.ipv4.ip_unprivileged_port_start = 80.
# Consumers on the home server reach it as smtp-usesend.gbrown.org (UniFi DNS maps
# that name to 192.168.2.2) so the *.gbrown.org cert still validates by hostname.
# Consumers on the home server reach it as smtp.usesend.gbrown.org (a UniFi DNS record
# maps that name to 192.168.2.2) so the cert still validates by hostname.
- "192.168.2.2:587:587"
- "192.168.2.2:465:465"
volumes:
@@ -121,6 +154,12 @@ services:
- API_RATE_LIMIT=${API_RATE_LIMIT:-1}
- SMTP_HOST=${SMTP_HOST:-smtp.usesend.gbrown.org}
- SMTP_USER=${SMTP_USER:-usesend}
# Editor image uploads (the rustfs service above). Uploads are disabled if any is unset.
- S3_COMPATIBLE_ACCESS_KEY=${RUSTFS_ACCESS_KEY:?err}
- S3_COMPATIBLE_SECRET_KEY=${RUSTFS_SECRET_KEY:?err}
- S3_COMPATIBLE_API_URL=${S3_COMPATIBLE_API_URL:?err}
- S3_COMPATIBLE_PUBLIC_URL=${S3_COMPATIBLE_PUBLIC_URL:?err}
- S3_COMPATIBLE_BUCKET=${S3_COMPATIBLE_BUCKET:-unsend}
labels:
com.centurylinklabs.watchtower.enable: 'true'
depends_on:
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# Keep the UseSend SMTP proxy's TLS certificate in step with NPM's Let's Encrypt store.
#
# The proxy refuses AUTH until STARTTLS succeeds, so an expired or mismatched cert
# takes down ALL outbound mail for every app that sends through it -- and it fails
# with a bare TLS error that never mentions certificates. Hence this timer.
#
# Copies rather than bind-mounts because NPM mounts its letsencrypt tree with :Z
# (private SELinux category) and two consumers on one path fight over the label.
#
# Uses npm-43 because it carries *.usesend.gbrown.org, the SAN that validates
# smtp.usesend.gbrown.org. A wildcard matches exactly ONE label, so *.gbrown.org
# alone would not cover it.
set -euo pipefail
SRC=$HOME/Server/Nginx_Proxy_Manager/volumes/letsencrypt/live/npm-43
DST=$HOME/Server/UseSend/certs
CONTAINER=usesend-smtp-server
fp() { openssl x509 -in "$1" -noout -fingerprint -sha256 2>/dev/null | cut -d= -f2; }
certpub(){ openssl x509 -in "$1" -noout -pubkey 2>/dev/null | openssl sha256 | awk '{print $2}'; }
keypub() { openssl pkey -in "$1" -pubout 2>/dev/null | openssl sha256 | awk '{print $2}'; }
[ -r "$SRC/fullchain.pem" ] && [ -r "$SRC/privkey.pem" ] || {
echo "source cert unreadable under $SRC -- did NPM move it?" >&2; exit 1; }
src_fp=$(fp "$SRC/fullchain.pem")
dst_fp=$(fp "$DST/fullchain.pem" 2>/dev/null || true)
if [ -n "$dst_fp" ] && [ "$src_fp" = "$dst_fp" ]; then
echo "cert unchanged (${src_fp:0:17}...); nothing to do"
exit 0
fi
# Refuse to install a half-renewed pair: certbot writes the two files separately, so
# a run that lands between them would leave the proxy with a key that is not its cert.
if [ "$(certpub "$SRC/fullchain.pem")" != "$(keypub "$SRC/privkey.pem")" ]; then
echo "source cert and key do not match -- renewal probably mid-flight, retrying next run" >&2
exit 0
fi
echo "cert changed (${dst_fp:0:17}... -> ${src_fp:0:17}...), installing"
# -L dereferences: live/ entries are symlinks into ../../archive/, outside this copy.
cp -L "$SRC/fullchain.pem" "$DST/fullchain.pem.new"
cp -L "$SRC/privkey.pem" "$DST/privkey.pem.new"
chmod 644 "$DST/fullchain.pem.new"
chmod 640 "$DST/privkey.pem.new"
mv -f "$DST/fullchain.pem.new" "$DST/fullchain.pem"
mv -f "$DST/privkey.pem.new" "$DST/privkey.pem"
echo "restarting $CONTAINER"
podman restart "$CONTAINER" >/dev/null
echo "done; cert now valid until $(openssl x509 -in "$DST/fullchain.pem" -noout -enddate | cut -d= -f2)"
@@ -0,0 +1,15 @@
[Unit]
Description=Refresh UseSend SMTP proxy TLS certificate from NPM's Let's Encrypt store
Documentation=file://%h/Server/UseSend/README.md
After=podman-usesend.service
[Service]
Type=oneshot
ExecStart=%h/Server/UseSend/refresh-smtp-certs
# The script calls `podman restart`, which leaves conmon and the container's
# supervising processes inside THIS unit's cgroup. With the default KillMode,
# systemd waits for that cgroup to empty and the unit times out and reports
# `failed` even though ExecStart returned 0 and the cert was installed correctly.
# KillMode=process makes systemd track only the script itself.
KillMode=process
TimeoutStartSec=300
@@ -0,0 +1,12 @@
[Unit]
Description=Daily check for a renewed UseSend SMTP proxy certificate
[Timer]
OnCalendar=daily
# Spread the load and avoid landing exactly when certbot is writing.
RandomizedDelaySec=2h
# Catch up after the host has been off; the cert does not care that we were asleep.
Persistent=true
[Install]
WantedBy=timers.target
@@ -1,4 +1,5 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: Vaultwarden
After=network-online.target podman.socket
Wants=network-online.target
+33
View File
@@ -0,0 +1,33 @@
# WristCode gateway: the backend for the WristCode Apple Watch app (git.gbrown.org/gib/wristcode).
# It holds a WebSocket to every T3 Code server, serves the watch's HTTPS API, and sends APNs pushes.
# NPM serves wristcode.gbrown.org and forwards to wristcode-gateway:8080 over nginx-bridge.
#
# .env beside this file (mode 600):
# APNS_KEY_ID=... the APNs key's ID
# APNS_TEAM_ID=GQJNM5YK2M
# APNS_KEY_PATH=/data/apns.p8 the .p8 file, in ./data
#
# Operator CLI: podman exec -it wristcode-gateway wristcode <host add|pair|device list|...>
networks:
nginx-bridge:
external: true # ALWAYS external; see AGENTS.md §5
services:
wristcode-gateway:
image: git.gbrown.org/gib/wristcode-gateway:latest
container_name: wristcode-gateway
hostname: wristcode-gateway
domainname: wristcode.gbrown.org
networks: ['nginx-bridge']
env_file: [.env]
environment:
- TZ=America/New_York
# The image runs as `bun` (uid 1000). keep-id maps it to gib, so ./data stays gib-owned (AGENTS.md §3.2).
userns_mode: "keep-id:uid=1000,gid=1000"
volumes:
- ./data:/data:z # lowercase :z survives update-containers recreates, as in Agentchat
restart: unless-stopped
healthcheck:
test: wget -qO- http://127.0.0.1:8080/v1/health || exit 1
interval: 30s
start_period: 10s
@@ -0,0 +1,24 @@
# TEMPLATE for a rootless user unit.
# Install to ~/.config/systemd/user/podman-<name>.service then:
# systemctl --user daemon-reload && systemctl --user enable --now podman-<name>.service
# Requires linger: sudo loginctl enable-linger $USER
[Unit]
Description=Podman Compose: wristcode
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=3
[Service]
Type=oneshot
WorkingDirectory=%h/Server/Wristcode
ExecStart=/usr/bin/podman compose up -d
ExecStop=/usr/bin/podman compose down
RemainAfterExit=yes
TimeoutStartSec=300
TimeoutStopSec=60
Restart=on-failure
RestartSec=30
[Install]
WantedBy=default.target
+194
View File
@@ -0,0 +1,194 @@
#!/usr/bin/env bash
#
# Daily backup of ~/Server to the home server, as hardlinked dated snapshots.
#
# WHAT THIS FIXES vs. the old version (2026-09-17):
# 1. OWNERSHIP. Rootless podman leaves most container data owned by subuids
# (gitea = 525288, postgres data = the userns root mapping). Plain `rsync -a`
# running as gib cannot chown on the far side, so every previous backup landed
# 100% owned by uid 1000. Restoring that gives you the right bytes with the wrong
# owners: postgres refuses to start unless PGDATA is owned correctly, and gitea
# fails on its own data dir. Fixed with `-M--fake-super`, which makes the REMOTE
# rsync stash mode/uid/gid in a user.rsync.%stat xattr (verified working on the
# home server's ZFS). Restore with --fake-super on the read side to put them back.
# 2. CONSISTENCY. 13 SQLite DBs and a live postgres were copied hot, mid-write. Now
# dumped properly first (sqlite3 .backup / pg_dumpall) into _backup_state/.
# 3. SPACE / HISTORY. Every run was a full ~19G copy into a new dated directory.
# Now it rsyncs into ONE stable directory, `current/`, and versioning is left to
# the home server: Media/Server_Backup is its own ZFS dataset with zfs-auto-snapshot
# keeping 14 dailies. ZFS snapshots are block-level, so a database that changes a
# few pages costs a few pages -- not a whole new copy the way dated dirs (or even
# rsync --link-dest hardlinks, which are per-file) would.
#
# Containers keep running throughout. That is safe *because* of the dumps above --
# the dumps, not the raw data dirs, are the authoritative restore path.
#
# See RESTORE.md for how to bring this back on a new machine.
set -uo pipefail
REMOTE_HOST="[email protected]"
REMOTE_ROOT="/home/gib/Public/Server_Backup/Containers/lilman.gib"
SOURCE="$HOME/Server/"
STATE_DIR="$HOME/Server/_backup_state"
DEST_NAME="current" # One stable directory. ZFS snapshots provide history.
LOG_DIR="$HOME/Server/logs"
LOG="$LOG_DIR/backup.log"
LOCK="${XDG_RUNTIME_DIR:-/tmp}/server-backup.lock"
# Versioning is ZFS's job, not ours -- see the header. We only sanity-check that it is
# actually still happening, so this can never silently degrade into a single mirror with
# no history. Warn if the newest snapshot of the dataset is older than this.
REMOTE_DATASET="Media/Server_Backup"
SNAPSHOT_MAX_AGE_HOURS=48
mkdir -p "$LOG_DIR"
log() { printf '%s %s\n' "$(date +'%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG"; }
die() { log "FATAL: $*"; exit 1; }
# Refuse to overlap with a still-running backup rather than corrupting a snapshot.
exec 9>"$LOCK" || die "cannot open lock $LOCK"
flock -n 9 || die "another backup is already running"
EXCLUSIONS=(
# --- build noise -------------------------------------------------------
# NOTE: these are bare patterns, so they match at ANY depth. Today they only
# catch genuine scratch dirs (checked 2026-09-17). If you add a service whose
# real data lives in a dir called cache/build/dist/tmp, it will be silently
# skipped -- re-check this list when adding services.
"node_modules" ".git" "*.log" "*.tmp" ".cache" "cache" "tmp" "temp"
".npm" ".yarn" "dist" "build" "target" ".venv" "venv" "__pycache__"
"*.pyc" ".DS_Store" "Thumbs.db" "vendor" "*.mp4" "*.mkv"
# --- unreadable to gib, and genuinely disposable ------------------------
# Redis/valkey cache dumps: mode 0600 under a subuid because the container
# chowns its own data. Pure cache (usesend + infisical), regenerated on boot.
"dump.rdb"
# Gitea runs WITHOUT userns_mode (s6 needs container-root -- see AGENTS.md §3),
# so these are subuid-owned and mode 0700. Excluding them is what lets this run
# without sudo. Nothing here is needed to restore gitea:
# sessions/ ephemeral web logins; worst case everyone logs in again
# issues.bleve search index, rebuilt from the DB on demand
# Gitea/ssh a 25-byte `environment` file for the git shell
#
# ⚠️ The SSH HOST KEYS are NOT here -- they live in Gitea/volume/ssh, are
# gib-owned, and DO get backed up. Losing those = host-key mismatch for every client.
"sessions" "issues.bleve" "Gitea/ssh"
# Mode 0600 under subuid 525288, so unreadable to gib -- this single file is what
# made every previous run exit 23. It is NOT disposable (it holds the MCP bridge's
# registered OAuth clients), so it is captured via `podman exec` into
# _backup_state/files/ instead of being read off disk.
"BillTracker/data/mcp/bridge.json"
# Stirling-PDF writes this 0600 under subuid 525287 (since its 2026-09-25 update),
# which made every run since exit 23. Excluded rather than captured: losing it only
# means re-entering any credentials stored in Stirling-PDF.
"pdf-tools/extraConfigs/credential-encryption.key"
)
EXCLUDE_ARGS=()
for e in "${EXCLUSIONS[@]}"; do EXCLUDE_ARGS+=( "--exclude=$e" ); done
# ---------------------------------------------------------------------------
# Phase 1: consistent dumps of everything transactional.
# ---------------------------------------------------------------------------
dump_state() {
log "Dumping databases to _backup_state/ ..."
rm -rf "$STATE_DIR"
mkdir -p "$STATE_DIR"/{postgres,sqlite,files}
printf 'Generated %s by ~/Server/backup. Authoritative restore source -- see RESTORE.md\n' \
"$(date -Is)" > "$STATE_DIR/README"
# Live postgres (serves npm, authentik, usesend, ... -- pg_dumpall gets all of them).
if podman ps --format '{{.Names}}' | grep -qx postgresql; then
if podman exec postgresql pg_dumpall -U npm 2>>"$LOG" | gzip -9 > "$STATE_DIR/postgres/all.sql.gz"; then
log " postgres: $(du -h "$STATE_DIR/postgres/all.sql.gz" | cut -f1)"
else
log " WARNING: pg_dumpall failed -- raw pg_data is still copied, but is a hot copy"
fi
else
log " WARNING: postgresql container not running, skipping pg_dumpall"
fi
# SQLite: .backup uses the online backup API, so this is safe while containers write.
local n=0
while IFS= read -r db; do
# *.db also matches stirling-pdf's H2 database, which is not SQLite. Check magic.
head -c 16 "$db" 2>/dev/null | grep -q "SQLite format 3" || continue
local rel="${db#"$HOME"/Server/}" out
out="$STATE_DIR/sqlite/$rel"
mkdir -p "$(dirname "$out")"
if sqlite3 -cmd ".timeout 30000" "$db" ".backup '$out'" 2>>"$LOG"; then n=$((n+1)); else log " WARNING: sqlite dump failed: $rel"; fi
done < <(find "$HOME/Server" -path "$STATE_DIR" -prune -o \
-type f \( -name '*.db' -o -name '*.sqlite' -o -name '*.sqlite3' \) -print 2>/dev/null)
log " sqlite: $n databases"
# Files gib cannot read off disk, pulled through their container instead.
if podman ps --format '{{.Names}}' | grep -qx bill-tracker-mcp; then
podman exec bill-tracker-mcp cat /data/bridge.json \
> "$STATE_DIR/files/BillTracker--data--mcp--bridge.json" 2>>"$LOG" \
&& log " bridge.json captured" \
|| log " WARNING: could not capture bridge.json"
fi
}
# ---------------------------------------------------------------------------
# Phase 2: confirm ZFS is still providing history.
#
# We deliberately overwrite one directory instead of keeping dated copies, which
# means ZFS snapshots are the ONLY thing standing between us and "a mirror of a
# mistake". If auto-snapshot ever stops, that must be loud, not silent.
# ---------------------------------------------------------------------------
check_zfs_history() {
local newest now age_h
newest=$(ssh -o BatchMode=yes "$REMOTE_HOST" \
"zfs list -H -p -o creation -t snapshot -s creation -d 1 '$REMOTE_DATASET' 2>/dev/null | tail -1" || true)
if [ -z "$newest" ]; then
log " ⚠️ WARNING: no ZFS snapshots on $REMOTE_DATASET -- this backup has NO history."
log " It is a plain mirror right now. Check zfs-auto-snapshot on the home server."
return
fi
now=$(date +%s)
age_h=$(( (now - newest) / 3600 ))
if [ "$age_h" -gt "$SNAPSHOT_MAX_AGE_HOURS" ]; then
log " ⚠️ WARNING: newest ZFS snapshot is ${age_h}h old (limit ${SNAPSHOT_MAX_AGE_HOURS}h) -- versioning may be broken."
else
log " ZFS history OK (newest snapshot ${age_h}h old, 14 dailies retained)"
fi
}
# ---------------------------------------------------------------------------
# Phase 3: mirror.
# ---------------------------------------------------------------------------
log "=== backup start -> $REMOTE_HOST:$REMOTE_ROOT/$DEST_NAME ==="
ssh -o BatchMode=yes -o ConnectTimeout=15 "$REMOTE_HOST" "mkdir -p '$REMOTE_ROOT/$DEST_NAME'" \
|| die "cannot reach $REMOTE_HOST"
dump_state
check_zfs_history
# --delete/--delete-excluded keep `current` an exact mirror of the source. Anything
# removed here still exists in yesterday's ZFS snapshot, so this is not destructive.
rsync -a --numeric-ids -M--fake-super --delete --delete-excluded \
--human-readable --stats \
"${EXCLUDE_ARGS[@]}" \
--rsync-path="mkdir -p '$REMOTE_ROOT/$DEST_NAME' && rsync" \
"$SOURCE" "$REMOTE_HOST:$REMOTE_ROOT/$DEST_NAME/" 2>&1 | tee -a "$LOG"
status=${PIPESTATUS[0]}
# 24 = "source files vanished during transfer". Normal on a live system, not a failure.
if [ "$status" -ne 0 ] && [ "$status" -ne 24 ]; then
die "rsync failed with exit $status -- 'current' may be half-updated; yesterday's ZFS snapshot is still good"
fi
[ "$status" -eq 24 ] && log "NOTE: rsync exit 24 (files vanished mid-run) -- expected on a live system"
# Optional heartbeat so a silent failure cannot go unnoticed. Create the file with an
# uptime-kuma push URL in it and this pings on success only; absent, nothing happens.
if [ -r "$HOME/Server/.backup-heartbeat-url" ]; then
url=$(< "$HOME/Server/.backup-heartbeat-url")
curl -fsS -m 20 "$url" >/dev/null 2>&1 \
&& log "Heartbeat sent" || log "NOTE: heartbeat ping failed (backup itself was fine)"
fi
log "=== backup complete ==="
+22
View File
@@ -0,0 +1,22 @@
[Unit]
Description=Back up ~/Server to the home server
Documentation=file:///home/gib/Server/RESTORE.md
After=network-online.target
Wants=network-online.target
# The backup reads podman-managed data and shells into containers for dumps.
RequiresMountsFor=/home/gib/Media
[Service]
Type=oneshot
ExecStart=%h/Server/backup
# A stalled rsync should fail loudly, not hang until the next timer fires.
TimeoutStartSec=6h
# This box is IO-bound before it is CPU-bound (~18%% avg60 pressure), so stay
# out of the way of the containers. best-effort/7 rather than idle, which can
# starve the transfer outright when the volume is busy.
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=7
[Install]
WantedBy=default.target
+18
View File
@@ -0,0 +1,18 @@
[Unit]
Description=Daily backup of ~/Server to the home server
[Timer]
# MUST finish before the home server's cron.daily fires zfs-auto-snapshot, which has
# been landing between 03:22 and 03:52. If the snapshot catches a half-finished rsync,
# that day's history entry is a partial backup. 01:00 leaves >2h of margin; the first
# full run took well under that and daily deltas are far smaller.
OnCalendar=*-*-* 01:00:00
# Small jitter only -- must not push us into the snapshot window.
RandomizedDelaySec=10m
# If the VPS was down at 01:00, run once after it comes back rather than
# silently skipping a day.
Persistent=true
Unit=server-backup.service
[Install]
WantedBy=timers.target
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env bash
# Post-reboot verification for the rootless podman stack.
# Run as gib. Compares against ~/premigration_backup_2026-08-10/post_migration_baseline.txt
set -uo pipefail
fail=0
ok() { printf ' \033[32mOK\033[0m %s\n' "$1"; }
bad() { printf ' \033[31mFAIL\033[0m %s\n' "$1"; fail=$((fail+1)); }
echo "== linger =="
[[ "$(loginctl show-user gib --property=Linger --value 2>/dev/null)" == "yes" ]] \
&& ok "Linger=yes" || bad "linger is OFF - user units will not start at boot"
echo "== unprivileged port start =="
p=$(sysctl -n net.ipv4.ip_unprivileged_port_start 2>/dev/null)
[[ "$p" == "80" ]] && ok "ip_unprivileged_port_start=80" \
|| bad "ip_unprivileged_port_start=$p (NPM cannot bind :80/:443)"
echo "== user units =="
for s in postgresql bang completeuphoria sierraandtyler uptime portainer beszel authentik tools isponsorblocktv vaultwarden infisical rustdesk nginx-proxy-manager gbrown-agent wristcode; do
st=$(systemctl --user is-active "podman-$s.service" 2>/dev/null)
[[ "$st" == "active" ]] && ok "$s" || bad "$s is $st"
done
echo "== containers =="
for c in postgresql nginx-proxy-manager uptime portainer bang completeuphoria sierraandtyler beszel beszel-agent authentik-server authentik-worker convertx it-tools pdf-tools bento-pdf iSponsorBlockTV vaultwarden infisical-redis infisical-backend rustdesk-server gbrown-agent; do
if podman ps --format '{{.Names}}' 2>/dev/null | grep -qx "$c"; then ok "$c"; else bad "$c not running"; fi
done
echo "== postgres health =="
h=$(podman inspect postgresql --format '{{.State.Health.Status}}' 2>/dev/null)
[[ "$h" == "healthy" ]] && ok "postgres healthy" || bad "postgres health=$h"
echo "== endpoints =="
chk() { local n=$1 exp=$2; shift 2; c=$(curl -sk -o /dev/null -w '%{http_code}' "$@" 2>/dev/null)
[[ "$c" == "$exp" ]] && ok "$n -> $c" || bad "$n -> $c (expected $exp)"; }
# Admin ports moved to the WireGuard address 2026-08-12 (AGENTS.md §13.2).
# They no longer listen on localhost, so probe 192.168.2.2.
chk "NPM :81" 200 http://192.168.2.2:81
chk "NPM :80" 200 http://localhost:80
chk "NPM :443" 200 --resolve nginx.gibbyb.com:443:127.0.0.1 https://nginx.gibbyb.com
chk "Uptime" 302 http://192.168.2.2:3001
chk "Portainer" 200 https://192.168.2.2:9443
# ─────────────────────────────────────────────────────────────────────────────
# Boot-enablement check (added 2026-08-12)
#
# Five stacks were once found RUNNING but DISABLED — healthy in `podman ps`, yet they
# would have disappeared on the next reboot. Starting a stack and enabling it are
# separate actions; this catches the gap. See AGENTS.md §13.3.
# ─────────────────────────────────────────────────────────────────────────────
echo
echo "== boot-enablement =="
# podman's own units are meant to stay disabled; n8n is intentionally down.
# podman's own units stay disabled by design; n8n is intentionally down;
# podman-update.service is `static` (the timer activates it, so it is never enabled);
# watchtower was RETIRED 2026-08-17 and must stay disabled (AGENTS.md §13.15).
SKIP='podman-auto-update|podman-restart|podman-clean-transient|podman-kube@|podman-user-wait-network-online|podman-n8n|podman-update|podman-watchtower'
bad=0
for u in $(systemctl --user list-unit-files 'podman-*.service' --no-legend 2>/dev/null | awk '{print $1}'); do
echo "$u" | grep -qE "$SKIP" && continue
state=$(systemctl --user is-enabled "$u" 2>/dev/null)
if [ "$state" != "enabled" ]; then
echo " WILL NOT SURVIVE REBOOT: $u ($state) -> systemctl --user enable $u"
bad=$((bad+1))
fi
done
[ "$bad" -eq 0 ] && echo " all service units enabled"
echo
echo "== nightly image updates (replaces watchtower — AGENTS.md §13.15) =="
if [ "$(systemctl --user is-enabled podman-update.timer 2>/dev/null)" = "enabled" ]; then
echo " timer enabled, next: $(systemctl --user list-timers podman-update.timer --no-pager 2>/dev/null | awk 'NR==2{print $1,$2,$3,$4}')"
else
echo " WARNING: podman-update.timer is NOT enabled — nothing will update"
fi
if [ "$(systemctl --user is-active podman-watchtower.service 2>/dev/null)" = "active" ]; then
echo " WARNING: watchtower is RUNNING — it recreates containers outside their pod (§13.13)"
fi
# A container outside its compose pod is the signature of a watchtower-style recreate.
podless=""
for c in $(podman ps --format '{{.Names}}' 2>/dev/null); do
[ -z "$(podman inspect "$c" --format '{{.Pod}}' 2>/dev/null)" ] && podless="$podless $c"
done
[ -n "$podless" ] && echo " PODLESS (rebuild with systemctl --user restart):$podless" || echo " all containers in their pod"
echo
echo "== admin ports bound to 0.0.0.0 (should be 192.168.2.2 — AGENTS.md §13.2) =="
podman ps --format '{{.Names}} {{.Ports}}' | grep -E '0\.0\.0\.0:(81|80-81|3001|9443|5432)(->|-)' \
| sed 's/^/ EXPOSED: /' || echo " none"
echo
if [[ $fail -eq 0 ]]; then
printf '\033[32mALL CHECKS PASSED\033[0m\n'
else
printf '\033[31m%d CHECK(S) FAILED\033[0m\n' "$fail"
echo "Investigate: journalctl --user -u podman-<name>.service -n 50"
fi
exit $fail
+4
View File
@@ -86,6 +86,10 @@ rbw
# The Snapshots page is snapper end to end; see scripts/panama-snapshots.
snapper
sushi
# T3 Code, from Terra: the desktop app, its background server and the `t3` CLI.
# panama-agent opens a T3 thread when preferredAgent is t3, and the ticket
# skill renders its plans and mocks into T3 threads.
t3code
# Fonts the desktop names by family and would otherwise render as tofu.
# VictorMono is required: it draws every glyph in the shell.
victormono-nerd-fonts
+10 -3
View File
@@ -77,7 +77,10 @@ matched the `digest` GitHub publishes for that release asset. The installer
accepts a Codex archive only when `tar -tzf` prints exactly the entries in
`codex-package.listing`. Both architectures share that listing. 0.165.0 dropped
the bundled `codex-resources/zsh/` and added `codex-resources/voice/`, and the
archives grew past 128 MiB, so `CODEX_*_MAX_BYTES` is 256 MiB. To bump Codex,
archives grew past 128 MiB, so `CODEX_*_MAX_BYTES` is 256 MiB. The installer keeps the
whole verified tree as the version directory and links `~/.local/bin/codex` to
its `bin/codex`, because Codex runs `bin/codex-code-mode-host` and reads
`codex-path/` and `codex-resources/` relative to itself. To bump Codex,
update `installers.conf`, regenerate the listing with `tar -tzf ARTIFACT`, and
run `tests/setup/package-provenance-contract`.
@@ -159,8 +162,12 @@ installed. Its effective `terra` configuration reported `gpgcheck = 1`,
`pkg_gpgcheck = 1`, and `repo_gpgcheck = 1`; no GPG-bypass option was used.
The package's own `/etc/yum.repos.d/terra.repo` uses its Terra metalink and
`RPM-GPG-KEY-terra44`. That differs from Panama's deliberately staged local
key/base-URL file in `install-packages`, which replaces the release-generated
file only after this verified bootstrap step.
key and base URL. `install-packages` applies those after this verified
bootstrap step as `/etc/dnf/repos.override.d/80-panama-terra.repo`, which dnf
lays over the package's file. The pin does not edit `terra.repo` itself,
because terra-release replaces that file on every update. Release 44-11 did
so on 2026-10-28, shipping `repo_gpgcheck=0`, and every Panama machine
stopped installing packages until the pin moved to the override directory.
Although the command runner returned after 30 seconds while DNF was still
loading metadata, Podman's retained event log records the exact command's
+90 -45
View File
@@ -417,20 +417,33 @@ _codex_version_matches() {
[[ "$output" =~ (^|[^0-9])${version_pattern}([^0-9]|$) ]]
}
# Codex ships as a package tree: bin/codex finds bin/codex-code-mode-host,
# codex-path/ and codex-resources/ beside itself, so the version directory keeps
# the whole verified tree and the active link points at its bin/codex.
_install_codex() (
local artifact_arch version_dir target bin_link parent expected_listing member
local stage archive listing staged_binary artifact_digest binary_digest
local retire_flat=0
stage=""
trap '[[ -z "$stage" ]] || rm -rf -- "$stage"' EXIT
# A retired flat install goes back if activation never happened.
trap '[[ -z "$stage" ]] || { [[ -d "$stage/retired" && ! -e "$version_dir" ]] \
&& mv -Tn -- "$stage/retired" "$version_dir"; rm -rf -- "$stage"; }' EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
_set_artifact_arch || return 1
version_dir="$HOME/.local/lib/panama/codex/${INSTALLER_PROVENANCE[CODEX_VERSION]}"
target="$version_dir/codex"
target="$version_dir/bin/codex"
bin_link="$HOME/.local/bin/codex"
artifact_digest="${INSTALLER_PROVENANCE[CODEX_${artifact_arch}_SHA256]}"
binary_digest="${INSTALLER_PROVENANCE[CODEX_${artifact_arch}_BINARY_SHA256]}"
if [[ -e "$version_dir" || -L "$version_dir" ]]; then
# Earlier installs of this same reviewed artifact kept only the codex binary
# at the top of the version directory, without the helpers it needs. That
# directory carries our receipt, so it is replaced once the full tree verifies.
if [[ ! -e "$version_dir/bin" && ! -L "$version_dir/bin" ]] \
&& _runtime_receipt_matches "$version_dir" "$version_dir/codex" \
"$artifact_digest" "$binary_digest"; then
retire_flat=1
elif [[ -e "$version_dir" || -L "$version_dir" ]]; then
_runtime_receipt_matches "$version_dir" "$target" \
"$artifact_digest" "$binary_digest" || return 1
[[ -x "$target" ]] || return 1
@@ -459,24 +472,24 @@ _install_codex() (
while IFS= read -r member; do
_archive_path_is_safe "$member" || { rm -rf -- "$stage"; return 1; }
done <<<"$listing"
mkdir -m 0700 "$stage/extract" "$stage/version" \
mkdir -m 0700 "$stage/version" \
|| { rm -rf -- "$stage"; return 1; }
tar -xzf "$archive" --no-same-owner --no-same-permissions -C "$stage/extract" \
tar -xzf "$archive" --no-same-owner --no-same-permissions -C "$stage/version" \
|| { rm -rf -- "$stage"; return 1; }
_tree_links_stay_inside "$stage/extract" || { rm -rf -- "$stage"; return 1; }
_tree_hardlinks_stay_inside "$stage/extract" || { rm -rf -- "$stage"; return 1; }
staged_binary="$stage/extract/bin/codex"
_tree_links_stay_inside "$stage/version" || { rm -rf -- "$stage"; return 1; }
_tree_hardlinks_stay_inside "$stage/version" || { rm -rf -- "$stage"; return 1; }
staged_binary="$stage/version/bin/codex"
[[ -f "$staged_binary" && ! -L "$staged_binary" && -x "$staged_binary"
&& "$(sha256sum "$staged_binary" | awk '{ print $1 }')" == "$binary_digest" ]] \
|| { rm -rf -- "$stage"; return 1; }
_codex_version_matches "$staged_binary" \
|| { rm -rf -- "$stage"; return 1; }
mv -- "$staged_binary" "$stage/version/codex" \
|| { rm -rf -- "$stage"; return 1; }
_write_runtime_receipt "$stage/version" "$artifact_digest" "$binary_digest" \
|| { rm -rf -- "$stage"; return 1; }
_activate_directory_no_replace "$stage/version" "$version_dir" \
|| { rm -rf -- "$stage"; return 1; }
if (( retire_flat )); then
mv -Tn -- "$version_dir" "$stage/retired" || return 1
fi
_activate_directory_no_replace "$stage/version" "$version_dir" || return 1
_runtime_receipt_matches "$version_dir" "$target" \
"$artifact_digest" "$binary_digest" || { rm -rf -- "$stage"; return 1; }
_codex_version_matches "$target" || { rm -rf -- "$stage"; return 1; }
@@ -729,7 +742,8 @@ _publish_repository_pair() {
# Reads the gpgkey of the single enabled Terra identity out of dnf's effective
# configuration, applying the structural safety rules either way: one enabled
# identity, named terra, with every signature check turned on.
# identity, named terra, with package signature checks turned on (and, in the
# pinned form, repository metadata checks too).
#
# With require_pinned set it additionally demands Panama's own reviewed baseurl
# and key path. Without it, the answer is just "what trust root is this machine
@@ -772,7 +786,11 @@ _effective_terra_key() {
bad = 1
}
if (values["gpgcheck"] != "1" || values["pkg_gpgcheck"] != "1" \
|| values["repo_gpgcheck"] != "1" || values["gpgkey"] == "") bad = 1
|| values["gpgkey"] == "") bad = 1
# Metadata signing is part of the pin, not of what adoption accepts:
# terra-release ships repo_gpgcheck=0, and the override adoption
# writes turns it back on.
if (require_pinned != "" && values["repo_gpgcheck"] != "1") bad = 1
trusted_key = values["gpgkey"]
}
BEGIN {
@@ -864,10 +882,12 @@ _terra_effective_status() {
#
# The trust root is the signing key, not the URL it is served from. A machine
# that installed Terra the way Terra documents has terra-release's own repo
# file: a metalink instead of the reviewed baseurl, and the key at
# RPM-GPG-KEY-terra44 rather than Panama's renamed copy. Every signature check
# is already on, and that key is the same fingerprint this repository reviewed
# and pinned. Cosmetics, in other words -- not a compromised trust root.
# file: a metalink instead of the reviewed baseurl, the key at
# RPM-GPG-KEY-terra44 rather than Panama's renamed copy, and (since
# terra-release 44-11) repo_gpgcheck=0. Package signature checks are on, and
# that key is the same fingerprint this repository reviewed and pinned. The pin
# adoption writes restores metadata checking, so none of that is a compromised
# trust root.
#
# Refusing it outright built a gate with no door. install_terra_repository
# declined to touch a machine terra-release had already reached, so an ordinary
@@ -1002,33 +1022,51 @@ install_rpmfusion_repositories() {
# trust root rolls back. The effective state is re-read afterwards, because the
# only acceptable proof that adoption worked is the check that judged it.
#
# terra-release owns /etc/yum.repos.d/terra.repo, so a later update to that
# package restores the stock file. That is fine and deliberate: the next run
# adopts it again, which is why adoption has to be repeatable rather than a
# one-time migration.
# Panama's Terra pin, as a dnf repos.override.d drop-in on top of whatever
# /etc/yum.repos.d/terra.repo says. terra-release owns that file and replaces
# it on every update (an edited copy is set aside as terra.repo.rpmsave), so a
# pin written there lasted only until the next terra-release release. Nothing
# owns the override directory, and its values win over the repo file's.
TERRA_OVERRIDE=/etc/dnf/repos.override.d/80-panama-terra.repo
_write_terra_pin() {
printf '%s\n' \
'[terra]' \
'name=Panama reviewed Terra 44' \
"baseurl=${INSTALLER_PROVENANCE[TERRA_BASEURL]}" \
'metalink=' \
'mirrorlist=' \
'enabled=1' \
'gpgcheck=1' \
'repo_gpgcheck=1' \
'gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama' > "$1"
chmod 0600 "$1"
}
_ensure_override_dir() {
[[ -d "$PANAMA_SYSTEM_ETC/dnf/repos.override.d" ]] && return 0
sudo mkdir -p -- /etc/dnf/repos.override.d
}
# A stock terra-release file (or one an older Panama rewrote) gets the pin laid
# over it. Adoption stays repeatable: if the override is ever removed, the next
# run puts it back.
adopt_terra_repository() {
local work staged_key staged_repo status=0 effective_status=0
work="$(mktemp -d)" || return 1
chmod 0700 "$work"
staged_key="$work/terra44.asc"
staged_repo="$work/terra.repo"
staged_repo="$work/80-panama-terra.repo"
if ! _stage_reviewed_key "$PANAMA_PATH/setup/provenance/keys/terra44.asc" "$staged_key" \
TERRA_FINGERPRINT AE09157A4DE88B497EA1D5D300CDAB43DE226D6F; then
rm -rf -- "$work"
return 1
fi
printf '%s\n' \
'[terra]' \
'name=Panama reviewed Terra 44' \
"baseurl=${INSTALLER_PROVENANCE[TERRA_BASEURL]}" \
'enabled=1' \
'gpgcheck=1' \
'repo_gpgcheck=1' \
'gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama' > "$staged_repo"
chmod 0600 "$staged_repo"
_publish_repository_pair \
_write_terra_pin "$staged_repo"
_ensure_override_dir || status=$?
(( status == 0 )) && { _publish_repository_pair \
"$staged_key" /etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama \
"$staged_repo" /etc/yum.repos.d/terra.repo || status=$?
"$staged_repo" "$TERRA_OVERRIDE" || status=$?; }
if (( status == 0 )); then
_terra_effective_status || effective_status=$?
(( effective_status == 0 )) || status="$TERRA_TRUST_FAILURE_STATUS"
@@ -1064,7 +1102,7 @@ install_terra_repository() {
work="$(mktemp -d)" || return 1
chmod 0700 "$work"
staged_key="$work/terra44.asc"
staged_repo="$work/terra.repo"
staged_repo="$work/80-panama-terra.repo"
if ! _stage_reviewed_key "$PANAMA_PATH/setup/provenance/keys/terra44.asc" "$staged_key" \
TERRA_FINGERPRINT AE09157A4DE88B497EA1D5D300CDAB43DE226D6F; then
rm -rf -- "$work"
@@ -1074,15 +1112,7 @@ install_terra_repository() {
rm -rf -- "$work"
return 1
}
printf '%s\n' \
'[terra]' \
'name=Panama reviewed Terra 44' \
"baseurl=${INSTALLER_PROVENANCE[TERRA_BASEURL]}" \
'enabled=1' \
'gpgcheck=1' \
'repo_gpgcheck=1' \
'gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama' > "$staged_repo"
chmod 0600 "$staged_repo"
_write_terra_pin "$staged_repo"
status=0
sudo dnf install -y \
--repofrompath "terra,${INSTALLER_PROVENANCE[TERRA_BASEURL]}" \
@@ -1091,7 +1121,10 @@ install_terra_repository() {
--setopt=terra.gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama \
terra-release || status=$?
if (( status == 0 )); then
sudo install -m 0644 "$staged_repo" /etc/yum.repos.d/terra.repo || status=$?
_ensure_override_dir || status=$?
fi
if (( status == 0 )); then
sudo install -m 0644 "$staged_repo" "$TERRA_OVERRIDE" || status=$?
fi
if (( status == 0 )); then
effective_status=0
@@ -1298,6 +1331,18 @@ fi
if [[ "$ROLE" == server ]]; then
echo -e "\n--- Installing packages (server) ---"
# A server never installs Terra, but one that already has it (installed by
# hand, or by an older Panama) has it enabled for the update below. Pin it
# first, exactly as a desktop would, rather than update through a stock
# terra-release file with metadata checks off.
if rpm -q terra-release >/dev/null 2>&1; then
terra_status=0
_terra_effective_status || terra_status=$?
if (( terra_status != 1 )); then
log "Pinning the Terra repository this server already has"
install_terra_repository > /dev/null
fi
fi
log "Updating all packages. This may take a while"
sudo dnf update -y --refresh > /dev/null
install_list core-packages "Core"
+64 -23
View File
@@ -23,10 +23,13 @@
# failure mode than leaving two lines for them to paste once.
#
# opencode has no CLI for this either, but its config is plain JSON, so this
# stage merges two keys into it with jq: one mcp entry per server row, and the
# rules directory under instructions, because opencode reads ~/.claude/CLAUDE.md
# and ~/.claude/skills on its own but not ~/.claude/rules. Every other key in
# that file is left as it was.
# stage writes it whole from user/agents/opencode/config.json -- the tracked
# template carrying everything that is not a secret -- plus the MCP block built
# here from the server rows. The whole file is generated rather than merged into
# because the point of the template is that every machine ends up with the same
# opencode experience, and a merged file drifts the moment one machine is edited
# by hand. The output says so at the top, and anything worth changing goes in
# the template, where it reaches the other machines too.
set -euo pipefail
@@ -37,6 +40,8 @@ PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
MCP_DIR="$PANAMA_PATH/user/agents/mcp"
SERVERS="$MCP_DIR/servers"
ENV_FILE="$MCP_DIR/env"
TEMPLATE="$PANAMA_PATH/user/agents/opencode/config.json"
PANAMA_OLD="$PANAMA_PATH/config/old"
STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/panama"
DECISION="$STATE_DIR/user-content"
@@ -152,14 +157,17 @@ while read -r name transport target token_var _rest; do
continue
fi
# ${args[@]+...} rather than "${args[@]}": the Mac's bash 3.2 treats an
# empty array as unset under set -u and aborts the whole stage.
#
# Re-registering is how a new server or a rotated token reaches the
# runtime. Removing first avoids the CLI refusing a name it already knows.
# Neither call may print the token, so both are quiet unless they fail.
claude mcp remove "$name" -s user >/dev/null 2>&1 || true
if [[ "$transport" == stdio ]]; then
add=(claude mcp add "$name" -s user "${args[@]}" -- "$launcher")
add=(claude mcp add "$name" -s user ${args[@]+"${args[@]}"} -- "$launcher")
else
add=(claude mcp add --transport "$transport" "$name" "$url" "${args[@]}" -s user)
add=(claude mcp add --transport "$transport" "$name" "$url" ${args[@]+"${args[@]}"} -s user)
fi
if "${add[@]}" >/dev/null 2>&1; then
log "Registered $name."
@@ -177,29 +185,62 @@ fi
$has_opencode || exit 0
# opencode reads opencode.jsonc or opencode.json, whichever this machine has.
# The file holds tokens once this runs, so it is written private and swapped in
# whole, the way ~/.claude.json already holds them for Claude Code.
# opencode reads opencode.jsonc. The file holds tokens once this runs, so it is
# written private and swapped in whole, the way ~/.claude.json already holds
# them for Claude Code.
OPENCODE_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/opencode"
opencode_config="$OPENCODE_DIR/opencode.jsonc"
[[ -e "$opencode_config" ]] || opencode_config="$OPENCODE_DIR/opencode.json"
rules="$HOME/.claude/rules/*.md"
mkdir -p "$OPENCODE_DIR"
current='{"$schema": "https://opencode.ai/config.json"}'
[[ -e "$opencode_config" ]] && current="$(cat "$opencode_config")"
# jq cannot read JSONC comments. A file that has them is left alone rather
# than rewritten without them.
if ! merged="$(jq --argjson mcp "$opencode_mcp" --arg rules "$rules" \
'.mcp = ((.mcp // {}) + $mcp) | .instructions = (((.instructions // []) - [$rules]) + [$rules])' \
<<<"$current" 2>/dev/null)"; then
warn "Could not parse $opencode_config as JSON; opencode MCP servers not written."
[[ -r "$TEMPLATE" ]] || { warn "No $TEMPLATE; opencode config not written."; exit 0; }
if ! jq -e . "$TEMPLATE" >/dev/null 2>&1; then
warn "$TEMPLATE is not valid JSON; opencode config not written."
exit 0
fi
# Nothing at the destination is destroyed, the promise link-user and link-skills
# make. A file this stage did not write is somebody's own work and goes to
# config/old/ under a name that says where it came from. One carrying the marker
# was written by an earlier run and is replaced in place, so re-running never
# buries its own output and config/old/ does not grow on every upgrade.
MARKER='generated by setup/scripts/link-mcp'
displace() {
local destination="$1" backup
[[ -e "$destination" || -L "$destination" ]] || return 0
if [[ -L "$destination" ]]; then
rm -f "$destination"
return 0
fi
grep -qF "$MARKER" "$destination" 2>/dev/null && return 0
backup="$PANAMA_OLD/mcp-$(printf '%s' "${destination#"$HOME"/}" | tr '/' '-')"
[[ -e "$backup" ]] && backup="$backup.$(date +%s)"
mv "$destination" "$backup"
log "Moved existing $destination to $backup"
}
mkdir -p "$OPENCODE_DIR" "$PANAMA_OLD"
# Two names for one file would leave opencode choosing between them, and the one
# this stage does not write would win or lose on discovery order.
displace "${opencode_config%.jsonc}.json"
displace "$opencode_config"
# The template is portable prose: it names the rules directory as ~/ so it does
# not carry this machine's login. opencode resolves a leading ~/ on its own, but
# expanding it here keeps the generated file readable the way a person would
# have written it by hand when they went looking for where the path came from.
merged="$(jq --argjson mcp "$opencode_mcp" --arg home "$HOME" \
'.mcp = $mcp
| .instructions = [(.instructions // [])[]
| if startswith("~/") then $home + .[1:] else . end]' \
<<<"$(cat "$TEMPLATE")")"
tmp="$(mktemp "$OPENCODE_DIR/.opencode-config.XXXXXX")"
{
printf '// %s\n' "$MARKER"
printf '// Edit user/agents/opencode/config.json in the Panama checkout instead.\n'
printf '// This file is replaced whenever ./install or panama update runs.\n'
printf '%s\n' "$merged"
} >"$tmp"
chmod 600 "$tmp"
printf '%s\n' "$merged" >"$tmp"
mv "$tmp" "$opencode_config"
log "opencode: $(jq -r 'length' <<<"$opencode_mcp") MCP servers and the rules written to $opencode_config."
log "opencode: $(jq -r 'length' <<<"$opencode_mcp") MCP servers and the template written to $opencode_config."
+3 -1
View File
@@ -69,10 +69,12 @@ displace() {
linked=0
copied=0
os="$(uname -s | tr '[:upper:]' '[:lower:]')"
while read -r kind source destination; do
while read -r kind source destination only_on _rest; do
[[ -n "${kind:-}" ]] || continue
[[ "$kind" == \#* ]] && continue
[[ -z "${only_on:-}" || "$only_on" == "$os" ]] || continue
src="$USER_DIR/$source"
dst="${destination/#\~/$HOME}"
+1
View File
@@ -270,6 +270,7 @@ hermetic tests/setup/launcher-search-contract
hermetic tests/setup/lid-contract
hermetic tests/setup/migrations-contract
hermetic tests/setup/nautilus-extensions-contract
hermetic tests/setup/opencode-config-contract
hermetic tests/setup/package-lists-contract
# Provenance uses local signed fixtures and stubs every network/package operation.
hermetic tests/setup/package-provenance-contract
@@ -59,7 +59,10 @@ SELF_INSTALLED='^(bun|claude|codex|node|npm|pnpm)$'
# fresh machine to keep five aliases company. Where docker is absent the
# aliases fail by naming the missing command, which is the honest outcome.
# Anything added here needs that same property: absence must be loud.
OPTIONAL='^(docker)$'
# opencode is the other kind of optional: link-mcp probes for it with
# `command -v` and writes its config only where someone installed it with
# opencode's own installer, the same way it treats claude.
OPTIONAL='^(docker|opencode)$'
# Provided by a package swap rather than a list entry. ffmpeg cannot be a list
# entry: RPM Fusion's ffmpeg CONFLICTS with the ffmpeg-free that Fedora ships
+53 -2
View File
@@ -27,8 +27,11 @@
# 6. panama-agent-crash carries all four coredump facts and the absolute path
# to the skill. The path is what makes the ladder work for an agent whose
# harness has no skill mechanism.
# 7. T3 Code opens no terminal. It starts a thread in the checkout's project
# with the prompt intact on stdin, and opens that thread's t3code:// link.
#
# Hermetic on purpose: kitty, claude, codex, setsid and coredumpctl are all
# Hermetic on purpose: kitty, claude, codex, t3, xdg-open, setsid and
# coredumpctl are all
# stubbed onto PATH and the settings file is fabricated, so no agent is ever
# launched and no window is ever opened. This is NOT in tests/desktop-hijacking
# for exactly that reason -- it must stay runnable mid-session.
@@ -79,6 +82,23 @@ for agent in claude codex; do
STUB
done
# T3 Code's CLI. It records each call and its stdin, and answers `thread start`
# with a thread id the way the real one does.
cat >"$stub/t3" <<STUB
#!/usr/bin/env bash
printf '%s\0' "\$@" >>"$work/t3-calls"
printf '\n' >>"$work/t3-calls"
if [[ "\$1 \$2" == "thread start" ]]; then
cat >"$work/t3-stdin"
printf 'thread-123\n'
fi
STUB
cat >"$stub/xdg-open" <<STUB
#!/usr/bin/env bash
printf '%s' "\$1" >"$work/opened"
STUB
# A hand-run PID has no core here; the real one would be a slow lookup against
# this machine's journal.
cat >"$stub/coredumpctl" <<'STUB'
@@ -104,7 +124,9 @@ chmod +x "$stub"/*
# from the previous run are cleared first, so "was not launched" is a missing
# file rather than a stale one.
launch() {
rm -f "$work/argv" "$work/cwd" "$work/setsid-used" "$work/agent-actually-ran"
rm -f "$work/argv" "$work/cwd" "$work/setsid-used" "$work/agent-actually-ran" \
"$work/t3-calls" "$work/t3-stdin" "$work/opened"
T3CODE_HOME="$work/t3home" \
PATH="$stub:/usr/bin:/bin" \
PANAMA_PATH="$repo_dir" \
PANAMA_AGENT_SETTINGS="$settings" \
@@ -252,6 +274,35 @@ else
note 'launching codex with auto-approve off opened no terminal'
fi
# ── 7. T3 Code ──────────────────────────────────────────────────────────────
# The recorded t3 calls, one per line, arguments joined by \x1f.
t3_calls() { tr '\0' '\037' <"$work/t3-calls" 2>/dev/null; }
mkdir -p "$work/t3home/userdata"
printf 'env-456' >"$work/t3home/userdata/environment-id"
printf '{"preferredAgent":"t3","agentAutoApprove":true}\n' >"$settings"
dash_prompt=$'--looks-like-a-flag\nsecond line'
launch "$launcher" --prompt "$dash_prompt"
status=$?
(( status == 0 )) || note "panama-agent exited $status launching T3 Code: $(head -1 "$work/stderr")"
[[ -e "$work/argv" ]] && note 'T3 Code was launched in a terminal; it should open a thread in the app'
t3_calls | grep -qxF "thread"$'\x1f'"start"$'\x1f'"$repo_dir"$'\x1f'"-"$'\x1f' \
|| note 'T3 Code was not asked to start a thread in the checkout with the prompt on stdin'
[[ "$(cat "$work/t3-stdin" 2>/dev/null)" == "$dash_prompt" ]] \
|| note 'the T3 thread did not receive the prompt intact on stdin'
[[ "$(cat "$work/opened" 2>/dev/null)" == "t3code://threads/env-456/thread-123" ]] \
|| note "T3 Code opened '$(cat "$work/opened" 2>/dev/null)' rather than the new thread's link"
# No prompt means nothing to start: the app opens on the project.
launch "$launcher"
t3_calls | grep -qF "thread"$'\x1f'"start" \
&& note 'with no prompt T3 Code still started a thread'
t3_calls | grep -qxF "app"$'\x1f'"$repo_dir"$'\x1f' \
|| note 'with no prompt T3 Code was not opened on the checkout'
# ── An agent installed where only the user's own PATH looks ─────────────────
#
# Both agents install themselves into ~/.local/bin. The rungs are clicked from
+13 -9
View File
@@ -3,9 +3,12 @@
# Every service in the catalog has the same shape, because panama-server and
# the update timer both depend on it blindly:
#
# * a compose.yml, which is the only file enable symlinks into ~/Server
# * a compose.yml, linked into ~/Server along with any helper script
# * exactly one podman-*.service unit -- its filename is the unit's
# identity, so two would be an ambiguity and zero an unenableable service
# identity, so two would be an ambiguity and zero an unenableable service.
# Other *.service and *.timer files are helpers (a cert refresh, say)
# that enable links beside it; each helper service needs a timer or a
# unit that pulls it in, so an orphan helper is flagged
# * a WorkingDirectory of %h/Server/<Name>, matching the directory enable
# creates -- a unit pointing anywhere else starts compose against a
# directory that has no .env and no data
@@ -31,20 +34,21 @@ for dir in "$containers_dir"/*/; do
[[ -f "$dir/compose.yml" ]] || note "$name has no compose.yml"
units=("$dir"/*.service)
units=("$dir"/podman-*.service)
if (( ${#units[@]} == 0 )); then
note "$name has no unit file"
note "$name has no podman-<name>.service unit"
continue
elif (( ${#units[@]} > 1 )); then
note "$name has ${#units[@]} unit files; its identity is ambiguous"
note "$name has ${#units[@]} podman-*.service units; its identity is ambiguous"
continue
fi
unit="${units[0]}"
case "$(basename "$unit")" in
podman-*.service) ;;
*) note "$name's unit is not named podman-<name>.service: $(basename "$unit")" ;;
esac
for helper in "$dir"/*.service; do
[[ "$helper" == "$unit" ]] && continue
[[ -f "${helper%.service}.timer" ]] \
|| note "$name's helper $(basename "$helper") has no matching .timer"
done
grep -q "^WorkingDirectory=%h/Server/$name\$" "$unit" \
|| note "$name's unit does not work in %h/Server/$name"
+33 -3
View File
@@ -6,9 +6,10 @@
# * enable seeds .env from .env.example and REFUSES to start while a
# CHANGE_ME placeholder survives -- a service brought up with the
# placeholder as its database password runs, wrongly, forever
# * a completed enable links exactly the tracked files (compose.yml and the
# unit), reloads systemd, and enables the unit
# * disable removes the unit and nothing else: the .env and data stay
# * a completed enable links the tracked files (compose.yml, helper scripts,
# the unit and any helper units), reloads systemd, enables the unit and
# each helper timer, and never links .env.example
# * disable removes the units and nothing else: the .env and data stay
# * relink refreshes links for enabled services only, and names a service
# whose definition changed since the machine last looked -- without
# restarting anything
@@ -55,6 +56,21 @@ RemainAfterExit=yes
[Install]
WantedBy=default.target
UNIT
cat >"$catalog/Example/refresh-example" <<'SH'
#!/usr/bin/env bash
SH
chmod +x "$catalog/Example/refresh-example"
cat >"$catalog/Example/example-refresh.service" <<'UNIT'
[Service]
Type=oneshot
ExecStart=%h/Server/Example/refresh-example
UNIT
cat >"$catalog/Example/example-refresh.timer" <<'UNIT'
[Timer]
OnCalendar=daily
[Install]
WantedBy=timers.target
UNIT
# The stub records every invocation; is-active answers 'inactive' so nothing
# here depends on a real systemd.
@@ -81,6 +97,8 @@ run() {
}
unit_link="$home/.config/systemd/user/podman-example.service"
helper_link="$home/.config/systemd/user/example-refresh.service"
timer_link="$home/.config/systemd/user/example-refresh.timer"
# ── enable stops on placeholders ─────────────────────────────────────────────
@@ -111,6 +129,14 @@ grep -q 'systemctl --user enable --now podman-example.service' "$calls" \
|| note 'enable did not enable --now the unit'
[[ "$(cat "$home/Server/Example/.env")" == "DB_PASSWORD=s3cret" ]] \
|| note 'enable rewrote an .env that already existed'
[[ -L "$home/Server/Example/refresh-example" ]] \
|| note 'enable did not link the helper script into ~/Server'
[[ -e "$home/Server/Example/.env.example" ]] \
&& note 'enable linked .env.example into ~/Server'
[[ -L "$helper_link" && -L "$timer_link" ]] \
|| note 'enable did not link the helper units'
grep -q 'systemctl --user enable --now example-refresh.timer' "$calls" \
|| note 'enable did not enable the helper timer'
# ── relink names a changed definition, restarts nothing ──────────────────────
@@ -136,6 +162,10 @@ run disable Example >/dev/null 2>&1 || note 'disable failed'
[[ -e "$unit_link" ]] \
&& note 'disable left the unit installed'
[[ -e "$helper_link" || -e "$timer_link" ]] \
&& note 'disable left a helper unit installed'
grep -q 'systemctl --user disable --now example-refresh.timer' "$calls" \
|| note 'disable did not stop the helper timer'
grep -q 'systemctl --user disable --now podman-example.service' "$calls" \
|| note 'disable did not stop the unit'
[[ -f "$home/Server/Example/.env" ]] \
+9
View File
@@ -90,6 +90,15 @@ PY
sed -n '/^if \[\[ "\$ROLE" == server \]\]; then/,/^fi/p' "$installer" | grep -q '^\s*exit 0' \
|| note 'the server branch does not exit before the desktop section'
# A server pins a Terra it already has, but never installs one: the Terra call
# in the server branch must sit behind the terra-release check.
server_branch="$(sed -n '/^if \[\[ "\$ROLE" == server \]\]; then/,/^fi/p' "$installer")"
if grep -q 'install_terra_repository' <<<"$server_branch"; then
awk '/rpm -q terra-release/ { guarded = 1 } /install_terra_repository/ && !guarded { bad = 1 }
END { exit bad }' <<<"$server_branch" \
|| note 'the server branch can install Terra on a server that never had it'
fi
# ── Everything fragile is actually tolerated ────────────────────────────────
#
# Being below the desktop is only half of it. A bare `dnf swap` below the
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env bash
# The opencode config, generated rather than linked.
#
# Every other personal destination is a symlink or a one-time copy, so getting
# one wrong is visible and cheap to undo. This one is written whole by
# setup/scripts/link-mcp, and it is the only file in the set that carries
# secrets -- which is the reason it is generated at all. Three properties are
# worth pinning:
#
# 1. The tracked template is the source of truth and holds no secret. It is
# readable by anyone who finds the repository, so a token in it is a
# disclosure rather than a mistake.
# 2. The generation is faithful: the template's keys survive, the MCP block
# appears, and a leading ~/ is resolved so the output is readable.
# 3. Nothing is destroyed and nothing churns. A file somebody wrote by hand
# goes to config/old/, and running the stage again does not fill it up.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
linker="$repo_dir/setup/scripts/link-mcp"
template="$repo_dir/user/agents/opencode/config.json"
servers="$repo_dir/user/agents/mcp/servers"
findings=()
note() { findings+=("$1"); }
[[ -x "$linker" ]] || { printf 'opencode config contract: %s is not executable\n' "$linker" >&2; exit 1; }
# ── 1. The template holds no secret ──────────────────────────────────────────
if [[ ! -r "$template" ]]; then
note 'user/agents/opencode/config.json is missing, so nothing generates an opencode config'
else
jq -e . "$template" >/dev/null 2>&1 \
|| note 'user/agents/opencode/config.json is not valid JSON'
if grep -qE 'BEGIN [A-Z ]*PRIVATE KEY|sk-ant-[A-Za-z0-9]|ghp_[A-Za-z0-9]{20}|xox[baprs]-[A-Za-z0-9]' "$template" 2>/dev/null; then
note 'user/agents/opencode/config.json carries something that looks like a token'
fi
fi
[[ -r "$servers" ]] || note 'user/agents/mcp/servers is missing, so no MCP servers can be registered'
# ── 2 and 3, against a throwaway home ────────────────────────────────────────
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
checkout="$work/Panama"
mkdir -p "$checkout/setup/scripts" "$checkout/user/agents/mcp/bin" "$checkout/user/agents/opencode"
cp "$linker" "$checkout/setup/scripts/link-mcp"
cp "$template" "$checkout/user/agents/opencode/config.json"
printf '#!/usr/bin/env bash\nexit 0\n' >"$checkout/user/agents/mcp/bin/gamma"
chmod +x "$checkout/user/agents/mcp/bin/gamma"
cat >"$checkout/user/agents/mcp/servers" <<'FIXTURE'
# a comment, then a row that needs a token, one that needs none, and a stdio one
alpha http https://alpha.example/mcp ALPHA_TOKEN
beta http https://beta.example/mcp
gamma stdio bin/gamma
FIXTURE
printf 'ALPHA_TOKEN=sekrit-token-value\n' >"$checkout/user/agents/mcp/env"
# link-mcp registers through whichever runtime it finds, and gives up entirely
# when it finds neither. A stub is enough to prove the config generation.
mkdir -p "$work/bin"
printf '#!/usr/bin/env bash\nexit 0\n' >"$work/bin/opencode"
chmod +x "$work/bin/opencode"
home="$work/home"
state="$work/state"
old="$checkout/config/old"
# Every XDG directory is pinned to the throwaway home. XDG_CONFIG_HOME is
# usually set in the environment this runs from, and leaving it alone would send
# the generated config at the real ~/.config/opencode rather than at the fixture.
mkdir -p "$home/.config/opencode" "$state/panama"
run() {
HOME="$home" XDG_CONFIG_HOME="$home/.config" XDG_STATE_HOME="$state" \
PANAMA_PATH="$checkout" PATH="$work/bin:$PATH" \
"$checkout/setup/scripts/link-mcp" >"$work/log" 2>&1
}
generated="$home/.config/opencode/opencode.jsonc"
# A file somebody wrote by hand is there before this stage ever runs.
printf '{"model": "someone-elses-hand-edit"}\n' >"$generated"
printf 'yes\n' >"$state/panama/user-content"
if ! run; then
note "link-mcp failed against a throwaway home: $(tail -1 "$work/log")"
fi
# ── The hand-edit is preserved, not deleted ──────────────────────────────────
if ! grep -rq 'someone-elses-hand-edit' "$old" 2>/dev/null; then
note 'a config written by hand was not preserved in config/old/'
fi
# ── The generation is faithful ───────────────────────────────────────────────
body="$(sed '/^\/\//d' "$generated" 2>/dev/null)"
jq -e . <<<"$body" >/dev/null 2>&1 \
|| note 'the generated opencode config is not valid JSON once its header comment is stripped'
grep -qF 'generated by setup/scripts/link-mcp' "$generated" 2>/dev/null \
|| note 'the generated opencode config does not say who wrote it or where to edit instead'
if ! jq -e --arg home "$home" '.instructions | index($home + "/.claude/rules/*.md")' <<<"$body" >/dev/null 2>&1; then
note 'the generated opencode config did not resolve the ~/ rules path to this home'
fi
jq -e '.permissions | length > 0' <<<"$body" >/dev/null 2>&1 \
|| note 'the template permissions did not reach the generated opencode config'
jq -e '.mcp.alpha.headers.Authorization == "sekrit-token-value"' <<<"$body" >/dev/null 2>&1 \
|| note 'a token-carrying server was registered without its Authorization header'
jq -e '.mcp.beta | has("headers") | not' <<<"$body" >/dev/null 2>&1 \
|| note 'a server that needs no token was given a header anyway'
jq -e --arg launcher "$checkout/user/agents/mcp/bin/gamma" '.mcp.gamma.command == [$launcher]' <<<"$body" >/dev/null 2>&1 \
|| note 'a stdio server was not registered against its launcher in this checkout'
# The file holds tokens, so it is private. Checked on the file rather than on
# the umask, because the umask is not what a later reader sees.
mode="$(stat -c '%a' "$generated" 2>/dev/null)"
[[ "$mode" == 600 ]] || note "the generated opencode config is mode $mode, not 600"
# The template is readable by anybody who finds the repository. The tokens live
# in the generated file and in user/agents/mcp/env, never here.
if grep -qF 'sekrit-token-value' "$template" 2>/dev/null; then
note 'the tracked opencode template carries an MCP token'
fi
# ── Re-running replaces, and does not churn ──────────────────────────────────
before="$(find "$old" -type f 2>/dev/null | wc -l)"
run
after="$(find "$old" -type f 2>/dev/null | wc -l)"
[[ "$before" == "$after" ]] \
|| note 're-running link-mcp buried its own output in config/old/, so it grows every upgrade'
grep -qF 'generated by setup/scripts/link-mcp' "$generated" 2>/dev/null \
|| note 're-running link-mcp did not rewrite the config it generates'
# A machine that never said yes is left alone, the same gate every other
# personal destination obeys.
rm -f "$generated"
printf 'no\n' >"$state/panama/user-content"
run
[[ ! -e "$generated" ]] \
|| note 'an opencode config was generated on a machine that never said yes'
if (( ${#findings[@]} > 0 )); then
printf 'opencode config contract: %d finding(s)\n' "${#findings[@]}" >&2
printf ' - %s\n' "${findings[@]}" >&2
exit 1
fi
printf 'opencode config contract: PASS\n'
+63 -19
View File
@@ -901,17 +901,26 @@ if [[ -n "$query" ]]; then
wrong-url) mode=override-url ;;
stock|stock-wrong-key) mode=stock ;;
esac
# Adoption rewrites the repository file. Once it is the pinned form the
# dump has to say so, or the re-verification adoption performs on itself
# could never pass.
if [[ "$mode" == stock && -f "$STUB_ETC/yum.repos.d/terra.repo" ]] \
&& grep -q '^gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama$' \
"$STUB_ETC/yum.repos.d/terra.repo"; then
# Adoption and a fresh install write the pin as a repos.override.d
# drop-in, which dnf lays over the repository file. Once it is there
# the dump has to say so, or the re-verification each performs on
# itself could never pass.
override="$STUB_ETC/dnf/repos.override.d/80-panama-terra.repo"
pinned_override=0
if [[ -f "$override" ]] \
&& grep -q '^gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama$' "$override" \
&& grep -q '^baseurl=https://repos.fyralabs.com/terra44$' "$override" \
&& grep -qx 'metalink=' "$override"; then
pinned_override=1
fi
if [[ "$mode" == stock && "$pinned_override" == 1 ]]; then
mode=trusted
fi
if [[ "$mode" == auto && -f "$STUB_ETC/yum.repos.d/terra.repo" ]] \
&& grep -q '^baseurl=https://repos.fyralabs.com/terra44$' "$STUB_ETC/yum.repos.d/terra.repo"; then
mode=trusted
elif [[ "$mode" == auto && "$pinned_override" == 1 ]]; then
mode=trusted
elif [[ "$mode" == auto ]]; then
mode=absent
fi
@@ -945,7 +954,7 @@ if [[ -n "$query" ]]; then
printf 'baseurl = \nenabled = 1\ngpgcheck = 1\n'
printf 'gpgkey = file:///etc/pki/rpm-gpg/RPM-GPG-KEY-terra44\n'
printf 'metalink = https://tetsudou.fyralabs.com/metalink?repo=terra44&arch=x86_64\n'
printf 'mirrorlist = \npkg_gpgcheck = 1\nrepo_gpgcheck = 1\n'
printf 'mirrorlist = \npkg_gpgcheck = 1\nrepo_gpgcheck = 0\n'
;;
override-url)
printf '======== "terra" repository configuration: ========\n'
@@ -1150,7 +1159,7 @@ run_installer_function() {
cp "$installer_fixture/setup/provenance/keys/flathub.asc" \
"$case_root/etc/pki/rpm-gpg/RPM-GPG-KEY-terra44"
fi
printf '[terra]\nmetalink=https://tetsudou.fyralabs.com/metalink?repo=terra44&arch=$basearch\nenabled=1\ngpgcheck=1\nrepo_gpgcheck=1\ngpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-terra44\n' \
printf '[terra]\nmetalink=https://tetsudou.fyralabs.com/metalink?repo=terra44&arch=$basearch\nenabled=1\ngpgcheck=1\nrepo_gpgcheck=0\ngpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-terra44\n' \
> "$case_root/etc/yum.repos.d/terra.repo"
;;
wrong-key)
@@ -1366,8 +1375,11 @@ grep -qFx 'nvm:alias default 24.20.0' "$test_tmp/cases/node-x86_64/commands.log"
[[ "$($test_tmp/cases/codex-x86_64/home/.local/bin/codex --version)" == \
'codex-cli 0.165.0' ]] || fail 'x86_64 Codex activation has the wrong version'
[[ "$(readlink "$test_tmp/cases/codex-x86_64/home/.local/bin/codex")" == \
"$test_tmp/cases/codex-x86_64/home/.local/lib/panama/codex/0.165.0/codex" ]] \
"$test_tmp/cases/codex-x86_64/home/.local/lib/panama/codex/0.165.0/bin/codex" ]] \
|| fail 'Codex did not atomically activate the reviewed version path'
[[ -x "$test_tmp/cases/codex-x86_64/home/.local/lib/panama/codex/0.165.0/bin/codex-code-mode-host" \
&& -f "$test_tmp/cases/codex-x86_64/home/.local/lib/panama/codex/0.165.0/codex-package.json" ]] \
|| fail 'Codex dropped the helpers its package ships beside bin/codex'
# Unsupported CPUs stop before curl. RustDesk's reviewed RPM is x86_64-only,
# so aarch64 is also an intentional, recorded soft failure without a download.
@@ -1502,7 +1514,7 @@ done
# Successful updates keep the old version directory and switch only the active
# symlink after the replacement binary has passed its version check.
for component_spec in 'Bun install_bun .bun/bin/bun .bun/versions/1.4.0/bin/bun' \
'Codex install_codex .local/bin/codex .local/lib/panama/codex/0.165.0/codex'; do
'Codex install_codex .local/bin/codex .local/lib/panama/codex/0.165.0/bin/codex'; do
read -r component function_name active_relative target_relative <<<"$component_spec"
name="${component,,}-atomic-update"
reset_installer_fixture
@@ -1519,12 +1531,34 @@ for component_spec in 'Bun install_bun .bun/bin/bun .bun/versions/1.4.0/bin/bun'
fi
done
# Installs before the package-layout fix kept only a receipted flat
# 0.165.0/codex. That directory is ours, so the full tree replaces it.
name=codex-flat-repair
reset_installer_fixture
STUB_SEED_OLD=Codex expect_success run_installer_function "$name" install_codex
home="$test_tmp/cases/$name/home"
flat="$home/.local/lib/panama/codex/0.165.0"
rm -rf -- "$flat"
mkdir -p -- "$flat"
cp "$artifact_root/codex-x86_64.bin" "$flat/codex"
chmod +x "$flat/codex"
printf 'schema=1\nartifact_sha256=%s\nbinary_sha256=%s\n' \
e97c4463d7f146692a955d7b0e1c8cf48f97cd4d616cd45c5dcab2e8d9743316 \
c06a67fd8ac76508dc78bd99ce20c9f317b8d568beda59485f3f4134df9962d5 > "$flat/.panama-provenance"
ln -sfn "$flat/codex" "$home/.local/bin/codex"
STUB_REUSE_CASE=1 expect_success run_installer_function "$name" install_codex
[[ "$(readlink "$home/.local/bin/codex")" == "$flat/bin/codex" \
&& -x "$flat/bin/codex-code-mode-host" && ! -e "$flat/codex" ]] \
|| fail "$name did not replace the flat install with the package tree"
[[ ! -s "$test_tmp/cases/$name/softly-failed" ]] || fail "$name recorded a soft failure"
assert_no_runtime_staging "$name"
# Receipt or executable tampering preserves the directory and leaves an older
# active link unchanged. A matching version string is not an attestation.
for tamper_spec in \
'Node install_node .nvm/versions/node/v24.20.0 bin/node v24.20.0' \
'Bun install_bun .bun/versions/1.4.0 bin/bun 1.4.0' \
'Codex install_codex .local/lib/panama/codex/0.165.0 codex codex-cli_0.165.0'; do
'Codex install_codex .local/lib/panama/codex/0.165.0 bin/codex codex-cli_0.165.0'; do
read -r component function_name target_relative binary_relative version_text <<<"$tamper_spec"
for tamper_kind in receipt binary; do
name="${component,,}-$tamper_kind-tamper"
@@ -1663,7 +1697,8 @@ rpm:query:terra-release
gpg:fingerprint:AE09157A4DE88B497EA1D5D300CDAB43DE226D6F
sudo:install:terra44.asc:/etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama
sudo:dnf install -y --repofrompath terra,https://repos.fyralabs.com/terra44 --setopt=terra.pkg_gpgcheck=1 --setopt=terra.repo_gpgcheck=1 --setopt=terra.gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama terra-release
sudo:install:terra.repo:/etc/yum.repos.d/terra.repo
sudo:mkdir -p -- /etc/dnf/repos.override.d
sudo:install:80-panama-terra.repo:/etc/dnf/repos.override.d/80-panama-terra.repo
dnf:dump-all:locale=C
gpg:fingerprint:AE09157A4DE88B497EA1D5D300CDAB43DE226D6F
gpg:fingerprint:AE09157A4DE88B497EA1D5D300CDAB43DE226D6F
@@ -1672,10 +1707,12 @@ EXPECTED
cmp -s "$installer_fixture/setup/provenance/keys/terra44.asc" \
"$test_tmp/cases/terra/etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama" \
|| fail 'Terra privileged install did not preserve the fully staged reviewed key'
assert_file_bytes "$test_tmp/cases/terra/etc/yum.repos.d/terra.repo" "$(cat <<'EXPECTED'
assert_file_bytes "$test_tmp/cases/terra/etc/dnf/repos.override.d/80-panama-terra.repo" "$(cat <<'EXPECTED'
[terra]
name=Panama reviewed Terra 44
baseurl=https://repos.fyralabs.com/terra44
metalink=
mirrorlist=
enabled=1
gpgcheck=1
repo_gpgcheck=1
@@ -1804,9 +1841,12 @@ for mode in nogpg wrong-url wrong-key absent; do
done
# A machine that installed Terra the way Terra documents it. The repository file
# is terra-release's own -- a metalink, and the key at its stock path -- so it is
# not Panama's pinned form, but it IS the fingerprint this repository reviewed,
# with every signature check on. That is an adoption, not a compromise.
# is terra-release's own -- a metalink, the key at its stock path, and
# repo_gpgcheck=0 as terra-release 44-11 ships it -- so it is not Panama's
# pinned form, but it IS the fingerprint this repository reviewed, with package
# signature checks on. That is an adoption, not a compromise. The pin goes in a
# repos.override.d drop-in, because terra-release replaces its own repo file on
# every update and a pin written there did not survive the next one.
#
# Refusing it was a gate with no door: the ordinary Fedora desktop could never
# reach the pinned state, and status 78 then stopped every stage of every run,
@@ -1818,13 +1858,17 @@ STUB_TERRA_INSTALLED=1 STUB_TERRA_REPO_MODE=stock \
reset_installer_fixture
STUB_TERRA_INSTALLED=1 STUB_TERRA_REPO_MODE=stock \
expect_success run_installer_function terra-stock-adopt install_terra_repository
terra_adopted="$test_tmp/cases/terra-stock-adopt/etc/yum.repos.d/terra.repo"
terra_adopted="$test_tmp/cases/terra-stock-adopt/etc/dnf/repos.override.d/80-panama-terra.repo"
grep -qx 'baseurl=https://repos.fyralabs.com/terra44' "$terra_adopted" \
|| fail 'adoption left Terra off the reviewed baseurl'
grep -qx 'gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama' "$terra_adopted" \
|| fail 'adoption left Terra off the reviewed key path'
grep -q 'metalink' "$terra_adopted" \
&& fail 'adoption kept the metalink it was supposed to replace'
grep -qx 'metalink=' "$terra_adopted" \
|| fail 'adoption did not clear the metalink it was supposed to replace'
grep -qx 'repo_gpgcheck=1' "$terra_adopted" \
|| fail 'adoption did not turn repository metadata checks back on'
grep -q 'RPM-GPG-KEY-terra44$' "$test_tmp/cases/terra-stock-adopt/etc/yum.repos.d/terra.repo" \
|| fail "adoption rewrote terra-release's own repository file"
[[ "$(<"$test_tmp/cases/terra-stock-adopt/commands.log")" != *'dnf:install'* ]] \
|| fail 'adoption opened a DNF transaction it does not need'
+9 -1
View File
@@ -46,13 +46,15 @@ done < <(find "$repo_dir/user" -type f \( -name 'id_*' -o -name '*.pem' -o -name
# ── Every manifest line is usable ───────────────────────────────────────────
while read -r kind source destination; do
while read -r kind source destination only_on; do
[[ -e "$repo_dir/user/$source" ]] \
|| note "the manifest points at $source, which is not in user/"
[[ "$kind" == link || "$kind" == linkdir || "$kind" == copy ]] \
|| note "the manifest uses an unknown kind: $kind"
[[ "$destination" == '~/'* ]] \
|| note "the manifest destination $destination is not under the home directory"
[[ -z "$only_on" || "$only_on" == linux || "$only_on" == darwin ]] \
|| note "the manifest names an unknown os: $only_on"
done < <(grep -vE '^\s*(#|$)' "$manifest")
# ── The behaviour, against a fake home ──────────────────────────────────────
@@ -77,6 +79,7 @@ linkdir agents/skills ~/.agents/skills
linkdir agents/skills ~/.claude/skills
copy plain.txt ~/.config/plain.txt
link missing.txt ~/.config/missing.txt
copy plain.txt ~/.config/elsewhere.txt nosuchos
FIXTURE
home="$work/home"
@@ -141,6 +144,11 @@ fi
grep -q 'missing.txt' "$work/log" \
|| note 'a skipped manifest entry was not reported'
# A line naming another OS is for another machine. The Mac takes the agent half
# of user/ and none of the Linux desktop half.
[[ -e "$home/.config/elsewhere.txt" ]] \
&& note 'a manifest entry for another OS was applied here'
# ── The answer sticks ───────────────────────────────────────────────────────
#
# So that `panama upgrade` on a configured machine relinks without an interview,
+28 -1
View File
@@ -14,8 +14,12 @@ and one file says where each piece goes.
| --- | --- | --- |
| `agents/AGENTS.md` | `~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md`, `~/.config/opencode/AGENTS.md` | Three tools, one file. These were byte-identical copies before this, waiting to disagree. |
| `agents/skills/` | `~/.agents/skills`, `~/.claude/skills` | Personal skills live once in the checkout. Both destinations use `linkdir` so Panama's shipped skills can live beside them. opencode reads both directories itself. |
| `agents/rules/` | `~/.claude/rules` | opencode loads these through `instructions` in its config, which `setup/scripts/link-mcp` writes. |
| `agents/rules/` | `~/.claude/rules` | opencode loads these through `instructions` in its config, which `setup/scripts/link-mcp` generates. |
| `agents/commands/` | `~/.config/opencode/commands` | Claude Code carries its commands as plugins, which opencode cannot read. These are the same commands in opencode's own format. |
| `agents/agents/` | `~/.config/opencode/agents` | The same again for subagents. |
| `agents/opencode/config.json` | read by `setup/scripts/link-mcp` | Not linked. The opencode config is generated from it, because that file also has to carry the MCP servers and their tokens, and a symlink cannot be half written. |
| `ssh/config` | `~/.ssh/config` | Host aliases only. Keys are per-machine and are never tracked. |
| `bin/pwswitch` | `~/.local/bin/pwswitch` | `pwswitch off` makes sudo passwordless for an agent session, `pwswitch on` restores the password. |
| `espanso/identity.yml` | `~/.config/espanso/match/identity.yml` | Copied, not linked, because a machine may add its own triggers. |
`manifest` is the authority; this table is a summary of it.
@@ -44,6 +48,23 @@ Two consequences, recorded rather than fixed:
A tool that installs directly into a home skill directory creates a
machine-local skill until it is moved into the checkout.
## What link-mcp generates
The opencode config is the one destination here that is generated rather than linked or
copied. It cannot be a symlink because it has to carry the MCP servers and their tokens,
and a token is exactly what cannot live in this repository. So `setup/scripts/link-mcp`
builds the whole file: `agents/opencode/config.json` supplies everything that is not a
secret, `agents/mcp/servers` supplies the server list, and the machine-local
`agents/mcp/env` supplies the tokens.
The result is `~/.config/opencode/opencode.jsonc`, written private and replaced in
place. It opens with a comment saying where to edit it, and that comment is the whole
warning: **hand-edits to the generated file do not survive an upgrade.** Put the change in
`agents/opencode/config.json` and it reaches every machine.
Anything already at that path that this stage did not write moves to `config/old/`, the
same promise everything else here makes.
## It is off unless you say yes
The installer asks, naming the destinations, and the default is no. Nothing here
@@ -65,6 +86,12 @@ Put the file under `user/`, add a line to `manifest`, run:
Anything already at the destination is moved to `config/old/` rather than
deleted, under a name that says where it came from.
A line can end with `linux` or `darwin` to apply only on that OS. The Mac mini
is not a Panama desktop, but it runs agents, so it takes the agent half of this
directory and skips the SSH config, `pwswitch` and espanso. Panama's shipped
skills describe the Linux desktop, so the Mac runs `link-user` and `link-mcp`
only, never `link-skills`.
## What does not go in here
Anything secret. This repository is readable by anyone who finds it, and the
+81 -4
View File
@@ -18,8 +18,6 @@ I wanted to share some of my preferences here so we can be more aligned as we wo
- Tests are good! Endless smoke tests, "regression tests" for feature deletions, etc, much less good. Tests should be focused. Not slop.
- Comments are a great way to clarify functionality & how code is used. Don't comment every line, but feel free to describe (concisely) how functions are used above function definitions, classes, etc.
- Keep all comments and documentation up to date! When making changes, it is important to keep everything in sync.
- Avoid starting long running dev servers by default; assume the user may already have one running unless the task requires otherwise.
- If the user does not have a dev server running, or if you need to restart the dev server, ask the user to do so.
## TypeScript Preferences
@@ -53,11 +51,27 @@ I wanted to share some of my preferences here so we can be more aligned as we wo
# Visual & Design Work
- Do not edit real components first unless asked to by the user. For any non-trivial UI, layout, or copy change, build serveral distinct static mocks, publish them with a simple http server, report the URL, and stop. Wait for a decision before implementing.
- Do not edit real components first unless asked to by the user. For any non-trivial UI, layout, or copy change, build several distinct static mocks, show them to me, and stop. Wait for a decision before implementing.
- In T3 Code, show each mock in the thread with `html_render`, and link the mock files by absolute path so they open full size in T3's file viewer. No server.
- Outside T3 Code, publish them with a simple http server and report the URL.
- Avoid continuously repainting CSS animations (pulse, shimmer, blur, spinners); they peg the GPU on high-refresh displays.
---
# T3 Code
I run my agents in T3 Code, and I want to stay in it rather than juggle terminals and browser windows. A thread has `T3CODE_THREAD_ID` set.
- Show visuals in the thread: charts, tables, mocks, plans, to-do lists. `html_preview`, then `html_render`.
- Link files by absolute path. HTML, PDF, images and Markdown open rendered in T3's file viewer, beside the thread. Do not start an http server just so I can look at a file.
- Reach my other machines through T3's linked environments (`t3_environment_links`, then that environment's id), not through ssh, unless I ask for ssh.
- Every subagent goes through `delegate_task`, whatever the provider. That includes same-provider work, which T3's own prompt says can use native tools: my rule wins. Native `Agent`, `Workflow`, Codex's `spawn_agent`, `claude -p`, `codex exec`, and `opencode run` are only for when I ask for one by name. A hook blocks the Claude Code routes and tells you how to pass when I have asked.
- `delegate_task` children know only their `task` text, and `/skill` lines do not expand there, so tell a child to call the Skill tool. A local child always runs in this thread's project.
- Klaude (`claudeAgent_klaude`) is my work Claude account, and it runs `pre-mr-review` and nothing else. Omit `model` to get its default. Everything else, reviews included, runs on my own accounts.
- T3's preview browser is the default for checking an app or capturing proof, and I can sign in there. Claude in Chrome is still the browser for my own signed-in sessions.
---
# Blast Radius
- Never touch production, live databases, or daily-driver build/preview channels unless explicitly asked to. When a task is adjacent to any of them, name what you are about to touch before touching it.
@@ -105,6 +119,68 @@ and record who decided what, because that history is useful.
- When it comes to personal projects, I am much less strict, but I prefer a title that makes it very clear what the MR is about.
- No confusing or complicated language.
---
# Operating context
Where things live, what is sensitive, and who may see it. This used to sit in one tool's
settings file, so only whichever agent read that file ever knew it.
## Source control and hosting
- I keep my own projects on `git.gbrown.org`, my self-hosted Git host. Ksense work lives in
the client's own GitLab, and that is where the merge requests go.
- `agentchat.gbrown.org` is my agent-to-agent message hub. It is personal infrastructure,
and it is not a place to publish anything sensitive outside the trust boundary.
- I have no internal package registry and no trusted cloud buckets. Nothing gets uploaded
to a bucket.
## Secrets
- Infisical carries staging and development secrets.
- `JIRA_CREDENTIALS` comes from `~/.bashrc`. Jira ticket work always uses my own
credentials, never the Jira credentials staging Infisical carries.
- `.claude/docs/env/prod/.env` and `.claude/docs/env/ci/.env` are read-only, and every
read needs explicit permission. Ask every time, even when I approved the same read
earlier in the same session.
## Sensitive locations
These are mine alone plus the cleared Ksense coworkers named at the end of this file, and
only at a "named and specific" bar, meaning I say which file and why. They never go to a
paste site, a snippet host, or an external tool:
- Jira credentials and the prod/CI env files above.
- `~/.infisical/secrets-backup/`.
- `~/.config/context7/credentials.json`.
- `~/.claude/.credentials.json`.
- Client project env files, such as `~/Documents/Code/GibSend/.env*` and
`~/Documents/Code/StPeteIT/.env*`.
## Network
My personal homelab. Everything under `gbrown.org` is inside the trust boundary:
`plex.`, `git.`, `chat.`, `radarr.`, `immich.`, `jellyfin.`, `home.`, and
`agentchat.`. Local hosts are `macnano.gib` and `nano.gib`, and the LAN is
`192.168.1.2` and `192.168.2.2`. The NanoKVM boards on those two hosts give an agent
console access to the machine, including what SSH cannot reach, so treat that access as
root-equivalent.
## Targets that need my explicit say-so
- Any host, namespace, or resource where `prod` or `production` is a whole word or a whole
name segment. Command Center production is the one I care about most.
- IAM, RBAC, networking, quota, and node-pool resources in any IaC, and anything whose
name or tag carries `prod` or `production`.
## CLIs I expect you to know
- `ctx7` for current library documentation.
- `infisical` for secrets.
- `claude`, `clauded`, and `claudedr` are Claude Code wrappers.
---
# Coworkers & Others that you will be working with & Interacting with when working on Ksense Projects
- Conrad Rohleder - Project Manager
@@ -115,7 +191,8 @@ and record who decided what, because that history is useful.
- For example, he once asked for a JSON blob of our schema so he could better understand it.
- Conrad is awesome & is great to work with.
- He is somewhat skeptical of AI & its ability to complete work.
- Henry Nguyen - Tech Owner (Tech Lead)
- Henry Nguyen - Tech Owner (Tech Lead) for Command Center only
- Henry is only involved in Command Center. On other projects (for example 360 Shop View), don't route reviews or sign-offs through him.
- Henry is who I interact with second most, just behind Conrad.
- Henry reviews all of my code & up until recently, would also write all the dev reviews for all the stories I completed as well. Nowadays, I write them myself & he reviews those too.
- Henry likes work to be very considerate & he always prefers solutions that result in 0 downtime.
+35
View File
@@ -0,0 +1,35 @@
---
description: Simplify recently changed code for clarity while leaving behaviour identical
mode: subagent
---
You simplify code. You never change what it does, only how it does it.
Work only on code changed in this session, or on the specific files you are pointed at.
Anything wider is out of scope unless you are asked for it.
What you do:
- Cut complexity that is not earning its keep: nesting that flattens, abstractions with one
caller, names that need a comment to be understood, values threaded through layers that
never use them.
- Keep the behaviour exactly as it was. Same outputs, same errors, same edge cases, same
failure modes.
- Follow the guidance that governs the code you are touching. Where that guidance and your
own instinct disagree, the guidance wins.
- Prefer explicit over compact. A clear line beats a clever one, and a name that says what
something is beats a shorter name that does not.
- Delete comments that restate the code. Keep comments that explain a reason, and update
them when the code they describe changed.
What you do not do:
- You do not refactor beyond the change. Renaming a public API, reshuffling a module, or
tidying a file you were not asked about is somebody else's job.
- You do not merge concerns into one expression to save a line. Nested ternaries and dense
one-liners are not simplification.
- You do not remove an abstraction just because it has one caller today.
- You do not add tests, types, or documentation as a side effect of simplifying.
Report what you changed and why, briefly. When a simplification you considered would have
changed behaviour, say that you left it alone and why.
+38
View File
@@ -0,0 +1,38 @@
---
description: Review a diff or merge request for real bugs and missed requirements
---
Review the change for real problems. Do not edit anything.
Target: $ARGUMENTS
When the target names a merge request or pull request, review that change. When it names a
file or directory, review what is uncommitted there. When it is empty, review the current
working diff.
How to review:
1. Read the change, then read the code around it. Context you did not read is context you
will misjudge.
2. Look for real problems only: logic that is wrong, a case that was missed, a contract
broken, data lost, an error swallowed, a race introduced, something the change claims to
do that it does not do.
3. Check the change against whatever guidance governs the code it touches. Guidance is
guidance for writing code, so not all of it applies to a review.
4. Read the git history of the lines being modified. A change that looks fine in isolation
often breaks a reason the code is the way it is.
5. Leave these out: style, naming, formatting, import order, missing tests, missing
documentation, and anything a linter, typechecker, or compiler will catch. CI runs those.
Also leave out problems on lines the change did not touch.
Output:
- Order findings by severity, highest first.
- Each finding gets the file and line, what is wrong, and why it matters. Link to code
rather than quoting long stretches of it.
- Say which requirement or guideline is broken when that is the reason.
- When a finding is a judgement call rather than a definite bug, say so plainly instead of
dressing it up as certain.
- If there is nothing worth reporting, say that and stop. Do not invent findings to fill
the space.
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# PreToolUse hook: inside a T3 Code thread, subagents go through delegate_task, so T3 can
# show them, track them, and wake the parent. Blocks the native Agent and Workflow tools and
# shelling out to another agent CLI. Does nothing outside T3 Code.
#
# The escape is for when Gib asks for a native route by name: start the Agent or Workflow
# description with "native:", or prefix the command with T3_NATIVE_OK=1.
#
# web-fetch stays allowed because it stands in for a WebFetch tool, not a worker.
set -euo pipefail
[[ -n "${T3CODE_THREAD_ID:-}" ]] || exit 0
input="$(cat)"
tool="$(jq -r '.tool_name' <<<"$input")"
deny() {
jq -n --arg reason "$1" '{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: $reason}}'
exit 0
}
advice="This is a T3 Code thread, so use the delegate_task tool (load it with ToolSearch: select:mcp__t3-code__delegate_task). This overrides T3's note that native subagents are fine for the same provider. If you are a delegated child, do the work yourself."
case "$tool" in
Agent | Task | Workflow)
[[ "$(jq -r '.tool_input.subagent_type // ""' <<<"$input")" == "web-fetch" ]] && exit 0
[[ "$(jq -r '.tool_input.description // ""' <<<"$input")" == native:* ]] && exit 0
deny "$tool is blocked. $advice Only if Gib asked for a native agent by name: start the description with \"native:\"."
;;
Bash)
# Only a command position counts, so grep patterns and prose that mention these pass.
if jq -r '.tool_input.command // ""' <<<"$input" |
grep -qE '(^|[;&|(]|\$\()[[:space:]]*(timeout[[:space:]]+[^[:space:]]+[[:space:]]+)?(claude|clauded|claudedr|klaude|codex|opencode2?)[[:space:]]+([^;&|]*[[:space:]])?(-p|--print|exec|run)([[:space:]]|$)'; then
deny "Starting an agent CLI from the shell is blocked. $advice Only if Gib asked for this by name: prefix the command with T3_NATIVE_OK=1."
fi
;;
esac
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
# Playwright's MCP server, for driving a real browser: pages, screenshots,
# forms, end-to-end runs. It belongs on every machine rather than only where a
# Claude Code plugin happened to be installed.
#
# link-mcp registers this file as a stdio server. It takes no token, so its
# row in user/agents/mcp/servers names no variable and nothing arrives in
# MCP_TOKEN.
set -euo pipefail
exec npx -y @playwright/mcp@latest
+4 -8
View File
@@ -38,11 +38,7 @@ penpot http https://penpot.gbrown.org/mcp/stream?userToken={TOKEN} PE
# The hosted mcp.sentry.dev only speaks to sentry.io.
sentry-gbrown stdio bin/sentry-gbrown SENTRY_GBROWN_TOKEN
# T3 Code environments, one per machine. Agents read projects and threads and,
# with more than read-only access, start and message threads there. No token:
# each is an OAuth server that asks for a pairing code from that machine on
# first sign-in, and the grant lasts 30 days.
t3-desktop http https://t3desktop.gbrown.org/mcp
t3-mac http https://t3mac.gbrown.org/mcp
t3-sr http https://t3sr.gbrown.org/mcp
t3-jr http https://t3jr.gbrown.org/mcp
# Playwright's MCP server. Browser automation: pages, screenshots, forms,
# end-to-end runs. The ticket skill captures its proof screenshots this way.
# No token, hence no variable in the last column.
playwright stdio bin/playwright
+15
View File
@@ -0,0 +1,15 @@
{
"$schema": "https://opencode.ai/config.json",
"instructions": ["~/.claude/rules/*.md"],
"permissions": [
{ "action": "read", "resource": "/etc/**", "effect": "allow" },
{ "action": "shell", "resource": "coredumpctl list*", "effect": "allow" },
{ "action": "shell", "resource": "coredumpctl info*", "effect": "allow" },
{ "action": "shell", "resource": "journalctl*", "effect": "allow" },
{ "action": "shell", "resource": "rpm -q*", "effect": "allow" },
{ "action": "shell", "resource": "panama doctor*", "effect": "allow" },
{ "action": "shell", "resource": "lspci*", "effect": "allow" },
{ "action": "shell", "resource": "lsusb*", "effect": "allow" },
{ "action": "shell", "resource": "getent hosts*", "effect": "allow" }
]
}
@@ -90,6 +90,9 @@ Same conventions as the `/ticket` skill:
`.claude/docs/epics/<EPIC-KEY>/<TICKET-KEY>/story.md` (or `spike.md` /
`task.md`), creating directories as needed. These are personal docs, never
committed.
9. **Report.** Give the Jira link, the title, and any numbered Notes that still name an
open owner, and link the local copy by absolute path so it opens rendered in T3's
file viewer.
## House rules for all content
@@ -0,0 +1,75 @@
---
name: frontend-design
description: Make deliberate visual design choices for new UI or a reshaping of an existing one, and run the mock-then-decide workflow before touching real components.
---
# Frontend Design
Distinctive UI comes from decisions made on purpose. Templated UI comes from decisions made
by habit. This is about catching the second kind before it ships.
## Start from the thing itself
If the brief does not say what the product is, who it is for, and what the screen is
supposed to accomplish, work that out first and confirm it before designing anything. A
dashboard for people reconciling invoices and a booking page for a surf school want
different palettes, different type, different density, and pretending otherwise is how you
end up with the same page twice.
Name the subject in one sentence, name the audience in one, name the job of the screen in
one. Design against those three sentences. When a choice cannot be justified from them, it
is decoration.
## Choices to make deliberately
**Type carries the personality.** Pick the family on purpose rather than reaching for the
usual one. Decide a scale up front and hold to it, so a heading is a heading everywhere and
not a heading in one place and a slightly larger paragraph in another. You rarely need more
than two families.
**Colour is a system, not a pick.** Name the roles before the values: what is background,
what is surface, what is text, what is the one accent. The accent is the only one that gets
to be loud, and it earns that by being used sparingly.
**Structure is information.** Borders, rules, labels, numbering, eyebrows and dividers
should be telling the reader something about the content. If a device is only there to fill
a gap, it is ornament. Numbered markers in particular only make sense when the content is
genuinely a sequence.
**Motion answers the user.** Motion that reacts to what somebody did is useful, because it
shows what changed. Motion that runs on its own is filling space. Do not use continuously
repainting effects such as pulse, shimmer, blur or spinners. They peg the GPU on high
refresh displays and read as generated.
## Tells to avoid
These are the shapes that show up again and again in generated UI, and they are the fastest
way to make a page look like nobody chose it:
- A cream background with a serif display face and a warm terracotta accent.
- A near-black background with one acid green or vermilion accent.
- A headline with exactly one word italicised, bolded or coloured.
- All-caps labels used as decoration rather than because the label is short and needs it.
- A label above every block of content, naming what the content already makes obvious.
- The same fade-and-slide entrance applied to every section, and a hover lift on every card.
- A hero that is a big number, a small label and a gradient, when the subject has something
more characteristic to show.
## Workflow
This is the rule from my preferences, spelled out:
1. Build several genuinely distinct directions, not three variations of one idea. Different
palettes, different type, different structure. If two mocks would be described the same
way in one sentence, they are the same mock.
2. Build them as static pages. Real content, not grey boxes labelled "image here", because
the content is where most of the design decisions turn out to matter.
3. Show them, and stop. In T3 Code, render each direction in the thread with `html_preview`
then `html_render`, one direction per render so each expands full screen on its own, and
link the files by absolute path for the file viewer. `html_render` overrides a page
background, so a direction that paints its own goes inside an `<iframe srcdoc>`. Outside
T3, publish them with a simple http server and report the URL.
4. Wait for a decision before implementing anything in real components.
Do not edit real components first. Do not keep polishing past the point where a choice
could be made, and do not pick a winner yourself and build it.
+2 -2
View File
@@ -121,8 +121,8 @@ Announce the path, then create a todo per item and work them in order.
immediately rather than spending a round on the details of something that needs splitting first.
Each sub-project earns its own design cycle.
- **Visual questions get mocks, not prose.** Where the open question is what something should look
like, stop describing it: build several distinct static mocks, serve them, report the URL, and wait
for a pick. Never edit a real component to answer a layout question.
like, stop describing it: build several distinct static mocks, show them in the thread (or serve
them and report the URL outside T3 Code), and wait for a pick. Never edit a real component to answer a layout question.
## The written spec (architectural only)
+4 -6
View File
@@ -40,16 +40,14 @@ an implementation with extra steps.
routing and file conventions; invent no new top-level structure.
2. **Trivial to run.** A UI prototype is one URL. A logic demo is one file that opens on a
double-click. No thinking required to start it.
3. **Do not start a dev server yourself.** Assume one may already be running. Where the prototype
needs a server that is not up, or needs a restart, ask rather than starting it.
4. **No persistence by default.** State lives in memory. Persistence is usually the thing being
3. **No persistence by default.** State lives in memory. Persistence is usually the thing being
*checked*, not something to depend on. Where the question genuinely involves the database, point
at a scratch one named so nobody mistakes it.
5. **Skip the polish.** No tests, no error handling beyond what makes it run, no abstractions. A
4. **Skip the polish.** No tests, no error handling beyond what makes it run, no abstractions. A
prototype that needs tests has stopped being a prototype.
6. **Surface the state.** After every action, or on every variant switch, render the full relevant
5. **Surface the state.** After every action, or on every variant switch, render the full relevant
state so the change is visible.
7. **Answer one question.** No "what if we also wanted X later".
6. **Answer one question.** No "what if we also wanted X later".
## Capture it when the answer arrives
+11 -5
View File
@@ -13,9 +13,10 @@ Where the question is about logic or state rather than appearance, this is the w
| **Static mocks** (default) | The question is layout, hierarchy, or visual direction. Standalone HTML, no app involved. | Minutes. Zero risk to the app. |
| **In-app variants** | The question is how it behaves against **real** data, density, and chrome. A layout that looks fine on lorem ipsum falls apart on a real row count. | Higher. Touches the repo. |
**Start with static mocks.** They are the house default: several distinct standalone pages, served by
a simple static server, URL handed over. Escalate to in-app variants only where real data is
genuinely load-bearing to the judgement, and say why when you do.
**Start with static mocks.** They are the house default: several distinct standalone pages, shown in
the T3 Code thread (or, outside T3, served by a simple static server with the URL handed over).
Escalate to in-app variants only where real data is genuinely load-bearing to the judgement, and
say why when you do.
## Static mocks (default)
@@ -23,8 +24,13 @@ Write each variant as a standalone HTML file in a scratch directory outside the
the project's design language closely enough to judge (Tailwind via CDN is fine here, since nothing
ships).
Serve the directory with something already installed, such as `python3 -m http.server`, and hand over
the URL plus the filenames. Then stop.
In T3 Code, render each variant in the thread with `html_preview` then `html_render`, one variant per
render, and link each file by absolute path so it opens full size in T3's file viewer. Keep each
variant one file: the viewer gives a file outside the workspace no neighbouring styles, scripts or
images. `html_render` overrides a page background, so pass a variant that paints its own inside an
`<iframe srcdoc>` (the ticket skill's `ticket-page view` does this). Outside T3, serve the directory
with something already installed, such as `python3 -m http.server`, and hand over the URL plus the
filenames. Then stop.
Never edit a real component to answer a layout question.
+4 -3
View File
@@ -12,8 +12,8 @@ Invoked as `/review-ticket KACP-XXXXX`. Two phases with a hard stop between them
This is a *dev review*, not a code review. It is part of writing a ticket, before any
code exists: it fills in implementation guidance, an estimate, a risk level, and the
testing tables so a developer can pick the story up. Reviewing code that has already
been written is a separate thing entirely, and lives in the `ticket` skill at Phase 2
step 5.
been written is a separate thing entirely, and lives in the `ticket` skill's
`COUNCIL.md`.
## Files this skill uses
@@ -63,7 +63,8 @@ step 5.
- Prose sweep: call the Skill tool with "unslop" and apply its pattern detection to `review.md`, meaning puffery, superficial -ing phrases, "not just X but Y", vague attributions, and rule of three padding, alongside the punctuation rules above. Skip its "Adding soul" section, the voice here is the Lead writing to the implementing developer, not a blog. Henry reads every one of these, and a review that reads as machine generated loses its credibility before its content is weighed.
- Anatomy check against the section list below.
- Confirm no Jira write has happened.
12. Stop. Hand the user the draft with the estimate, risk level, dependency list, and any split recommendation surfaced in the summary, and wait for their review. Do not apply in the same run unless the user has already told you to.
12. If the Story Risk Level is MEDIUM or above, or the story changes the datamodel, get the draft checked before Gib reads it. Read **Seats** and **Checking a dev review** in `~/.agents/skills/ticket/COUNCIL.md`, start one `delegate_task` per seat, and end your turn. T3 wakes you as each finishes. Verify each finding against the code yourself, fix what is real in `review.md`, and rerun the path check and prose sweep on what changed. A seat that cannot run is named in the summary, never replaced with another model. Outside T3 Code, skip this step and say so.
13. Stop. Link `review.md` by absolute path so it opens rendered in T3's file viewer. Hand the user the draft with the estimate, risk level, dependency list, any split recommendation, and what the seats changed surfaced in the summary, and wait for their review. Do not apply in the same run unless the user has already told you to.
## Phase 2, apply
+137
View File
@@ -0,0 +1,137 @@
# The review council
Several fresh agents from different model families review the same change, each knowing nothing
of how it was built. You judge what they return. This runs before `pre-mr-review`, because
that audit may not run anything and so cannot catch a claim that is plausible on the page and
false in reality. A reviewer that can run things can. It has caught a mitigation proven by
construction to be wrong, on a branch whose own audit had blessed it.
Order matters. `pre-mr-review`'s verdict gets pasted verbatim into `mr.md`, and anything that
lands after it invalidates that verdict. Council first, fix, commit, then the audit over the
final tree.
## Seats
| Seat | `providerInstanceId` | `model` |
|---|---|---|
| Claude | `claudeAgent` | `claude-opus-5-5` |
| Codex | `codex` | `gpt-6.1-sol` |
Klaude never takes a seat. It is the work account, and its only job is the `pre-mr-review` run
in Phase 2 step 7, which stays a separate required step after the council.
Adding a seat is adding a row. `orchestrator_capabilities` has the IDs, with `search` for a
model it does not list. Nothing else here changes with the seat count. T3 can leave out a model
the provider really has, as it does with `opencode-go/*`. List such a model under that provider's
`customModels` in `~/.t3/userdata/settings.json`, which T3 picks up without a restart.
## When it runs
Run it whenever the change adds or modifies a procedure, touches schema or migrations, touches
permissions or auth, involves concurrency, or has a risk table in its ticket. When unsure, run
it. A copy tweak, a styling fix, or a one line correction with no logic skips it.
## Convene
1. Confirm the suite is green, and note the base and head of the commit range. A seat should
never spend its review on code the suite already rejects.
2. In one turn, call `delegate_task` once per seat with the brief below: `role: "review"`,
`mode: "async"`, a title of `Council <seat> <KEY>`, and the seat's `target`. Leave `isolation`
unset, so each seat reads this checkout, and leave `runtimeMode` unset.
3. End your turn. T3 wakes you as each seat finishes. Leave the checkout alone until every seat
has returned, because they are reading it.
4. A seat refused with `model_unavailable`, or one that fails: record it with `ticket-page event`,
carry on with the rest, and name it at hand-off. Keep the seat empty rather than filling it with
another model. If no seat can run, ask Gib with AskUserQuestion. A timeout or silence is not
an approval.
Outside T3 Code (no `T3CODE_THREAD_ID`), run one fresh subagent from the harness with the same
brief. Never review the change in your own context: the value here is the reviewer not being
the author.
### The brief
A delegate gets none of this thread, so the brief is all it knows. Fill the placeholders.
```
Review only. Do not edit, commit, push, run anything destructive, or start subagents.
Repo: <absolute path> Range: <base>..<head> Ticket: <KEY>
Read in this order, or you will report conventions this project already settled:
1. .claude/docs/epics/<EPIC-KEY>/summary.md, if it exists
2. <scratch path of `ticket-page md <KEY>`>
3. <target-dir>/resources/
4. AGENTS.md
5. git diff <base>..<head>, then the code around it
Verify rather than suspect. You may run tests and read-only checks. Try to refute each finding
before you report it. A plausible worry is not a finding.
Re-derive every "proven by X" already written into the ticket's risk or test tables. A false
proof claim in Jira is worse than an unproven one, because it stops anyone else from checking.
Answer these against the ticket and plan, not the diff alone, quoting the AC ID or plan step:
- What did the ticket ask for that is missing or only partly done?
- What behavior is in the diff that nothing asked for?
- What looks implemented but is implemented wrongly?
Hold the change to its smallest correct form. Flag code, tests, or guards the ticket does not need.
blocker = the intended case is wrong. should = a real bug callers will hit. nit = everything else,
including rare paths, small performance costs, and style.
Reply with:
VERDICT: APPROVE or CHANGES
S1 [blocker|should|nit] [CONFIRMED|PLAUSIBLE] <file>:<line> <claim>. Failure: <concrete scenario>. Fix: <smallest fix>
```
## Judge the findings
Findings are not automatically true, and reviewers produce confident wrong answers. Check each
one against the code yourself, and regrade it: a rare path is a nit whatever label it came with.
Two seats reporting the same thing is one finding with more weight behind it. A finding only one
seat raised is still worth checking, since catching what the others miss is why the seats differ.
Put each on the page as you judge it, with its grade and its seats in the text:
`finding <KEY> add "[should] <text> (Claude, Codex)" --severity CONFIRMED --where <file:line>`, then
`fixed --commit <sha>`, or `rejected --reason "<why>"`.
- **Fix every blocker and should you confirm,** each meaningful fix as its own commit. These do
not defer to a follow-up ticket unless Gib decides that.
- **Take the smallest fix.** Prefer deleting or narrowing code over a new lock, cache, helper, or
option. Compare `git diff --shortstat <base>` before and after: if review grew the diff, cut
first. Review loops grow a change one cheap-looking fix at a time, and it is your job to hold
the scope.
- **Decline nits in one line** with `rejected --reason`, and list them at hand-off as follow-ups.
Agreeing just to end the review is how a change bloats.
- **When a fix changes a mechanism rather than a line,** write a test that fails against the old
behavior and passes against the new one, and confirm it fails by reverting the fix. A test that
passes either way proves nothing.
- **If a finding invalidates something already in Jira or a deliverable,** correct it there too.
Fixing the code does not fix the false claim.
## Stop
One round is the default. `pre-mr-review` reviews the whole diff, your fixes included, and serves
as the next pass. A second round runs only if a fix changed a mechanism: fresh `delegate_task`
calls, the same brief, the ledger of what you fixed and rejected with your evidence, and an
instruction to check only the open ids and the changed code. Two rounds is the limit. A finding
still disputed then goes to Gib with AskUserQuestion, both sides in two or three lines each.
## Checking a dev review
`review-ticket` borrows the seats for a draft instead of a diff. Same `delegate_task` settings, and
no code gets changed. Give each seat this:
```
Review only. Do not edit anything, write to Jira, or start subagents.
Repo: <absolute path> Draft: <dir>/review.md Story: <scratch path of the fetched story>
Check the draft against the code. Report each Current State claim that is false, each "does not
exist" that does, each named pattern that is a poor fit or has a better neighbour, each seam the
Relationships section gets wrong, and any story AC the implementation guidance would not satisfy.
Say whether the estimate misses work the code paths table leaves out.
Reply with:
VERDICT: APPROVE or CHANGES
S1 [blocker|should|nit] <section> <claim>. Evidence: <file:line>. Fix: <suggestion>
```
+35 -5
View File
@@ -7,7 +7,6 @@ sees. It is a personal working file, exempt from the prose bar like the rest of
```
.claude/docs/epics/
_site/site.css, site.js # shared renderer, refreshed from templates/site/ on every write
<EPIC-KEY>/
index.html, epic.data.js # the epic index, in build order
<TICKET-KEY>/
@@ -16,6 +15,37 @@ sees. It is a personal working file, exempt from the prose bar like the rest of
tickets/<TICKET-KEY>/ # same shape, for a ticket with no epic, and no index
```
Each `index.html` carries the renderer from `templates/site/` and its own data inline, rewritten
on every write, so it renders alone with no server and no other file. That is what T3 Code's
file viewer needs: it sandboxes the page, with no `fetch` or `localStorage`, and serves files
only from the page's own folder and below. The `.data.js` beside it is the store `ticket-page`
reads and writes. An open page polls it to stay live, and shows its last write if it cannot.
Screenshots and attachments stay as relative files under the ticket folder.
## In T3 Code
Gib follows the work in a T3 Code thread, so the page and the plan come to him there.
- **The live page.** `ticket-page open <KEY>` refreshes the page and prints its absolute path.
Link that path in your reply, and it opens in T3's file viewer beside the thread. Outside T3 (no `T3CODE_THREAD_ID`) it also opens the browser.
Never start an http server for the page.
- **Inline snapshots.** `ticket-page view <KEY> <view>` prints a self-contained page styled
with T3's theme. Pass its output, verbatim, to `html_preview`, check the screenshot, then
to `html_render` with the `contentHeight` it reported (capped at 2000) as the height. Do not
restate what the snapshot shows in your reply. Snapshots do not update, so render one at
the moment it is for, not on every write.
| Moment | View |
|---|---|
| Plan ready for approval (Phase 1, last step) | `plan`, then `mocks --mock <id>` once per artboard |
| Starting pre-mr-review, final hand-off, and on resume | `progress` |
- One artboard per render, so each can be expanded full screen on its own and options sit
one after another for comparison. `view` refuses a page over html_render's 512 KB and
names the artboards to render one at a time.
- Keep artboards light: CSS and real copy, not base64 screenshots. A screenshot pasted into an
artboard as a data URI is most of its size, and every byte goes through the conversation.
## Write through `ticket-page`, always
`~/.agents/skills/ticket/scripts/ticket-page` is the only writer of the data files. It validates
@@ -31,7 +61,7 @@ the repo.
| Moment | Command |
|---|---|
| Issue fetched to a scratch path | `init <KEY> --issue <issue.raw.json>`. Prints the ticket folder, then any populated Jira fields the page does not show. |
| Issue fetched to a scratch path | `init <KEY> --issue <issue.raw.json>`. Prints the ticket folder, then any populated Jira fields the page does not show. Then `open <KEY>` and link the path. |
| Attachments downloaded, a video transcribed, frames extracted | `scan <KEY>` |
| Ticket branch resolved, ticket classified | `meta <KEY> --branch <name> --classified bounded` |
| Research notes worth keeping: what a video says, what a spreadsheet holds, a check against prod | `note <KEY> "<Title>" --file notes.html` |
@@ -40,8 +70,8 @@ the repo.
| Starting a step | `step <KEY> <n> running` |
| A step's commit exists | `step <KEY> <n> done --commit <sha>`, one `--commit` per commit |
| A criterion is met | `criterion <KEY> <ID> done --evidence "<what shows it>"` |
| Proof captured, or dropped from `proof/` | `proof <KEY> --file proof/<name> --proves AC1,AC3 --caption "<test case>" [--mock <id>]`, `proof <KEY> --test "<name>" ...`, `unproof <KEY> <file>` |
| Reviewer finding, and its resolution | `finding <KEY> add "<text>" --severity CONFIRMED --where <file:line>`, then `finding <KEY> F1 fixed --commit <sha>` or `rejected --reason "<why>"` |
| Proof captured (image, mp4, or text), or dropped from `proof/` | `proof <KEY> --file proof/<name> --proves AC1,AC3 --caption "<test case>" [--mock <id>]`, `proof <KEY> --test "<name>" ...`, `unproof <KEY> <file>` |
| Council finding, and its resolution | `finding <KEY> add "[should] <text> (<seats>)" --severity CONFIRMED --where <file:line>`, then `finding <KEY> F1 fixed --commit <sha>` or `rejected --reason "<why>"` |
| pre-mr-review audit read | `verdict <KEY> <verdict text> --recommendation <R> --head <sha>` |
| A Jira field PUT and verified | `jira <KEY> "<field name>"` |
| `mr.md` written | `mr <KEY>` |
@@ -135,7 +165,7 @@ The rest of the index is yours to seed and keep true.
## Reading it as an agent
`ticket-page md <KEY>` prints the ticket and the plan as markdown. Use it when you resume a
ticket, and hand it to the fresh reviewer as the ticket and plan in its reading order.
ticket, and write it to a scratch file for the council and the audit, which read it there.
## Tickets from before the page
+154 -148
View File
@@ -1,6 +1,6 @@
---
name: ticket
description: End-to-end Jira ticket workflow — fetch a ticket into a live ticket page under .claude/docs/epics/, plan it, implement it with clean commits, drive the pre-mr-review audit to convergence (the user runs it themselves from their work Claude account), and write the MR doc. Use when the user gives you a Jira ticket key (e.g. KACP-11111) to work, or asks to plan/implement/wrap up a ticket.
description: End-to-end Jira ticket workflow — fetch a ticket into a live ticket page under .claude/docs/epics/, plan it, implement it with clean commits, review it with a council of models, drive the pre-mr-review audit to convergence on the work Claude account, and write the MR doc. Use when the user gives you a Jira ticket key (e.g. KACP-11111) to work, or asks to plan/implement/wrap up a ticket.
---
# Ticket Workflow
@@ -16,7 +16,7 @@ again later with the same key, it resumes wherever it left off — see **Phase 0
Not to be confused with `/review-ticket`. That skill writes the *developer review* on a
Jira story, the estimate, risk level, and testing tables, as part of authoring a ticket
before anyone builds it. It has nothing to do with reviewing code. The code review in
this skill is Phase 2 step 5 and is performed by a dispatched agent, not by a skill.
this skill is Phase 2 step 5 and is performed by the review council in [COUNCIL.md](COUNCIL.md).
## The ticket page
@@ -25,18 +25,39 @@ Jira has it, the plan with its mocks, live progress, proof beside the mocks, and
Every epic gets an **epic index** listing its stories in build order. Both render from data
files that only `ticket-page` writes, and both update themselves as it writes.
Gib works in T3 Code. The page opens in T3's file viewer beside the thread when you link its
absolute path, so never start a server for it. At two moments, plan approval and hand-off,
the skill also shows a snapshot inline in the thread with `ticket-page view`, so he can read
it without leaving the conversation. SITE.md has the commands, under **In T3 Code**.
**Read [SITE.md](SITE.md) before the first write of every run.** It holds the command for each
moment the phases below name, the plan's JSON shape, how to draw mocks, and how the epic order
is seeded. Record each moment when it happens, not in a batch at the end, because Gib is
watching the page change.
## Working in T3 Code
Gib runs this skill in a T3 Code thread (`T3CODE_THREAD_ID` is set) and stays there, so the work
comes to him through T3's own tools.
- **Agents are T3 delegates.** Every agent this skill starts is a `delegate_task`, and a local one
always runs in this thread's project. A delegate gets only its `task` text, so write the brief
as though it knows nothing. `/skill` lines do not expand there: tell it to call the Skill tool.
Start one with `mode: "async"` and end your turn, because T3 wakes you when it finishes.
- **Proof comes from T3's browser,** the `preview_*` tools. `/ticket` is the request for browser
work that T3's rules ask for, so use them at the proof step. Phase 2 step 9 has the recipe.
Claude in Chrome stays for reading Gib's signed-in sites, such as a Jira page.
- **Questions go through AskUserQuestion,** with the context, what blocks you, and your
recommendation first, because a question in a normal message leaves the thread looking done.
Without `T3CODE_THREAD_ID`, the fallbacks are named where each one applies.
## Directory layout this skill maintains
```
.claude/docs/epics/
_site/ # the shared renderer, refreshed by ticket-page
<EPIC-KEY>/
index.html, epic.data.js # the epic index
index.html, epic.data.js # the epic index (each index.html carries its own renderer)
<TICKET-KEY>/
index.html, ticket.data.js # the ticket page
resources/
@@ -60,9 +81,10 @@ watching the page change.
- `~/.agents/skills/ticket/scripts/ticket-page <command> <KEY> ...` — the only writer of the
ticket page and epic index. See [SITE.md](SITE.md), or `ticket-page help`. Runs under bun.
- `~/.agents/skills/ticket/scripts/capture-proof` — sharp Playwright screenshots of the
running app for proof and spike screenshots. `capture-proof help` lists its options. The
first run installs a pinned Playwright into `~/.cache/ticket-page/`.
- `~/.agents/skills/ticket/scripts/capture-proof` — the fallback for proof screenshots: 2x
Playwright captures, full page or cropped, for what T3's browser cannot do (Phase 2 step 9).
`capture-proof help` lists its options. The first run installs a pinned Playwright into
`~/.cache/ticket-page/`.
- `~/.agents/skills/ticket/scripts/jira-fetch-issue.sh <KEY> <OUT.json>` — fetches one
issue (`fields=*all`, rendered HTML description, field-name map) via `JIRA_BASE_URL` /
`JIRA_CREDENTIALS`.
@@ -352,11 +374,10 @@ what was quick to wire up. Ship both, and don't let either stand in for the othe
- **Visual mocks alongside the code, one or two of them.** The built version is
deliberately rough, so on its own it under-sells the idea to anyone reviewing it.
Produce one or two polished mocks that aren't constrained by what was cheap to build:
the layout, spacing, and density as they should actually be. In Claude Code the
`design` skill produces a canvas of artboards for exactly this; in a harness without
it, a self-contained HTML page does the same job. These are design intent, not
implementation promises, so call out anything a mock shows that the code doesn't do
yet.
the layout, spacing, and density as they should actually be. A self-contained HTML
page does this job, shown through `html_preview` and `html_render`. These are design
intent, not implementation promises, so call out anything a mock shows that the code
doesn't do yet.
- **Scope discipline still applies.** This is a proof of concept, not a production
feature. Don't chase every edge case and don't fix unrelated pre-existing bugs you
happen to notice along the way, just implement enough of the real thing to be
@@ -364,8 +385,8 @@ what was quick to wire up. Ship both, and don't let either stand in for the othe
than a static mock would, say so in the plan's approach rather than silently scoping it down
without mentioning the tradeoff.
- **Screenshot the real thing.** Once it's built and running, use the `run` skill to
get the app up and capture the actual screens with `capture-proof`, as the proof step
in Phase 2 describes, instead of drawing a wireframe. Save screenshots under
get the app up and capture the actual screens as the proof step in Phase 2 describes,
instead of drawing a wireframe. Save screenshots under
`deliverables/<slug>/screenshots/` and the visual mocks under
`deliverables/<slug>/mocks/`, and embed both in `<slug>.typ` via `image()` when
building the mockups deliverable. Label which is which. A reader who can't tell a
@@ -423,6 +444,11 @@ through, and the branch needs to already exist when that happens.
5. **More than one match** — don't guess which one is "the" branch. List them (last
commit date and subject each) and ask the user which to use.
In a T3 worktree thread (`git rev-parse --git-dir` differs from `--git-common-dir`) the
default branch is usually checked out elsewhere, so cut from the remote instead:
`git fetch origin` and `git checkout -b <name> origin/<default>`. `ticket-page` finds the
main checkout's `.claude/docs/` from a worktree on its own.
Once this resolves you're on the correct branch, and Phase 1/Phase 2 work happens
there. `.claude/docs/` itself is gitignored in this repo, so which branch you're on
doesn't affect the ticket page or `mr.md` directly — this is about making sure any
@@ -449,7 +475,7 @@ place from the start.
- Proceed to implementing the existing plan as-is (they reviewed and approved it)
- Resume implementation (some steps are already done on the page / some commits
already exist on the ticket branch — pick up from the first open step)
- Move to the pre-mr-review stage (implementation looks done, just need the audit + MR doc; the user runs the audit from their work account, see Phase 2 step 7)
- Move to the pre-mr-review stage (implementation looks done, just need the audit + MR doc, see Phase 2 step 7)
Route to **Phase 1** or **Phase 2** accordingly.
- **A plan and `mr.md`** — this ticket looks finished. Tell the user
`mr.md` already exists at its path and ask whether they want you to refresh it
@@ -468,8 +494,8 @@ place from the start.
epic (or `tickets/` without one), imports every rich-text field verbatim as sanitized
HTML, writes the page, and prints the ticket folder. Copy the fetched JSON to
`<target-dir>/resources/issue.raw.json`, which later re-imports and the attachment
script read. Record the branch from **Branch setup** with `ticket-page meta`, then open
the page with `ticket-page open <KEY>` so Gib can follow from here on.
script read. Record the branch from **Branch setup** with `ticket-page meta`, then run
`ticket-page open <KEY>` and link the path it prints, so Gib can follow from here on.
4. `init` ends by listing populated Jira fields the page does not show. Read each one and
add any that bear on implementing or verifying the ticket as a note. This sweep exists
because real content sometimes hides in per-project custom fields with unpredictable
@@ -512,8 +538,9 @@ place from the start.
ticket's real requirements, write what it says as a note (`ticket-page note`), the
way "What the video says" summarizes a recording point by point.
10. Now actually understand the ticket in the context of this codebase: read whatever
source files, tests, or docs are relevant to what's being asked. Use Explore/grep
as needed — this is normal engineering research, not scripted. Use the
source files, tests, or docs are relevant to what's being asked. Read them yourself:
this is normal engineering research, not scripted. Only a sweep too broad for one
pass earns a `delegate_task` with `role: "research"`. Use the
**Verification tools available** above to check the ticket's own claims against
reality (codebase, Jira, staging, and prod/CI env with permission) rather than
taking the ticket's description at face value — this is what makes the plan
@@ -580,7 +607,9 @@ place from the start.
otherwise.
12. When a step changes what a user sees, draw its mock artboards with `ticket-page mock`,
as [SITE.md](SITE.md) describes under **Mocks**.
13. Stop here. Tell the user the plan is on the ticket page, give its path from
13. Stop here. Show the plan in the thread: `ticket-page view <KEY> plan`, then each mock
artboard with `ticket-page view <KEY> mocks --mock <id>`, each through `html_preview`
and then `html_render` (SITE.md, **In T3 Code**). Then link the page path from
`ticket-page path <KEY>`, summarize the approach in a couple of sentences, and mention
any open questions that need their input before you'd implement it. Do not start
implementing in this same run — wait for them to review the plan and invoke
@@ -589,7 +618,9 @@ place from the start.
## Phase 2 — implement, verify, and hand off
Entered only when the user has confirmed (per Phase 0) that the plan is approved.
Entered only when the user has confirmed (per Phase 0) that the plan is approved. From
there this phase runs through to the step 10 hand-off without waiting on Gib: the council
and the audit both run as delegates. Stop only for a real blocker, asked with AskUserQuestion.
1. Do **Branch setup** above first, unconditionally — even if Phase 1 already did this
for the same ticket earlier, confirm you're still actually on that branch now (the
@@ -631,89 +662,51 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
wouldn't actually catch, e.g. confirming behavior against real staging data via
Infisical, or a specific prod/CI config question, when that's genuinely what it
takes to be confident the change works as intended rather than just "looks right."
5. Get an independent code review, from a FRESH agent, before running `pre-mr-review`.
This step exists because `pre-mr-review` cannot do it. That skill is constitutionally
read-only: it may not run tests, execute code, probe a database, or run any
experiment, and its own rules tell it to mark anything unprovable as "Needs
verification" rather than proving it. So it cannot catch a claim that is plausible on
the page and false in reality. A reviewer that can actually run things can. It has
already caught a mitigation that was proven by construction to be wrong, on a branch
whose own audit had blessed it.
Order matters and is not just convenience. `pre-mr-review`'s output is a readiness
verdict that gets pasted verbatim into `mr.md`. Anything that runs after it
invalidates that verdict by construction, and you end up rerunning it and rewriting
the handoff. Review first, fix, commit, and only then have the user
run `pre-mr-review` over the final tree.
- **Skip this step for genuinely trivial changes**: a copy tweak, a styling fix, a
one line correction with no logic in it. Run it whenever the change adds or
modifies a procedure, touches schema or migrations, touches permissions or
auth, involves concurrency, or has a risk table in its ticket. When unsure, run it.
- **Always dispatch a NEW agent.** Never reuse an agent from earlier in this ticket
and never review your own work in your own context. The value here is entirely in
the reviewer not being the author, so an agent that helped implement or plan the
change cannot provide it.
- **Give the reviewer its reading order explicitly**, or it produces noise about
conventions the project already settled: the epic summary if there is one
(`.claude/docs/epics/<EPIC-KEY>/summary.md`), then the ticket and plan as
`ticket-page md <KEY>` prints them, then this ticket's `resources/`, then
`AGENTS.md`, and only then the diff. Give it the exact commit range.
- **Tell it to verify rather than suspect**, to try to refute its own findings before
reporting them, to mark each finding CONFIRMED or PLAUSIBLE, and to give exact
`file:line` plus a concrete failure scenario. Tell it explicitly not to write,
commit, push, or run anything destructive.
- **Point it at the claims already made.** Every "proven by X" already written into
the ticket's risk or test tables must be re-derived, not taken on trust. A false
proof claim in Jira is worse than an unproven one, because it stops anyone else
from checking.
- **Ask the spec questions explicitly**, or the review only finds bugs in what you
did write and never notices what you didn't. Three questions, each answered against
the ticket and the plan rather than the diff alone: what the ticket asked for that
is missing or only partly done; what behavior is in the diff that nothing asked for;
and what looks implemented but is implemented wrongly. Have it quote the AC ID or
the plan step each finding traces to.
Then respond to the review, in writing, and put each finding on the page as you judge
it: added, then fixed with its commit or rejected with the reason.
- **Findings are not automatically true.** Verify each one against the codebase
before acting on it. Reviewers do produce confident wrong answers. Say plainly
which findings you rejected and why, rather than quietly accepting all of them.
- **Fix everything real**, each meaningful fix as its own commit. Do not defer a
known hole to a follow-up ticket unless the user decides that.
- **When a fix changes a mechanism rather than a line**, write a test that fails
against the old behavior and passes against the new one, and actually confirm it
fails by reverting the fix. A test that passes either way proves nothing.
- **If a finding invalidates something already written into Jira or a deliverable,
correct it there too.** The code being fixed does not fix the false claim.
- **Terminate deliberately.** Run the reviewer ONCE on the branch. `pre-mr-review`
then reviews the whole diff including your fixes and serves as the second pass.
Only dispatch a second reviewer if a fix changed a mechanism, not for line level
fixes, and never loop "review until clean."
5. Convene the review council before `pre-mr-review`. Read [COUNCIL.md](COUNCIL.md) and follow
it: it holds the seats, when the step applies, the brief, how to judge what comes back,
and when to stop. This step is done when every seat has returned or been recorded as
unavailable, and every finding on the page is fixed or rejected with its reason.
6. Confirm this project actually has a `pre-mr-review` skill (`.claude/skills/pre-mr-review/`
in the current repo). If it doesn't, stop here, tell the user implementation and
local verification are done but this repo has no `pre-mr-review` skill to run, and
let them decide how to proceed.
7. **Do NOT invoke the `pre-mr-review` skill yourself.** The org asks that this
review run from the developer's separate work Claude account, so the audit on
record must come from there, not from this session. Instead:
1. Tell the user the branch is ready for its pre-mr-review and ask them to run
`/pre-mr-review` from their work account, then let you know when it has
finished. Stop and wait, this is a hard gate.
2. When they say it ran, read the audit and handoff files it wrote under
`.claude/audits/pre-mr/` (the context script from the repo's `pre-mr-review`
skill prints the exact paths). Check the `last_reviewed_head` in the audit
header matches the current HEAD; if the branch moved after their run, say so
and ask them to rerun before acting on a stale audit.
3. Read the verdict, and record it on the page with `ticket-page verdict`.
7. **Run `pre-mr-review` on the work account, from a Klaude delegate.** The org asks
that this review run from the developer's work Claude account, so the audit on record must
come from there, never from this session and never from another provider. Klaude is that
account (`~/.klaude`). The audit is one agent, not a council.
1. Show where the build stands with `ticket-page view <KEY> progress`, through
`html_preview` and then `html_render`. Write `ticket-page md <KEY>` to a scratch file.
2. Start it, then end your turn. T3 wakes you when it finishes:
```
delegate_task
title: pre-mr-review <KEY>
role: review
mode: async
target: { providerInstanceId: "claudeAgent_klaude" }
task: Call the Skill tool with "pre-mr-review" and run the audit on this branch.
Follow that skill's own rules, and start no subagents. The ticket and its
plan are in <scratch file>. <decisions from earlier runs, if any>
Reply with the verdict, the recommendation, the audited HEAD, and the audit
and handoff paths.
```
Leave `model` out so it runs Klaude's default, and leave `isolation` unset so the audit
lands in this checkout. A rerun is always a new `delegate_task`, one at a time.
Without `T3CODE_THREAD_ID`, or if Klaude is refused, ask Gib to run `/pre-mr-review`
from his work account and wait for him. Never fall back to another provider.
3. Read the audit and handoff files it wrote under `.claude/audits/pre-mr/` (the context
script from the repo's `pre-mr-review` skill prints the exact paths), not just the
delegate's summary. Check the `last_reviewed_head` in the audit header matches the
current HEAD. If the branch moved after the run, start a new run before acting on a
stale audit.
4. Read the verdict, and record it on the page with `ticket-page verdict`.
- **Ready to Open MR**: continue to step 8.
- **Almost Ready / Not Ready Yet**: fix what it flagged (each meaningful fix
as its own commit), then ask the user to rerun the review from the work
account and wait again. Repeat until the verdict is Ready to Open MR. Don't
write `mr.md` before that verdict is reached.
- **Almost Ready / Not Ready Yet**: fix what it flagged (each meaningful fix as its own
commit), then start a new run. Repeat until the verdict is Ready to Open MR. Don't
write `mr.md` before that verdict is reached. After three runs with no Ready verdict,
stop and ask Gib how to proceed.
Findings that need no code change (mentions, disclosures) get addressed in
`mr.md`'s Additional Notes or the Jira fields as usual.
@@ -729,22 +722,18 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
normal and expected at a Ready verdict. Never treat the recommendation as a
blocker or report it as one.
**Reruns converge by giving the audit more, not by repeating the command.**
When a rerun is needed, the user is running it, so every rerun costs them.
Make each one count:
- **Feed it context on the first run.** It reviews the diff, and left alone it
re-derives intent from the branch name and re-raises decisions that are
already settled. Ask the user to point it at the ticket's `ticket.data.js`, or give
them the output of `ticket-page md <KEY>` to paste in. This is the cheapest thing that reduces
repeat findings.
- **Answer its decisions explicitly in the rerun.** When it parks a finding
under "Decide before opening", write the decision and its reasoning into the
prompt for the next run, so it can reclassify rather than re-ask. A silent
rerun on an unchanged diff re-asks the same question.
- **Say so when it finds new things in unchanged code.** That means the earlier
run was an incomplete pass, not that the branch got worse. Tell the user
plainly, because it is the audit's problem to fix and it changes how much
weight the next run deserves.
**Reruns converge by giving the audit more, not by repeating the command.** Each run
spends the work account's quota, so make each one count:
- **Feed it context on the first run.** It reviews the diff, and left alone it re-derives
intent from the branch name and re-raises decisions that are already settled. The
scratch file in the task is the cheapest thing that reduces repeat findings.
- **Answer its decisions explicitly in the rerun.** When it parks a finding under "Decide
before opening", write the decision and its reasoning into the next task, so it can
reclassify rather than re-ask. A silent rerun on an unchanged diff re-asks the same
question. A decision that is genuinely Gib's goes to him first.
- **Say so when it finds new things in unchanged code.** That means the earlier run was an
incomplete pass, not that the branch got worse, and it changes how much weight the next
run deserves. Tell Gib at hand-off.
8. Read `~/.agents/skills/ticket/templates/mr.md` — this is the org's MR template,
copied into this skill so it still works even though the original
`.claude/docs/mr/template.md` no longer exists in the command-center repo. `mr.md`
@@ -753,8 +742,9 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
Fill it in for real:
- H1: from the ticket and the actual work done, not the ticket title verbatim if the
real change ended up narrower/broader.
- Summary: one short paragraph max. High level only, what changed, not how. This
is not the place for a list of every file touched.
- Summary: one short paragraph max. Open with the problem in plain words, then say
what changed, not how. Lead with why this MR exists rather than an inventory of what
it touches.
- Issue Link: `<JIRA_BASE_URL>/browse/<KEY>`.
- New ENVs: list any env vars actually introduced; "None" if none.
- Additional Notes: a bulleted checklist of the changes you actually made, not a
@@ -778,9 +768,9 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
the audit's own words, not a retelling of them. Concretely:
- Never hand-write a handoff section, and never edit one in place. If it is
wrong, thin, or stale, ask the user to rerun `/pre-mr-review` from their work
account to regenerate the handoff file, then re-paste.
- On EVERY rerun of theirs, replace the whole existing handoff block with the whole
wrong, thin, or stale, start a new Klaude run (step 7) to regenerate the handoff
file, then re-paste.
- On EVERY rerun, replace the whole existing handoff block with the whole
regenerated one. Do not patch the copy sitting in `mr.md` to match the new
head — that is how the two silently diverge, and the version the reviewer
reads stops being the version the audit actually produced.
@@ -831,34 +821,46 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
it to ADF, proof and mitigation cells included. Everything here is read by the Lead
and the PM.
**Proof first.** Before filling any proof column, capture working feature proof
yourself wherever possible: run the app (`run` skill) and screenshot the real
feature with `capture-proof`, or capture test output for behavior with no UI.
**Proof first.** Proof is evidence you observed yourself in this session. Before filling
any proof column, capture it wherever possible, using the most direct form the change allows:
**Screenshots come from Playwright, never from a browser-extension screen grab.**
Extension captures come out soft and hard to read, and a proof screenshot exists to be
read. `capture-proof shot` renders at twice the pixel density with animations frozen,
fonts loaded, and dev overlays hidden:
- For a bug, reproduce the failure, apply the fix, and watch the same reproduction pass.
- For UI and runtime behavior, run the app (`run` skill) and look at the real feature.
Green mocked tests do not prove the user experience works.
- For behavior with no UI, capture the test or command output.
```
capture-proof shot http://localhost:3000/companies --wait-for "text=Canonical ID" --out <target-dir>/proof/companies-grid-canonical-ids.png
```
**Screenshots and recordings come from T3's browser.** It runs in the thread, so Gib can
watch and sign in without leaving T3. Use one tab and keep its `tabId`:
- **Sign-in.** The first `shot` against an app that needs a session stops with a sign-in
hint. Ask Gib to run `capture-proof login <origin>` himself: it opens a visible
browser, he signs in and closes the window, and every later `shot` against that
origin reuses the session. It is saved under `~/.cache/ticket-page/auth/`, never in a
repo. The hint only catches a redirect to a sign-in path, so look at every capture
before recording it.
- **Frame the evidence.** Pass `--wait-for` with the thing the proof is about, so the
capture never catches a loading spinner. Crop to the part that matters with
`--selector`, or take the whole page with `--full`.
- **Interaction first.** For state that takes clicks or typing (an open dialog, a
form showing its warning), write a small script,
`export default async (page) => { await page.getByLabel("Canonical ID").fill("NIKON"); }`,
and pass it with `--script`. Keep scripts in a scratch path, not in `proof/`.
- Read every capture with the Read tool before recording it. A crisp picture of the
wrong screen is still wrong.
1. `preview_open` with the app's URL, or `preview_navigate` with
`target: {kind: "environment-port", port}` for a dev server.
2. `preview_resize` to `{mode: "freeform", width: 1280, height: 800}`. Saved screenshots
top out at 1280x800, so a larger viewport comes out downscaled and soft.
3. `preview_wait_for` the thing the proof is about, so the capture never catches a spinner.
Drive any state with `preview_snapshot` refs and `preview_act`: open the dialog, fill the
form, trigger the warning. Use `preview_set_appearance` for dark mode.
4. `preview_snapshot` with `save: true`, then copy `screenshotPath` into `proof/`.
5. For a flow a still cannot show, `preview_recording_start`, drive it, then
`preview_recording_stop` and copy the mp4 from its `path` into `proof/`. Keep it short:
a recording caps at 50 MiB.
6. `t3_preview_close` when the proof is done.
- **Sign-in.** If the tab lands on a sign-in page, ask Gib with AskUserQuestion to sign in in
that tab, then continue. Do not drive a login yourself.
- **Look before you record it.** Read every capture with the Read tool. A crisp picture of
the wrong screen is still wrong.
- **The fallback.** Use `capture-proof` when the screen is too dense to read at 1280x800, when
the proof needs a full page or an element crop, or outside T3 Code. It renders at 2x with
animations frozen and dev overlays hidden:
```
capture-proof shot http://localhost:3000/companies --wait-for "text=Canonical ID" --out <target-dir>/proof/companies-grid-canonical-ids.png
```
Pass `--selector` to crop, `--full` for the whole page, and `--script` for clicks and
typing. It keeps its own session, so the first `shot` against an app that needs one stops
with a sign-in hint: ask Gib to run `capture-proof login <origin>`. That hint only catches a
redirect to a sign-in path.
Save artifacts under `<target-dir>/proof/` with names that match the test-case
rows they prove, and put each on the page with `ticket-page proof`, naming the
criteria it proves and the mock it answers. A proof cell references its artifact by filename plus "attached"
@@ -934,7 +936,10 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
unanswered.
10. Tell the user: implementation is committed on `<branch>`, `pre-mr-review` verdict is
Ready to Open MR, the Jira fields are filled (name which), `mr.md` is ready at its
path, and the ticket page shows the finished work beside its mocks. Then the manual list, which should only ever be:
path, and the ticket page shows the finished work beside its mocks. Show
`ticket-page view <KEY> progress` in the thread, embed each proof screenshot by absolute
path as `![caption](path)`, and link `mr.md` and the page path. Name any council seat that
could not run, and list the nits you declined as follow-ups. Then the manual list, which should only ever be:
- push the branch, open the MR, and paste `mr.md` in as the description
- upload the named proof or deliverable files (from `proof/` or `deliverables/`)
to the ticket, plus anything flagged as proof only Gib can capture
@@ -944,6 +949,7 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
If Phase 0 routes here because some steps are already done on the page: check
`git log` on the current branch against the page's step commits to sanity-check they actually
match reality (a checked-off step should have a corresponding commit), then continue
from the first open step. If the plan and the git history disagree, stop and ask
the user rather than guessing which one is right.
match reality (a checked-off step should have a corresponding commit), then show
`ticket-page view <KEY> progress` in the thread and continue from the first open step.
If the plan and the git history disagree, stop and ask the user rather than guessing
which one is right.
@@ -0,0 +1,125 @@
// Thread views: compact, static HTML snapshots of a ticket for T3 Code's html_render, which
// shows a page inline in the conversation. The live ticket page stays the full record; these
// are what Gib reads without leaving the thread. `ticket-page view` prints one, and the skill
// hands it to html_preview, then html_render.
//
// html_render's rules shape everything here: no background on html, body or the outer
// element, colours only from T3's theme variables, no vh units, and a box with its own
// background gets 16px padding and var(--radius) corners. No script except the one that
// sizes each mock frame to its artboard.
import type { Criterion, Mock, Step, Ticket } from "./ticket-data";
export const VIEWS = ["plan", "progress", "mocks"] as const;
/** html_render's limit on a page, in characters. */
export const RENDER_LIMIT = 512_000;
export type View = (typeof VIEWS)[number];
const esc = (s: string) => s.replace(/[&<>"]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]!);
const short = (sha: string) => sha.slice(0, 8);
const CSS = `
body{margin:0;font:14px/1.5 var(--font-sans);color:var(--foreground)}
h1{font-size:16px;margin:0 0 2px}h2{font-size:13px;text-transform:uppercase;letter-spacing:.04em;color:var(--muted-foreground);margin:20px 0 8px}
p{margin:0 0 8px}code{font:12px var(--font-mono);background:var(--code-background);padding:1px 4px;border-radius:4px}
.sub{color:var(--muted-foreground);font-size:12px}
.ask{background:var(--warning-surface);color:var(--foreground);border-radius:var(--radius);padding:16px;margin-top:12px}
.ask h2{margin-top:0;color:var(--warning)}.ask ol{margin:0;padding-left:20px}
ul.rows{list-style:none;margin:0;padding:0}ul.rows li{display:flex;gap:10px;padding:6px 0;border-top:1px solid var(--border)}
ul.rows li:first-child{border-top:0}
.box{flex:none;width:16px;height:16px;margin-top:2px;border:1.5px solid var(--border);border-radius:4px;display:grid;place-items:center;font-size:11px;line-height:1}
.box.done{background:var(--success);border-color:var(--success);color:var(--background)}.box.run{border-color:var(--primary);color:var(--primary)}
.grow{flex:1;min-width:0}.tag{font-size:11px;color:var(--muted-foreground);border:1px solid var(--border);border-radius:999px;padding:0 6px;margin-left:6px;white-space:nowrap}
.id{font-weight:600;margin-right:6px}.meta{font-size:12px;color:var(--muted-foreground)}
table{width:100%;border-collapse:collapse;font-size:13px}td,th{text-align:left;vertical-align:top;padding:6px 8px 6px 0;border-top:1px solid var(--border)}th{font-weight:600;color:var(--muted-foreground);font-size:12px}
.bar{height:6px;border-radius:3px;background:var(--secondary);overflow:hidden;margin:4px 0 2px}.bar i{display:block;height:100%;background:var(--success)}
.stats{display:flex;gap:24px;flex-wrap:wrap}.stats div{min-width:140px}
.mock{margin-top:16px}.mock iframe{display:block;width:100%;height:420px;border:1px solid var(--border);border-radius:var(--radius);background:#fff}
`;
// Collapses each frame before measuring it: an iframe reports its own viewport height as
// the document's scrollHeight, so measuring at 420px never reads less than 420.
const MOCK_SIZER = `<script>
for (const f of document.querySelectorAll('iframe[data-fit]')) f.addEventListener('load', () => {
try { f.style.height = '0px'; f.style.height = f.contentDocument.documentElement.scrollHeight + 2 + 'px'; }
catch { f.style.height = ''; }
});
</script>`;
function page(title: string, body: string, extra = "") {
return `<!doctype html><html><head><meta charset="utf-8"><title>${esc(title)}</title><style>${CSS}</style></head><body>${body}${extra}</body></html>`;
}
const box = (status: string) =>
`<span class="box${status === "done" ? " done" : status === "running" ? " run" : ""}">${status === "done" ? "✓" : status === "running" ? "•" : ""}</span>`;
const head = (t: Ticket, what: string) =>
`<h1>${esc(t.key)}: ${esc(t.title)}</h1><div class="sub">${esc(what)}${t.branch ? ` on <code>${esc(t.branch)}</code>` : ""}</div>`;
const stepRow = (s: Step) =>
`<li>${box(s.status)}<div class="grow">${s.n}. ${esc(s.text)}${s.kind !== "code" ? `<span class="tag">${s.kind}</span>` : ""}${s.tdd ? `<span class="tag">tests first</span>` : ""}` +
`${s.commits.length ? `<div class="meta">${s.commits.map(c => `<code>${esc(short(c))}</code>`).join(" ")}</div>` : ""}</div></li>`;
const criterionRow = (t: Ticket, c: Criterion) => {
const proof = t.proof.filter(p => p.proves.includes(c.id)).length;
const extra = [`steps ${c.steps.join(", ")}`, proof ? `${proof} proof` : "", c.evidence ? esc(c.evidence) : ""].filter(Boolean).join(" · ");
return `<li>${box(c.status)}<div class="grow"><span class="id">${esc(c.id)}</span>${esc(c.text)}<div class="meta">${extra}</div></div></li>`;
};
const pct = (a: number, b: number) => (b ? Math.round((100 * a) / b) : 0);
/** The plan as it stands for approval: questions first, because they are what blocks it. */
function plan(t: Ticket): string {
const pl = t.plan!;
const parts = [head(t, pl.approvedAt ? "Plan, approved" : "Plan, awaiting approval")];
if (pl.questions.length) parts.push(`<div class="ask"><h2>Needs your answer</h2><ol>${pl.questions.map(q => `<li>${q}</li>`).join("")}</ol></div>`);
parts.push(`<h2>Approach</h2>${pl.approach}`);
parts.push(`<h2>Acceptance criteria</h2><ul class="rows">${pl.criteria.map(c => criterionRow(t, c)).join("")}</ul>`);
parts.push(`<h2>Steps</h2><ul class="rows">${pl.steps.map(stepRow).join("")}</ul>`);
if (pl.decisions.length)
parts.push(`<h2>Decisions</h2><table><tr><th>Question</th><th>Choice</th><th>Why</th></tr>${pl.decisions.map(([q, c, w]) => `<tr><td>${esc(q)}</td><td>${esc(c)}</td><td>${esc(w)}</td></tr>`).join("")}</table>`);
if (pl.risks.length)
parts.push(`<h2>Risks</h2><table><tr><th>Risk</th><th>Handling</th><th>Covers</th></tr>${pl.risks.map(r => `<tr><td>${esc(r.risk)}${r.fromTicket ? `<span class="tag">ticket</span>` : ""}</td><td>${esc(r.handling)}</td><td>${esc((r.criteria ?? []).join(", "))}</td></tr>`).join("")}</table>`);
if (pl.tests.length)
parts.push(`<h2>Test plan</h2><table><tr><th>Kind</th><th>Test</th><th>Covers</th></tr>${pl.tests.map(x => `<tr><td>${esc(x.kind)}</td><td>${esc(x.text)}${x.fromTicket ? `<span class="tag">ticket</span>` : ""}</td><td>${esc((x.criteria ?? []).join(", "))}</td></tr>`).join("")}</table>`);
if (pl.mocks.length) parts.push(`<p class="sub">${pl.mocks.length} mock artboard${pl.mocks.length > 1 ? "s" : ""}: ${pl.mocks.map(m => esc(m.title)).join(", ")}.</p>`);
return page(`${t.key} plan`, parts.join(""));
}
/** The to-do list: steps and criteria with their marks, then what review still holds open. */
function progressView(t: Ticket): string {
const pl = t.plan!;
const steps = pl.steps.filter(s => s.status === "done").length;
const crit = pl.criteria.filter(c => c.status === "done").length;
const open = t.review.findings.filter(f => f.status === "open");
const stat = (label: string, a: number, b: number) => `<div><div class="meta">${label}</div><div class="bar"><i style="width:${pct(a, b)}%"></i></div><b>${a}</b> of ${b}</div>`;
const parts = [head(t, "Progress"), `<div class="stats" style="margin-top:12px">${stat("Steps", steps, pl.steps.length)}${stat("Criteria", crit, pl.criteria.length)}<div><div class="meta">Proof</div><b>${t.proof.length}</b> captured</div></div>`];
parts.push(`<h2>Steps</h2><ul class="rows">${pl.steps.map(stepRow).join("")}</ul>`);
parts.push(`<h2>Acceptance criteria</h2><ul class="rows">${pl.criteria.map(c => criterionRow(t, c)).join("")}</ul>`);
if (open.length) parts.push(`<h2>Open review findings</h2><ul class="rows">${open.map(f => `<li>${box("todo")}<div class="grow"><span class="id">${esc(f.id)}</span>${esc(f.text)}${f.where ? `<div class="meta"><code>${esc(f.where)}</code></div>` : ""}</div></li>`).join("")}</ul>`);
const v = t.review.preMr;
if (v) parts.push(`<h2>pre-mr-review</h2><p><b>${esc(v.verdict)}</b>${v.head ? ` at <code>${esc(short(v.head))}</code>` : ""}, run ${v.runs}</p>`);
return page(`${t.key} progress`, parts.join(""));
}
/** Each artboard in its own frame, sized to its content, under its title, criteria and note. */
function mocks(t: Ticket, only?: string): string {
const all = t.plan!.mocks;
const list: Mock[] = only ? [all.find(m => m.id === only) ?? fail(`no mock ${only}. Mocks: ${all.map(m => m.id).join(", ") || "none"}`)] : all;
if (!list.length) fail(`${t.key} has no mocks`);
const body = list.map(m =>
`<div class="mock"><b>${esc(m.title)}</b> <span class="sub">${esc(m.id)}${m.criteria.length ? ` · ${esc(m.criteria.join(", "))}` : ""}</span>` +
`${m.note ? `<div class="meta">${esc(m.note)}</div>` : ""}<iframe data-fit sandbox="allow-same-origin" srcdoc="${esc(m.html)}" title="${esc(m.title)}"></iframe></div>`).join("");
return page(`${t.key} mocks`, head(t, "Mocks") + body, MOCK_SIZER);
}
function fail(msg: string): never { throw new Error(msg); }
export function renderView(t: Ticket, view: View, mock?: string): string {
if (!t.plan) fail(`${t.key} has no plan yet, so there is nothing to show`);
const html = view === "plan" ? plan(t) : view === "progress" ? progressView(t) : mocks(t, mock);
if (html.length > RENDER_LIMIT)
fail(`the ${view} view is ${Math.round(html.length / 1000)} KB, over html_render's ${RENDER_LIMIT / 1000} KB.` +
(view === "mocks" && !mock ? ` Show one artboard at a time with --mock: ${t.plan.mocks.map(m => m.id).join(", ")}.` : " Link the ticket page instead."));
return html;
}
@@ -1,6 +1,6 @@
// The ticket page's data model: one `ticket.data.js` per ticket and one `epic.data.js`
// per epic, each a single `window.X = <strict JSON>;` assignment so a page opened from
// file:// can load it with a <script> tag. Everything that reads or writes those files
// per epic, each a single `window.X = <strict JSON>;` assignment so an open page can poll
// it with a <script> tag, where file:// and T3 Code's sandboxed viewer cannot fetch. Everything that reads or writes those files
// goes through this module, so the shape is defined once and validated on every write.
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
@@ -70,7 +70,7 @@ export interface Plan {
}
export interface Proof {
kind: "image" | "text" | "test";
kind: "image" | "video" | "text" | "test";
/** Relative to the ticket directory, for image and text proof. */
file?: string;
/** Embedded content of a text proof, because file:// pages cannot fetch it. */
@@ -67,10 +67,35 @@ describe("the CLI, end to end", () => {
expect(cli("init", "KACP-1", "--issue", issuePath).status).toBe(0);
for (const f of ["index.html", "ticket.data.js"]) expect(existsSync(join(dir, f))).toBe(true);
expect(existsSync(join(docs, "KACP-9", "index.html"))).toBe(true);
expect(readFileSync(join(dir, "index.html"), "utf8")).toMatch(/href="..\/..\/_site\/site.css\?v=[0-9a-f]{10}"/);
// Self-contained: T3 Code's file viewer serves nothing from above the page's own folder.
const page = readFileSync(join(dir, "index.html"), "utf8");
expect(page).toContain(readFileSync(join(import.meta.dir, "../templates/site/site.js"), "utf8"));
expect(page).toContain(`"key":"KACP-1"`);
expect(page).not.toMatch(/(src|href)="\.\./);
expect(epic().stories.map(s => [s.key, s.status])).toEqual([["KACP-1", "todo"]]);
});
test("inline data cannot close the page's script block, and follows every write", () => {
cli("event", "KACP-1", "pasted </script><img src=x>");
const page = readFileSync(join(dir, "index.html"), "utf8");
const inline = page.match(/window\.TICKET = (.*);\n/)![1];
expect(inline).not.toContain("</script>");
expect(JSON.parse(inline).events.at(-1).text).toBe("pasted </script><img src=x>");
});
test("a linked worktree shares the main checkout's docs", () => {
const repo = mkdtempSync(join(tmpdir(), "tp-repo-"));
const git = (...a: string[]) => spawnSync("git", a, { cwd: repo, encoding: "utf8" });
git("init", "-q");
git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "--allow-empty", "-m", "x");
const tree = join(mkdtempSync(join(tmpdir(), "tp-tree-")), "wt");
git("worktree", "add", "-q", tree);
const { TICKET_DOCS: _, ...env } = process.env;
expect(spawnSync("bun", [join(import.meta.dir, "ticket-page.ts"), "init", "KACP-1", "--issue", issuePath], { cwd: tree, encoding: "utf8", env }).status).toBe(0);
expect(existsSync(join(repo, ".claude/docs/epics/KACP-9/KACP-1/ticket.data.js"))).toBe(true);
expect(existsSync(join(tree, ".claude"))).toBe(false);
});
test("a step is done only once its commit exists, and the epic row follows", () => {
const plan = join(scratch, "plan.json");
writeFileSync(plan, JSON.stringify({ approach: "<p>a</p>", criteria: [{ id: "AC1", text: "c", steps: [1] }], steps: [{ n: 1, text: "s" }, { n: 2, text: "t" }] }));
@@ -85,12 +110,25 @@ describe("the CLI, end to end", () => {
expect(epic().stories[0]).toMatchObject({ status: "building", progress: { steps: [1, 2] } });
});
test("view prints a thread snapshot of the plan's progress, and refuses an unknown view", () => {
const out = cli("view", "KACP-1", "progress");
expect(out.status).toBe(0);
expect(out.stdout).toMatch(/^<!doctype html>/);
expect(out.stdout).toContain("<code>abc1234</code>");
expect(cli("view", "KACP-1", "mocks").status).not.toBe(0);
expect(cli("view", "KACP-1", "everything").status).toBe(2);
});
test("text proof is embedded, because a file:// page cannot fetch it", () => {
mkdirSync(join(dir, "proof"), { recursive: true });
writeFileSync(join(dir, "proof", "counts.txt"), "70 of 70 codes stored");
expect(cli("proof", "KACP-1", "--file", "proof/counts.txt", "--proves", "AC1", "--caption", "Counts").status).toBe(0);
expect(ticket().proof[0]).toMatchObject({ kind: "text", text: "70 of 70 codes stored" });
expect(cli("proof", "KACP-1", "--file", "proof/counts.txt", "--proves", "AC7", "--caption", "x").status).toBe(1);
writeFileSync(join(dir, "proof", "flow.mp4"), "");
expect(cli("proof", "KACP-1", "--file", "proof/flow.mp4", "--proves", "AC1", "--caption", "Flow").status).toBe(0);
expect(ticket().proof[1]).toMatchObject({ kind: "video", file: "proof/flow.mp4" });
expect(cli("unproof", "KACP-1", "proof/flow.mp4").status).toBe(0);
});
test("--amend keeps progress for the steps that remain, a rewrite starts over", () => {
@@ -1,11 +1,11 @@
// ticket-page: the only writer of a ticket's page data. See ../SITE.md for when the
// ticket skill calls each command. Run `ticket-page help` for the command list.
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join, relative, resolve } from "node:path";
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { htmlToText, importIssue, type JiraIssue, scanResources } from "./jira-import";
import { renderView, VIEWS } from "./thread-view";
import {
type Criterion, type Epic, type Finding, type Mock, now, type Plan, progress, readData, type Step,
storyStatus, type Ticket, writeData,
@@ -34,11 +34,13 @@ const list = (s?: string) => (s ? s.split(",").map(x => x.trim()).filter(Boolean
// ── Where things live ────────────────────────────────────────────────────────
/** The main checkout's docs, even from a linked worktree. `.claude/docs` is usually gitignored,
* so a T3 Code worktree thread would otherwise start a second, disconnected ticket page. */
export function docsRoot(): string {
if (process.env.TICKET_DOCS) return resolve(process.env.TICKET_DOCS);
const git = spawnSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" });
if (git.status !== 0) fail("not inside a git repository, and TICKET_DOCS is not set");
return join(git.stdout.trim(), ".claude/docs/epics");
const git = spawnSync("git", ["worktree", "list", "--porcelain"], { encoding: "utf8" });
const main = git.status === 0 ? git.stdout.match(/^worktree (.+)$/m)?.[1] : undefined;
return join(main ?? fail("not inside a git repository, and TICKET_DOCS is not set"), ".claude/docs/epics");
}
function ticketDir(key: string): string {
@@ -49,26 +51,28 @@ function ticketDir(key: string): string {
const ticketFile = (dir: string) => join(dir, "ticket.data.js");
const epicFile = (epicKey: string) => join(docsRoot(), epicKey, "epic.data.js");
/** Copies the shared renderer into _site/ when the skill's copy changed, and writes the
* page shell. Run on every write so an old ticket picks up the current renderer. */
function refreshSite(pageDir: string, kind: "ticket" | "epic", title: string) {
const site = join(docsRoot(), "_site");
mkdirSync(site, { recursive: true });
const version = createHash("sha1");
for (const f of ["site.css", "site.js"]) {
const from = join(SITE_TEMPLATES, f), to = join(site, f);
const body = readFileSync(from, "utf8");
version.update(body);
if (!existsSync(to) || readFileSync(to, "utf8") !== body) copyFileSync(from, to);
}
// The shell names the renderer by content hash, so a browser that cached an older
// renderer loads the new one the next time the page opens.
const shell = readFileSync(join(SITE_TEMPLATES, `${kind}.html`), "utf8")
.replaceAll("{{SITE}}", relative(pageDir, site) || ".")
.replaceAll("{{VERSION}}", version.digest("hex").slice(0, 10))
.replaceAll("{{TITLE}}", title.replace(/[<&]/g, c => (c === "<" ? "&lt;" : "&amp;")));
/** Writes the page shell with the renderer and the data inlined, so index.html renders with
* nothing else: no server and no other file. T3 Code's file viewer sandboxes the page and
* serves assets only from its own folder and below, so a shell that leaned on a shared
* ../../_site/ renderer rendered blank there. The sibling data file stays the store, and the
* open page polls it to stay live. Run on every write, so the page matches the data and an
* old ticket picks up the current renderer. */
function refreshSite(pageDir: string, kind: "ticket" | "epic", data: Ticket | Epic) {
const fill: Record<string, string> = {
CSS: readFileSync(join(SITE_TEMPLATES, "site.css"), "utf8"),
JS: readFileSync(join(SITE_TEMPLATES, "site.js"), "utf8"),
TITLE: `${data.key}: ${data.title}`.replace(/[<&]/g, c => (c === "<" ? "&lt;" : "&amp;")),
// Jira HTML and mock artboards hold `</script>`, which would end the inline block early.
DATA: `window.${kind.toUpperCase()} = ${JSON.stringify(data).replace(/</g, "\\u003c")};`,
};
// A replacer function, because the renderer's own source is full of `$` sequences that
// a replacement string would read as patterns.
const shell = readFileSync(join(SITE_TEMPLATES, `${kind}.html`), "utf8").replace(/\{\{(CSS|JS|TITLE|DATA)\}\}/g, (_, k: string) => fill[k]);
const page = join(pageDir, "index.html");
if (!existsSync(page) || readFileSync(page, "utf8") !== shell) writeFileSync(page, shell);
if (existsSync(page) && readFileSync(page, "utf8") === shell) return;
const tmp = `${page}.${process.pid}.tmp`;
writeFileSync(tmp, shell);
renameSync(tmp, page);
}
// ── Ticket reads and writes ──────────────────────────────────────────────────
@@ -80,7 +84,7 @@ function load(key: string) {
function save(dir: string, t: Ticket) {
writeData(ticketFile(dir), "ticket", t);
refreshSite(dir, "ticket", `${t.key}: ${t.title}`);
refreshSite(dir, "ticket", t);
if (t.epic) syncStory(t);
}
@@ -102,7 +106,7 @@ function syncStory(t: Ticket) {
s.progress = progress(t);
if (s.status !== "done") s.status = storyStatus(t);
writeData(file, "epic", e);
refreshSite(dirname(file), "epic", `${e.key}: ${e.title}`);
refreshSite(dirname(file), "epic", e);
}
// ── Commands ─────────────────────────────────────────────────────────────────
@@ -264,9 +268,10 @@ command("proof", "proof <KEY> (--file proof/<name> | --test NAME) --proves AC1,A
const caption = a.one("caption") ?? fail("proof needs --caption");
if (file && !existsSync(join(dir, file))) fail(`${file} does not exist in ${dir}`);
const isText = !!file && /\.(txt|log|md|json|csv|diff)$/i.test(file);
const isVideo = !!file && /\.(mp4|webm|mov)$/i.test(file);
t.proof = t.proof.filter(p => (file ? p.file !== file : p.test !== test));
t.proof.push({
kind: test ? "test" : isText ? "text" : "image", ...(file ? { file } : { test }),
kind: test ? "test" : isText ? "text" : isVideo ? "video" : "image", ...(file ? { file } : { test }),
...(isText ? { text: readFileSync(join(dir, file!), "utf8") } : {}),
proves, caption, ...(a.one("mock") ? { mock: a.one("mock") } : {}), at: now(),
});
@@ -342,8 +347,24 @@ command("show", "show <KEY> print where the ticket stands", a => {
command("path", "path <KEY> print the page path", a => console.log(join(ticketDir(a.pos[0]), "index.html")));
command("open", "open <KEY> open the page in the browser", a => {
spawnSync("xdg-open", [join(ticketDir(a.pos[0]), "index.html")], { stdio: "ignore" });
command("open", "open <KEY> print the page path, and open it in the browser outside T3 Code", a => {
const { dir, t } = load(a.pos[0]);
// Rewrites the shells first, so a page last written before the renderer was inlined
// opens in T3 too.
refreshSite(dir, "ticket", t);
const epic = t.epic && epicFile(t.epic.key);
if (epic && existsSync(epic)) { const e = readData(epic, "epic"); refreshSite(dirname(epic), "epic", e); }
const page = join(dir, "index.html");
// Inside a T3 Code thread the agent links this path and the page opens in T3's file
// viewer, beside the conversation. A browser window would be one more thing to juggle.
if (!process.env.T3CODE_THREAD_ID) spawnSync("xdg-open", [page], { stdio: "ignore" });
console.log(page);
});
command("view", `view <KEY> <${VIEWS.join("|")}> [--mock <id>] print a thread view for T3 Code's html_render`, a => {
const [key, name] = a.pos;
const view = VIEWS.find(v => v === name) ?? fail(`view takes ${VIEWS.join(", ")}`);
console.log(renderView(load(key).t, view, a.one("mock")));
});
command("md", "md <KEY> print the ticket and plan as markdown, for an agent's reading order", a => {
@@ -377,7 +398,7 @@ function withEpic(key: string, fn: (e: Epic) => void) {
const e = existsSync(file) ? readData(file, "epic") : fail(`no epic page for ${key}. Run: ticket-page epic ${key} init --title T`);
fn(e);
writeData(file, "epic", e);
refreshSite(dirname(file), "epic", `${e.key}: ${e.title}`);
refreshSite(dirname(file), "epic", e);
}
command("epic", "epic <EPIC> init --title T [--jira URL] | story <KEY> [--title T --hours H --risk R --after K1,K2 --status S] | order <KEY>... | why --file <reasons.txt> | closed <KEY> --title T", a => {
@@ -388,7 +409,7 @@ command("epic", "epic <EPIC> init --title T [--jira URL] | story <KEY> [--title
e.title = a.one("title") ?? (e.title || fail("epic init needs --title"));
e.jira = a.one("jira") ?? (e.jira || `${process.env.JIRA_BASE_URL ?? "https://ksense-tech.atlassian.net"}/browse/${key}`);
writeData(file, "epic", e);
refreshSite(dirname(file), "epic", `${e.key}: ${e.title}`);
refreshSite(dirname(file), "epic", e);
return console.log(join(dirname(file), "index.html"));
}
withEpic(key, e => {
@@ -4,10 +4,15 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{TITLE}}</title>
<link rel="stylesheet" href="{{SITE}}/site.css?v={{VERSION}}">
<style>
{{CSS}}
</style>
</head>
<body data-page="epic" data-src="epic.data.js">
<div id="app"><p class="loading">Loading epic.data.js…</p></div>
<script src="{{SITE}}/site.js?v={{VERSION}}"></script>
<script>
{{DATA}}
{{JS}}
</script>
</body>
</html>
@@ -1,4 +1,4 @@
/* Shared stylesheet for ticket and epic pages. Copied into .claude/docs/epics/_site/ by ticket-page. */
/* Shared stylesheet for ticket and epic pages. ticket-page inlines it into every page shell. */
:root {
--bg: #f6f6f3; --surface: #ffffff; --sunk: #efefea; --ink: #1c1c1a; --muted: #6b6b66;
--line: #e2e2dd; --accent: #2f5fd0; --accent-ink: #fff; --accent-soft: #e8eeff;
@@ -1,7 +1,9 @@
// Renderer for ticket and epic pages. Copied into .claude/docs/epics/_site/ by ticket-page.
// A page shell names its data file in <body data-src>. This script loads that file with a
// <script> tag (file:// pages cannot fetch), re-loads it every two seconds, and redraws
// only when the data changed. The data shape is defined in scripts/ticket-data.ts.
// Renderer for ticket and epic pages. ticket-page inlines it and the page's data into every
// page shell, so index.html renders on its own with no server and no other file.
// A page shell also names its data file in <body data-src>. Re-loading that file every two
// seconds with a <script> tag (file:// and T3 Code's sandboxed viewer cannot fetch) is what
// keeps an open page live, and a page that cannot reach it still shows its last write.
// The data shape is defined in scripts/ticket-data.ts.
// @ts-check
/** @typedef {import('../../scripts/ticket-data').Ticket} Ticket */
/** @typedef {import('../../scripts/ticket-data').Epic} Epic */
@@ -39,12 +41,14 @@
const mark = st => h('span.mark' + (st === 'done' ? '.done' : st === 'running' ? '.run' : ''), { title: st });
const pill = (text, tone) => h('span.pill' + (tone ? '.' + tone : ''), text);
// T3 Code's sandboxed viewer has no localStorage, so ticks there last while the page stays open.
const ticks = new Map();
/** The viewer's own review tick, kept in this browser only and separate from the agent's done mark. */
function seen(scope, id) {
const key = `seen:${scope}:${id}`;
let on = false; try { on = localStorage.getItem(key) === '1'; } catch {}
let on = ticks.get(key) ?? false; try { on ||= localStorage.getItem(key) === '1'; } catch {}
return h('label.seen', { title: 'Your own review tick, stored in this browser' },
h('input', { type: 'checkbox', checked: on || null, onchange: e => { try { localStorage.setItem(key, e.target.checked ? '1' : '0'); } catch {} } }), 'reviewed');
h('input', { type: 'checkbox', checked: on || null, onchange: e => { ticks.set(key, e.target.checked); try { localStorage.setItem(key, e.target.checked ? '1' : '0'); } catch {} } }), 'reviewed');
}
// ── Loading and live reload ──────────────────────────────────────────────
@@ -65,7 +69,7 @@
const el = document.querySelector('.live');
if (!el) return;
el.classList.toggle('stale', failed);
el.textContent = failed ? 'Live: last reload failed, showing the previous version' : `Live, checked ${clock(new Date(loadedAt).toISOString())}`;
el.textContent = failed ? 'Snapshot from the last write' : `Live, checked ${clock(new Date(loadedAt).toISOString())}`;
}
function draw() {
@@ -150,7 +154,8 @@
/** @param {Ticket} t @param {import('../../scripts/ticket-data').Proof} p */
function compare(t, p) {
const m = p.mock && t.plan?.mocks.find(x => x.id === p.mock);
const evidence = p.kind === 'text' ? h('pre.textproof', p.text || '') : p.kind === 'test' ? h('pre.textproof', 'Passing test: ' + p.test) : h('a', { href: p.file, target: '_blank' }, h('img.shot', { src: p.file, alt: p.file }));
const evidence = p.kind === 'text' ? h('pre.textproof', p.text || '') : p.kind === 'test' ? h('pre.textproof', 'Passing test: ' + p.test)
: p.kind === 'video' ? keep('video:' + p.file, () => h('video.shot', { src: p.file, controls: true, preload: 'metadata' })) : h('a', { href: p.file, target: '_blank' }, h('img.shot', { src: p.file, alt: p.file }));
return h('div.compare',
h('figure', h('figcaption', 'Mock, drawn at plan time'), m ? artboard(m) : h('div.placeholder', 'No mock for this one.')),
h('figure', h('figcaption', 'Proof, captured from the build'), evidence));
@@ -303,6 +308,7 @@
e.closed.length ? h('p.muted', { style: 'margin-top:18px;font-size:.9em' }, 'Closed: ', e.closed.map(c => `${c.key} ${c.title}`).join(', '), '.') : null));
}
if (window[GLOBAL]) { last = JSON.stringify(window[GLOBAL]); loadedAt = Date.now(); draw(); }
load();
setInterval(load, 2000);
})();
@@ -4,10 +4,15 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{TITLE}}</title>
<link rel="stylesheet" href="{{SITE}}/site.css?v={{VERSION}}">
<style>
{{CSS}}
</style>
</head>
<body data-page="ticket" data-src="ticket.data.js">
<div id="app"><p class="loading">Loading ticket.data.js…</p></div>
<script src="{{SITE}}/site.js?v={{VERSION}}"></script>
<script>
{{DATA}}
{{JS}}
</script>
</body>
</html>
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Toggle passwordless sudo for the current user.
# pwswitch on -> sudo asks for a password again (default behavior)
# pwswitch off -> sudo runs without a password
# Works by adding/removing a drop-in file in /etc/sudoers.d.
set -euo pipefail
user="${SUDO_USER:-$USER}"
# "zz-" prefix so it sorts last and overrides earlier rules.
dropin="/etc/sudoers.d/zz-passwordswitch-${user}"
case "${1:-}" in
off)
tmp="$(mktemp)"
trap 'rm -f "$tmp"' EXIT
# verifypw=any lets `sudo -v` pass on NOPASSWD alone. Without it, the
# wheel group's password rule still makes -v (and Panama's installer,
# which starts with it) ask for a password.
printf '%s\n' "Defaults:${user} verifypw=any" "${user} ALL=(ALL) NOPASSWD: ALL" >"$tmp"
# Validate before installing so a bad file can never lock sudo.
sudo visudo -cqf "$tmp"
sudo install -m 0440 -o root -g root "$tmp" "$dropin"
echo "Passwordless sudo ENABLED for ${user}. Run '$(basename "$0") on' when done."
;;
on)
sudo rm -f "$dropin"
sudo -k
echo "Password required for sudo again."
;;
*)
echo "Usage: $(basename "$0") on|off" >&2
exit 1
;;
esac
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff. Show more