14 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
46 changed files with 1376 additions and 332 deletions

No files matched your search

+40 -5
View File
@@ -10,9 +10,16 @@
# start time, so choosing an agent in Settings takes effect on the next crash # start time, so choosing an agent in Settings takes effect on the next crash
# without restarting anything: # 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" # agentAutoApprove true -> the agent starts in its own "don't stop to ask"
# mode; false -> its normal prompting mode, untouched. # 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 # "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 # shipped state, and a rung that shouted about it would be a rung that gets
@@ -95,8 +102,9 @@ usage() {
cat <<'EOF' cat <<'EOF'
Usage: panama-agent [--prompt "text"] Usage: panama-agent [--prompt "text"]
Opens the agent named by preferredAgent in a terminal, in the Panama checkout. Opens the agent named by preferredAgent in the Panama checkout: a terminal for
With no agent chosen, exits silently: choose one on Settings > System > Agents. 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 EOF
} }
@@ -139,7 +147,7 @@ agent="$(read_setting preferredAgent none)"
auto_approve="$(read_setting agentAutoApprove true)" auto_approve="$(read_setting agentAutoApprove true)"
case "$agent" in case "$agent" in
claude | codex) ;; claude | codex | t3) ;;
*) *)
printf 'panama-agent: unsupported preferredAgent: %s\n' "$agent" >&2 printf 'panama-agent: unsupported preferredAgent: %s\n' "$agent" >&2
printf 'Choose one on Settings > System > Agents.\n' >&2 printf 'Choose one on Settings > System > Agents.\n' >&2
@@ -148,7 +156,8 @@ case "$agent" in
esac esac
# Resolved to a path before argv is built, so kitty is never asked to repeat a # 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 if ! agent_bin="$(resolve_agent "$agent")"; then
printf 'panama-agent: %s is not installed.\n' "$agent" >&2 printf 'panama-agent: %s is not installed.\n' "$agent" >&2
printf 'Looked on PATH and in %s.\n' "$HOME/.local/bin" >&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 exit 1
fi 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 # 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 # 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. # from release to release, so re-run --help before changing them.
@@ -184,7 +184,8 @@ Singleton {
options: [ options: [
{ value: "none", label: "None" }, { value: "none", label: "None" },
{ value: "claude", label: "Claude Code" }, { 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", key: "agentAutoApprove", type: "bool", def: true, group: "agents",
label: "Launched agents approve their own tools", 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", key: "agentUsageClaude", type: "bool", def: true, group: "agents",
@@ -37,8 +37,8 @@ SettingsPage {
// them not to believe the page. // them not to believe the page.
// //
// Probed through a LOGIN shell rather than this one. Quickshell is started // Probed through a LOGIN shell rather than this one. Quickshell is started
// by systemd, whose PATH does not include ~/.local/bin -- where both of // by systemd, whose PATH does not include ~/.local/bin -- where all three of
// these usually land -- and a login shell is the environment the launcher // 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 // 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. // PATH would report "not installed" for an agent that starts perfectly.
property var installed: ({}) property var installed: ({})
@@ -60,7 +60,7 @@ SettingsPage {
running: true running: true
command: ["bash", "-lc", 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 { stdout: StdioCollector {
onStreamFinished: root.absorbProbe(this.text) onStreamFinished: root.absorbProbe(this.text)
} }
@@ -70,6 +70,7 @@ SettingsPage {
switch (value) { switch (value) {
case "claude": return "starred-symbolic"; case "claude": return "starred-symbolic";
case "codex": return "utilities-terminal-symbolic"; case "codex": return "utilities-terminal-symbolic";
case "t3": return "view-dual-symbolic";
default: return "notifications-disabled-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: "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: "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: "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: "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: "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" }, { 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.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg # @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Open Agents in Settings. # @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 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 | | 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 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 | | **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 | | **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 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 | | **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. | | **Refresh interval**<br>`agentUsageRefreshMinutes` | 15 min | How often the usage collectors ask for fresh numbers, in minutes. Range 5–60. |
+5 -3
View File
@@ -129,9 +129,11 @@ them.
- `home/RESTORE.md`: how to restore the home server's `~/Server` from the - `home/RESTORE.md`: how to restore the home server's `~/Server` from the
ZFS backup. ZFS backup.
- `home/backup` and `home/systemd/`: the home server's backup trigger and its - `home/backup` and `home/systemd/`: the home server's backup trigger and its
root system units (`server-backup` nightly, `media-backup` weekly). The root system units (`server-backup` nightly, `media-backup` weekly).
scripts those units run live in `/usr/local/sbin` on that machine. They are - `home/server-backup` and `home/media-backup`: the scripts those units run.
readable by root alone, so this tree has no copy of them. 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 The VPS units carry `RequiresMountsFor=/home/gib/Media` because that host
keeps its podman graphroot on an attached volume mounted there. The setting keeps its podman graphroot on an attached volume mounted there. The setting
+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 ==="
+3 -2
View File
@@ -6,8 +6,9 @@ Description=Weekly Media -> Backup pool copy
# timeshift (04:00), arr-queue-reconcile (04:31), and both ~/Server backups (01:00, 02:00). # 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 # Saturday specifically because Monday carries the Media scrub and Sunday has
# raid-check (01:00), docker-prune (05:33) and dind-prune (06:03). # raid-check (01:00), docker-prune (05:33) and dind-prune (06:03).
# gib also runs Nextcloud's borg by hand roughly monthly and is, in his words, # Nextcloud AIO's borg runs daily from 07:30 UTC for about 2 hours, so its lock can
# not going to be doing that at 5 AM on a Saturday -- so the borg lock should be free. # 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 OnCalendar=Sat *-*-* 05:00:00
RandomizedDelaySec=15m RandomizedDelaySec=15m
Persistent=true Persistent=true
+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
+1 -1
View File
@@ -17,7 +17,7 @@ p=$(sysctl -n net.ipv4.ip_unprivileged_port_start 2>/dev/null)
|| bad "ip_unprivileged_port_start=$p (NPM cannot bind :80/:443)" || bad "ip_unprivileged_port_start=$p (NPM cannot bind :80/:443)"
echo "== user units ==" echo "== user units =="
for s in postgresql bang completeuphoria sierraandtyler uptime portainer beszel authentik tools isponsorblocktv vaultwarden infisical rustdesk nginx-proxy-manager gbrown-agent; do 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=$(systemctl --user is-active "podman-$s.service" 2>/dev/null)
[[ "$st" == "active" ]] && ok "$s" || bad "$s is $st" [[ "$st" == "active" ]] && ok "$s" || bad "$s is $st"
done done
+4
View File
@@ -86,6 +86,10 @@ rbw
# The Snapshots page is snapper end to end; see scripts/panama-snapshots. # The Snapshots page is snapper end to end; see scripts/panama-snapshots.
snapper snapper
sushi 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. # Fonts the desktop names by family and would otherwise render as tofu.
# VictorMono is required: it draws every glyph in the shell. # VictorMono is required: it draws every glyph in the shell.
victormono-nerd-fonts 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 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 `codex-package.listing`. Both architectures share that listing. 0.165.0 dropped
the bundled `codex-resources/zsh/` and added `codex-resources/voice/`, and the 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 update `installers.conf`, regenerate the listing with `tar -tzf ARTIFACT`, and
run `tests/setup/package-provenance-contract`. 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. `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 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 `RPM-GPG-KEY-terra44`. That differs from Panama's deliberately staged local
key/base-URL file in `install-packages`, which replaces the release-generated key and base URL. `install-packages` applies those after this verified
file only after this verified bootstrap step. 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 Although the command runner returned after 30 seconds while DNF was still
loading metadata, Podman's retained event log records the exact command's 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]|$) ]] [[ "$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() ( _install_codex() (
local artifact_arch version_dir target bin_link parent expected_listing member local artifact_arch version_dir target bin_link parent expected_listing member
local stage archive listing staged_binary artifact_digest binary_digest local stage archive listing staged_binary artifact_digest binary_digest
local retire_flat=0
stage="" 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 130' INT
trap 'exit 143' TERM trap 'exit 143' TERM
_set_artifact_arch || return 1 _set_artifact_arch || return 1
version_dir="$HOME/.local/lib/panama/codex/${INSTALLER_PROVENANCE[CODEX_VERSION]}" 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" bin_link="$HOME/.local/bin/codex"
artifact_digest="${INSTALLER_PROVENANCE[CODEX_${artifact_arch}_SHA256]}" artifact_digest="${INSTALLER_PROVENANCE[CODEX_${artifact_arch}_SHA256]}"
binary_digest="${INSTALLER_PROVENANCE[CODEX_${artifact_arch}_BINARY_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" \ _runtime_receipt_matches "$version_dir" "$target" \
"$artifact_digest" "$binary_digest" || return 1 "$artifact_digest" "$binary_digest" || return 1
[[ -x "$target" ]] || return 1 [[ -x "$target" ]] || return 1
@@ -459,24 +472,24 @@ _install_codex() (
while IFS= read -r member; do while IFS= read -r member; do
_archive_path_is_safe "$member" || { rm -rf -- "$stage"; return 1; } _archive_path_is_safe "$member" || { rm -rf -- "$stage"; return 1; }
done <<<"$listing" done <<<"$listing"
mkdir -m 0700 "$stage/extract" "$stage/version" \ mkdir -m 0700 "$stage/version" \
|| { rm -rf -- "$stage"; return 1; } || { 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; } || { rm -rf -- "$stage"; return 1; }
_tree_links_stay_inside "$stage/extract" || { rm -rf -- "$stage"; return 1; } _tree_links_stay_inside "$stage/version" || { rm -rf -- "$stage"; return 1; }
_tree_hardlinks_stay_inside "$stage/extract" || { rm -rf -- "$stage"; return 1; } _tree_hardlinks_stay_inside "$stage/version" || { rm -rf -- "$stage"; return 1; }
staged_binary="$stage/extract/bin/codex" staged_binary="$stage/version/bin/codex"
[[ -f "$staged_binary" && ! -L "$staged_binary" && -x "$staged_binary" [[ -f "$staged_binary" && ! -L "$staged_binary" && -x "$staged_binary"
&& "$(sha256sum "$staged_binary" | awk '{ print $1 }')" == "$binary_digest" ]] \ && "$(sha256sum "$staged_binary" | awk '{ print $1 }')" == "$binary_digest" ]] \
|| { rm -rf -- "$stage"; return 1; } || { rm -rf -- "$stage"; return 1; }
_codex_version_matches "$staged_binary" \ _codex_version_matches "$staged_binary" \
|| { rm -rf -- "$stage"; return 1; } || { 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" \ _write_runtime_receipt "$stage/version" "$artifact_digest" "$binary_digest" \
|| { rm -rf -- "$stage"; return 1; } || { rm -rf -- "$stage"; return 1; }
_activate_directory_no_replace "$stage/version" "$version_dir" \ if (( retire_flat )); then
|| { rm -rf -- "$stage"; return 1; } 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" \ _runtime_receipt_matches "$version_dir" "$target" \
"$artifact_digest" "$binary_digest" || { rm -rf -- "$stage"; return 1; } "$artifact_digest" "$binary_digest" || { rm -rf -- "$stage"; return 1; }
_codex_version_matches "$target" || { 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 # Reads the gpgkey of the single enabled Terra identity out of dnf's effective
# configuration, applying the structural safety rules either way: one enabled # 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 # 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 # and key path. Without it, the answer is just "what trust root is this machine
@@ -772,7 +786,11 @@ _effective_terra_key() {
bad = 1 bad = 1
} }
if (values["gpgcheck"] != "1" || values["pkg_gpgcheck"] != "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"] trusted_key = values["gpgkey"]
} }
BEGIN { BEGIN {
@@ -864,10 +882,12 @@ _terra_effective_status() {
# #
# The trust root is the signing key, not the URL it is served from. A machine # 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 # 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 # file: a metalink instead of the reviewed baseurl, the key at
# RPM-GPG-KEY-terra44 rather than Panama's renamed copy. Every signature check # RPM-GPG-KEY-terra44 rather than Panama's renamed copy, and (since
# is already on, and that key is the same fingerprint this repository reviewed # terra-release 44-11) repo_gpgcheck=0. Package signature checks are on, and
# and pinned. Cosmetics, in other words -- not a compromised trust root. # 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 # 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 # 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 # 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. # 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 # Panama's Terra pin, as a dnf repos.override.d drop-in on top of whatever
# package restores the stock file. That is fine and deliberate: the next run # /etc/yum.repos.d/terra.repo says. terra-release owns that file and replaces
# adopts it again, which is why adoption has to be repeatable rather than a # it on every update (an edited copy is set aside as terra.repo.rpmsave), so a
# one-time migration. # 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() { adopt_terra_repository() {
local work staged_key staged_repo status=0 effective_status=0 local work staged_key staged_repo status=0 effective_status=0
work="$(mktemp -d)" || return 1 work="$(mktemp -d)" || return 1
chmod 0700 "$work" chmod 0700 "$work"
staged_key="$work/terra44.asc" 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" \ if ! _stage_reviewed_key "$PANAMA_PATH/setup/provenance/keys/terra44.asc" "$staged_key" \
TERRA_FINGERPRINT AE09157A4DE88B497EA1D5D300CDAB43DE226D6F; then TERRA_FINGERPRINT AE09157A4DE88B497EA1D5D300CDAB43DE226D6F; then
rm -rf -- "$work" rm -rf -- "$work"
return 1 return 1
fi fi
printf '%s\n' \ _write_terra_pin "$staged_repo"
'[terra]' \ _ensure_override_dir || status=$?
'name=Panama reviewed Terra 44' \ (( status == 0 )) && { _publish_repository_pair \
"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 \
"$staged_key" /etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama \ "$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 if (( status == 0 )); then
_terra_effective_status || effective_status=$? _terra_effective_status || effective_status=$?
(( effective_status == 0 )) || status="$TERRA_TRUST_FAILURE_STATUS" (( effective_status == 0 )) || status="$TERRA_TRUST_FAILURE_STATUS"
@@ -1064,7 +1102,7 @@ install_terra_repository() {
work="$(mktemp -d)" || return 1 work="$(mktemp -d)" || return 1
chmod 0700 "$work" chmod 0700 "$work"
staged_key="$work/terra44.asc" 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" \ if ! _stage_reviewed_key "$PANAMA_PATH/setup/provenance/keys/terra44.asc" "$staged_key" \
TERRA_FINGERPRINT AE09157A4DE88B497EA1D5D300CDAB43DE226D6F; then TERRA_FINGERPRINT AE09157A4DE88B497EA1D5D300CDAB43DE226D6F; then
rm -rf -- "$work" rm -rf -- "$work"
@@ -1074,15 +1112,7 @@ install_terra_repository() {
rm -rf -- "$work" rm -rf -- "$work"
return 1 return 1
} }
printf '%s\n' \ _write_terra_pin "$staged_repo"
'[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"
status=0 status=0
sudo dnf install -y \ sudo dnf install -y \
--repofrompath "terra,${INSTALLER_PROVENANCE[TERRA_BASEURL]}" \ --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 \ --setopt=terra.gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama \
terra-release || status=$? terra-release || status=$?
if (( status == 0 )); then 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 fi
if (( status == 0 )); then if (( status == 0 )); then
effective_status=0 effective_status=0
@@ -1298,6 +1331,18 @@ fi
if [[ "$ROLE" == server ]]; then if [[ "$ROLE" == server ]]; then
echo -e "\n--- Installing packages (server) ---" 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" log "Updating all packages. This may take a while"
sudo dnf update -y --refresh > /dev/null sudo dnf update -y --refresh > /dev/null
install_list core-packages "Core" install_list core-packages "Core"
+5 -2
View File
@@ -157,14 +157,17 @@ while read -r name transport target token_var _rest; do
continue continue
fi 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 # 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. # 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. # Neither call may print the token, so both are quiet unless they fail.
claude mcp remove "$name" -s user >/dev/null 2>&1 || true claude mcp remove "$name" -s user >/dev/null 2>&1 || true
if [[ "$transport" == stdio ]]; then if [[ "$transport" == stdio ]]; then
add=(claude mcp add "$name" -s user "${args[@]}" -- "$launcher") add=(claude mcp add "$name" -s user ${args[@]+"${args[@]}"} -- "$launcher")
else 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 fi
if "${add[@]}" >/dev/null 2>&1; then if "${add[@]}" >/dev/null 2>&1; then
log "Registered $name." log "Registered $name."
+3 -1
View File
@@ -69,10 +69,12 @@ displace() {
linked=0 linked=0
copied=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 [[ -n "${kind:-}" ]] || continue
[[ "$kind" == \#* ]] && continue [[ "$kind" == \#* ]] && continue
[[ -z "${only_on:-}" || "$only_on" == "$os" ]] || continue
src="$USER_DIR/$source" src="$USER_DIR/$source"
dst="${destination/#\~/$HOME}" dst="${destination/#\~/$HOME}"
+53 -2
View File
@@ -27,8 +27,11 @@
# 6. panama-agent-crash carries all four coredump facts and the absolute path # 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 # to the skill. The path is what makes the ladder work for an agent whose
# harness has no skill mechanism. # 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 # 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 # launched and no window is ever opened. This is NOT in tests/desktop-hijacking
# for exactly that reason -- it must stay runnable mid-session. # for exactly that reason -- it must stay runnable mid-session.
@@ -79,6 +82,23 @@ for agent in claude codex; do
STUB STUB
done 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 # A hand-run PID has no core here; the real one would be a slow lookup against
# this machine's journal. # this machine's journal.
cat >"$stub/coredumpctl" <<'STUB' 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 # from the previous run are cleared first, so "was not launched" is a missing
# file rather than a stale one. # file rather than a stale one.
launch() { 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" \ PATH="$stub:/usr/bin:/bin" \
PANAMA_PATH="$repo_dir" \ PANAMA_PATH="$repo_dir" \
PANAMA_AGENT_SETTINGS="$settings" \ PANAMA_AGENT_SETTINGS="$settings" \
@@ -252,6 +274,35 @@ else
note 'launching codex with auto-approve off opened no terminal' note 'launching codex with auto-approve off opened no terminal'
fi 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 ───────────────── # ── An agent installed where only the user's own PATH looks ─────────────────
# #
# Both agents install themselves into ~/.local/bin. The rungs are clicked from # Both agents install themselves into ~/.local/bin. The rungs are clicked from
+9
View File
@@ -90,6 +90,15 @@ PY
sed -n '/^if \[\[ "\$ROLE" == server \]\]; then/,/^fi/p' "$installer" | grep -q '^\s*exit 0' \ 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' || 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 ──────────────────────────────── # ── Everything fragile is actually tolerated ────────────────────────────────
# #
# Being below the desktop is only half of it. A bare `dnf swap` below the # Being below the desktop is only half of it. A bare `dnf swap` below the
+63 -19
View File
@@ -901,17 +901,26 @@ if [[ -n "$query" ]]; then
wrong-url) mode=override-url ;; wrong-url) mode=override-url ;;
stock|stock-wrong-key) mode=stock ;; stock|stock-wrong-key) mode=stock ;;
esac esac
# Adoption rewrites the repository file. Once it is the pinned form the # Adoption and a fresh install write the pin as a repos.override.d
# dump has to say so, or the re-verification adoption performs on itself # drop-in, which dnf lays over the repository file. Once it is there
# could never pass. # the dump has to say so, or the re-verification each performs on
if [[ "$mode" == stock && -f "$STUB_ETC/yum.repos.d/terra.repo" ]] \ # itself could never pass.
&& grep -q '^gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama$' \ override="$STUB_ETC/dnf/repos.override.d/80-panama-terra.repo"
"$STUB_ETC/yum.repos.d/terra.repo"; then 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 mode=trusted
fi fi
if [[ "$mode" == auto && -f "$STUB_ETC/yum.repos.d/terra.repo" ]] \ 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 && grep -q '^baseurl=https://repos.fyralabs.com/terra44$' "$STUB_ETC/yum.repos.d/terra.repo"; then
mode=trusted mode=trusted
elif [[ "$mode" == auto && "$pinned_override" == 1 ]]; then
mode=trusted
elif [[ "$mode" == auto ]]; then elif [[ "$mode" == auto ]]; then
mode=absent mode=absent
fi fi
@@ -945,7 +954,7 @@ if [[ -n "$query" ]]; then
printf 'baseurl = \nenabled = 1\ngpgcheck = 1\n' printf 'baseurl = \nenabled = 1\ngpgcheck = 1\n'
printf 'gpgkey = file:///etc/pki/rpm-gpg/RPM-GPG-KEY-terra44\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 '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) override-url)
printf '======== "terra" repository configuration: ========\n' printf '======== "terra" repository configuration: ========\n'
@@ -1150,7 +1159,7 @@ run_installer_function() {
cp "$installer_fixture/setup/provenance/keys/flathub.asc" \ cp "$installer_fixture/setup/provenance/keys/flathub.asc" \
"$case_root/etc/pki/rpm-gpg/RPM-GPG-KEY-terra44" "$case_root/etc/pki/rpm-gpg/RPM-GPG-KEY-terra44"
fi 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" > "$case_root/etc/yum.repos.d/terra.repo"
;; ;;
wrong-key) 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)" == \ [[ "$($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' '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")" == \ [[ "$(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' || 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, # 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. # 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 # Successful updates keep the old version directory and switch only the active
# symlink after the replacement binary has passed its version check. # 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' \ 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" read -r component function_name active_relative target_relative <<<"$component_spec"
name="${component,,}-atomic-update" name="${component,,}-atomic-update"
reset_installer_fixture 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 fi
done 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 # Receipt or executable tampering preserves the directory and leaves an older
# active link unchanged. A matching version string is not an attestation. # active link unchanged. A matching version string is not an attestation.
for tamper_spec in \ for tamper_spec in \
'Node install_node .nvm/versions/node/v24.20.0 bin/node v24.20.0' \ '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' \ '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" read -r component function_name target_relative binary_relative version_text <<<"$tamper_spec"
for tamper_kind in receipt binary; do for tamper_kind in receipt binary; do
name="${component,,}-$tamper_kind-tamper" name="${component,,}-$tamper_kind-tamper"
@@ -1663,7 +1697,8 @@ rpm:query:terra-release
gpg:fingerprint:AE09157A4DE88B497EA1D5D300CDAB43DE226D6F gpg:fingerprint:AE09157A4DE88B497EA1D5D300CDAB43DE226D6F
sudo:install:terra44.asc:/etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama 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: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 dnf:dump-all:locale=C
gpg:fingerprint:AE09157A4DE88B497EA1D5D300CDAB43DE226D6F gpg:fingerprint:AE09157A4DE88B497EA1D5D300CDAB43DE226D6F
gpg:fingerprint:AE09157A4DE88B497EA1D5D300CDAB43DE226D6F gpg:fingerprint:AE09157A4DE88B497EA1D5D300CDAB43DE226D6F
@@ -1672,10 +1707,12 @@ EXPECTED
cmp -s "$installer_fixture/setup/provenance/keys/terra44.asc" \ cmp -s "$installer_fixture/setup/provenance/keys/terra44.asc" \
"$test_tmp/cases/terra/etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama" \ "$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' || 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] [terra]
name=Panama reviewed Terra 44 name=Panama reviewed Terra 44
baseurl=https://repos.fyralabs.com/terra44 baseurl=https://repos.fyralabs.com/terra44
metalink=
mirrorlist=
enabled=1 enabled=1
gpgcheck=1 gpgcheck=1
repo_gpgcheck=1 repo_gpgcheck=1
@@ -1804,9 +1841,12 @@ for mode in nogpg wrong-url wrong-key absent; do
done done
# A machine that installed Terra the way Terra documents it. The repository file # 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 # is terra-release's own -- a metalink, the key at its stock path, and
# not Panama's pinned form, but it IS the fingerprint this repository reviewed, # repo_gpgcheck=0 as terra-release 44-11 ships it -- so it is not Panama's
# with every signature check on. That is an adoption, not a compromise. # 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 # 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, # 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 reset_installer_fixture
STUB_TERRA_INSTALLED=1 STUB_TERRA_REPO_MODE=stock \ STUB_TERRA_INSTALLED=1 STUB_TERRA_REPO_MODE=stock \
expect_success run_installer_function terra-stock-adopt install_terra_repository 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" \ grep -qx 'baseurl=https://repos.fyralabs.com/terra44' "$terra_adopted" \
|| fail 'adoption left Terra off the reviewed baseurl' || fail 'adoption left Terra off the reviewed baseurl'
grep -qx 'gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama' "$terra_adopted" \ grep -qx 'gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama' "$terra_adopted" \
|| fail 'adoption left Terra off the reviewed key path' || fail 'adoption left Terra off the reviewed key path'
grep -q 'metalink' "$terra_adopted" \ grep -qx 'metalink=' "$terra_adopted" \
&& fail 'adoption kept the metalink it was supposed to replace' || 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'* ]] \ [[ "$(<"$test_tmp/cases/terra-stock-adopt/commands.log")" != *'dnf:install'* ]] \
|| fail 'adoption opened a DNF transaction it does not need' || 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 ─────────────────────────────────────────── # ── 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" ]] \ [[ -e "$repo_dir/user/$source" ]] \
|| note "the manifest points at $source, which is not in user/" || note "the manifest points at $source, which is not in user/"
[[ "$kind" == link || "$kind" == linkdir || "$kind" == copy ]] \ [[ "$kind" == link || "$kind" == linkdir || "$kind" == copy ]] \
|| note "the manifest uses an unknown kind: $kind" || note "the manifest uses an unknown kind: $kind"
[[ "$destination" == '~/'* ]] \ [[ "$destination" == '~/'* ]] \
|| note "the manifest destination $destination is not under the home directory" || 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") done < <(grep -vE '^\s*(#|$)' "$manifest")
# ── The behaviour, against a fake home ────────────────────────────────────── # ── The behaviour, against a fake home ──────────────────────────────────────
@@ -77,6 +79,7 @@ linkdir agents/skills ~/.agents/skills
linkdir agents/skills ~/.claude/skills linkdir agents/skills ~/.claude/skills
copy plain.txt ~/.config/plain.txt copy plain.txt ~/.config/plain.txt
link missing.txt ~/.config/missing.txt link missing.txt ~/.config/missing.txt
copy plain.txt ~/.config/elsewhere.txt nosuchos
FIXTURE FIXTURE
home="$work/home" home="$work/home"
@@ -141,6 +144,11 @@ fi
grep -q 'missing.txt' "$work/log" \ grep -q 'missing.txt' "$work/log" \
|| note 'a skipped manifest entry was not reported' || 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 ─────────────────────────────────────────────────────── # ── The answer sticks ───────────────────────────────────────────────────────
# #
# So that `panama upgrade` on a configured machine relinks without an interview, # So that `panama upgrade` on a configured machine relinks without an interview,
+6
View File
@@ -86,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 Anything already at the destination is moved to `config/old/` rather than
deleted, under a name that says where it came from. 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 ## What does not go in here
Anything secret. This repository is readable by anyone who finds it, and the Anything secret. This repository is readable by anyone who finds it, and the
+17 -3
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. - 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. - 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. - 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 ## 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 # 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. - 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 # 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. - 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.
+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
+2 -3
View File
@@ -1,8 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Playwright's MCP server, for driving a real browser: pages, screenshots, # Playwright's MCP server, for driving a real browser: pages, screenshots,
# forms, end-to-end runs. This is what the ticket skill's proof screenshots are # forms, end-to-end runs. It belongs on every machine rather than only where a
# captured with, so it belongs on every machine rather than only where a Claude # Claude Code plugin happened to be installed.
# Code plugin happened to be installed.
# #
# link-mcp registers this file as a stdio server. It takes no token, so its # 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 # row in user/agents/mcp/servers names no variable and nothing arrives in
-9
View File
@@ -42,12 +42,3 @@ sentry-gbrown stdio bin/sentry-gbrown SENTRY_GBROWN_TOKEN
# end-to-end runs. The ticket skill captures its proof screenshots this way. # end-to-end runs. The ticket skill captures its proof screenshots this way.
# No token, hence no variable in the last column. # No token, hence no variable in the last column.
playwright stdio bin/playwright playwright stdio bin/playwright
# 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
@@ -90,6 +90,9 @@ Same conventions as the `/ticket` skill:
`.claude/docs/epics/<EPIC-KEY>/<TICKET-KEY>/story.md` (or `spike.md` / `.claude/docs/epics/<EPIC-KEY>/<TICKET-KEY>/story.md` (or `spike.md` /
`task.md`), creating directories as needed. These are personal docs, never `task.md`), creating directories as needed. These are personal docs, never
committed. 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 ## House rules for all content
+5 -1
View File
@@ -64,7 +64,11 @@ This is the rule from my preferences, spelled out:
way in one sentence, they are the same mock. way in one sentence, they are the same mock.
2. Build them as static pages. Real content, not grey boxes labelled "image here", because 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. the content is where most of the design decisions turn out to matter.
3. Publish them with a simple http server, report the URL, and stop. 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. 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 Do not edit real components first. Do not keep polishing past the point where a choice
+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. immediately rather than spending a round on the details of something that needs splitting first.
Each sub-project earns its own design cycle. Each sub-project earns its own design cycle.
- **Visual questions get mocks, not prose.** Where the open question is what something should look - **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 like, stop describing it: build several distinct static mocks, show them in the thread (or serve
for a pick. Never edit a real component to answer a layout question. 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) ## 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. 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 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. 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 3. **No persistence by default.** State lives in memory. Persistence is usually the thing being
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
*checked*, not something to depend on. Where the question genuinely involves the database, point *checked*, not something to depend on. Where the question genuinely involves the database, point
at a scratch one named so nobody mistakes it. 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. 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. 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 ## 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. | | **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. | | **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 **Start with static mocks.** They are the house default: several distinct standalone pages, shown in
a simple static server, URL handed over. Escalate to in-app variants only where real data is the T3 Code thread (or, outside T3, served by a simple static server with the URL handed over).
genuinely load-bearing to the judgement, and say why when you do. 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) ## 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 the project's design language closely enough to judge (Tailwind via CDN is fine here, since nothing
ships). ships).
Serve the directory with something already installed, such as `python3 -m http.server`, and hand over In T3 Code, render each variant in the thread with `html_preview` then `html_render`, one variant per
the URL plus the filenames. Then stop. 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. 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 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 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 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 been written is a separate thing entirely, and lives in the `ticket` skill's
step 5. `COUNCIL.md`.
## Files this skill uses ## 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. - 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. - Anatomy check against the section list below.
- Confirm no Jira write has happened. - 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 ## 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/ .claude/docs/epics/
_site/site.css, site.js # shared renderer, refreshed from templates/site/ on every write
<EPIC-KEY>/ <EPIC-KEY>/
index.html, epic.data.js # the epic index, in build order index.html, epic.data.js # the epic index, in build order
<TICKET-KEY>/ <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 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 ## Write through `ticket-page`, always
`~/.agents/skills/ticket/scripts/ticket-page` is the only writer of the data files. It validates `~/.agents/skills/ticket/scripts/ticket-page` is the only writer of the data files. It validates
@@ -31,7 +61,7 @@ the repo.
| Moment | Command | | 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>` | | Attachments downloaded, a video transcribed, frames extracted | `scan <KEY>` |
| Ticket branch resolved, ticket classified | `meta <KEY> --branch <name> --classified bounded` | | 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` | | 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` | | Starting a step | `step <KEY> <n> running` |
| A step's commit exists | `step <KEY> <n> done --commit <sha>`, one `--commit` per commit | | 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>"` | | 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>` | | 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>` |
| 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>"` | | 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>` | | pre-mr-review audit read | `verdict <KEY> <verdict text> --recommendation <R> --head <sha>` |
| A Jira field PUT and verified | `jira <KEY> "<field name>"` | | A Jira field PUT and verified | `jira <KEY> "<field name>"` |
| `mr.md` written | `mr <KEY>` | | `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 ## Reading it as an agent
`ticket-page md <KEY>` prints the ticket and the plan as markdown. Use it when you resume a `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 ## Tickets from before the page
+154 -148
View File
@@ -1,6 +1,6 @@
--- ---
name: ticket 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 # 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 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 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 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 ## 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 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. 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 **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 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 is seeded. Record each moment when it happens, not in a batch at the end, because Gib is
watching the page change. 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 ## Directory layout this skill maintains
``` ```
.claude/docs/epics/ .claude/docs/epics/
_site/ # the shared renderer, refreshed by ticket-page
<EPIC-KEY>/ <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>/ <TICKET-KEY>/
index.html, ticket.data.js # the ticket page index.html, ticket.data.js # the ticket page
resources/ resources/
@@ -60,9 +81,10 @@ watching the page change.
- `~/.agents/skills/ticket/scripts/ticket-page <command> <KEY> ...` — the only writer of the - `~/.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. 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 - `~/.agents/skills/ticket/scripts/capture-proof` — the fallback for proof screenshots: 2x
running app for proof and spike screenshots. `capture-proof help` lists its options. The Playwright captures, full page or cropped, for what T3's browser cannot do (Phase 2 step 9).
first run installs a pinned Playwright into `~/.cache/ticket-page/`. `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 - `~/.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` / issue (`fields=*all`, rendered HTML description, field-name map) via `JIRA_BASE_URL` /
`JIRA_CREDENTIALS`. `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 - **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. 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: 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 the layout, spacing, and density as they should actually be. A self-contained HTML
`design` skill produces a canvas of artboards for exactly this; in a harness without page does this job, shown through `html_preview` and `html_render`. These are design
it, a self-contained HTML page does the same job. These are design intent, not intent, not implementation promises, so call out anything a mock shows that the code
implementation promises, so call out anything a mock shows that the code doesn't do doesn't do yet.
yet.
- **Scope discipline still applies.** This is a proof of concept, not a production - **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 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 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 than a static mock would, say so in the plan's approach rather than silently scoping it down
without mentioning the tradeoff. without mentioning the tradeoff.
- **Screenshot the real thing.** Once it's built and running, use the `run` skill to - **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 get the app up and capture the actual screens as the proof step in Phase 2 describes,
in Phase 2 describes, instead of drawing a wireframe. Save screenshots under instead of drawing a wireframe. Save screenshots under
`deliverables/<slug>/screenshots/` and the visual mocks under `deliverables/<slug>/screenshots/` and the visual mocks under
`deliverables/<slug>/mocks/`, and embed both in `<slug>.typ` via `image()` when `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 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 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. 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 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 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 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) - 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 - 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) 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. Route to **Phase 1** or **Phase 2** accordingly.
- **A plan and `mr.md`** — this ticket looks finished. Tell the user - **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 `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 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 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 `<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 script read. Record the branch from **Branch setup** with `ticket-page meta`, then run
the page with `ticket-page open <KEY>` so Gib can follow from here on. `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 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 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 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 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. 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 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 source files, tests, or docs are relevant to what's being asked. Read them yourself:
as needed — this is normal engineering research, not scripted. Use the 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 **Verification tools available** above to check the ticket's own claims against
reality (codebase, Jira, staging, and prod/CI env with permission) rather than 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 taking the ticket's description at face value — this is what makes the plan
@@ -580,7 +607,9 @@ place from the start.
otherwise. otherwise.
12. When a step changes what a user sees, draw its mock artboards with `ticket-page mock`, 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**. 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 `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 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 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 ## 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 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 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 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 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." 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`. 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,
This step exists because `pre-mr-review` cannot do it. That skill is constitutionally and when to stop. This step is done when every seat has returned or been recorded as
read-only: it may not run tests, execute code, probe a database, or run any unavailable, and every finding on the page is fixed or rejected with its reason.
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."
6. Confirm this project actually has a `pre-mr-review` skill (`.claude/skills/pre-mr-review/` 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 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 local verification are done but this repo has no `pre-mr-review` skill to run, and
let them decide how to proceed. let them decide how to proceed.
7. **Do NOT invoke the `pre-mr-review` skill yourself.** The org asks that this 7. **Run `pre-mr-review` on the work account, from a Klaude delegate.** The org asks
review run from the developer's separate work Claude account, so the audit on that this review run from the developer's work Claude account, so the audit on record must
record must come from there, not from this session. Instead: come from there, never from this session and never from another provider. Klaude is that
1. Tell the user the branch is ready for its pre-mr-review and ask them to run account (`~/.klaude`). The audit is one agent, not a council.
`/pre-mr-review` from their work account, then let you know when it has 1. Show where the build stands with `ticket-page view <KEY> progress`, through
finished. Stop and wait, this is a hard gate. `html_preview` and then `html_render`. Write `ticket-page md <KEY>` to a scratch file.
2. When they say it ran, read the audit and handoff files it wrote under 2. Start it, then end your turn. T3 wakes you when it finishes:
`.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 delegate_task
and ask them to rerun before acting on a stale audit. title: pre-mr-review <KEY>
3. Read the verdict, and record it on the page with `ticket-page verdict`. 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. - **Ready to Open MR**: continue to step 8.
- **Almost Ready / Not Ready Yet**: fix what it flagged (each meaningful fix - **Almost Ready / Not Ready Yet**: fix what it flagged (each meaningful fix as its own
as its own commit), then ask the user to rerun the review from the work commit), then start a new run. Repeat until the verdict is Ready to Open MR. Don't
account and wait again. 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,
write `mr.md` before that verdict is reached. stop and ask Gib how to proceed.
Findings that need no code change (mentions, disclosures) get addressed in Findings that need no code change (mentions, disclosures) get addressed in
`mr.md`'s Additional Notes or the Jira fields as usual. `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 normal and expected at a Ready verdict. Never treat the recommendation as a
blocker or report it as one. blocker or report it as one.
**Reruns converge by giving the audit more, not by repeating the command.** **Reruns converge by giving the audit more, not by repeating the command.** Each run
When a rerun is needed, the user is running it, so every rerun costs them. spends the work account's quota, so make each one count:
Make each one count: - **Feed it context on the first run.** It reviews the diff, and left alone it re-derives
- **Feed it context on the first run.** It reviews the diff, and left alone it intent from the branch name and re-raises decisions that are already settled. The
re-derives intent from the branch name and re-raises decisions that are scratch file in the task is the cheapest thing that reduces repeat findings.
already settled. Ask the user to point it at the ticket's `ticket.data.js`, or give - **Answer its decisions explicitly in the rerun.** When it parks a finding under "Decide
them the output of `ticket-page md <KEY>` to paste in. This is the cheapest thing that reduces before opening", write the decision and its reasoning into the next task, so it can
repeat findings. reclassify rather than re-ask. A silent rerun on an unchanged diff re-asks the same
- **Answer its decisions explicitly in the rerun.** When it parks a finding question. A decision that is genuinely Gib's goes to him first.
under "Decide before opening", write the decision and its reasoning into the - **Say so when it finds new things in unchanged code.** That means the earlier run was an
prompt for the next run, so it can reclassify rather than re-ask. A silent incomplete pass, not that the branch got worse, and it changes how much weight the next
rerun on an unchanged diff re-asks the same question. run deserves. Tell Gib at hand-off.
- **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.
8. Read `~/.agents/skills/ticket/templates/mr.md` — this is the org's MR template, 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 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` `.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: Fill it in for real:
- H1: from the ticket and the actual work done, not the ticket title verbatim if the - H1: from the ticket and the actual work done, not the ticket title verbatim if the
real change ended up narrower/broader. real change ended up narrower/broader.
- Summary: one short paragraph max. High level only, what changed, not how. This - Summary: one short paragraph max. Open with the problem in plain words, then say
is not the place for a list of every file touched. 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>`. - Issue Link: `<JIRA_BASE_URL>/browse/<KEY>`.
- New ENVs: list any env vars actually introduced; "None" if none. - New ENVs: list any env vars actually introduced; "None" if none.
- Additional Notes: a bulleted checklist of the changes you actually made, not a - 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: 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 - 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 wrong, thin, or stale, start a new Klaude run (step 7) to regenerate the handoff
account to regenerate the handoff file, then re-paste. file, then re-paste.
- On EVERY rerun of theirs, replace the whole existing handoff block with the whole - 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 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 head — that is how the two silently diverge, and the version the reviewer
reads stops being the version the audit actually produced. 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 it to ADF, proof and mitigation cells included. Everything here is read by the Lead
and the PM. and the PM.
**Proof first.** Before filling any proof column, capture working feature proof **Proof first.** Proof is evidence you observed yourself in this session. Before filling
yourself wherever possible: run the app (`run` skill) and screenshot the real any proof column, capture it wherever possible, using the most direct form the change allows:
feature with `capture-proof`, or capture test output for behavior with no UI.
**Screenshots come from Playwright, never from a browser-extension screen grab.** - For a bug, reproduce the failure, apply the fix, and watch the same reproduction pass.
Extension captures come out soft and hard to read, and a proof screenshot exists to be - For UI and runtime behavior, run the app (`run` skill) and look at the real feature.
read. `capture-proof shot` renders at twice the pixel density with animations frozen, Green mocked tests do not prove the user experience works.
fonts loaded, and dev overlays hidden: - For behavior with no UI, capture the test or command output.
``` **Screenshots and recordings come from T3's browser.** It runs in the thread, so Gib can
capture-proof shot http://localhost:3000/companies --wait-for "text=Canonical ID" --out <target-dir>/proof/companies-grid-canonical-ids.png 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 1. `preview_open` with the app's URL, or `preview_navigate` with
hint. Ask Gib to run `capture-proof login <origin>` himself: it opens a visible `target: {kind: "environment-port", port}` for a dev server.
browser, he signs in and closes the window, and every later `shot` against that 2. `preview_resize` to `{mode: "freeform", width: 1280, height: 800}`. Saved screenshots
origin reuses the session. It is saved under `~/.cache/ticket-page/auth/`, never in a top out at 1280x800, so a larger viewport comes out downscaled and soft.
repo. The hint only catches a redirect to a sign-in path, so look at every capture 3. `preview_wait_for` the thing the proof is about, so the capture never catches a spinner.
before recording it. Drive any state with `preview_snapshot` refs and `preview_act`: open the dialog, fill the
- **Frame the evidence.** Pass `--wait-for` with the thing the proof is about, so the form, trigger the warning. Use `preview_set_appearance` for dark mode.
capture never catches a loading spinner. Crop to the part that matters with 4. `preview_snapshot` with `save: true`, then copy `screenshotPath` into `proof/`.
`--selector`, or take the whole page with `--full`. 5. For a flow a still cannot show, `preview_recording_start`, drive it, then
- **Interaction first.** For state that takes clicks or typing (an open dialog, a `preview_recording_stop` and copy the mp4 from its `path` into `proof/`. Keep it short:
form showing its warning), write a small script, a recording caps at 50 MiB.
`export default async (page) => { await page.getByLabel("Canonical ID").fill("NIKON"); }`, 6. `t3_preview_close` when the proof is done.
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 - **Sign-in.** If the tab lands on a sign-in page, ask Gib with AskUserQuestion to sign in in
wrong screen is still wrong. 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 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 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" 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. unanswered.
10. Tell the user: implementation is committed on `<branch>`, `pre-mr-review` verdict is 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 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 - 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/`) - upload the named proof or deliverable files (from `proof/` or `deliverables/`)
to the ticket, plus anything flagged as proof only Gib can capture 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 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 `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 match reality (a checked-off step should have a corresponding commit), then show
from the first open step. If the plan and the git history disagree, stop and ask `ticket-page view <KEY> progress` in the thread and continue from the first open step.
the user rather than guessing which one is right. 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` // 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 // per epic, each a single `window.X = <strict JSON>;` assignment so an open page can poll
// file:// can load it with a <script> tag. Everything that reads or writes those files // 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. // goes through this module, so the shape is defined once and validated on every write.
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
@@ -70,7 +70,7 @@ export interface Plan {
} }
export interface Proof { export interface Proof {
kind: "image" | "text" | "test"; kind: "image" | "video" | "text" | "test";
/** Relative to the ticket directory, for image and text proof. */ /** Relative to the ticket directory, for image and text proof. */
file?: string; file?: string;
/** Embedded content of a text proof, because file:// pages cannot fetch it. */ /** 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); 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); 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(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"]]); 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", () => { test("a step is done only once its commit exists, and the epic row follows", () => {
const plan = join(scratch, "plan.json"); 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" }] })); 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] } }); 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", () => { test("text proof is embedded, because a file:// page cannot fetch it", () => {
mkdirSync(join(dir, "proof"), { recursive: true }); mkdirSync(join(dir, "proof"), { recursive: true });
writeFileSync(join(dir, "proof", "counts.txt"), "70 of 70 codes stored"); 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(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(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); 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", () => { 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-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. // ticket skill calls each command. Run `ticket-page help` for the command list.
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { dirname, join, relative, resolve } from "node:path"; import { dirname, join, resolve } from "node:path";
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { htmlToText, importIssue, type JiraIssue, scanResources } from "./jira-import"; import { htmlToText, importIssue, type JiraIssue, scanResources } from "./jira-import";
import { renderView, VIEWS } from "./thread-view";
import { import {
type Criterion, type Epic, type Finding, type Mock, now, type Plan, progress, readData, type Step, type Criterion, type Epic, type Finding, type Mock, now, type Plan, progress, readData, type Step,
storyStatus, type Ticket, writeData, storyStatus, type Ticket, writeData,
@@ -34,11 +34,13 @@ const list = (s?: string) => (s ? s.split(",").map(x => x.trim()).filter(Boolean
// ── Where things live ──────────────────────────────────────────────────────── // ── 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 { export function docsRoot(): string {
if (process.env.TICKET_DOCS) return resolve(process.env.TICKET_DOCS); if (process.env.TICKET_DOCS) return resolve(process.env.TICKET_DOCS);
const git = spawnSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" }); const git = spawnSync("git", ["worktree", "list", "--porcelain"], { encoding: "utf8" });
if (git.status !== 0) fail("not inside a git repository, and TICKET_DOCS is not set"); const main = git.status === 0 ? git.stdout.match(/^worktree (.+)$/m)?.[1] : undefined;
return join(git.stdout.trim(), ".claude/docs/epics"); return join(main ?? fail("not inside a git repository, and TICKET_DOCS is not set"), ".claude/docs/epics");
} }
function ticketDir(key: string): string { function ticketDir(key: string): string {
@@ -49,26 +51,28 @@ function ticketDir(key: string): string {
const ticketFile = (dir: string) => join(dir, "ticket.data.js"); const ticketFile = (dir: string) => join(dir, "ticket.data.js");
const epicFile = (epicKey: string) => join(docsRoot(), epicKey, "epic.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 /** Writes the page shell with the renderer and the data inlined, so index.html renders with
* page shell. Run on every write so an old ticket picks up the current renderer. */ * nothing else: no server and no other file. T3 Code's file viewer sandboxes the page and
function refreshSite(pageDir: string, kind: "ticket" | "epic", title: string) { * serves assets only from its own folder and below, so a shell that leaned on a shared
const site = join(docsRoot(), "_site"); * ../../_site/ renderer rendered blank there. The sibling data file stays the store, and the
mkdirSync(site, { recursive: true }); * open page polls it to stay live. Run on every write, so the page matches the data and an
const version = createHash("sha1"); * old ticket picks up the current renderer. */
for (const f of ["site.css", "site.js"]) { function refreshSite(pageDir: string, kind: "ticket" | "epic", data: Ticket | Epic) {
const from = join(SITE_TEMPLATES, f), to = join(site, f); const fill: Record<string, string> = {
const body = readFileSync(from, "utf8"); CSS: readFileSync(join(SITE_TEMPLATES, "site.css"), "utf8"),
version.update(body); JS: readFileSync(join(SITE_TEMPLATES, "site.js"), "utf8"),
if (!existsSync(to) || readFileSync(to, "utf8") !== body) copyFileSync(from, to); 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.
// The shell names the renderer by content hash, so a browser that cached an older DATA: `window.${kind.toUpperCase()} = ${JSON.stringify(data).replace(/</g, "\\u003c")};`,
// renderer loads the new one the next time the page opens. };
const shell = readFileSync(join(SITE_TEMPLATES, `${kind}.html`), "utf8") // A replacer function, because the renderer's own source is full of `$` sequences that
.replaceAll("{{SITE}}", relative(pageDir, site) || ".") // a replacement string would read as patterns.
.replaceAll("{{VERSION}}", version.digest("hex").slice(0, 10)) const shell = readFileSync(join(SITE_TEMPLATES, `${kind}.html`), "utf8").replace(/\{\{(CSS|JS|TITLE|DATA)\}\}/g, (_, k: string) => fill[k]);
.replaceAll("{{TITLE}}", title.replace(/[<&]/g, c => (c === "<" ? "&lt;" : "&amp;")));
const page = join(pageDir, "index.html"); 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 ────────────────────────────────────────────────── // ── Ticket reads and writes ──────────────────────────────────────────────────
@@ -80,7 +84,7 @@ function load(key: string) {
function save(dir: string, t: Ticket) { function save(dir: string, t: Ticket) {
writeData(ticketFile(dir), "ticket", t); writeData(ticketFile(dir), "ticket", t);
refreshSite(dir, "ticket", `${t.key}: ${t.title}`); refreshSite(dir, "ticket", t);
if (t.epic) syncStory(t); if (t.epic) syncStory(t);
} }
@@ -102,7 +106,7 @@ function syncStory(t: Ticket) {
s.progress = progress(t); s.progress = progress(t);
if (s.status !== "done") s.status = storyStatus(t); if (s.status !== "done") s.status = storyStatus(t);
writeData(file, "epic", e); writeData(file, "epic", e);
refreshSite(dirname(file), "epic", `${e.key}: ${e.title}`); refreshSite(dirname(file), "epic", e);
} }
// ── Commands ───────────────────────────────────────────────────────────────── // ── 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"); const caption = a.one("caption") ?? fail("proof needs --caption");
if (file && !existsSync(join(dir, file))) fail(`${file} does not exist in ${dir}`); 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 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 = t.proof.filter(p => (file ? p.file !== file : p.test !== test));
t.proof.push({ 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") } : {}), ...(isText ? { text: readFileSync(join(dir, file!), "utf8") } : {}),
proves, caption, ...(a.one("mock") ? { mock: a.one("mock") } : {}), at: now(), 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("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 => { command("open", "open <KEY> print the page path, and open it in the browser outside T3 Code", a => {
spawnSync("xdg-open", [join(ticketDir(a.pos[0]), "index.html")], { stdio: "ignore" }); 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 => { 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`); const e = existsSync(file) ? readData(file, "epic") : fail(`no epic page for ${key}. Run: ticket-page epic ${key} init --title T`);
fn(e); fn(e);
writeData(file, "epic", 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 => { 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.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}`); e.jira = a.one("jira") ?? (e.jira || `${process.env.JIRA_BASE_URL ?? "https://ksense-tech.atlassian.net"}/browse/${key}`);
writeData(file, "epic", e); 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")); return console.log(join(dirname(file), "index.html"));
} }
withEpic(key, e => { withEpic(key, e => {
@@ -4,10 +4,15 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{TITLE}}</title> <title>{{TITLE}}</title>
<link rel="stylesheet" href="{{SITE}}/site.css?v={{VERSION}}"> <style>
{{CSS}}
</style>
</head> </head>
<body data-page="epic" data-src="epic.data.js"> <body data-page="epic" data-src="epic.data.js">
<div id="app"><p class="loading">Loading epic.data.js…</p></div> <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> </body>
</html> </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 { :root {
--bg: #f6f6f3; --surface: #ffffff; --sunk: #efefea; --ink: #1c1c1a; --muted: #6b6b66; --bg: #f6f6f3; --surface: #ffffff; --sunk: #efefea; --ink: #1c1c1a; --muted: #6b6b66;
--line: #e2e2dd; --accent: #2f5fd0; --accent-ink: #fff; --accent-soft: #e8eeff; --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. // Renderer for ticket and epic pages. ticket-page inlines it and the page's data into every
// A page shell names its data file in <body data-src>. This script loads that file with a // page shell, so index.html renders on its own with no server and no other file.
// <script> tag (file:// pages cannot fetch), re-loads it every two seconds, and redraws // A page shell also names its data file in <body data-src>. Re-loading that file every two
// only when the data changed. The data shape is defined in scripts/ticket-data.ts. // 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 // @ts-check
/** @typedef {import('../../scripts/ticket-data').Ticket} Ticket */ /** @typedef {import('../../scripts/ticket-data').Ticket} Ticket */
/** @typedef {import('../../scripts/ticket-data').Epic} Epic */ /** @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 mark = st => h('span.mark' + (st === 'done' ? '.done' : st === 'running' ? '.run' : ''), { title: st });
const pill = (text, tone) => h('span.pill' + (tone ? '.' + tone : ''), text); 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. */ /** The viewer's own review tick, kept in this browser only and separate from the agent's done mark. */
function seen(scope, id) { function seen(scope, id) {
const key = `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' }, 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 ────────────────────────────────────────────── // ── Loading and live reload ──────────────────────────────────────────────
@@ -65,7 +69,7 @@
const el = document.querySelector('.live'); const el = document.querySelector('.live');
if (!el) return; if (!el) return;
el.classList.toggle('stale', failed); 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() { function draw() {
@@ -150,7 +154,8 @@
/** @param {Ticket} t @param {import('../../scripts/ticket-data').Proof} p */ /** @param {Ticket} t @param {import('../../scripts/ticket-data').Proof} p */
function compare(t, p) { function compare(t, p) {
const m = p.mock && t.plan?.mocks.find(x => x.id === p.mock); 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', 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', '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)); 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)); 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(); load();
setInterval(load, 2000); setInterval(load, 2000);
})(); })();
@@ -4,10 +4,15 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{TITLE}}</title> <title>{{TITLE}}</title>
<link rel="stylesheet" href="{{SITE}}/site.css?v={{VERSION}}"> <style>
{{CSS}}
</style>
</head> </head>
<body data-page="ticket" data-src="ticket.data.js"> <body data-page="ticket" data-src="ticket.data.js">
<div id="app"><p class="loading">Loading ticket.data.js…</p></div> <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> </body>
</html> </html>
+4 -1
View File
@@ -13,7 +13,10 @@ case "${1:-}" in
off) off)
tmp="$(mktemp)" tmp="$(mktemp)"
trap 'rm -f "$tmp"' EXIT trap 'rm -f "$tmp"' EXIT
echo "${user} ALL=(ALL) NOPASSWD: ALL" >"$tmp" # 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. # Validate before installing so a bad file can never lock sudo.
sudo visudo -cqf "$tmp" sudo visudo -cqf "$tmp"
sudo install -m 0440 -o root -g root "$tmp" "$dropin" sudo install -m 0440 -o root -g root "$tmp" "$dropin"
+10 -5
View File
@@ -1,6 +1,6 @@
# What in user/ goes where, and in what form. # What in user/ goes where, and in what form.
# #
# <kind> <source under user/> <destination> # <kind> <source under user/> <destination> [os]
# #
# kind is one of: # kind is one of:
# link symlink the destination at the source, so editing either edits the # link symlink the destination at the source, so editing either edits the
@@ -14,6 +14,10 @@
# copy copy once, and never touch it again if the destination exists. Use # copy copy once, and never touch it again if the destination exists. Use
# this for files a machine legitimately diverges on. # this for files a machine legitimately diverges on.
# #
# os is optional: linux or darwin, matched against `uname -s`. A line naming
# one is skipped everywhere else. Leave it off for anything every machine
# wants, which is most of this file; the Mac mini only takes the agent half.
#
# Lines beginning with # are comments and blank lines are ignored. ~ in a # Lines beginning with # are comments and blank lines are ignored. ~ in a
# destination is this user's home. # destination is this user's home.
# #
@@ -43,21 +47,22 @@ link agents/rules ~/.claude/rules
# ── SSH ────────────────────────────────────────────────────────────────────── # ── SSH ──────────────────────────────────────────────────────────────────────
# Host aliases only. Keys are per-machine and are never tracked; this is the # Host aliases only. Keys are per-machine and are never tracked; this is the
# file that makes `ssh server` work the moment a machine is set up, and it is # file that makes `ssh server` work the moment a machine is set up, and it is
# what the SSH Hosts launcher command reads. # what the SSH Hosts launcher command reads. Linux only: it names id_ed25519
link ssh/config ~/.ssh/config # and the desktop keyring's agent socket, and the Mac has neither.
link ssh/config ~/.ssh/config linux
# ── Scripts ────────────────────────────────────────────────────────────────── # ── Scripts ──────────────────────────────────────────────────────────────────
# Personal commands wanted on every machine, desktop or server. ~/.local/bin is # Personal commands wanted on every machine, desktop or server. ~/.local/bin is
# on PATH in both shell configs. pwswitch toggles passwordless sudo through a # on PATH in both shell configs. pwswitch toggles passwordless sudo through a
# sudoers.d drop-in, so an agent can run sudo and the password comes back after. # sudoers.d drop-in, so an agent can run sudo and the password comes back after.
link bin/pwswitch ~/.local/bin/pwswitch link bin/pwswitch ~/.local/bin/pwswitch linux
# ── Espanso ────────────────────────────────────────────────────────────────── # ── Espanso ──────────────────────────────────────────────────────────────────
# The personal expansion triggers. Copied rather than linked, because this is a # The personal expansion triggers. Copied rather than linked, because this is a
# file each machine may add its own triggers to. It lands before setup-identity # file each machine may add its own triggers to. It lands before setup-identity
# runs, and that stage only seeds a file that does not exist yet, so what is # runs, and that stage only seeds a file that does not exist yet, so what is
# tracked here wins over the name and email the interview would have written. # tracked here wins over the name and email the interview would have written.
copy espanso/identity.yml ~/.config/espanso/match/identity.yml copy espanso/identity.yml ~/.config/espanso/match/identity.yml linux
# ── OpenCode ───────────────────────────────────────────────────────────────── # ── OpenCode ─────────────────────────────────────────────────────────────────
# Claude Code carries its commands and agents as plugins, which opencode cannot # Claude Code carries its commands and agents as plugins, which opencode cannot