Compare commits
10
Commits
5af5d7a96b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8316c8a1bf | ||
|
|
86ea1a3374 | ||
|
|
c6d299b675 | ||
|
|
6c8db4e845 | ||
|
|
86f6e391b7 | ||
|
|
968e0a1045 | ||
|
|
d5ef335d61 | ||
|
|
27e28b0dd1 | ||
|
|
4e39be860c | ||
|
|
e21a4c24f0 |
No files matched your search
+40
-5
@@ -10,9 +10,16 @@
|
||||
# start time, so choosing an agent in Settings takes effect on the next crash
|
||||
# without restarting anything:
|
||||
#
|
||||
# preferredAgent none | claude | codex ("none" is the default: silence)
|
||||
# preferredAgent none | claude | codex | t3 ("none" is the default: silence)
|
||||
# agentAutoApprove true -> the agent starts in its own "don't stop to ask"
|
||||
# mode; false -> its normal prompting mode, untouched.
|
||||
# T3 Code ignores it: a T3 thread runs in the mode its
|
||||
# project is set to, which is a choice made inside T3.
|
||||
#
|
||||
# claude and codex open in a kitty window. t3 starts a thread in the Panama
|
||||
# project on the running T3 Code server and brings it up in the desktop app,
|
||||
# so the investigation lives beside every other thread instead of in a
|
||||
# terminal of its own.
|
||||
#
|
||||
# "none" exits 0 without a word. It is not an error to have no agent; it is the
|
||||
# shipped state, and a rung that shouted about it would be a rung that gets
|
||||
@@ -95,8 +102,9 @@ usage() {
|
||||
cat <<'EOF'
|
||||
Usage: panama-agent [--prompt "text"]
|
||||
|
||||
Opens the agent named by preferredAgent in a terminal, in the Panama checkout.
|
||||
With no agent chosen, exits silently: choose one on Settings > System > Agents.
|
||||
Opens the agent named by preferredAgent in the Panama checkout: a terminal for
|
||||
claude and codex, a thread in the T3 Code app for t3. With no agent chosen,
|
||||
exits silently: choose one on Settings > System > Agents.
|
||||
EOF
|
||||
}
|
||||
|
||||
@@ -139,7 +147,7 @@ agent="$(read_setting preferredAgent none)"
|
||||
auto_approve="$(read_setting agentAutoApprove true)"
|
||||
|
||||
case "$agent" in
|
||||
claude | codex) ;;
|
||||
claude | codex | t3) ;;
|
||||
*)
|
||||
printf 'panama-agent: unsupported preferredAgent: %s\n' "$agent" >&2
|
||||
printf 'Choose one on Settings > System > Agents.\n' >&2
|
||||
@@ -148,7 +156,8 @@ case "$agent" in
|
||||
esac
|
||||
|
||||
# Resolved to a path before argv is built, so kitty is never asked to repeat a
|
||||
# PATH lookup this script has already done more carefully than kitty could.
|
||||
# PATH lookup this script has already done more carefully than kitty could. T3
|
||||
# Code's CLI is `t3`, which the desktop app writes into ~/.local/bin.
|
||||
if ! agent_bin="$(resolve_agent "$agent")"; then
|
||||
printf 'panama-agent: %s is not installed.\n' "$agent" >&2
|
||||
printf 'Looked on PATH and in %s.\n' "$HOME/.local/bin" >&2
|
||||
@@ -156,6 +165,32 @@ if ! agent_bin="$(resolve_agent "$agent")"; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# T3 Code: no terminal. The thread starts on the running server in the
|
||||
# project whose root is the checkout, with the project's default model, and
|
||||
# the t3code:// link opens it in the app. `project add` refuses a root that is
|
||||
# already a project, which is the common case, so its failure is not one. The
|
||||
# prompt goes over stdin ("-"), so a prompt that begins with a dash is never
|
||||
# read as a flag. With no prompt there is nothing to start, so the app just
|
||||
# opens on the project.
|
||||
if [[ "$agent" == "t3" ]]; then
|
||||
repair_path
|
||||
"$agent_bin" project add "$PANAMA_PATH" >/dev/null 2>&1 || true
|
||||
if [[ -z "$prompt" ]]; then
|
||||
exec setsid "$agent_bin" app "$PANAMA_PATH"
|
||||
fi
|
||||
if ! thread="$(printf '%s' "$prompt" | "$agent_bin" thread start "$PANAMA_PATH" -)"; then
|
||||
printf 'panama-agent: T3 Code could not start a thread.\n' >&2
|
||||
printf 'Is the T3 Code server running? Check: systemctl --user status t3code\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
environment_file="${T3CODE_HOME:-$HOME/.t3}/userdata/environment-id"
|
||||
environment="$(cat "$environment_file" 2>/dev/null || true)"
|
||||
if [[ -n "$environment" ]]; then
|
||||
exec setsid xdg-open "t3code://threads/$environment/$thread"
|
||||
fi
|
||||
exec setsid "$agent_bin" app "$PANAMA_PATH"
|
||||
fi
|
||||
|
||||
# The per-agent launch table. VERIFIED against the installed binaries' --help on
|
||||
# 2026-08-25 (claude 2.1.245, codex-cli 0.149.1) -- these flags are not the same
|
||||
# from release to release, so re-run --help before changing them.
|
||||
|
||||
@@ -184,7 +184,8 @@ Singleton {
|
||||
options: [
|
||||
{ value: "none", label: "None" },
|
||||
{ value: "claude", label: "Claude Code" },
|
||||
{ value: "codex", label: "Codex" }
|
||||
{ value: "codex", label: "Codex" },
|
||||
{ value: "t3", label: "T3 Code" }
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -205,7 +206,7 @@ Singleton {
|
||||
{
|
||||
key: "agentAutoApprove", type: "bool", def: true, group: "agents",
|
||||
label: "Launched agents approve their own tools",
|
||||
detail: "Investigations run without permission prompts. The diagnose skill still holds agents to reading rather than fixing, and root still goes through panama-sudo, reason and all"
|
||||
detail: "Investigations run without permission prompts. T3 Code threads use the mode set on the Panama project in T3 instead. The diagnose skill still holds agents to reading rather than fixing, and root still goes through panama-sudo, reason and all"
|
||||
},
|
||||
{
|
||||
key: "agentUsageClaude", type: "bool", def: true, group: "agents",
|
||||
|
||||
@@ -37,8 +37,8 @@ SettingsPage {
|
||||
// them not to believe the page.
|
||||
//
|
||||
// Probed through a LOGIN shell rather than this one. Quickshell is started
|
||||
// by systemd, whose PATH does not include ~/.local/bin -- where both of
|
||||
// these usually land -- and a login shell is the environment the launcher
|
||||
// by systemd, whose PATH does not include ~/.local/bin -- where all three of
|
||||
// these usually land (T3 Code's CLI is `t3`) -- and a login shell is the environment the launcher
|
||||
// hands the agent when it spawns a terminal. Asking with the shell's own
|
||||
// PATH would report "not installed" for an agent that starts perfectly.
|
||||
property var installed: ({})
|
||||
@@ -60,7 +60,7 @@ SettingsPage {
|
||||
|
||||
running: true
|
||||
command: ["bash", "-lc",
|
||||
"for agent in claude codex; do command -v \"$agent\" >/dev/null 2>&1 && printf '%s\\n' \"$agent\"; done"]
|
||||
"for agent in claude codex t3; do command -v \"$agent\" >/dev/null 2>&1 && printf '%s\\n' \"$agent\"; done"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: root.absorbProbe(this.text)
|
||||
}
|
||||
@@ -70,6 +70,7 @@ SettingsPage {
|
||||
switch (value) {
|
||||
case "claude": return "starred-symbolic";
|
||||
case "codex": return "utilities-terminal-symbolic";
|
||||
case "t3": return "view-dual-symbolic";
|
||||
default: return "notifications-disabled-symbolic";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,6 +356,7 @@ Singleton {
|
||||
{ label: "AI assistant", detail: "Choose the agent this desktop hands crashes, failed reloads and red health checks to", page: "agents" },
|
||||
{ label: "Claude Code", detail: "Use Claude Code as the agent the desktop escalates to, and watch its usage", page: "agents" },
|
||||
{ label: "Codex", detail: "Use Codex as the agent the desktop escalates to, and watch its usage", page: "agents" },
|
||||
{ label: "T3 Code", detail: "Open a T3 Code thread in the Panama project when the desktop escalates something", page: "agents" },
|
||||
{ label: "Diagnose crashes", detail: "When a program dumps core, the notification carries a click that opens your AI assistant with the crash details", page: "agents" },
|
||||
{ label: "Ask the agent", detail: "A red System Health check with no repair left hands its snapshot to your AI assistant", page: "agents" },
|
||||
{ label: "Agent usage panel", detail: "Limits, resets and tokens for each agent, opened from the bar", page: "agents" },
|
||||
|
||||
@@ -5,6 +5,6 @@
|
||||
# @vicinae.mode silent
|
||||
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
|
||||
# @vicinae.description Open Agents in Settings.
|
||||
# @vicinae.keywords ["settings", "preferred agent", "offer to diagnose crashes", "offer help when the shell fails to reload", "system health hands off unrepairable checks", "launched agents approve their own tools", "collect claude code usage", "collect codex usage", "refresh interval", "ai assistant", "claude code", "codex", "diagnose crashes"]
|
||||
# @vicinae.keywords ["settings", "preferred agent", "offer to diagnose crashes", "offer help when the shell fails to reload", "system health hands off unrepairable checks", "launched agents approve their own tools", "collect claude code usage", "collect codex usage", "refresh interval", "ai assistant", "claude code", "codex", "t3 code"]
|
||||
|
||||
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page agents
|
||||
+2
-2
@@ -28,11 +28,11 @@ Found on **System › Agents**.
|
||||
|
||||
| Setting | Default | What it does |
|
||||
|---|---|---|
|
||||
| **Preferred agent**<br>`preferredAgent` | none | Who answers when the desktop offers to investigate something Choices: None, Claude Code, Codex. |
|
||||
| **Preferred agent**<br>`preferredAgent` | none | Who answers when the desktop offers to investigate something Choices: None, Claude Code, Codex, T3 Code. |
|
||||
| **Offer to diagnose crashes**<br>`crashDiagnoseOffer` | true | When a program dumps core, the notification carries a click that opens the preferred agent mid-investigation with the crash details in hand |
|
||||
| **Offer help when the shell fails to reload**<br>`reloadFailureOffer` | true | A broken change to the shell's own configuration offers the failing log to the agent |
|
||||
| **System Health hands off unrepairable checks**<br>`healthAgentHandoff` | true | A red check with no repair, or whose repair failed, grows an Ask-the-agent button carrying the check's snapshot |
|
||||
| **Launched agents approve their own tools**<br>`agentAutoApprove` | true | Investigations run without permission prompts. The diagnose skill still holds agents to reading rather than fixing, and root still goes through panama-sudo, reason and all |
|
||||
| **Launched agents approve their own tools**<br>`agentAutoApprove` | true | Investigations run without permission prompts. T3 Code threads use the mode set on the Panama project in T3 instead. The diagnose skill still holds agents to reading rather than fixing, and root still goes through panama-sudo, reason and all |
|
||||
| **Collect Claude Code usage**<br>`agentUsageClaude` | true | Limits from Anthropic's usage endpoint, tokens from the local transcripts |
|
||||
| **Collect Codex usage**<br>`agentUsageCodex` | true | Limits over the Codex app-server, sessions from its local files |
|
||||
| **Refresh interval**<br>`agentUsageRefreshMinutes` | 15 min | How often the usage collectors ask for fresh numbers, in minutes. Range 5–60. |
|
||||
|
||||
@@ -81,13 +81,25 @@ 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. Defer rather than queue -- the next weekly run will catch it.
|
||||
# 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 ))
|
||||
|
||||
@@ -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).
|
||||
# Saturday specifically because Monday carries the Media scrub and Sunday has
|
||||
# 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,
|
||||
# not going to be doing that at 5 AM on a Saturday -- so the borg lock should be free.
|
||||
# Nextcloud AIO's borg runs daily from 07:30 UTC for about 2 hours, so its lock can
|
||||
# overlap this fire. The script waits up to 6 h for the lock, checking every 5 min,
|
||||
# and defers the week only if borg is still running after that.
|
||||
OnCalendar=Sat *-*-* 05:00:00
|
||||
RandomizedDelaySec=15m
|
||||
Persistent=true
|
||||
|
||||
@@ -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
|
||||
@@ -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)"
|
||||
|
||||
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" == "active" ]] && ok "$s" || bad "$s is $st"
|
||||
done
|
||||
|
||||
@@ -86,6 +86,10 @@ rbw
|
||||
# The Snapshots page is snapper end to end; see scripts/panama-snapshots.
|
||||
snapper
|
||||
sushi
|
||||
# T3 Code, from Terra: the desktop app, its background server and the `t3` CLI.
|
||||
# panama-agent opens a T3 thread when preferredAgent is t3, and the ticket
|
||||
# skill renders its plans and mocks into T3 threads.
|
||||
t3code
|
||||
# Fonts the desktop names by family and would otherwise render as tofu.
|
||||
# VictorMono is required: it draws every glyph in the shell.
|
||||
victormono-nerd-fonts
|
||||
|
||||
@@ -77,7 +77,10 @@ matched the `digest` GitHub publishes for that release asset. The installer
|
||||
accepts a Codex archive only when `tar -tzf` prints exactly the entries in
|
||||
`codex-package.listing`. Both architectures share that listing. 0.165.0 dropped
|
||||
the bundled `codex-resources/zsh/` and added `codex-resources/voice/`, and the
|
||||
archives grew past 128 MiB, so `CODEX_*_MAX_BYTES` is 256 MiB. To bump Codex,
|
||||
archives grew past 128 MiB, so `CODEX_*_MAX_BYTES` is 256 MiB. The installer keeps the
|
||||
whole verified tree as the version directory and links `~/.local/bin/codex` to
|
||||
its `bin/codex`, because Codex runs `bin/codex-code-mode-host` and reads
|
||||
`codex-path/` and `codex-resources/` relative to itself. To bump Codex,
|
||||
update `installers.conf`, regenerate the listing with `tar -tzf ARTIFACT`, and
|
||||
run `tests/setup/package-provenance-contract`.
|
||||
|
||||
|
||||
@@ -417,20 +417,33 @@ _codex_version_matches() {
|
||||
[[ "$output" =~ (^|[^0-9])${version_pattern}([^0-9]|$) ]]
|
||||
}
|
||||
|
||||
# Codex ships as a package tree: bin/codex finds bin/codex-code-mode-host,
|
||||
# codex-path/ and codex-resources/ beside itself, so the version directory keeps
|
||||
# the whole verified tree and the active link points at its bin/codex.
|
||||
_install_codex() (
|
||||
local artifact_arch version_dir target bin_link parent expected_listing member
|
||||
local stage archive listing staged_binary artifact_digest binary_digest
|
||||
local retire_flat=0
|
||||
stage=""
|
||||
trap '[[ -z "$stage" ]] || rm -rf -- "$stage"' EXIT
|
||||
# A retired flat install goes back if activation never happened.
|
||||
trap '[[ -z "$stage" ]] || { [[ -d "$stage/retired" && ! -e "$version_dir" ]] \
|
||||
&& mv -Tn -- "$stage/retired" "$version_dir"; rm -rf -- "$stage"; }' EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
_set_artifact_arch || return 1
|
||||
version_dir="$HOME/.local/lib/panama/codex/${INSTALLER_PROVENANCE[CODEX_VERSION]}"
|
||||
target="$version_dir/codex"
|
||||
target="$version_dir/bin/codex"
|
||||
bin_link="$HOME/.local/bin/codex"
|
||||
artifact_digest="${INSTALLER_PROVENANCE[CODEX_${artifact_arch}_SHA256]}"
|
||||
binary_digest="${INSTALLER_PROVENANCE[CODEX_${artifact_arch}_BINARY_SHA256]}"
|
||||
if [[ -e "$version_dir" || -L "$version_dir" ]]; then
|
||||
# Earlier installs of this same reviewed artifact kept only the codex binary
|
||||
# at the top of the version directory, without the helpers it needs. That
|
||||
# directory carries our receipt, so it is replaced once the full tree verifies.
|
||||
if [[ ! -e "$version_dir/bin" && ! -L "$version_dir/bin" ]] \
|
||||
&& _runtime_receipt_matches "$version_dir" "$version_dir/codex" \
|
||||
"$artifact_digest" "$binary_digest"; then
|
||||
retire_flat=1
|
||||
elif [[ -e "$version_dir" || -L "$version_dir" ]]; then
|
||||
_runtime_receipt_matches "$version_dir" "$target" \
|
||||
"$artifact_digest" "$binary_digest" || return 1
|
||||
[[ -x "$target" ]] || return 1
|
||||
@@ -459,24 +472,24 @@ _install_codex() (
|
||||
while IFS= read -r member; do
|
||||
_archive_path_is_safe "$member" || { rm -rf -- "$stage"; return 1; }
|
||||
done <<<"$listing"
|
||||
mkdir -m 0700 "$stage/extract" "$stage/version" \
|
||||
mkdir -m 0700 "$stage/version" \
|
||||
|| { rm -rf -- "$stage"; return 1; }
|
||||
tar -xzf "$archive" --no-same-owner --no-same-permissions -C "$stage/extract" \
|
||||
tar -xzf "$archive" --no-same-owner --no-same-permissions -C "$stage/version" \
|
||||
|| { rm -rf -- "$stage"; return 1; }
|
||||
_tree_links_stay_inside "$stage/extract" || { rm -rf -- "$stage"; return 1; }
|
||||
_tree_hardlinks_stay_inside "$stage/extract" || { rm -rf -- "$stage"; return 1; }
|
||||
staged_binary="$stage/extract/bin/codex"
|
||||
_tree_links_stay_inside "$stage/version" || { rm -rf -- "$stage"; return 1; }
|
||||
_tree_hardlinks_stay_inside "$stage/version" || { rm -rf -- "$stage"; return 1; }
|
||||
staged_binary="$stage/version/bin/codex"
|
||||
[[ -f "$staged_binary" && ! -L "$staged_binary" && -x "$staged_binary"
|
||||
&& "$(sha256sum "$staged_binary" | awk '{ print $1 }')" == "$binary_digest" ]] \
|
||||
|| { rm -rf -- "$stage"; return 1; }
|
||||
_codex_version_matches "$staged_binary" \
|
||||
|| { rm -rf -- "$stage"; return 1; }
|
||||
mv -- "$staged_binary" "$stage/version/codex" \
|
||||
|| { rm -rf -- "$stage"; return 1; }
|
||||
_write_runtime_receipt "$stage/version" "$artifact_digest" "$binary_digest" \
|
||||
|| { rm -rf -- "$stage"; return 1; }
|
||||
_activate_directory_no_replace "$stage/version" "$version_dir" \
|
||||
|| { rm -rf -- "$stage"; return 1; }
|
||||
if (( retire_flat )); then
|
||||
mv -Tn -- "$version_dir" "$stage/retired" || return 1
|
||||
fi
|
||||
_activate_directory_no_replace "$stage/version" "$version_dir" || return 1
|
||||
_runtime_receipt_matches "$version_dir" "$target" \
|
||||
"$artifact_digest" "$binary_digest" || { rm -rf -- "$stage"; return 1; }
|
||||
_codex_version_matches "$target" || { rm -rf -- "$stage"; return 1; }
|
||||
|
||||
@@ -157,14 +157,17 @@ while read -r name transport target token_var _rest; do
|
||||
continue
|
||||
fi
|
||||
|
||||
# ${args[@]+...} rather than "${args[@]}": the Mac's bash 3.2 treats an
|
||||
# empty array as unset under set -u and aborts the whole stage.
|
||||
#
|
||||
# Re-registering is how a new server or a rotated token reaches the
|
||||
# runtime. Removing first avoids the CLI refusing a name it already knows.
|
||||
# Neither call may print the token, so both are quiet unless they fail.
|
||||
claude mcp remove "$name" -s user >/dev/null 2>&1 || true
|
||||
if [[ "$transport" == stdio ]]; then
|
||||
add=(claude mcp add "$name" -s user "${args[@]}" -- "$launcher")
|
||||
add=(claude mcp add "$name" -s user ${args[@]+"${args[@]}"} -- "$launcher")
|
||||
else
|
||||
add=(claude mcp add --transport "$transport" "$name" "$url" "${args[@]}" -s user)
|
||||
add=(claude mcp add --transport "$transport" "$name" "$url" ${args[@]+"${args[@]}"} -s user)
|
||||
fi
|
||||
if "${add[@]}" >/dev/null 2>&1; then
|
||||
log "Registered $name."
|
||||
|
||||
@@ -69,10 +69,12 @@ displace() {
|
||||
|
||||
linked=0
|
||||
copied=0
|
||||
os="$(uname -s | tr '[:upper:]' '[:lower:]')"
|
||||
|
||||
while read -r kind source destination; do
|
||||
while read -r kind source destination only_on _rest; do
|
||||
[[ -n "${kind:-}" ]] || continue
|
||||
[[ "$kind" == \#* ]] && continue
|
||||
[[ -z "${only_on:-}" || "$only_on" == "$os" ]] || continue
|
||||
|
||||
src="$USER_DIR/$source"
|
||||
dst="${destination/#\~/$HOME}"
|
||||
|
||||
@@ -27,8 +27,11 @@
|
||||
# 6. panama-agent-crash carries all four coredump facts and the absolute path
|
||||
# to the skill. The path is what makes the ladder work for an agent whose
|
||||
# harness has no skill mechanism.
|
||||
# 7. T3 Code opens no terminal. It starts a thread in the checkout's project
|
||||
# with the prompt intact on stdin, and opens that thread's t3code:// link.
|
||||
#
|
||||
# Hermetic on purpose: kitty, claude, codex, setsid and coredumpctl are all
|
||||
# Hermetic on purpose: kitty, claude, codex, t3, xdg-open, setsid and
|
||||
# coredumpctl are all
|
||||
# stubbed onto PATH and the settings file is fabricated, so no agent is ever
|
||||
# launched and no window is ever opened. This is NOT in tests/desktop-hijacking
|
||||
# for exactly that reason -- it must stay runnable mid-session.
|
||||
@@ -79,6 +82,23 @@ for agent in claude codex; do
|
||||
STUB
|
||||
done
|
||||
|
||||
# T3 Code's CLI. It records each call and its stdin, and answers `thread start`
|
||||
# with a thread id the way the real one does.
|
||||
cat >"$stub/t3" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\0' "\$@" >>"$work/t3-calls"
|
||||
printf '\n' >>"$work/t3-calls"
|
||||
if [[ "\$1 \$2" == "thread start" ]]; then
|
||||
cat >"$work/t3-stdin"
|
||||
printf 'thread-123\n'
|
||||
fi
|
||||
STUB
|
||||
|
||||
cat >"$stub/xdg-open" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
printf '%s' "\$1" >"$work/opened"
|
||||
STUB
|
||||
|
||||
# A hand-run PID has no core here; the real one would be a slow lookup against
|
||||
# this machine's journal.
|
||||
cat >"$stub/coredumpctl" <<'STUB'
|
||||
@@ -104,7 +124,9 @@ chmod +x "$stub"/*
|
||||
# from the previous run are cleared first, so "was not launched" is a missing
|
||||
# file rather than a stale one.
|
||||
launch() {
|
||||
rm -f "$work/argv" "$work/cwd" "$work/setsid-used" "$work/agent-actually-ran"
|
||||
rm -f "$work/argv" "$work/cwd" "$work/setsid-used" "$work/agent-actually-ran" \
|
||||
"$work/t3-calls" "$work/t3-stdin" "$work/opened"
|
||||
T3CODE_HOME="$work/t3home" \
|
||||
PATH="$stub:/usr/bin:/bin" \
|
||||
PANAMA_PATH="$repo_dir" \
|
||||
PANAMA_AGENT_SETTINGS="$settings" \
|
||||
@@ -252,6 +274,35 @@ else
|
||||
note 'launching codex with auto-approve off opened no terminal'
|
||||
fi
|
||||
|
||||
# ── 7. T3 Code ──────────────────────────────────────────────────────────────
|
||||
|
||||
# The recorded t3 calls, one per line, arguments joined by \x1f.
|
||||
t3_calls() { tr '\0' '\037' <"$work/t3-calls" 2>/dev/null; }
|
||||
|
||||
mkdir -p "$work/t3home/userdata"
|
||||
printf 'env-456' >"$work/t3home/userdata/environment-id"
|
||||
|
||||
printf '{"preferredAgent":"t3","agentAutoApprove":true}\n' >"$settings"
|
||||
dash_prompt=$'--looks-like-a-flag\nsecond line'
|
||||
launch "$launcher" --prompt "$dash_prompt"
|
||||
status=$?
|
||||
|
||||
(( status == 0 )) || note "panama-agent exited $status launching T3 Code: $(head -1 "$work/stderr")"
|
||||
[[ -e "$work/argv" ]] && note 'T3 Code was launched in a terminal; it should open a thread in the app'
|
||||
t3_calls | grep -qxF "thread"$'\x1f'"start"$'\x1f'"$repo_dir"$'\x1f'"-"$'\x1f' \
|
||||
|| note 'T3 Code was not asked to start a thread in the checkout with the prompt on stdin'
|
||||
[[ "$(cat "$work/t3-stdin" 2>/dev/null)" == "$dash_prompt" ]] \
|
||||
|| note 'the T3 thread did not receive the prompt intact on stdin'
|
||||
[[ "$(cat "$work/opened" 2>/dev/null)" == "t3code://threads/env-456/thread-123" ]] \
|
||||
|| note "T3 Code opened '$(cat "$work/opened" 2>/dev/null)' rather than the new thread's link"
|
||||
|
||||
# No prompt means nothing to start: the app opens on the project.
|
||||
launch "$launcher"
|
||||
t3_calls | grep -qF "thread"$'\x1f'"start" \
|
||||
&& note 'with no prompt T3 Code still started a thread'
|
||||
t3_calls | grep -qxF "app"$'\x1f'"$repo_dir"$'\x1f' \
|
||||
|| note 'with no prompt T3 Code was not opened on the checkout'
|
||||
|
||||
# ── An agent installed where only the user's own PATH looks ─────────────────
|
||||
#
|
||||
# Both agents install themselves into ~/.local/bin. The rungs are clicked from
|
||||
|
||||
@@ -1375,8 +1375,11 @@ grep -qFx 'nvm:alias default 24.20.0' "$test_tmp/cases/node-x86_64/commands.log"
|
||||
[[ "$($test_tmp/cases/codex-x86_64/home/.local/bin/codex --version)" == \
|
||||
'codex-cli 0.165.0' ]] || fail 'x86_64 Codex activation has the wrong version'
|
||||
[[ "$(readlink "$test_tmp/cases/codex-x86_64/home/.local/bin/codex")" == \
|
||||
"$test_tmp/cases/codex-x86_64/home/.local/lib/panama/codex/0.165.0/codex" ]] \
|
||||
"$test_tmp/cases/codex-x86_64/home/.local/lib/panama/codex/0.165.0/bin/codex" ]] \
|
||||
|| fail 'Codex did not atomically activate the reviewed version path'
|
||||
[[ -x "$test_tmp/cases/codex-x86_64/home/.local/lib/panama/codex/0.165.0/bin/codex-code-mode-host" \
|
||||
&& -f "$test_tmp/cases/codex-x86_64/home/.local/lib/panama/codex/0.165.0/codex-package.json" ]] \
|
||||
|| fail 'Codex dropped the helpers its package ships beside bin/codex'
|
||||
|
||||
# Unsupported CPUs stop before curl. RustDesk's reviewed RPM is x86_64-only,
|
||||
# so aarch64 is also an intentional, recorded soft failure without a download.
|
||||
@@ -1511,7 +1514,7 @@ done
|
||||
# Successful updates keep the old version directory and switch only the active
|
||||
# symlink after the replacement binary has passed its version check.
|
||||
for component_spec in 'Bun install_bun .bun/bin/bun .bun/versions/1.4.0/bin/bun' \
|
||||
'Codex install_codex .local/bin/codex .local/lib/panama/codex/0.165.0/codex'; do
|
||||
'Codex install_codex .local/bin/codex .local/lib/panama/codex/0.165.0/bin/codex'; do
|
||||
read -r component function_name active_relative target_relative <<<"$component_spec"
|
||||
name="${component,,}-atomic-update"
|
||||
reset_installer_fixture
|
||||
@@ -1528,12 +1531,34 @@ for component_spec in 'Bun install_bun .bun/bin/bun .bun/versions/1.4.0/bin/bun'
|
||||
fi
|
||||
done
|
||||
|
||||
# Installs before the package-layout fix kept only a receipted flat
|
||||
# 0.165.0/codex. That directory is ours, so the full tree replaces it.
|
||||
name=codex-flat-repair
|
||||
reset_installer_fixture
|
||||
STUB_SEED_OLD=Codex expect_success run_installer_function "$name" install_codex
|
||||
home="$test_tmp/cases/$name/home"
|
||||
flat="$home/.local/lib/panama/codex/0.165.0"
|
||||
rm -rf -- "$flat"
|
||||
mkdir -p -- "$flat"
|
||||
cp "$artifact_root/codex-x86_64.bin" "$flat/codex"
|
||||
chmod +x "$flat/codex"
|
||||
printf 'schema=1\nartifact_sha256=%s\nbinary_sha256=%s\n' \
|
||||
e97c4463d7f146692a955d7b0e1c8cf48f97cd4d616cd45c5dcab2e8d9743316 \
|
||||
c06a67fd8ac76508dc78bd99ce20c9f317b8d568beda59485f3f4134df9962d5 > "$flat/.panama-provenance"
|
||||
ln -sfn "$flat/codex" "$home/.local/bin/codex"
|
||||
STUB_REUSE_CASE=1 expect_success run_installer_function "$name" install_codex
|
||||
[[ "$(readlink "$home/.local/bin/codex")" == "$flat/bin/codex" \
|
||||
&& -x "$flat/bin/codex-code-mode-host" && ! -e "$flat/codex" ]] \
|
||||
|| fail "$name did not replace the flat install with the package tree"
|
||||
[[ ! -s "$test_tmp/cases/$name/softly-failed" ]] || fail "$name recorded a soft failure"
|
||||
assert_no_runtime_staging "$name"
|
||||
|
||||
# Receipt or executable tampering preserves the directory and leaves an older
|
||||
# active link unchanged. A matching version string is not an attestation.
|
||||
for tamper_spec in \
|
||||
'Node install_node .nvm/versions/node/v24.20.0 bin/node v24.20.0' \
|
||||
'Bun install_bun .bun/versions/1.4.0 bin/bun 1.4.0' \
|
||||
'Codex install_codex .local/lib/panama/codex/0.165.0 codex codex-cli_0.165.0'; do
|
||||
'Codex install_codex .local/lib/panama/codex/0.165.0 bin/codex codex-cli_0.165.0'; do
|
||||
read -r component function_name target_relative binary_relative version_text <<<"$tamper_spec"
|
||||
for tamper_kind in receipt binary; do
|
||||
name="${component,,}-$tamper_kind-tamper"
|
||||
|
||||
@@ -46,13 +46,15 @@ done < <(find "$repo_dir/user" -type f \( -name 'id_*' -o -name '*.pem' -o -name
|
||||
|
||||
# ── Every manifest line is usable ───────────────────────────────────────────
|
||||
|
||||
while read -r kind source destination; do
|
||||
while read -r kind source destination only_on; do
|
||||
[[ -e "$repo_dir/user/$source" ]] \
|
||||
|| note "the manifest points at $source, which is not in user/"
|
||||
[[ "$kind" == link || "$kind" == linkdir || "$kind" == copy ]] \
|
||||
|| note "the manifest uses an unknown kind: $kind"
|
||||
[[ "$destination" == '~/'* ]] \
|
||||
|| note "the manifest destination $destination is not under the home directory"
|
||||
[[ -z "$only_on" || "$only_on" == linux || "$only_on" == darwin ]] \
|
||||
|| note "the manifest names an unknown os: $only_on"
|
||||
done < <(grep -vE '^\s*(#|$)' "$manifest")
|
||||
|
||||
# ── The behaviour, against a fake home ──────────────────────────────────────
|
||||
@@ -77,6 +79,7 @@ linkdir agents/skills ~/.agents/skills
|
||||
linkdir agents/skills ~/.claude/skills
|
||||
copy plain.txt ~/.config/plain.txt
|
||||
link missing.txt ~/.config/missing.txt
|
||||
copy plain.txt ~/.config/elsewhere.txt nosuchos
|
||||
FIXTURE
|
||||
|
||||
home="$work/home"
|
||||
@@ -141,6 +144,11 @@ fi
|
||||
grep -q 'missing.txt' "$work/log" \
|
||||
|| note 'a skipped manifest entry was not reported'
|
||||
|
||||
# A line naming another OS is for another machine. The Mac takes the agent half
|
||||
# of user/ and none of the Linux desktop half.
|
||||
[[ -e "$home/.config/elsewhere.txt" ]] \
|
||||
&& note 'a manifest entry for another OS was applied here'
|
||||
|
||||
# ── The answer sticks ───────────────────────────────────────────────────────
|
||||
#
|
||||
# So that `panama upgrade` on a configured machine relinks without an interview,
|
||||
|
||||
@@ -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
|
||||
deleted, under a name that says where it came from.
|
||||
|
||||
A line can end with `linux` or `darwin` to apply only on that OS. The Mac mini
|
||||
is not a Panama desktop, but it runs agents, so it takes the agent half of this
|
||||
directory and skips the SSH config, `pwswitch` and espanso. Panama's shipped
|
||||
skills describe the Linux desktop, so the Mac runs `link-user` and `link-mcp`
|
||||
only, never `link-skills`.
|
||||
|
||||
## What does not go in here
|
||||
|
||||
Anything secret. This repository is readable by anyone who finds it, and the
|
||||
|
||||
+17
-3
@@ -18,8 +18,6 @@ I wanted to share some of my preferences here so we can be more aligned as we wo
|
||||
- Tests are good! Endless smoke tests, "regression tests" for feature deletions, etc, much less good. Tests should be focused. Not slop.
|
||||
- Comments are a great way to clarify functionality & how code is used. Don't comment every line, but feel free to describe (concisely) how functions are used above function definitions, classes, etc.
|
||||
- Keep all comments and documentation up to date! When making changes, it is important to keep everything in sync.
|
||||
- Avoid starting long running dev servers by default; assume the user may already have one running unless the task requires otherwise.
|
||||
- If the user does not have a dev server running, or if you need to restart the dev server, ask the user to do so.
|
||||
|
||||
## TypeScript Preferences
|
||||
|
||||
@@ -53,11 +51,27 @@ I wanted to share some of my preferences here so we can be more aligned as we wo
|
||||
|
||||
# Visual & Design Work
|
||||
|
||||
- Do not edit real components first unless asked to by the user. For any non-trivial UI, layout, or copy change, build serveral distinct static mocks, publish them with a simple http server, report the URL, and stop. Wait for a decision before implementing.
|
||||
- Do not edit real components first unless asked to by the user. For any non-trivial UI, layout, or copy change, build several distinct static mocks, show them to me, and stop. Wait for a decision before implementing.
|
||||
- In T3 Code, show each mock in the thread with `html_render`, and link the mock files by absolute path so they open full size in T3's file viewer. No server.
|
||||
- Outside T3 Code, publish them with a simple http server and report the URL.
|
||||
- Avoid continuously repainting CSS animations (pulse, shimmer, blur, spinners); they peg the GPU on high-refresh displays.
|
||||
|
||||
---
|
||||
|
||||
# T3 Code
|
||||
|
||||
I run my agents in T3 Code, and I want to stay in it rather than juggle terminals and browser windows. A thread has `T3CODE_THREAD_ID` set.
|
||||
|
||||
- Show visuals in the thread: charts, tables, mocks, plans, to-do lists. `html_preview`, then `html_render`.
|
||||
- Link files by absolute path. HTML, PDF, images and Markdown open rendered in T3's file viewer, beside the thread. Do not start an http server just so I can look at a file.
|
||||
- Reach my other machines through T3's linked environments (`t3_environment_links`, then that environment's id), not through ssh, unless I ask for ssh.
|
||||
- Every subagent goes through `delegate_task`, whatever the provider. That includes same-provider work, which T3's own prompt says can use native tools: my rule wins. Native `Agent`, `Workflow`, Codex's `spawn_agent`, `claude -p`, `codex exec`, and `opencode run` are only for when I ask for one by name. A hook blocks the Claude Code routes and tells you how to pass when I have asked.
|
||||
- `delegate_task` children know only their `task` text, and `/skill` lines do not expand there, so tell a child to call the Skill tool. A local child always runs in this thread's project.
|
||||
- Klaude (`claudeAgent_klaude`) is my work Claude account, and it runs `pre-mr-review` and nothing else. Omit `model` to get its default. Everything else, reviews included, runs on my own accounts.
|
||||
- T3's preview browser is the default for checking an app or capturing proof, and I can sign in there. Claude in Chrome is still the browser for my own signed-in sessions.
|
||||
|
||||
---
|
||||
|
||||
# Blast Radius
|
||||
|
||||
- Never touch production, live databases, or daily-driver build/preview channels unless explicitly asked to. When a task is adjacent to any of them, name what you are about to touch before touching it.
|
||||
|
||||
Executable
+37
@@ -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
|
||||
@@ -1,8 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# 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
|
||||
# captured with, so it belongs on every machine rather than only where a Claude
|
||||
# Code plugin happened to be installed.
|
||||
# forms, end-to-end runs. It belongs on every machine rather than only where a
|
||||
# Claude Code plugin happened to be installed.
|
||||
#
|
||||
# link-mcp registers this file as a stdio server. It takes no token, so its
|
||||
# row in user/agents/mcp/servers names no variable and nothing arrives in
|
||||
|
||||
@@ -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.
|
||||
# No token, hence no variable in the last column.
|
||||
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` /
|
||||
`task.md`), creating directories as needed. These are personal docs, never
|
||||
committed.
|
||||
9. **Report.** Give the Jira link, the title, and any numbered Notes that still name an
|
||||
open owner, and link the local copy by absolute path so it opens rendered in T3's
|
||||
file viewer.
|
||||
|
||||
## House rules for all content
|
||||
|
||||
|
||||
@@ -64,7 +64,11 @@ This is the rule from my preferences, spelled out:
|
||||
way in one sentence, they are the same mock.
|
||||
2. Build them as static pages. Real content, not grey boxes labelled "image here", because
|
||||
the content is where most of the design decisions turn out to matter.
|
||||
3. 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.
|
||||
|
||||
Do not edit real components first. Do not keep polishing past the point where a choice
|
||||
|
||||
@@ -121,8 +121,8 @@ Announce the path, then create a todo per item and work them in order.
|
||||
immediately rather than spending a round on the details of something that needs splitting first.
|
||||
Each sub-project earns its own design cycle.
|
||||
- **Visual questions get mocks, not prose.** Where the open question is what something should look
|
||||
like, stop describing it: build several distinct static mocks, serve them, report the URL, and wait
|
||||
for a pick. Never edit a real component to answer a layout question.
|
||||
like, stop describing it: build several distinct static mocks, show them in the thread (or serve
|
||||
them and report the URL outside T3 Code), and wait for a pick. Never edit a real component to answer a layout question.
|
||||
|
||||
## The written spec (architectural only)
|
||||
|
||||
|
||||
@@ -40,16 +40,14 @@ an implementation with extra steps.
|
||||
routing and file conventions; invent no new top-level structure.
|
||||
2. **Trivial to run.** A UI prototype is one URL. A logic demo is one file that opens on a
|
||||
double-click. No thinking required to start it.
|
||||
3. **Do not start a dev server yourself.** Assume one may already be running. Where the prototype
|
||||
needs a server that is not up, or needs a restart, ask rather than starting it.
|
||||
4. **No persistence by default.** State lives in memory. Persistence is usually the thing being
|
||||
3. **No persistence by default.** State lives in memory. Persistence is usually the thing being
|
||||
*checked*, not something to depend on. Where the question genuinely involves the database, point
|
||||
at a scratch one named so nobody mistakes it.
|
||||
5. **Skip the polish.** No tests, no error handling beyond what makes it run, no abstractions. A
|
||||
4. **Skip the polish.** No tests, no error handling beyond what makes it run, no abstractions. A
|
||||
prototype that needs tests has stopped being a prototype.
|
||||
6. **Surface the state.** After every action, or on every variant switch, render the full relevant
|
||||
5. **Surface the state.** After every action, or on every variant switch, render the full relevant
|
||||
state so the change is visible.
|
||||
7. **Answer one question.** No "what if we also wanted X later".
|
||||
6. **Answer one question.** No "what if we also wanted X later".
|
||||
|
||||
## Capture it when the answer arrives
|
||||
|
||||
|
||||
@@ -13,9 +13,10 @@ Where the question is about logic or state rather than appearance, this is the w
|
||||
| **Static mocks** (default) | The question is layout, hierarchy, or visual direction. Standalone HTML, no app involved. | Minutes. Zero risk to the app. |
|
||||
| **In-app variants** | The question is how it behaves against **real** data, density, and chrome. A layout that looks fine on lorem ipsum falls apart on a real row count. | Higher. Touches the repo. |
|
||||
|
||||
**Start with static mocks.** They are the house default: several distinct standalone pages, served by
|
||||
a simple static server, URL handed over. Escalate to in-app variants only where real data is
|
||||
genuinely load-bearing to the judgement, and say why when you do.
|
||||
**Start with static mocks.** They are the house default: several distinct standalone pages, shown in
|
||||
the T3 Code thread (or, outside T3, served by a simple static server with the URL handed over).
|
||||
Escalate to in-app variants only where real data is genuinely load-bearing to the judgement, and
|
||||
say why when you do.
|
||||
|
||||
## Static mocks (default)
|
||||
|
||||
@@ -23,8 +24,13 @@ Write each variant as a standalone HTML file in a scratch directory outside the
|
||||
the project's design language closely enough to judge (Tailwind via CDN is fine here, since nothing
|
||||
ships).
|
||||
|
||||
Serve the directory with something already installed, such as `python3 -m http.server`, and hand over
|
||||
the URL plus the filenames. Then stop.
|
||||
In T3 Code, render each variant in the thread with `html_preview` then `html_render`, one variant per
|
||||
render, and link each file by absolute path so it opens full size in T3's file viewer. Keep each
|
||||
variant one file: the viewer gives a file outside the workspace no neighbouring styles, scripts or
|
||||
images. `html_render` overrides a page background, so pass a variant that paints its own inside an
|
||||
`<iframe srcdoc>` (the ticket skill's `ticket-page view` does this). Outside T3, serve the directory
|
||||
with something already installed, such as `python3 -m http.server`, and hand over the URL plus the
|
||||
filenames. Then stop.
|
||||
|
||||
Never edit a real component to answer a layout question.
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@ Invoked as `/review-ticket KACP-XXXXX`. Two phases with a hard stop between them
|
||||
This is a *dev review*, not a code review. It is part of writing a ticket, before any
|
||||
code exists: it fills in implementation guidance, an estimate, a risk level, and the
|
||||
testing tables so a developer can pick the story up. Reviewing code that has already
|
||||
been written is a separate thing entirely, and lives in the `ticket` skill at Phase 2
|
||||
step 5.
|
||||
been written is a separate thing entirely, and lives in the `ticket` skill's
|
||||
`COUNCIL.md`.
|
||||
|
||||
## Files this skill uses
|
||||
|
||||
@@ -63,7 +63,8 @@ step 5.
|
||||
- Prose sweep: call the Skill tool with "unslop" and apply its pattern detection to `review.md`, meaning puffery, superficial -ing phrases, "not just X but Y", vague attributions, and rule of three padding, alongside the punctuation rules above. Skip its "Adding soul" section, the voice here is the Lead writing to the implementing developer, not a blog. Henry reads every one of these, and a review that reads as machine generated loses its credibility before its content is weighed.
|
||||
- Anatomy check against the section list below.
|
||||
- Confirm no Jira write has happened.
|
||||
12. Stop. Hand the user the draft with the estimate, risk level, dependency list, and any split recommendation surfaced in the summary, and wait for their review. Do not apply in the same run unless the user has already told you to.
|
||||
12. If the Story Risk Level is MEDIUM or above, or the story changes the datamodel, get the draft checked before Gib reads it. Read **Seats** and **Checking a dev review** in `~/.agents/skills/ticket/COUNCIL.md`, start one `delegate_task` per seat, and end your turn. T3 wakes you as each finishes. Verify each finding against the code yourself, fix what is real in `review.md`, and rerun the path check and prose sweep on what changed. A seat that cannot run is named in the summary, never replaced with another model. Outside T3 Code, skip this step and say so.
|
||||
13. Stop. Link `review.md` by absolute path so it opens rendered in T3's file viewer. Hand the user the draft with the estimate, risk level, dependency list, any split recommendation, and what the seats changed surfaced in the summary, and wait for their review. Do not apply in the same run unless the user has already told you to.
|
||||
|
||||
## Phase 2, apply
|
||||
|
||||
|
||||
@@ -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>
|
||||
```
|
||||
@@ -7,7 +7,6 @@ sees. It is a personal working file, exempt from the prose bar like the rest of
|
||||
|
||||
```
|
||||
.claude/docs/epics/
|
||||
_site/site.css, site.js # shared renderer, refreshed from templates/site/ on every write
|
||||
<EPIC-KEY>/
|
||||
index.html, epic.data.js # the epic index, in build order
|
||||
<TICKET-KEY>/
|
||||
@@ -16,6 +15,37 @@ sees. It is a personal working file, exempt from the prose bar like the rest of
|
||||
tickets/<TICKET-KEY>/ # same shape, for a ticket with no epic, and no index
|
||||
```
|
||||
|
||||
Each `index.html` carries the renderer from `templates/site/` and its own data inline, rewritten
|
||||
on every write, so it renders alone with no server and no other file. That is what T3 Code's
|
||||
file viewer needs: it sandboxes the page, with no `fetch` or `localStorage`, and serves files
|
||||
only from the page's own folder and below. The `.data.js` beside it is the store `ticket-page`
|
||||
reads and writes. An open page polls it to stay live, and shows its last write if it cannot.
|
||||
Screenshots and attachments stay as relative files under the ticket folder.
|
||||
|
||||
## In T3 Code
|
||||
|
||||
Gib follows the work in a T3 Code thread, so the page and the plan come to him there.
|
||||
|
||||
- **The live page.** `ticket-page open <KEY>` refreshes the page and prints its absolute path.
|
||||
Link that path in your reply, and it opens in T3's file viewer beside the thread. Outside T3 (no `T3CODE_THREAD_ID`) it also opens the browser.
|
||||
Never start an http server for the page.
|
||||
- **Inline snapshots.** `ticket-page view <KEY> <view>` prints a self-contained page styled
|
||||
with T3's theme. Pass its output, verbatim, to `html_preview`, check the screenshot, then
|
||||
to `html_render` with the `contentHeight` it reported (capped at 2000) as the height. Do not
|
||||
restate what the snapshot shows in your reply. Snapshots do not update, so render one at
|
||||
the moment it is for, not on every write.
|
||||
|
||||
| Moment | View |
|
||||
|---|---|
|
||||
| Plan ready for approval (Phase 1, last step) | `plan`, then `mocks --mock <id>` once per artboard |
|
||||
| Starting pre-mr-review, final hand-off, and on resume | `progress` |
|
||||
|
||||
- One artboard per render, so each can be expanded full screen on its own and options sit
|
||||
one after another for comparison. `view` refuses a page over html_render's 512 KB and
|
||||
names the artboards to render one at a time.
|
||||
- Keep artboards light: CSS and real copy, not base64 screenshots. A screenshot pasted into an
|
||||
artboard as a data URI is most of its size, and every byte goes through the conversation.
|
||||
|
||||
## Write through `ticket-page`, always
|
||||
|
||||
`~/.agents/skills/ticket/scripts/ticket-page` is the only writer of the data files. It validates
|
||||
@@ -31,7 +61,7 @@ the repo.
|
||||
|
||||
| Moment | Command |
|
||||
|---|---|
|
||||
| Issue fetched to a scratch path | `init <KEY> --issue <issue.raw.json>`. Prints the ticket folder, then any populated Jira fields the page does not show. |
|
||||
| Issue fetched to a scratch path | `init <KEY> --issue <issue.raw.json>`. Prints the ticket folder, then any populated Jira fields the page does not show. Then `open <KEY>` and link the path. |
|
||||
| Attachments downloaded, a video transcribed, frames extracted | `scan <KEY>` |
|
||||
| Ticket branch resolved, ticket classified | `meta <KEY> --branch <name> --classified bounded` |
|
||||
| Research notes worth keeping: what a video says, what a spreadsheet holds, a check against prod | `note <KEY> "<Title>" --file notes.html` |
|
||||
@@ -40,8 +70,8 @@ the repo.
|
||||
| Starting a step | `step <KEY> <n> running` |
|
||||
| A step's commit exists | `step <KEY> <n> done --commit <sha>`, one `--commit` per commit |
|
||||
| A criterion is met | `criterion <KEY> <ID> done --evidence "<what shows it>"` |
|
||||
| Proof captured, or dropped from `proof/` | `proof <KEY> --file proof/<name> --proves AC1,AC3 --caption "<test case>" [--mock <id>]`, `proof <KEY> --test "<name>" ...`, `unproof <KEY> <file>` |
|
||||
| Reviewer finding, and its resolution | `finding <KEY> add "<text>" --severity CONFIRMED --where <file:line>`, then `finding <KEY> F1 fixed --commit <sha>` or `rejected --reason "<why>"` |
|
||||
| Proof captured (image, mp4, or text), or dropped from `proof/` | `proof <KEY> --file proof/<name> --proves AC1,AC3 --caption "<test case>" [--mock <id>]`, `proof <KEY> --test "<name>" ...`, `unproof <KEY> <file>` |
|
||||
| Council finding, and its resolution | `finding <KEY> add "[should] <text> (<seats>)" --severity CONFIRMED --where <file:line>`, then `finding <KEY> F1 fixed --commit <sha>` or `rejected --reason "<why>"` |
|
||||
| pre-mr-review audit read | `verdict <KEY> <verdict text> --recommendation <R> --head <sha>` |
|
||||
| A Jira field PUT and verified | `jira <KEY> "<field name>"` |
|
||||
| `mr.md` written | `mr <KEY>` |
|
||||
@@ -135,7 +165,7 @@ The rest of the index is yours to seed and keep true.
|
||||
## Reading it as an agent
|
||||
|
||||
`ticket-page md <KEY>` prints the ticket and the plan as markdown. Use it when you resume a
|
||||
ticket, and hand it to the fresh reviewer as the ticket and plan in its reading order.
|
||||
ticket, and write it to a scratch file for the council and the audit, which read it there.
|
||||
|
||||
## Tickets from before the page
|
||||
|
||||
|
||||
+154
-148
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: ticket
|
||||
description: End-to-end Jira ticket workflow — fetch a ticket into a live ticket page under .claude/docs/epics/, plan it, implement it with clean commits, drive the pre-mr-review audit to convergence (the user runs it themselves from their work Claude account), and write the MR doc. Use when the user gives you a Jira ticket key (e.g. KACP-11111) to work, or asks to plan/implement/wrap up a ticket.
|
||||
description: End-to-end Jira ticket workflow — fetch a ticket into a live ticket page under .claude/docs/epics/, plan it, implement it with clean commits, review it with a council of models, drive the pre-mr-review audit to convergence on the work Claude account, and write the MR doc. Use when the user gives you a Jira ticket key (e.g. KACP-11111) to work, or asks to plan/implement/wrap up a ticket.
|
||||
---
|
||||
|
||||
# Ticket Workflow
|
||||
@@ -16,7 +16,7 @@ again later with the same key, it resumes wherever it left off — see **Phase 0
|
||||
Not to be confused with `/review-ticket`. That skill writes the *developer review* on a
|
||||
Jira story, the estimate, risk level, and testing tables, as part of authoring a ticket
|
||||
before anyone builds it. It has nothing to do with reviewing code. The code review in
|
||||
this skill is Phase 2 step 5 and is performed by a dispatched agent, not by a skill.
|
||||
this skill is Phase 2 step 5 and is performed by the review council in [COUNCIL.md](COUNCIL.md).
|
||||
|
||||
## The ticket page
|
||||
|
||||
@@ -25,18 +25,39 @@ Jira has it, the plan with its mocks, live progress, proof beside the mocks, and
|
||||
Every epic gets an **epic index** listing its stories in build order. Both render from data
|
||||
files that only `ticket-page` writes, and both update themselves as it writes.
|
||||
|
||||
Gib works in T3 Code. The page opens in T3's file viewer beside the thread when you link its
|
||||
absolute path, so never start a server for it. At two moments, plan approval and hand-off,
|
||||
the skill also shows a snapshot inline in the thread with `ticket-page view`, so he can read
|
||||
it without leaving the conversation. SITE.md has the commands, under **In T3 Code**.
|
||||
|
||||
**Read [SITE.md](SITE.md) before the first write of every run.** It holds the command for each
|
||||
moment the phases below name, the plan's JSON shape, how to draw mocks, and how the epic order
|
||||
is seeded. Record each moment when it happens, not in a batch at the end, because Gib is
|
||||
watching the page change.
|
||||
|
||||
## Working in T3 Code
|
||||
|
||||
Gib runs this skill in a T3 Code thread (`T3CODE_THREAD_ID` is set) and stays there, so the work
|
||||
comes to him through T3's own tools.
|
||||
|
||||
- **Agents are T3 delegates.** Every agent this skill starts is a `delegate_task`, and a local one
|
||||
always runs in this thread's project. A delegate gets only its `task` text, so write the brief
|
||||
as though it knows nothing. `/skill` lines do not expand there: tell it to call the Skill tool.
|
||||
Start one with `mode: "async"` and end your turn, because T3 wakes you when it finishes.
|
||||
- **Proof comes from T3's browser,** the `preview_*` tools. `/ticket` is the request for browser
|
||||
work that T3's rules ask for, so use them at the proof step. Phase 2 step 9 has the recipe.
|
||||
Claude in Chrome stays for reading Gib's signed-in sites, such as a Jira page.
|
||||
- **Questions go through AskUserQuestion,** with the context, what blocks you, and your
|
||||
recommendation first, because a question in a normal message leaves the thread looking done.
|
||||
|
||||
Without `T3CODE_THREAD_ID`, the fallbacks are named where each one applies.
|
||||
|
||||
## Directory layout this skill maintains
|
||||
|
||||
```
|
||||
.claude/docs/epics/
|
||||
_site/ # the shared renderer, refreshed by ticket-page
|
||||
<EPIC-KEY>/
|
||||
index.html, epic.data.js # the epic index
|
||||
index.html, epic.data.js # the epic index (each index.html carries its own renderer)
|
||||
<TICKET-KEY>/
|
||||
index.html, ticket.data.js # the ticket page
|
||||
resources/
|
||||
@@ -60,9 +81,10 @@ watching the page change.
|
||||
|
||||
- `~/.agents/skills/ticket/scripts/ticket-page <command> <KEY> ...` — the only writer of the
|
||||
ticket page and epic index. See [SITE.md](SITE.md), or `ticket-page help`. Runs under bun.
|
||||
- `~/.agents/skills/ticket/scripts/capture-proof` — sharp Playwright screenshots of the
|
||||
running app for proof and spike screenshots. `capture-proof help` lists its options. The
|
||||
first run installs a pinned Playwright into `~/.cache/ticket-page/`.
|
||||
- `~/.agents/skills/ticket/scripts/capture-proof` — the fallback for proof screenshots: 2x
|
||||
Playwright captures, full page or cropped, for what T3's browser cannot do (Phase 2 step 9).
|
||||
`capture-proof help` lists its options. The first run installs a pinned Playwright into
|
||||
`~/.cache/ticket-page/`.
|
||||
- `~/.agents/skills/ticket/scripts/jira-fetch-issue.sh <KEY> <OUT.json>` — fetches one
|
||||
issue (`fields=*all`, rendered HTML description, field-name map) via `JIRA_BASE_URL` /
|
||||
`JIRA_CREDENTIALS`.
|
||||
@@ -352,11 +374,10 @@ what was quick to wire up. Ship both, and don't let either stand in for the othe
|
||||
- **Visual mocks alongside the code, one or two of them.** The built version is
|
||||
deliberately rough, so on its own it under-sells the idea to anyone reviewing it.
|
||||
Produce one or two polished mocks that aren't constrained by what was cheap to build:
|
||||
the layout, spacing, and density as they should actually be. In Claude Code the
|
||||
`design` skill produces a canvas of artboards for exactly this; in a harness without
|
||||
it, a self-contained HTML page does the same job. These are design intent, not
|
||||
implementation promises, so call out anything a mock shows that the code doesn't do
|
||||
yet.
|
||||
the layout, spacing, and density as they should actually be. A self-contained HTML
|
||||
page does this job, shown through `html_preview` and `html_render`. These are design
|
||||
intent, not implementation promises, so call out anything a mock shows that the code
|
||||
doesn't do yet.
|
||||
- **Scope discipline still applies.** This is a proof of concept, not a production
|
||||
feature. Don't chase every edge case and don't fix unrelated pre-existing bugs you
|
||||
happen to notice along the way, just implement enough of the real thing to be
|
||||
@@ -364,8 +385,8 @@ what was quick to wire up. Ship both, and don't let either stand in for the othe
|
||||
than a static mock would, say so in the plan's approach rather than silently scoping it down
|
||||
without mentioning the tradeoff.
|
||||
- **Screenshot the real thing.** Once it's built and running, use the `run` skill to
|
||||
get the app up and capture the actual screens with `capture-proof`, as the proof step
|
||||
in Phase 2 describes, instead of drawing a wireframe. Save screenshots under
|
||||
get the app up and capture the actual screens as the proof step in Phase 2 describes,
|
||||
instead of drawing a wireframe. Save screenshots under
|
||||
`deliverables/<slug>/screenshots/` and the visual mocks under
|
||||
`deliverables/<slug>/mocks/`, and embed both in `<slug>.typ` via `image()` when
|
||||
building the mockups deliverable. Label which is which. A reader who can't tell a
|
||||
@@ -423,6 +444,11 @@ through, and the branch needs to already exist when that happens.
|
||||
5. **More than one match** — don't guess which one is "the" branch. List them (last
|
||||
commit date and subject each) and ask the user which to use.
|
||||
|
||||
In a T3 worktree thread (`git rev-parse --git-dir` differs from `--git-common-dir`) the
|
||||
default branch is usually checked out elsewhere, so cut from the remote instead:
|
||||
`git fetch origin` and `git checkout -b <name> origin/<default>`. `ticket-page` finds the
|
||||
main checkout's `.claude/docs/` from a worktree on its own.
|
||||
|
||||
Once this resolves you're on the correct branch, and Phase 1/Phase 2 work happens
|
||||
there. `.claude/docs/` itself is gitignored in this repo, so which branch you're on
|
||||
doesn't affect the ticket page or `mr.md` directly — this is about making sure any
|
||||
@@ -449,7 +475,7 @@ place from the start.
|
||||
- Proceed to implementing the existing plan as-is (they reviewed and approved it)
|
||||
- Resume implementation (some steps are already done on the page / some commits
|
||||
already exist on the ticket branch — pick up from the first open step)
|
||||
- Move to the pre-mr-review stage (implementation looks done, just need the audit + MR doc; the user runs the audit from their work account, see Phase 2 step 7)
|
||||
- Move to the pre-mr-review stage (implementation looks done, just need the audit + MR doc, see Phase 2 step 7)
|
||||
Route to **Phase 1** or **Phase 2** accordingly.
|
||||
- **A plan and `mr.md`** — this ticket looks finished. Tell the user
|
||||
`mr.md` already exists at its path and ask whether they want you to refresh it
|
||||
@@ -468,8 +494,8 @@ place from the start.
|
||||
epic (or `tickets/` without one), imports every rich-text field verbatim as sanitized
|
||||
HTML, writes the page, and prints the ticket folder. Copy the fetched JSON to
|
||||
`<target-dir>/resources/issue.raw.json`, which later re-imports and the attachment
|
||||
script read. Record the branch from **Branch setup** with `ticket-page meta`, then open
|
||||
the page with `ticket-page open <KEY>` so Gib can follow from here on.
|
||||
script read. Record the branch from **Branch setup** with `ticket-page meta`, then run
|
||||
`ticket-page open <KEY>` and link the path it prints, so Gib can follow from here on.
|
||||
4. `init` ends by listing populated Jira fields the page does not show. Read each one and
|
||||
add any that bear on implementing or verifying the ticket as a note. This sweep exists
|
||||
because real content sometimes hides in per-project custom fields with unpredictable
|
||||
@@ -512,8 +538,9 @@ place from the start.
|
||||
ticket's real requirements, write what it says as a note (`ticket-page note`), the
|
||||
way "What the video says" summarizes a recording point by point.
|
||||
10. Now actually understand the ticket in the context of this codebase: read whatever
|
||||
source files, tests, or docs are relevant to what's being asked. Use Explore/grep
|
||||
as needed — this is normal engineering research, not scripted. Use the
|
||||
source files, tests, or docs are relevant to what's being asked. Read them yourself:
|
||||
this is normal engineering research, not scripted. Only a sweep too broad for one
|
||||
pass earns a `delegate_task` with `role: "research"`. Use the
|
||||
**Verification tools available** above to check the ticket's own claims against
|
||||
reality (codebase, Jira, staging, and prod/CI env with permission) rather than
|
||||
taking the ticket's description at face value — this is what makes the plan
|
||||
@@ -580,7 +607,9 @@ place from the start.
|
||||
otherwise.
|
||||
12. When a step changes what a user sees, draw its mock artboards with `ticket-page mock`,
|
||||
as [SITE.md](SITE.md) describes under **Mocks**.
|
||||
13. Stop here. Tell the user the plan is on the ticket page, give its path from
|
||||
13. Stop here. Show the plan in the thread: `ticket-page view <KEY> plan`, then each mock
|
||||
artboard with `ticket-page view <KEY> mocks --mock <id>`, each through `html_preview`
|
||||
and then `html_render` (SITE.md, **In T3 Code**). Then link the page path from
|
||||
`ticket-page path <KEY>`, summarize the approach in a couple of sentences, and mention
|
||||
any open questions that need their input before you'd implement it. Do not start
|
||||
implementing in this same run — wait for them to review the plan and invoke
|
||||
@@ -589,7 +618,9 @@ place from the start.
|
||||
|
||||
## Phase 2 — implement, verify, and hand off
|
||||
|
||||
Entered only when the user has confirmed (per Phase 0) that the plan is approved.
|
||||
Entered only when the user has confirmed (per Phase 0) that the plan is approved. From
|
||||
there this phase runs through to the step 10 hand-off without waiting on Gib: the council
|
||||
and the audit both run as delegates. Stop only for a real blocker, asked with AskUserQuestion.
|
||||
|
||||
1. Do **Branch setup** above first, unconditionally — even if Phase 1 already did this
|
||||
for the same ticket earlier, confirm you're still actually on that branch now (the
|
||||
@@ -631,89 +662,51 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
|
||||
wouldn't actually catch, e.g. confirming behavior against real staging data via
|
||||
Infisical, or a specific prod/CI config question, when that's genuinely what it
|
||||
takes to be confident the change works as intended rather than just "looks right."
|
||||
5. Get an independent code review, from a FRESH agent, before running `pre-mr-review`.
|
||||
|
||||
This step exists because `pre-mr-review` cannot do it. That skill is constitutionally
|
||||
read-only: it may not run tests, execute code, probe a database, or run any
|
||||
experiment, and its own rules tell it to mark anything unprovable as "Needs
|
||||
verification" rather than proving it. So it cannot catch a claim that is plausible on
|
||||
the page and false in reality. A reviewer that can actually run things can. It has
|
||||
already caught a mitigation that was proven by construction to be wrong, on a branch
|
||||
whose own audit had blessed it.
|
||||
|
||||
Order matters and is not just convenience. `pre-mr-review`'s output is a readiness
|
||||
verdict that gets pasted verbatim into `mr.md`. Anything that runs after it
|
||||
invalidates that verdict by construction, and you end up rerunning it and rewriting
|
||||
the handoff. Review first, fix, commit, and only then have the user
|
||||
run `pre-mr-review` over the final tree.
|
||||
|
||||
- **Skip this step for genuinely trivial changes**: a copy tweak, a styling fix, a
|
||||
one line correction with no logic in it. Run it whenever the change adds or
|
||||
modifies a procedure, touches schema or migrations, touches permissions or
|
||||
auth, involves concurrency, or has a risk table in its ticket. When unsure, run it.
|
||||
- **Always dispatch a NEW agent.** Never reuse an agent from earlier in this ticket
|
||||
and never review your own work in your own context. The value here is entirely in
|
||||
the reviewer not being the author, so an agent that helped implement or plan the
|
||||
change cannot provide it.
|
||||
- **Give the reviewer its reading order explicitly**, or it produces noise about
|
||||
conventions the project already settled: the epic summary if there is one
|
||||
(`.claude/docs/epics/<EPIC-KEY>/summary.md`), then the ticket and plan as
|
||||
`ticket-page md <KEY>` prints them, then this ticket's `resources/`, then
|
||||
`AGENTS.md`, and only then the diff. Give it the exact commit range.
|
||||
- **Tell it to verify rather than suspect**, to try to refute its own findings before
|
||||
reporting them, to mark each finding CONFIRMED or PLAUSIBLE, and to give exact
|
||||
`file:line` plus a concrete failure scenario. Tell it explicitly not to write,
|
||||
commit, push, or run anything destructive.
|
||||
- **Point it at the claims already made.** Every "proven by X" already written into
|
||||
the ticket's risk or test tables must be re-derived, not taken on trust. A false
|
||||
proof claim in Jira is worse than an unproven one, because it stops anyone else
|
||||
from checking.
|
||||
- **Ask the spec questions explicitly**, or the review only finds bugs in what you
|
||||
did write and never notices what you didn't. Three questions, each answered against
|
||||
the ticket and the plan rather than the diff alone: what the ticket asked for that
|
||||
is missing or only partly done; what behavior is in the diff that nothing asked for;
|
||||
and what looks implemented but is implemented wrongly. Have it quote the AC ID or
|
||||
the plan step each finding traces to.
|
||||
|
||||
Then respond to the review, in writing, and put each finding on the page as you judge
|
||||
it: added, then fixed with its commit or rejected with the reason.
|
||||
|
||||
- **Findings are not automatically true.** Verify each one against the codebase
|
||||
before acting on it. Reviewers do produce confident wrong answers. Say plainly
|
||||
which findings you rejected and why, rather than quietly accepting all of them.
|
||||
- **Fix everything real**, each meaningful fix as its own commit. Do not defer a
|
||||
known hole to a follow-up ticket unless the user decides that.
|
||||
- **When a fix changes a mechanism rather than a line**, write a test that fails
|
||||
against the old behavior and passes against the new one, and actually confirm it
|
||||
fails by reverting the fix. A test that passes either way proves nothing.
|
||||
- **If a finding invalidates something already written into Jira or a deliverable,
|
||||
correct it there too.** The code being fixed does not fix the false claim.
|
||||
- **Terminate deliberately.** Run the reviewer ONCE on the branch. `pre-mr-review`
|
||||
then reviews the whole diff including your fixes and serves as the second pass.
|
||||
Only dispatch a second reviewer if a fix changed a mechanism, not for line level
|
||||
fixes, and never loop "review until clean."
|
||||
5. Convene the review council before `pre-mr-review`. Read [COUNCIL.md](COUNCIL.md) and follow
|
||||
it: it holds the seats, when the step applies, the brief, how to judge what comes back,
|
||||
and when to stop. This step is done when every seat has returned or been recorded as
|
||||
unavailable, and every finding on the page is fixed or rejected with its reason.
|
||||
|
||||
6. Confirm this project actually has a `pre-mr-review` skill (`.claude/skills/pre-mr-review/`
|
||||
in the current repo). If it doesn't, stop here, tell the user implementation and
|
||||
local verification are done but this repo has no `pre-mr-review` skill to run, and
|
||||
let them decide how to proceed.
|
||||
7. **Do NOT invoke the `pre-mr-review` skill yourself.** The org asks that this
|
||||
review run from the developer's separate work Claude account, so the audit on
|
||||
record must come from there, not from this session. Instead:
|
||||
1. Tell the user the branch is ready for its pre-mr-review and ask them to run
|
||||
`/pre-mr-review` from their work account, then let you know when it has
|
||||
finished. Stop and wait, this is a hard gate.
|
||||
2. When they say it ran, read the audit and handoff files it wrote under
|
||||
`.claude/audits/pre-mr/` (the context script from the repo's `pre-mr-review`
|
||||
skill prints the exact paths). Check the `last_reviewed_head` in the audit
|
||||
header matches the current HEAD; if the branch moved after their run, say so
|
||||
and ask them to rerun before acting on a stale audit.
|
||||
3. Read the verdict, and record it on the page with `ticket-page verdict`.
|
||||
7. **Run `pre-mr-review` on the work account, from a Klaude delegate.** The org asks
|
||||
that this review run from the developer's work Claude account, so the audit on record must
|
||||
come from there, never from this session and never from another provider. Klaude is that
|
||||
account (`~/.klaude`). The audit is one agent, not a council.
|
||||
1. Show where the build stands with `ticket-page view <KEY> progress`, through
|
||||
`html_preview` and then `html_render`. Write `ticket-page md <KEY>` to a scratch file.
|
||||
2. Start it, then end your turn. T3 wakes you when it finishes:
|
||||
|
||||
```
|
||||
delegate_task
|
||||
title: pre-mr-review <KEY>
|
||||
role: review
|
||||
mode: async
|
||||
target: { providerInstanceId: "claudeAgent_klaude" }
|
||||
task: Call the Skill tool with "pre-mr-review" and run the audit on this branch.
|
||||
Follow that skill's own rules, and start no subagents. The ticket and its
|
||||
plan are in <scratch file>. <decisions from earlier runs, if any>
|
||||
Reply with the verdict, the recommendation, the audited HEAD, and the audit
|
||||
and handoff paths.
|
||||
```
|
||||
|
||||
Leave `model` out so it runs Klaude's default, and leave `isolation` unset so the audit
|
||||
lands in this checkout. A rerun is always a new `delegate_task`, one at a time.
|
||||
Without `T3CODE_THREAD_ID`, or if Klaude is refused, ask Gib to run `/pre-mr-review`
|
||||
from his work account and wait for him. Never fall back to another provider.
|
||||
3. Read the audit and handoff files it wrote under `.claude/audits/pre-mr/` (the context
|
||||
script from the repo's `pre-mr-review` skill prints the exact paths), not just the
|
||||
delegate's summary. Check the `last_reviewed_head` in the audit header matches the
|
||||
current HEAD. If the branch moved after the run, start a new run before acting on a
|
||||
stale audit.
|
||||
4. Read the verdict, and record it on the page with `ticket-page verdict`.
|
||||
- **Ready to Open MR**: continue to step 8.
|
||||
- **Almost Ready / Not Ready Yet**: fix what it flagged (each meaningful fix
|
||||
as its own commit), then ask the user to rerun the review from the work
|
||||
account and wait again. Repeat until the verdict is Ready to Open MR. Don't
|
||||
write `mr.md` before that verdict is reached.
|
||||
- **Almost Ready / Not Ready Yet**: fix what it flagged (each meaningful fix as its own
|
||||
commit), then start a new run. Repeat until the verdict is Ready to Open MR. Don't
|
||||
write `mr.md` before that verdict is reached. After three runs with no Ready verdict,
|
||||
stop and ask Gib how to proceed.
|
||||
Findings that need no code change (mentions, disclosures) get addressed in
|
||||
`mr.md`'s Additional Notes or the Jira fields as usual.
|
||||
|
||||
@@ -729,22 +722,18 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
|
||||
normal and expected at a Ready verdict. Never treat the recommendation as a
|
||||
blocker or report it as one.
|
||||
|
||||
**Reruns converge by giving the audit more, not by repeating the command.**
|
||||
When a rerun is needed, the user is running it, so every rerun costs them.
|
||||
Make each one count:
|
||||
- **Feed it context on the first run.** It reviews the diff, and left alone it
|
||||
re-derives intent from the branch name and re-raises decisions that are
|
||||
already settled. Ask the user to point it at the ticket's `ticket.data.js`, or give
|
||||
them the output of `ticket-page md <KEY>` to paste in. This is the cheapest thing that reduces
|
||||
repeat findings.
|
||||
- **Answer its decisions explicitly in the rerun.** When it parks a finding
|
||||
under "Decide before opening", write the decision and its reasoning into the
|
||||
prompt for the next run, so it can reclassify rather than re-ask. A silent
|
||||
rerun on an unchanged diff re-asks the same question.
|
||||
- **Say so when it finds new things in unchanged code.** That means the earlier
|
||||
run was an incomplete pass, not that the branch got worse. Tell the user
|
||||
plainly, because it is the audit's problem to fix and it changes how much
|
||||
weight the next run deserves.
|
||||
**Reruns converge by giving the audit more, not by repeating the command.** Each run
|
||||
spends the work account's quota, so make each one count:
|
||||
- **Feed it context on the first run.** It reviews the diff, and left alone it re-derives
|
||||
intent from the branch name and re-raises decisions that are already settled. The
|
||||
scratch file in the task is the cheapest thing that reduces repeat findings.
|
||||
- **Answer its decisions explicitly in the rerun.** When it parks a finding under "Decide
|
||||
before opening", write the decision and its reasoning into the next task, so it can
|
||||
reclassify rather than re-ask. A silent rerun on an unchanged diff re-asks the same
|
||||
question. A decision that is genuinely Gib's goes to him first.
|
||||
- **Say so when it finds new things in unchanged code.** That means the earlier run was an
|
||||
incomplete pass, not that the branch got worse, and it changes how much weight the next
|
||||
run deserves. Tell Gib at hand-off.
|
||||
8. Read `~/.agents/skills/ticket/templates/mr.md` — this is the org's MR template,
|
||||
copied into this skill so it still works even though the original
|
||||
`.claude/docs/mr/template.md` no longer exists in the command-center repo. `mr.md`
|
||||
@@ -753,8 +742,9 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
|
||||
Fill it in for real:
|
||||
- H1: from the ticket and the actual work done, not the ticket title verbatim if the
|
||||
real change ended up narrower/broader.
|
||||
- Summary: one short paragraph max. High level only, what changed, not how. This
|
||||
is not the place for a list of every file touched.
|
||||
- Summary: one short paragraph max. Open with the problem in plain words, then say
|
||||
what changed, not how. Lead with why this MR exists rather than an inventory of what
|
||||
it touches.
|
||||
- Issue Link: `<JIRA_BASE_URL>/browse/<KEY>`.
|
||||
- New ENVs: list any env vars actually introduced; "None" if none.
|
||||
- Additional Notes: a bulleted checklist of the changes you actually made, not a
|
||||
@@ -778,9 +768,9 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
|
||||
the audit's own words, not a retelling of them. Concretely:
|
||||
|
||||
- Never hand-write a handoff section, and never edit one in place. If it is
|
||||
wrong, thin, or stale, ask the user to rerun `/pre-mr-review` from their work
|
||||
account to regenerate the handoff file, then re-paste.
|
||||
- On EVERY rerun of theirs, replace the whole existing handoff block with the whole
|
||||
wrong, thin, or stale, start a new Klaude run (step 7) to regenerate the handoff
|
||||
file, then re-paste.
|
||||
- On EVERY rerun, replace the whole existing handoff block with the whole
|
||||
regenerated one. Do not patch the copy sitting in `mr.md` to match the new
|
||||
head — that is how the two silently diverge, and the version the reviewer
|
||||
reads stops being the version the audit actually produced.
|
||||
@@ -831,34 +821,46 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
|
||||
it to ADF, proof and mitigation cells included. Everything here is read by the Lead
|
||||
and the PM.
|
||||
|
||||
**Proof first.** Before filling any proof column, capture working feature proof
|
||||
yourself wherever possible: run the app (`run` skill) and screenshot the real
|
||||
feature with `capture-proof`, or capture test output for behavior with no UI.
|
||||
**Proof first.** Proof is evidence you observed yourself in this session. Before filling
|
||||
any proof column, capture it wherever possible, using the most direct form the change allows:
|
||||
|
||||
**Screenshots come from Playwright, never from a browser-extension screen grab.**
|
||||
Extension captures come out soft and hard to read, and a proof screenshot exists to be
|
||||
read. `capture-proof shot` renders at twice the pixel density with animations frozen,
|
||||
fonts loaded, and dev overlays hidden:
|
||||
- For a bug, reproduce the failure, apply the fix, and watch the same reproduction pass.
|
||||
- For UI and runtime behavior, run the app (`run` skill) and look at the real feature.
|
||||
Green mocked tests do not prove the user experience works.
|
||||
- For behavior with no UI, capture the test or command output.
|
||||
|
||||
```
|
||||
capture-proof shot http://localhost:3000/companies --wait-for "text=Canonical ID" --out <target-dir>/proof/companies-grid-canonical-ids.png
|
||||
```
|
||||
**Screenshots and recordings come from T3's browser.** It runs in the thread, so Gib can
|
||||
watch and sign in without leaving T3. Use one tab and keep its `tabId`:
|
||||
|
||||
- **Sign-in.** The first `shot` against an app that needs a session stops with a sign-in
|
||||
hint. Ask Gib to run `capture-proof login <origin>` himself: it opens a visible
|
||||
browser, he signs in and closes the window, and every later `shot` against that
|
||||
origin reuses the session. It is saved under `~/.cache/ticket-page/auth/`, never in a
|
||||
repo. The hint only catches a redirect to a sign-in path, so look at every capture
|
||||
before recording it.
|
||||
- **Frame the evidence.** Pass `--wait-for` with the thing the proof is about, so the
|
||||
capture never catches a loading spinner. Crop to the part that matters with
|
||||
`--selector`, or take the whole page with `--full`.
|
||||
- **Interaction first.** For state that takes clicks or typing (an open dialog, a
|
||||
form showing its warning), write a small script,
|
||||
`export default async (page) => { await page.getByLabel("Canonical ID").fill("NIKON"); }`,
|
||||
and pass it with `--script`. Keep scripts in a scratch path, not in `proof/`.
|
||||
- Read every capture with the Read tool before recording it. A crisp picture of the
|
||||
wrong screen is still wrong.
|
||||
1. `preview_open` with the app's URL, or `preview_navigate` with
|
||||
`target: {kind: "environment-port", port}` for a dev server.
|
||||
2. `preview_resize` to `{mode: "freeform", width: 1280, height: 800}`. Saved screenshots
|
||||
top out at 1280x800, so a larger viewport comes out downscaled and soft.
|
||||
3. `preview_wait_for` the thing the proof is about, so the capture never catches a spinner.
|
||||
Drive any state with `preview_snapshot` refs and `preview_act`: open the dialog, fill the
|
||||
form, trigger the warning. Use `preview_set_appearance` for dark mode.
|
||||
4. `preview_snapshot` with `save: true`, then copy `screenshotPath` into `proof/`.
|
||||
5. For a flow a still cannot show, `preview_recording_start`, drive it, then
|
||||
`preview_recording_stop` and copy the mp4 from its `path` into `proof/`. Keep it short:
|
||||
a recording caps at 50 MiB.
|
||||
6. `t3_preview_close` when the proof is done.
|
||||
|
||||
- **Sign-in.** If the tab lands on a sign-in page, ask Gib with AskUserQuestion to sign in in
|
||||
that tab, then continue. Do not drive a login yourself.
|
||||
- **Look before you record it.** Read every capture with the Read tool. A crisp picture of
|
||||
the wrong screen is still wrong.
|
||||
- **The fallback.** Use `capture-proof` when the screen is too dense to read at 1280x800, when
|
||||
the proof needs a full page or an element crop, or outside T3 Code. It renders at 2x with
|
||||
animations frozen and dev overlays hidden:
|
||||
|
||||
```
|
||||
capture-proof shot http://localhost:3000/companies --wait-for "text=Canonical ID" --out <target-dir>/proof/companies-grid-canonical-ids.png
|
||||
```
|
||||
|
||||
Pass `--selector` to crop, `--full` for the whole page, and `--script` for clicks and
|
||||
typing. It keeps its own session, so the first `shot` against an app that needs one stops
|
||||
with a sign-in hint: ask Gib to run `capture-proof login <origin>`. That hint only catches a
|
||||
redirect to a sign-in path.
|
||||
Save artifacts under `<target-dir>/proof/` with names that match the test-case
|
||||
rows they prove, and put each on the page with `ticket-page proof`, naming the
|
||||
criteria it proves and the mock it answers. A proof cell references its artifact by filename plus "attached"
|
||||
@@ -934,7 +936,10 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
|
||||
unanswered.
|
||||
10. Tell the user: implementation is committed on `<branch>`, `pre-mr-review` verdict is
|
||||
Ready to Open MR, the Jira fields are filled (name which), `mr.md` is ready at its
|
||||
path, and the ticket page shows the finished work beside its mocks. Then the manual list, which should only ever be:
|
||||
path, and the ticket page shows the finished work beside its mocks. Show
|
||||
`ticket-page view <KEY> progress` in the thread, embed each proof screenshot by absolute
|
||||
path as ``, and link `mr.md` and the page path. Name any council seat that
|
||||
could not run, and list the nits you declined as follow-ups. Then the manual list, which should only ever be:
|
||||
- push the branch, open the MR, and paste `mr.md` in as the description
|
||||
- upload the named proof or deliverable files (from `proof/` or `deliverables/`)
|
||||
to the ticket, plus anything flagged as proof only Gib can capture
|
||||
@@ -944,6 +949,7 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
|
||||
|
||||
If Phase 0 routes here because some steps are already done on the page: check
|
||||
`git log` on the current branch against the page's step commits to sanity-check they actually
|
||||
match reality (a checked-off step should have a corresponding commit), then continue
|
||||
from the first open step. If the plan and the git history disagree, stop and ask
|
||||
the user rather than guessing which one is right.
|
||||
match reality (a checked-off step should have a corresponding commit), then show
|
||||
`ticket-page view <KEY> progress` in the thread and continue from the first open step.
|
||||
If the plan and the git history disagree, stop and ask the user rather than guessing
|
||||
which one is right.
|
||||
@@ -0,0 +1,125 @@
|
||||
// Thread views: compact, static HTML snapshots of a ticket for T3 Code's html_render, which
|
||||
// shows a page inline in the conversation. The live ticket page stays the full record; these
|
||||
// are what Gib reads without leaving the thread. `ticket-page view` prints one, and the skill
|
||||
// hands it to html_preview, then html_render.
|
||||
//
|
||||
// html_render's rules shape everything here: no background on html, body or the outer
|
||||
// element, colours only from T3's theme variables, no vh units, and a box with its own
|
||||
// background gets 16px padding and var(--radius) corners. No script except the one that
|
||||
// sizes each mock frame to its artboard.
|
||||
|
||||
import type { Criterion, Mock, Step, Ticket } from "./ticket-data";
|
||||
|
||||
export const VIEWS = ["plan", "progress", "mocks"] as const;
|
||||
/** html_render's limit on a page, in characters. */
|
||||
export const RENDER_LIMIT = 512_000;
|
||||
export type View = (typeof VIEWS)[number];
|
||||
|
||||
const esc = (s: string) => s.replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]!);
|
||||
const short = (sha: string) => sha.slice(0, 8);
|
||||
|
||||
const CSS = `
|
||||
body{margin:0;font:14px/1.5 var(--font-sans);color:var(--foreground)}
|
||||
h1{font-size:16px;margin:0 0 2px}h2{font-size:13px;text-transform:uppercase;letter-spacing:.04em;color:var(--muted-foreground);margin:20px 0 8px}
|
||||
p{margin:0 0 8px}code{font:12px var(--font-mono);background:var(--code-background);padding:1px 4px;border-radius:4px}
|
||||
.sub{color:var(--muted-foreground);font-size:12px}
|
||||
.ask{background:var(--warning-surface);color:var(--foreground);border-radius:var(--radius);padding:16px;margin-top:12px}
|
||||
.ask h2{margin-top:0;color:var(--warning)}.ask ol{margin:0;padding-left:20px}
|
||||
ul.rows{list-style:none;margin:0;padding:0}ul.rows li{display:flex;gap:10px;padding:6px 0;border-top:1px solid var(--border)}
|
||||
ul.rows li:first-child{border-top:0}
|
||||
.box{flex:none;width:16px;height:16px;margin-top:2px;border:1.5px solid var(--border);border-radius:4px;display:grid;place-items:center;font-size:11px;line-height:1}
|
||||
.box.done{background:var(--success);border-color:var(--success);color:var(--background)}.box.run{border-color:var(--primary);color:var(--primary)}
|
||||
.grow{flex:1;min-width:0}.tag{font-size:11px;color:var(--muted-foreground);border:1px solid var(--border);border-radius:999px;padding:0 6px;margin-left:6px;white-space:nowrap}
|
||||
.id{font-weight:600;margin-right:6px}.meta{font-size:12px;color:var(--muted-foreground)}
|
||||
table{width:100%;border-collapse:collapse;font-size:13px}td,th{text-align:left;vertical-align:top;padding:6px 8px 6px 0;border-top:1px solid var(--border)}th{font-weight:600;color:var(--muted-foreground);font-size:12px}
|
||||
.bar{height:6px;border-radius:3px;background:var(--secondary);overflow:hidden;margin:4px 0 2px}.bar i{display:block;height:100%;background:var(--success)}
|
||||
.stats{display:flex;gap:24px;flex-wrap:wrap}.stats div{min-width:140px}
|
||||
.mock{margin-top:16px}.mock iframe{display:block;width:100%;height:420px;border:1px solid var(--border);border-radius:var(--radius);background:#fff}
|
||||
`;
|
||||
|
||||
// Collapses each frame before measuring it: an iframe reports its own viewport height as
|
||||
// the document's scrollHeight, so measuring at 420px never reads less than 420.
|
||||
const MOCK_SIZER = `<script>
|
||||
for (const f of document.querySelectorAll('iframe[data-fit]')) f.addEventListener('load', () => {
|
||||
try { f.style.height = '0px'; f.style.height = f.contentDocument.documentElement.scrollHeight + 2 + 'px'; }
|
||||
catch { f.style.height = ''; }
|
||||
});
|
||||
</script>`;
|
||||
|
||||
function page(title: string, body: string, extra = "") {
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><title>${esc(title)}</title><style>${CSS}</style></head><body>${body}${extra}</body></html>`;
|
||||
}
|
||||
|
||||
const box = (status: string) =>
|
||||
`<span class="box${status === "done" ? " done" : status === "running" ? " run" : ""}">${status === "done" ? "✓" : status === "running" ? "•" : ""}</span>`;
|
||||
|
||||
const head = (t: Ticket, what: string) =>
|
||||
`<h1>${esc(t.key)}: ${esc(t.title)}</h1><div class="sub">${esc(what)}${t.branch ? ` on <code>${esc(t.branch)}</code>` : ""}</div>`;
|
||||
|
||||
const stepRow = (s: Step) =>
|
||||
`<li>${box(s.status)}<div class="grow">${s.n}. ${esc(s.text)}${s.kind !== "code" ? `<span class="tag">${s.kind}</span>` : ""}${s.tdd ? `<span class="tag">tests first</span>` : ""}` +
|
||||
`${s.commits.length ? `<div class="meta">${s.commits.map(c => `<code>${esc(short(c))}</code>`).join(" ")}</div>` : ""}</div></li>`;
|
||||
|
||||
const criterionRow = (t: Ticket, c: Criterion) => {
|
||||
const proof = t.proof.filter(p => p.proves.includes(c.id)).length;
|
||||
const extra = [`steps ${c.steps.join(", ")}`, proof ? `${proof} proof` : "", c.evidence ? esc(c.evidence) : ""].filter(Boolean).join(" · ");
|
||||
return `<li>${box(c.status)}<div class="grow"><span class="id">${esc(c.id)}</span>${esc(c.text)}<div class="meta">${extra}</div></div></li>`;
|
||||
};
|
||||
|
||||
const pct = (a: number, b: number) => (b ? Math.round((100 * a) / b) : 0);
|
||||
|
||||
/** The plan as it stands for approval: questions first, because they are what blocks it. */
|
||||
function plan(t: Ticket): string {
|
||||
const pl = t.plan!;
|
||||
const parts = [head(t, pl.approvedAt ? "Plan, approved" : "Plan, awaiting approval")];
|
||||
if (pl.questions.length) parts.push(`<div class="ask"><h2>Needs your answer</h2><ol>${pl.questions.map(q => `<li>${q}</li>`).join("")}</ol></div>`);
|
||||
parts.push(`<h2>Approach</h2>${pl.approach}`);
|
||||
parts.push(`<h2>Acceptance criteria</h2><ul class="rows">${pl.criteria.map(c => criterionRow(t, c)).join("")}</ul>`);
|
||||
parts.push(`<h2>Steps</h2><ul class="rows">${pl.steps.map(stepRow).join("")}</ul>`);
|
||||
if (pl.decisions.length)
|
||||
parts.push(`<h2>Decisions</h2><table><tr><th>Question</th><th>Choice</th><th>Why</th></tr>${pl.decisions.map(([q, c, w]) => `<tr><td>${esc(q)}</td><td>${esc(c)}</td><td>${esc(w)}</td></tr>`).join("")}</table>`);
|
||||
if (pl.risks.length)
|
||||
parts.push(`<h2>Risks</h2><table><tr><th>Risk</th><th>Handling</th><th>Covers</th></tr>${pl.risks.map(r => `<tr><td>${esc(r.risk)}${r.fromTicket ? `<span class="tag">ticket</span>` : ""}</td><td>${esc(r.handling)}</td><td>${esc((r.criteria ?? []).join(", "))}</td></tr>`).join("")}</table>`);
|
||||
if (pl.tests.length)
|
||||
parts.push(`<h2>Test plan</h2><table><tr><th>Kind</th><th>Test</th><th>Covers</th></tr>${pl.tests.map(x => `<tr><td>${esc(x.kind)}</td><td>${esc(x.text)}${x.fromTicket ? `<span class="tag">ticket</span>` : ""}</td><td>${esc((x.criteria ?? []).join(", "))}</td></tr>`).join("")}</table>`);
|
||||
if (pl.mocks.length) parts.push(`<p class="sub">${pl.mocks.length} mock artboard${pl.mocks.length > 1 ? "s" : ""}: ${pl.mocks.map(m => esc(m.title)).join(", ")}.</p>`);
|
||||
return page(`${t.key} plan`, parts.join(""));
|
||||
}
|
||||
|
||||
/** The to-do list: steps and criteria with their marks, then what review still holds open. */
|
||||
function progressView(t: Ticket): string {
|
||||
const pl = t.plan!;
|
||||
const steps = pl.steps.filter(s => s.status === "done").length;
|
||||
const crit = pl.criteria.filter(c => c.status === "done").length;
|
||||
const open = t.review.findings.filter(f => f.status === "open");
|
||||
const stat = (label: string, a: number, b: number) => `<div><div class="meta">${label}</div><div class="bar"><i style="width:${pct(a, b)}%"></i></div><b>${a}</b> of ${b}</div>`;
|
||||
const parts = [head(t, "Progress"), `<div class="stats" style="margin-top:12px">${stat("Steps", steps, pl.steps.length)}${stat("Criteria", crit, pl.criteria.length)}<div><div class="meta">Proof</div><b>${t.proof.length}</b> captured</div></div>`];
|
||||
parts.push(`<h2>Steps</h2><ul class="rows">${pl.steps.map(stepRow).join("")}</ul>`);
|
||||
parts.push(`<h2>Acceptance criteria</h2><ul class="rows">${pl.criteria.map(c => criterionRow(t, c)).join("")}</ul>`);
|
||||
if (open.length) parts.push(`<h2>Open review findings</h2><ul class="rows">${open.map(f => `<li>${box("todo")}<div class="grow"><span class="id">${esc(f.id)}</span>${esc(f.text)}${f.where ? `<div class="meta"><code>${esc(f.where)}</code></div>` : ""}</div></li>`).join("")}</ul>`);
|
||||
const v = t.review.preMr;
|
||||
if (v) parts.push(`<h2>pre-mr-review</h2><p><b>${esc(v.verdict)}</b>${v.head ? ` at <code>${esc(short(v.head))}</code>` : ""}, run ${v.runs}</p>`);
|
||||
return page(`${t.key} progress`, parts.join(""));
|
||||
}
|
||||
|
||||
/** Each artboard in its own frame, sized to its content, under its title, criteria and note. */
|
||||
function mocks(t: Ticket, only?: string): string {
|
||||
const all = t.plan!.mocks;
|
||||
const list: Mock[] = only ? [all.find(m => m.id === only) ?? fail(`no mock ${only}. Mocks: ${all.map(m => m.id).join(", ") || "none"}`)] : all;
|
||||
if (!list.length) fail(`${t.key} has no mocks`);
|
||||
const body = list.map(m =>
|
||||
`<div class="mock"><b>${esc(m.title)}</b> <span class="sub">${esc(m.id)}${m.criteria.length ? ` · ${esc(m.criteria.join(", "))}` : ""}</span>` +
|
||||
`${m.note ? `<div class="meta">${esc(m.note)}</div>` : ""}<iframe data-fit sandbox="allow-same-origin" srcdoc="${esc(m.html)}" title="${esc(m.title)}"></iframe></div>`).join("");
|
||||
return page(`${t.key} mocks`, head(t, "Mocks") + body, MOCK_SIZER);
|
||||
}
|
||||
|
||||
function fail(msg: string): never { throw new Error(msg); }
|
||||
|
||||
export function renderView(t: Ticket, view: View, mock?: string): string {
|
||||
if (!t.plan) fail(`${t.key} has no plan yet, so there is nothing to show`);
|
||||
const html = view === "plan" ? plan(t) : view === "progress" ? progressView(t) : mocks(t, mock);
|
||||
if (html.length > RENDER_LIMIT)
|
||||
fail(`the ${view} view is ${Math.round(html.length / 1000)} KB, over html_render's ${RENDER_LIMIT / 1000} KB.` +
|
||||
(view === "mocks" && !mock ? ` Show one artboard at a time with --mock: ${t.plan.mocks.map(m => m.id).join(", ")}.` : " Link the ticket page instead."));
|
||||
return html;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// The ticket page's data model: one `ticket.data.js` per ticket and one `epic.data.js`
|
||||
// per epic, each a single `window.X = <strict JSON>;` assignment so a page opened from
|
||||
// file:// can load it with a <script> tag. Everything that reads or writes those files
|
||||
// per epic, each a single `window.X = <strict JSON>;` assignment so an open page can poll
|
||||
// it with a <script> tag, where file:// and T3 Code's sandboxed viewer cannot fetch. Everything that reads or writes those files
|
||||
// goes through this module, so the shape is defined once and validated on every write.
|
||||
|
||||
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||
@@ -70,7 +70,7 @@ export interface Plan {
|
||||
}
|
||||
|
||||
export interface Proof {
|
||||
kind: "image" | "text" | "test";
|
||||
kind: "image" | "video" | "text" | "test";
|
||||
/** Relative to the ticket directory, for image and text proof. */
|
||||
file?: string;
|
||||
/** Embedded content of a text proof, because file:// pages cannot fetch it. */
|
||||
|
||||
@@ -67,10 +67,35 @@ describe("the CLI, end to end", () => {
|
||||
expect(cli("init", "KACP-1", "--issue", issuePath).status).toBe(0);
|
||||
for (const f of ["index.html", "ticket.data.js"]) expect(existsSync(join(dir, f))).toBe(true);
|
||||
expect(existsSync(join(docs, "KACP-9", "index.html"))).toBe(true);
|
||||
expect(readFileSync(join(dir, "index.html"), "utf8")).toMatch(/href="..\/..\/_site\/site.css\?v=[0-9a-f]{10}"/);
|
||||
// Self-contained: T3 Code's file viewer serves nothing from above the page's own folder.
|
||||
const page = readFileSync(join(dir, "index.html"), "utf8");
|
||||
expect(page).toContain(readFileSync(join(import.meta.dir, "../templates/site/site.js"), "utf8"));
|
||||
expect(page).toContain(`"key":"KACP-1"`);
|
||||
expect(page).not.toMatch(/(src|href)="\.\./);
|
||||
expect(epic().stories.map(s => [s.key, s.status])).toEqual([["KACP-1", "todo"]]);
|
||||
});
|
||||
|
||||
test("inline data cannot close the page's script block, and follows every write", () => {
|
||||
cli("event", "KACP-1", "pasted </script><img src=x>");
|
||||
const page = readFileSync(join(dir, "index.html"), "utf8");
|
||||
const inline = page.match(/window\.TICKET = (.*);\n/)![1];
|
||||
expect(inline).not.toContain("</script>");
|
||||
expect(JSON.parse(inline).events.at(-1).text).toBe("pasted </script><img src=x>");
|
||||
});
|
||||
|
||||
test("a linked worktree shares the main checkout's docs", () => {
|
||||
const repo = mkdtempSync(join(tmpdir(), "tp-repo-"));
|
||||
const git = (...a: string[]) => spawnSync("git", a, { cwd: repo, encoding: "utf8" });
|
||||
git("init", "-q");
|
||||
git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "--allow-empty", "-m", "x");
|
||||
const tree = join(mkdtempSync(join(tmpdir(), "tp-tree-")), "wt");
|
||||
git("worktree", "add", "-q", tree);
|
||||
const { TICKET_DOCS: _, ...env } = process.env;
|
||||
expect(spawnSync("bun", [join(import.meta.dir, "ticket-page.ts"), "init", "KACP-1", "--issue", issuePath], { cwd: tree, encoding: "utf8", env }).status).toBe(0);
|
||||
expect(existsSync(join(repo, ".claude/docs/epics/KACP-9/KACP-1/ticket.data.js"))).toBe(true);
|
||||
expect(existsSync(join(tree, ".claude"))).toBe(false);
|
||||
});
|
||||
|
||||
test("a step is done only once its commit exists, and the epic row follows", () => {
|
||||
const plan = join(scratch, "plan.json");
|
||||
writeFileSync(plan, JSON.stringify({ approach: "<p>a</p>", criteria: [{ id: "AC1", text: "c", steps: [1] }], steps: [{ n: 1, text: "s" }, { n: 2, text: "t" }] }));
|
||||
@@ -85,12 +110,25 @@ describe("the CLI, end to end", () => {
|
||||
expect(epic().stories[0]).toMatchObject({ status: "building", progress: { steps: [1, 2] } });
|
||||
});
|
||||
|
||||
test("view prints a thread snapshot of the plan's progress, and refuses an unknown view", () => {
|
||||
const out = cli("view", "KACP-1", "progress");
|
||||
expect(out.status).toBe(0);
|
||||
expect(out.stdout).toMatch(/^<!doctype html>/);
|
||||
expect(out.stdout).toContain("<code>abc1234</code>");
|
||||
expect(cli("view", "KACP-1", "mocks").status).not.toBe(0);
|
||||
expect(cli("view", "KACP-1", "everything").status).toBe(2);
|
||||
});
|
||||
|
||||
test("text proof is embedded, because a file:// page cannot fetch it", () => {
|
||||
mkdirSync(join(dir, "proof"), { recursive: true });
|
||||
writeFileSync(join(dir, "proof", "counts.txt"), "70 of 70 codes stored");
|
||||
expect(cli("proof", "KACP-1", "--file", "proof/counts.txt", "--proves", "AC1", "--caption", "Counts").status).toBe(0);
|
||||
expect(ticket().proof[0]).toMatchObject({ kind: "text", text: "70 of 70 codes stored" });
|
||||
expect(cli("proof", "KACP-1", "--file", "proof/counts.txt", "--proves", "AC7", "--caption", "x").status).toBe(1);
|
||||
writeFileSync(join(dir, "proof", "flow.mp4"), "");
|
||||
expect(cli("proof", "KACP-1", "--file", "proof/flow.mp4", "--proves", "AC1", "--caption", "Flow").status).toBe(0);
|
||||
expect(ticket().proof[1]).toMatchObject({ kind: "video", file: "proof/flow.mp4" });
|
||||
expect(cli("unproof", "KACP-1", "proof/flow.mp4").status).toBe(0);
|
||||
});
|
||||
|
||||
test("--amend keeps progress for the steps that remain, a rewrite starts over", () => {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// ticket-page: the only writer of a ticket's page data. See ../SITE.md for when the
|
||||
// ticket skill calls each command. Run `ticket-page help` for the command list.
|
||||
|
||||
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { htmlToText, importIssue, type JiraIssue, scanResources } from "./jira-import";
|
||||
import { renderView, VIEWS } from "./thread-view";
|
||||
import {
|
||||
type Criterion, type Epic, type Finding, type Mock, now, type Plan, progress, readData, type Step,
|
||||
storyStatus, type Ticket, writeData,
|
||||
@@ -34,11 +34,13 @@ const list = (s?: string) => (s ? s.split(",").map(x => x.trim()).filter(Boolean
|
||||
|
||||
// ── Where things live ────────────────────────────────────────────────────────
|
||||
|
||||
/** The main checkout's docs, even from a linked worktree. `.claude/docs` is usually gitignored,
|
||||
* so a T3 Code worktree thread would otherwise start a second, disconnected ticket page. */
|
||||
export function docsRoot(): string {
|
||||
if (process.env.TICKET_DOCS) return resolve(process.env.TICKET_DOCS);
|
||||
const git = spawnSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" });
|
||||
if (git.status !== 0) fail("not inside a git repository, and TICKET_DOCS is not set");
|
||||
return join(git.stdout.trim(), ".claude/docs/epics");
|
||||
const git = spawnSync("git", ["worktree", "list", "--porcelain"], { encoding: "utf8" });
|
||||
const main = git.status === 0 ? git.stdout.match(/^worktree (.+)$/m)?.[1] : undefined;
|
||||
return join(main ?? fail("not inside a git repository, and TICKET_DOCS is not set"), ".claude/docs/epics");
|
||||
}
|
||||
|
||||
function ticketDir(key: string): string {
|
||||
@@ -49,26 +51,28 @@ function ticketDir(key: string): string {
|
||||
const ticketFile = (dir: string) => join(dir, "ticket.data.js");
|
||||
const epicFile = (epicKey: string) => join(docsRoot(), epicKey, "epic.data.js");
|
||||
|
||||
/** Copies the shared renderer into _site/ when the skill's copy changed, and writes the
|
||||
* page shell. Run on every write so an old ticket picks up the current renderer. */
|
||||
function refreshSite(pageDir: string, kind: "ticket" | "epic", title: string) {
|
||||
const site = join(docsRoot(), "_site");
|
||||
mkdirSync(site, { recursive: true });
|
||||
const version = createHash("sha1");
|
||||
for (const f of ["site.css", "site.js"]) {
|
||||
const from = join(SITE_TEMPLATES, f), to = join(site, f);
|
||||
const body = readFileSync(from, "utf8");
|
||||
version.update(body);
|
||||
if (!existsSync(to) || readFileSync(to, "utf8") !== body) copyFileSync(from, to);
|
||||
}
|
||||
// The shell names the renderer by content hash, so a browser that cached an older
|
||||
// renderer loads the new one the next time the page opens.
|
||||
const shell = readFileSync(join(SITE_TEMPLATES, `${kind}.html`), "utf8")
|
||||
.replaceAll("{{SITE}}", relative(pageDir, site) || ".")
|
||||
.replaceAll("{{VERSION}}", version.digest("hex").slice(0, 10))
|
||||
.replaceAll("{{TITLE}}", title.replace(/[<&]/g, c => (c === "<" ? "<" : "&")));
|
||||
/** Writes the page shell with the renderer and the data inlined, so index.html renders with
|
||||
* nothing else: no server and no other file. T3 Code's file viewer sandboxes the page and
|
||||
* serves assets only from its own folder and below, so a shell that leaned on a shared
|
||||
* ../../_site/ renderer rendered blank there. The sibling data file stays the store, and the
|
||||
* open page polls it to stay live. Run on every write, so the page matches the data and an
|
||||
* old ticket picks up the current renderer. */
|
||||
function refreshSite(pageDir: string, kind: "ticket" | "epic", data: Ticket | Epic) {
|
||||
const fill: Record<string, string> = {
|
||||
CSS: readFileSync(join(SITE_TEMPLATES, "site.css"), "utf8"),
|
||||
JS: readFileSync(join(SITE_TEMPLATES, "site.js"), "utf8"),
|
||||
TITLE: `${data.key}: ${data.title}`.replace(/[<&]/g, c => (c === "<" ? "<" : "&")),
|
||||
// Jira HTML and mock artboards hold `</script>`, which would end the inline block early.
|
||||
DATA: `window.${kind.toUpperCase()} = ${JSON.stringify(data).replace(/</g, "\\u003c")};`,
|
||||
};
|
||||
// A replacer function, because the renderer's own source is full of `$` sequences that
|
||||
// a replacement string would read as patterns.
|
||||
const shell = readFileSync(join(SITE_TEMPLATES, `${kind}.html`), "utf8").replace(/\{\{(CSS|JS|TITLE|DATA)\}\}/g, (_, k: string) => fill[k]);
|
||||
const page = join(pageDir, "index.html");
|
||||
if (!existsSync(page) || readFileSync(page, "utf8") !== shell) writeFileSync(page, shell);
|
||||
if (existsSync(page) && readFileSync(page, "utf8") === shell) return;
|
||||
const tmp = `${page}.${process.pid}.tmp`;
|
||||
writeFileSync(tmp, shell);
|
||||
renameSync(tmp, page);
|
||||
}
|
||||
|
||||
// ── Ticket reads and writes ──────────────────────────────────────────────────
|
||||
@@ -80,7 +84,7 @@ function load(key: string) {
|
||||
|
||||
function save(dir: string, t: Ticket) {
|
||||
writeData(ticketFile(dir), "ticket", t);
|
||||
refreshSite(dir, "ticket", `${t.key}: ${t.title}`);
|
||||
refreshSite(dir, "ticket", t);
|
||||
if (t.epic) syncStory(t);
|
||||
}
|
||||
|
||||
@@ -102,7 +106,7 @@ function syncStory(t: Ticket) {
|
||||
s.progress = progress(t);
|
||||
if (s.status !== "done") s.status = storyStatus(t);
|
||||
writeData(file, "epic", e);
|
||||
refreshSite(dirname(file), "epic", `${e.key}: ${e.title}`);
|
||||
refreshSite(dirname(file), "epic", e);
|
||||
}
|
||||
|
||||
// ── Commands ─────────────────────────────────────────────────────────────────
|
||||
@@ -264,9 +268,10 @@ command("proof", "proof <KEY> (--file proof/<name> | --test NAME) --proves AC1,A
|
||||
const caption = a.one("caption") ?? fail("proof needs --caption");
|
||||
if (file && !existsSync(join(dir, file))) fail(`${file} does not exist in ${dir}`);
|
||||
const isText = !!file && /\.(txt|log|md|json|csv|diff)$/i.test(file);
|
||||
const isVideo = !!file && /\.(mp4|webm|mov)$/i.test(file);
|
||||
t.proof = t.proof.filter(p => (file ? p.file !== file : p.test !== test));
|
||||
t.proof.push({
|
||||
kind: test ? "test" : isText ? "text" : "image", ...(file ? { file } : { test }),
|
||||
kind: test ? "test" : isText ? "text" : isVideo ? "video" : "image", ...(file ? { file } : { test }),
|
||||
...(isText ? { text: readFileSync(join(dir, file!), "utf8") } : {}),
|
||||
proves, caption, ...(a.one("mock") ? { mock: a.one("mock") } : {}), at: now(),
|
||||
});
|
||||
@@ -342,8 +347,24 @@ command("show", "show <KEY> print where the ticket stands", a => {
|
||||
|
||||
command("path", "path <KEY> print the page path", a => console.log(join(ticketDir(a.pos[0]), "index.html")));
|
||||
|
||||
command("open", "open <KEY> open the page in the browser", a => {
|
||||
spawnSync("xdg-open", [join(ticketDir(a.pos[0]), "index.html")], { stdio: "ignore" });
|
||||
command("open", "open <KEY> print the page path, and open it in the browser outside T3 Code", a => {
|
||||
const { dir, t } = load(a.pos[0]);
|
||||
// Rewrites the shells first, so a page last written before the renderer was inlined
|
||||
// opens in T3 too.
|
||||
refreshSite(dir, "ticket", t);
|
||||
const epic = t.epic && epicFile(t.epic.key);
|
||||
if (epic && existsSync(epic)) { const e = readData(epic, "epic"); refreshSite(dirname(epic), "epic", e); }
|
||||
const page = join(dir, "index.html");
|
||||
// Inside a T3 Code thread the agent links this path and the page opens in T3's file
|
||||
// viewer, beside the conversation. A browser window would be one more thing to juggle.
|
||||
if (!process.env.T3CODE_THREAD_ID) spawnSync("xdg-open", [page], { stdio: "ignore" });
|
||||
console.log(page);
|
||||
});
|
||||
|
||||
command("view", `view <KEY> <${VIEWS.join("|")}> [--mock <id>] print a thread view for T3 Code's html_render`, a => {
|
||||
const [key, name] = a.pos;
|
||||
const view = VIEWS.find(v => v === name) ?? fail(`view takes ${VIEWS.join(", ")}`);
|
||||
console.log(renderView(load(key).t, view, a.one("mock")));
|
||||
});
|
||||
|
||||
command("md", "md <KEY> print the ticket and plan as markdown, for an agent's reading order", a => {
|
||||
@@ -377,7 +398,7 @@ function withEpic(key: string, fn: (e: Epic) => void) {
|
||||
const e = existsSync(file) ? readData(file, "epic") : fail(`no epic page for ${key}. Run: ticket-page epic ${key} init --title T`);
|
||||
fn(e);
|
||||
writeData(file, "epic", e);
|
||||
refreshSite(dirname(file), "epic", `${e.key}: ${e.title}`);
|
||||
refreshSite(dirname(file), "epic", e);
|
||||
}
|
||||
|
||||
command("epic", "epic <EPIC> init --title T [--jira URL] | story <KEY> [--title T --hours H --risk R --after K1,K2 --status S] | order <KEY>... | why --file <reasons.txt> | closed <KEY> --title T", a => {
|
||||
@@ -388,7 +409,7 @@ command("epic", "epic <EPIC> init --title T [--jira URL] | story <KEY> [--title
|
||||
e.title = a.one("title") ?? (e.title || fail("epic init needs --title"));
|
||||
e.jira = a.one("jira") ?? (e.jira || `${process.env.JIRA_BASE_URL ?? "https://ksense-tech.atlassian.net"}/browse/${key}`);
|
||||
writeData(file, "epic", e);
|
||||
refreshSite(dirname(file), "epic", `${e.key}: ${e.title}`);
|
||||
refreshSite(dirname(file), "epic", e);
|
||||
return console.log(join(dirname(file), "index.html"));
|
||||
}
|
||||
withEpic(key, e => {
|
||||
|
||||
@@ -4,10 +4,15 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{TITLE}}</title>
|
||||
<link rel="stylesheet" href="{{SITE}}/site.css?v={{VERSION}}">
|
||||
<style>
|
||||
{{CSS}}
|
||||
</style>
|
||||
</head>
|
||||
<body data-page="epic" data-src="epic.data.js">
|
||||
<div id="app"><p class="loading">Loading epic.data.js…</p></div>
|
||||
<script src="{{SITE}}/site.js?v={{VERSION}}"></script>
|
||||
<script>
|
||||
{{DATA}}
|
||||
{{JS}}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,4 +1,4 @@
|
||||
/* Shared stylesheet for ticket and epic pages. Copied into .claude/docs/epics/_site/ by ticket-page. */
|
||||
/* Shared stylesheet for ticket and epic pages. ticket-page inlines it into every page shell. */
|
||||
:root {
|
||||
--bg: #f6f6f3; --surface: #ffffff; --sunk: #efefea; --ink: #1c1c1a; --muted: #6b6b66;
|
||||
--line: #e2e2dd; --accent: #2f5fd0; --accent-ink: #fff; --accent-soft: #e8eeff;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// Renderer for ticket and epic pages. Copied into .claude/docs/epics/_site/ by ticket-page.
|
||||
// A page shell names its data file in <body data-src>. This script loads that file with a
|
||||
// <script> tag (file:// pages cannot fetch), re-loads it every two seconds, and redraws
|
||||
// only when the data changed. The data shape is defined in scripts/ticket-data.ts.
|
||||
// Renderer for ticket and epic pages. ticket-page inlines it and the page's data into every
|
||||
// page shell, so index.html renders on its own with no server and no other file.
|
||||
// A page shell also names its data file in <body data-src>. Re-loading that file every two
|
||||
// seconds with a <script> tag (file:// and T3 Code's sandboxed viewer cannot fetch) is what
|
||||
// keeps an open page live, and a page that cannot reach it still shows its last write.
|
||||
// The data shape is defined in scripts/ticket-data.ts.
|
||||
// @ts-check
|
||||
/** @typedef {import('../../scripts/ticket-data').Ticket} Ticket */
|
||||
/** @typedef {import('../../scripts/ticket-data').Epic} Epic */
|
||||
@@ -39,12 +41,14 @@
|
||||
const mark = st => h('span.mark' + (st === 'done' ? '.done' : st === 'running' ? '.run' : ''), { title: st });
|
||||
const pill = (text, tone) => h('span.pill' + (tone ? '.' + tone : ''), text);
|
||||
|
||||
// T3 Code's sandboxed viewer has no localStorage, so ticks there last while the page stays open.
|
||||
const ticks = new Map();
|
||||
/** The viewer's own review tick, kept in this browser only and separate from the agent's done mark. */
|
||||
function seen(scope, id) {
|
||||
const key = `seen:${scope}:${id}`;
|
||||
let on = false; try { on = localStorage.getItem(key) === '1'; } catch {}
|
||||
let on = ticks.get(key) ?? false; try { on ||= localStorage.getItem(key) === '1'; } catch {}
|
||||
return h('label.seen', { title: 'Your own review tick, stored in this browser' },
|
||||
h('input', { type: 'checkbox', checked: on || null, onchange: e => { try { localStorage.setItem(key, e.target.checked ? '1' : '0'); } catch {} } }), 'reviewed');
|
||||
h('input', { type: 'checkbox', checked: on || null, onchange: e => { ticks.set(key, e.target.checked); try { localStorage.setItem(key, e.target.checked ? '1' : '0'); } catch {} } }), 'reviewed');
|
||||
}
|
||||
|
||||
// ── Loading and live reload ──────────────────────────────────────────────
|
||||
@@ -65,7 +69,7 @@
|
||||
const el = document.querySelector('.live');
|
||||
if (!el) return;
|
||||
el.classList.toggle('stale', failed);
|
||||
el.textContent = failed ? 'Live: last reload failed, showing the previous version' : `Live, checked ${clock(new Date(loadedAt).toISOString())}`;
|
||||
el.textContent = failed ? 'Snapshot from the last write' : `Live, checked ${clock(new Date(loadedAt).toISOString())}`;
|
||||
}
|
||||
|
||||
function draw() {
|
||||
@@ -150,7 +154,8 @@
|
||||
/** @param {Ticket} t @param {import('../../scripts/ticket-data').Proof} p */
|
||||
function compare(t, p) {
|
||||
const m = p.mock && t.plan?.mocks.find(x => x.id === p.mock);
|
||||
const evidence = p.kind === 'text' ? h('pre.textproof', p.text || '') : p.kind === 'test' ? h('pre.textproof', 'Passing test: ' + p.test) : h('a', { href: p.file, target: '_blank' }, h('img.shot', { src: p.file, alt: p.file }));
|
||||
const evidence = p.kind === 'text' ? h('pre.textproof', p.text || '') : p.kind === 'test' ? h('pre.textproof', 'Passing test: ' + p.test)
|
||||
: p.kind === 'video' ? keep('video:' + p.file, () => h('video.shot', { src: p.file, controls: true, preload: 'metadata' })) : h('a', { href: p.file, target: '_blank' }, h('img.shot', { src: p.file, alt: p.file }));
|
||||
return h('div.compare',
|
||||
h('figure', h('figcaption', 'Mock, drawn at plan time'), m ? artboard(m) : h('div.placeholder', 'No mock for this one.')),
|
||||
h('figure', h('figcaption', 'Proof, captured from the build'), evidence));
|
||||
@@ -303,6 +308,7 @@
|
||||
e.closed.length ? h('p.muted', { style: 'margin-top:18px;font-size:.9em' }, 'Closed: ', e.closed.map(c => `${c.key} ${c.title}`).join(', '), '.') : null));
|
||||
}
|
||||
|
||||
if (window[GLOBAL]) { last = JSON.stringify(window[GLOBAL]); loadedAt = Date.now(); draw(); }
|
||||
load();
|
||||
setInterval(load, 2000);
|
||||
})();
|
||||
@@ -4,10 +4,15 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{TITLE}}</title>
|
||||
<link rel="stylesheet" href="{{SITE}}/site.css?v={{VERSION}}">
|
||||
<style>
|
||||
{{CSS}}
|
||||
</style>
|
||||
</head>
|
||||
<body data-page="ticket" data-src="ticket.data.js">
|
||||
<div id="app"><p class="loading">Loading ticket.data.js…</p></div>
|
||||
<script src="{{SITE}}/site.js?v={{VERSION}}"></script>
|
||||
<script>
|
||||
{{DATA}}
|
||||
{{JS}}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+10
-5
@@ -1,6 +1,6 @@
|
||||
# What in user/ goes where, and in what form.
|
||||
#
|
||||
# <kind> <source under user/> <destination>
|
||||
# <kind> <source under user/> <destination> [os]
|
||||
#
|
||||
# kind is one of:
|
||||
# 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
|
||||
# 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
|
||||
# destination is this user's home.
|
||||
#
|
||||
@@ -43,21 +47,22 @@ link agents/rules ~/.claude/rules
|
||||
# ── SSH ──────────────────────────────────────────────────────────────────────
|
||||
# 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
|
||||
# what the SSH Hosts launcher command reads.
|
||||
link ssh/config ~/.ssh/config
|
||||
# what the SSH Hosts launcher command reads. Linux only: it names id_ed25519
|
||||
# and the desktop keyring's agent socket, and the Mac has neither.
|
||||
link ssh/config ~/.ssh/config linux
|
||||
|
||||
# ── Scripts ──────────────────────────────────────────────────────────────────
|
||||
# Personal commands wanted on every machine, desktop or server. ~/.local/bin is
|
||||
# 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.
|
||||
link bin/pwswitch ~/.local/bin/pwswitch
|
||||
link bin/pwswitch ~/.local/bin/pwswitch linux
|
||||
|
||||
# ── Espanso ──────────────────────────────────────────────────────────────────
|
||||
# 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
|
||||
# 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.
|
||||
copy espanso/identity.yml ~/.config/espanso/match/identity.yml
|
||||
copy espanso/identity.yml ~/.config/espanso/match/identity.yml linux
|
||||
|
||||
# ── OpenCode ─────────────────────────────────────────────────────────────────
|
||||
# Claude Code carries its commands and agents as plugins, which opencode cannot
|
||||
|
||||
Reference in new issue
Block a user