Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88032490c0 | ||
|
|
fd24b69039 | ||
|
|
dfc009e422 | ||
|
|
1843c0495a | ||
|
|
43ae5bb53b | ||
|
|
05054afdc9 | ||
|
|
9022847971 | ||
|
|
9b06e9e24b | ||
|
|
59da2bb2fc | ||
|
|
5ebecd59bb | ||
|
|
501f7306ed | ||
|
|
130137485e | ||
|
|
5ebebc8aa2 | ||
|
|
85160ffef2 | ||
|
|
59192143c5 | ||
|
|
2f2e3b88e8 | ||
|
|
9156a7e05c | ||
|
|
39a2e8e9ca | ||
|
|
6206565d95 | ||
|
|
cceb7a707b | ||
|
|
c1bbc69c8a | ||
|
|
fa8b14e05e | ||
|
|
b5832fc94a | ||
|
|
99156442b5 | ||
|
|
4c27203214 | ||
|
|
c71d6c8799 | ||
|
|
cb305f6662 | ||
|
|
1ee42f2cb6 | ||
|
|
1ae3825fda |
@@ -9,7 +9,7 @@ You are editing a running desktop, not a codebase that gets deployed later. Ever
|
|||||||
repository is symlinked into `~/.config`, so a save is live the moment it lands. That single fact
|
repository is symlinked into `~/.config`, so a save is live the moment it lands. That single fact
|
||||||
drives every rule below.
|
drives every rule below.
|
||||||
|
|
||||||
Read before large work, in this order:
|
Before large work, read these in order:
|
||||||
|
|
||||||
- `README.md` — layout, the `panama` command, how installing and updating work
|
- `README.md` — layout, the `panama` command, how installing and updating work
|
||||||
- `config/dot/hypr/README.md` — the compositor config is **Lua, not hyprlang**; read "The one
|
- `config/dot/hypr/README.md` — the compositor config is **Lua, not hyprlang**; read "The one
|
||||||
@@ -62,7 +62,8 @@ Every executable contract under `tests/` is classified in `tests/contracts.manif
|
|||||||
`PANAMA_TEST_TIMEOUT_SECONDS` value. Failures print the contract's captured stdout and stderr.
|
`PANAMA_TEST_TIMEOUT_SECONDS` value. Failures print the contract's captured stdout and stderr.
|
||||||
Successful stdout stays quiet. Successful stderr is surfaced as a warning.
|
Successful stdout stays quiet. Successful stderr is surfaced as a warning.
|
||||||
- Contracts run directly too: `tests/setup/interview-contract`.
|
- Contracts run directly too: `tests/setup/interview-contract`.
|
||||||
- After changing `PreferenceSchema.qml` or `services/SettingsRoutes.qml`, regenerate:
|
- After changing `PreferenceSchema.qml` or
|
||||||
|
`config/dot/quickshell/services/SettingsRoutes.qml`, regenerate:
|
||||||
`config/dot/quickshell/scripts/panama-settings-docs` (writes `docs/settings.md`) and
|
`config/dot/quickshell/scripts/panama-settings-docs` (writes `docs/settings.md`) and
|
||||||
`config/dot/quickshell/scripts/panama-settings-commands` (writes the launcher deep links).
|
`config/dot/quickshell/scripts/panama-settings-commands` (writes the launcher deep links).
|
||||||
Both take `--check`; `tests/quickshell/settings-docs-contract` fails when stale.
|
Both take `--check`; `tests/quickshell/settings-docs-contract` fails when stale.
|
||||||
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
../../.agents/skills/panama
|
||||||
@@ -12,6 +12,10 @@
|
|||||||
/config/dot/espanso/match/identity.yml
|
/config/dot/espanso/match/identity.yml
|
||||||
# Ignore backups of old config files
|
# Ignore backups of old config files
|
||||||
/config/old
|
/config/old
|
||||||
|
# MCP bearer tokens. The server list in user/agents/mcp/servers is tracked
|
||||||
|
# and names which variable carries each token; the tokens themselves are
|
||||||
|
# machine-local, because this repo is public.
|
||||||
|
/user/agents/mcp/env
|
||||||
# Ignore Wireguard config of course!
|
# Ignore Wireguard config of course!
|
||||||
/config/wg/**
|
/config/wg/**
|
||||||
# Ignore Neovim lazy-lock file
|
# Ignore Neovim lazy-lock file
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ in order, without stopping again:
|
|||||||
| `interview` | Every prompt, before anything is installed. Answers last one run and are never written to a durable path |
|
| `interview` | Every prompt, before anything is installed. Answers last one run and are never written to a durable path |
|
||||||
| `install-packages` | Repos (RPM Fusion, Terra, Hyprland COPR), the package lists in `setup/packages/`, then whichever optional categories were chosen |
|
| `install-packages` | Repos (RPM Fusion, Terra, Hyprland COPR), the package lists in `setup/packages/`, then whichever optional categories were chosen |
|
||||||
| `link-dotfiles` | Symlinks `config/dot/<name>` → `~/.config/<name>`, and seeds the wallpaper, cursor theme and Firefox chrome |
|
| `link-dotfiles` | Symlinks `config/dot/<name>` → `~/.config/<name>`, and seeds the wallpaper, cursor theme and Firefox chrome |
|
||||||
| `link-skills` | Links the agent skills in `skills/` into `~/.claude/skills`, one per skill. Every machine gets these; personal ones link after and win a name clash |
|
| `link-skills` | Links the agent skills in `skills/` into `~/.agents/skills` and `~/.claude/skills`, one per skill. Every machine gets these; personal ones link after and win a name clash |
|
||||||
| `link-user` | Links the personal content in `user/` — agent instructions, SSH host aliases — but only on a machine that answered yes. See [user/README.md](user/README.md) |
|
| `link-user` | Links the personal content in `user/` — agent instructions, SSH host aliases — but only on a machine that answered yes. See [user/README.md](user/README.md) |
|
||||||
| `change-settings` | Copies `config/copy/` over `/`, applies gsettings, enables user services |
|
| `change-settings` | Copies `config/copy/` over `/`, applies gsettings, enables user services |
|
||||||
| `link-vicinae-scripts` | Publishes the Vicinae script commands |
|
| `link-vicinae-scripts` | Publishes the Vicinae script commands |
|
||||||
@@ -235,10 +235,13 @@ server/ The server role: compose services (one directory per
|
|||||||
service), the nightly image updater, and its units. See
|
service), the nightly image updater, and its units. See
|
||||||
server/README.md
|
server/README.md
|
||||||
skills/ Agent skills for operating this desktop, linked into
|
skills/ Agent skills for operating this desktop, linked into
|
||||||
~/.claude/skills
|
~/.agents/skills and ~/.claude/skills
|
||||||
setup/
|
setup/
|
||||||
apps/ Applications built from source, one file each
|
apps/ Applications built from source, one file each
|
||||||
lib/ Shared by more than one stage; the extras catalog reader
|
keys/ Pinned signing keys, for publishers that ship no fetchable
|
||||||
|
one; setup/keys/README.md records where each came from
|
||||||
|
lib/ Shared by more than one stage; the extras catalog reader,
|
||||||
|
the machine role, the verified ChatGPT repository
|
||||||
packages/ One package per line; extras/ holds the optional categories
|
packages/ One package per line; extras/ holds the optional categories
|
||||||
scripts/ Run in order by ./install
|
scripts/ Run in order by ./install
|
||||||
tests/ Contracts. See below
|
tests/ Contracts. See below
|
||||||
@@ -247,7 +250,7 @@ docs/ Settings reference, and the design specs behind the work
|
|||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
188 of them, under `tests/`. `tests/contracts.manifest` classifies every
|
190 of them, under `tests/`. `tests/contracts.manifest` classifies every
|
||||||
contract by the capabilities it needs. Run the hermetic set, or grant a
|
contract by the capabilities it needs. Run the hermetic set, or grant a
|
||||||
specific external capability when automation needs it:
|
specific external capability when automation needs it:
|
||||||
|
|
||||||
|
|||||||
+8
-2
@@ -134,7 +134,6 @@ ${BOLD}Examples:${RESET}
|
|||||||
$PROGRAM upgrade
|
$PROGRAM upgrade
|
||||||
$PROGRAM apps
|
$PROGRAM apps
|
||||||
$PROGRAM app
|
$PROGRAM app
|
||||||
$PROGRAM app chatgpt-desktop
|
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,11 +450,16 @@ CONTRACT_CAPABILITIES=(hermetic live-host live-compositor live-desktop network p
|
|||||||
|
|
||||||
contract_paths() {
|
contract_paths() {
|
||||||
local candidate
|
local candidate
|
||||||
|
# The manifest is kept in byte order, so both the discovery sort and the
|
||||||
|
# comparison below have to be byte order too. A UTF-8 collation folds the
|
||||||
|
# punctuation away -- `calendar_agenda_bridge_test.py` sorts before
|
||||||
|
# `calendar-agenda-helper-contract` under en_US and after it under C -- and
|
||||||
|
# a gate that passes or fails on the machine's LANG is not a gate.
|
||||||
while IFS= read -r candidate; do
|
while IFS= read -r candidate; do
|
||||||
[[ -x "$candidate" || "$candidate" == *_test.py ]] || continue
|
[[ -x "$candidate" || "$candidate" == *_test.py ]] || continue
|
||||||
printf 'tests/%s\n' "${candidate#"$PANAMA_DIR/tests/"}"
|
printf 'tests/%s\n' "${candidate#"$PANAMA_DIR/tests/"}"
|
||||||
done < <(find "$PANAMA_DIR/tests" -type f \
|
done < <(find "$PANAMA_DIR/tests" -type f \
|
||||||
-not -path '*/fixtures/*' -not -path '*__pycache__*' | sort)
|
-not -path '*/fixtures/*' -not -path '*__pycache__*' | LC_ALL=C sort)
|
||||||
}
|
}
|
||||||
|
|
||||||
contract_manifest_entries() {
|
contract_manifest_entries() {
|
||||||
@@ -478,6 +482,8 @@ validate_contract_manifest() {
|
|||||||
require_contract_manifest || return 1
|
require_contract_manifest || return 1
|
||||||
|
|
||||||
local manifest="$PANAMA_DIR/$CONTRACT_MANIFEST"
|
local manifest="$PANAMA_DIR/$CONTRACT_MANIFEST"
|
||||||
|
# Byte order, for the same reason contract_paths sorts in it.
|
||||||
|
local LC_ALL=C
|
||||||
local line capabilities path extra previous_comment="" previous_was_comment=0
|
local line capabilities path extra previous_comment="" previous_was_comment=0
|
||||||
local previous_path="" capability discovered
|
local previous_path="" capability discovered
|
||||||
local -a capability_list=() findings=()
|
local -a capability_list=() findings=()
|
||||||
|
|||||||
@@ -45,65 +45,6 @@ checkout_command() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Git's index hints are performance promises, not trust evidence. In
|
|
||||||
# particular, assume-unchanged and skip-worktree can make porcelain status
|
|
||||||
# report a clean checkout whose files no longer match HEAD. Compare every
|
|
||||||
# tracked blob and Git mode with the verified commit before handing control to
|
|
||||||
# any file in the worktree.
|
|
||||||
checkout_matches_verified_commit() (
|
|
||||||
local checkout="$1" listing="" entry metadata mode type expected path actual
|
|
||||||
local link_target_with_sentinel link_target
|
|
||||||
|
|
||||||
trap '[[ -z "$listing" ]] || rm -f -- "$listing"' EXIT
|
|
||||||
trap 'exit 130' INT
|
|
||||||
trap 'exit 143' TERM
|
|
||||||
listing="$(mktemp -u -t panama-boot-tree.XXXXXX)" || exit 1
|
|
||||||
umask 077
|
|
||||||
if ! (set -o noclobber; : >"$listing") 2>/dev/null; then
|
|
||||||
listing=""
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
checkout_command git -C "$checkout" ls-tree -rz --full-tree \
|
|
||||||
"$PANAMA_BOOT_REVISION" >"$listing" || exit 1
|
|
||||||
while IFS= read -r -d '' entry; do
|
|
||||||
[[ "$entry" == *$'\t'* ]] || exit 1
|
|
||||||
metadata="${entry%%$'\t'*}"
|
|
||||||
path="${entry#*$'\t'}"
|
|
||||||
read -r mode type expected <<<"$metadata"
|
|
||||||
[[ "$type" == blob && -n "$path" && "$path" != /* ]] || exit 1
|
|
||||||
|
|
||||||
case "$mode" in
|
|
||||||
100644) [[ -f "$checkout/$path" && ! -L "$checkout/$path" \
|
|
||||||
&& ! -x "$checkout/$path" ]] || exit 1 ;;
|
|
||||||
100755) [[ -f "$checkout/$path" && ! -L "$checkout/$path" \
|
|
||||||
&& -x "$checkout/$path" ]] || exit 1 ;;
|
|
||||||
120000)
|
|
||||||
[[ -L "$checkout/$path" ]] || exit 1
|
|
||||||
# hash-object given a pathname follows a symlink. Git's 120000 blob is
|
|
||||||
# the link text itself, including any trailing newlines, so preserve
|
|
||||||
# those bytes with a sentinel and hash stdin instead.
|
|
||||||
link_target_with_sentinel="$(
|
|
||||||
readlink -n -- "$checkout/$path" && printf .
|
|
||||||
)" || exit 1
|
|
||||||
[[ "$link_target_with_sentinel" == *. ]] || exit 1
|
|
||||||
link_target="${link_target_with_sentinel%.}"
|
|
||||||
actual="$(
|
|
||||||
printf '%s' "$link_target" \
|
|
||||||
| checkout_command git -C "$checkout" hash-object --stdin
|
|
||||||
)" || exit 1
|
|
||||||
[[ "$actual" == "$expected" ]] || exit 1
|
|
||||||
continue
|
|
||||||
;;
|
|
||||||
*) exit 1 ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
actual="$(checkout_command git -C "$checkout" hash-object --no-filters -- "$path")" \
|
|
||||||
|| exit 1
|
|
||||||
[[ "$actual" == "$expected" ]] || exit 1
|
|
||||||
done <"$listing"
|
|
||||||
)
|
|
||||||
|
|
||||||
prepare_panama_checkout() {
|
prepare_panama_checkout() {
|
||||||
local checkout="$1" actual_head checkout_status
|
local checkout="$1" actual_head checkout_status
|
||||||
|
|
||||||
@@ -169,30 +110,6 @@ for arg in "$@"; do
|
|||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
# Keep the worktree comparison at the last possible boundary. Checkout
|
|
||||||
# preparation may invoke several commands and return to the caller; performing
|
|
||||||
# the byte/mode/link check here ensures a change in that interval is rejected
|
|
||||||
# before any tracked file is executed.
|
|
||||||
verified_install_handoff() {
|
|
||||||
local use_tty="$1"
|
|
||||||
if ! checkout_matches_verified_commit "$PANAMA_PATH"; then
|
|
||||||
echo "boot: checkout files do not match PANAMA_BOOT_REVISION" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
if [[ -n "$BOOTSTRAP_USER" ]]; then
|
|
||||||
if (( use_tty )); then
|
|
||||||
exec runuser -u "$BOOTSTRAP_USER" -- env PANAMA_PATH="$PANAMA_PATH" \
|
|
||||||
"$PANAMA_PATH/install" ${INSTALL_ARGS[@]+"${INSTALL_ARGS[@]}"} </dev/tty
|
|
||||||
fi
|
|
||||||
exec runuser -u "$BOOTSTRAP_USER" -- env PANAMA_PATH="$PANAMA_PATH" \
|
|
||||||
"$PANAMA_PATH/install" ${INSTALL_ARGS[@]+"${INSTALL_ARGS[@]}"}
|
|
||||||
fi
|
|
||||||
if (( use_tty )); then
|
|
||||||
exec "$PANAMA_PATH/install" ${INSTALL_ARGS[@]+"${INSTALL_ARGS[@]}"} </dev/tty
|
|
||||||
fi
|
|
||||||
exec "$PANAMA_PATH/install" ${INSTALL_ARGS[@]+"${INSTALL_ARGS[@]}"}
|
|
||||||
}
|
|
||||||
|
|
||||||
# The public bootstrap contract runs this branch as an ordinary user with a
|
# The public bootstrap contract runs this branch as an ordinary user with a
|
||||||
# stubbed root identity. Keep its filesystem adapter unavailable to a real root
|
# stubbed root identity. Keep its filesystem adapter unavailable to a real root
|
||||||
# shell so it cannot redirect a real installation by accident.
|
# shell so it cannot redirect a real installation by accident.
|
||||||
@@ -554,7 +471,7 @@ if [[ "$(id -u)" -eq 0 ]]; then
|
|||||||
|
|
||||||
if ! command -v git >/dev/null 2>&1; then
|
if ! command -v git >/dev/null 2>&1; then
|
||||||
echo "Installing git, which the clone needs"
|
echo "Installing git, which the clone needs"
|
||||||
dnf install -y --repo=fedora --repo=updates --from-repo=fedora,updates git
|
dnf install -y git
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Create or advance the checkout as the target user. A root-owned .git in a
|
# Create or advance the checkout as the target user. A root-owned .git in a
|
||||||
@@ -564,14 +481,15 @@ if [[ "$(id -u)" -eq 0 ]]; then
|
|||||||
prepare_panama_checkout "$PANAMA_PATH"
|
prepare_panama_checkout "$PANAMA_PATH"
|
||||||
|
|
||||||
echo "Handing off to install as $username"
|
echo "Handing off to install as $username"
|
||||||
verified_install_handoff 1
|
exec runuser -u "$username" -- env PANAMA_PATH="$PANAMA_PATH" \
|
||||||
|
"$PANAMA_PATH/install" --server </dev/tty
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# git is the one dependency the clone itself needs. Everything else -- gum
|
# git is the one dependency the clone itself needs. Everything else -- gum
|
||||||
# included -- is bootstrapped by `install`.
|
# included -- is bootstrapped by `install`.
|
||||||
if ! command -v git >/dev/null 2>&1; then
|
if ! command -v git >/dev/null 2>&1; then
|
||||||
echo "Installing git, which the clone needs"
|
echo "Installing git, which the clone needs"
|
||||||
sudo dnf install -y --repo=fedora --repo=updates --from-repo=fedora,updates git
|
sudo dnf install -y git
|
||||||
fi
|
fi
|
||||||
|
|
||||||
prepare_panama_checkout "$PANAMA_PATH"
|
prepare_panama_checkout "$PANAMA_PATH"
|
||||||
@@ -582,8 +500,7 @@ prepare_panama_checkout "$PANAMA_PATH"
|
|||||||
# so itself.
|
# so itself.
|
||||||
# The probe actually opens /dev/tty rather than testing -r: a process with no
|
# The probe actually opens /dev/tty rather than testing -r: a process with no
|
||||||
# controlling terminal passes -r and then fails the redirect.
|
# controlling terminal passes -r and then fails the redirect.
|
||||||
handoff_tty=0
|
|
||||||
if [[ ! -t 0 ]] && (exec </dev/tty) 2>/dev/null; then
|
if [[ ! -t 0 ]] && (exec </dev/tty) 2>/dev/null; then
|
||||||
handoff_tty=1
|
exec "$PANAMA_PATH/install" ${INSTALL_ARGS[@]+"${INSTALL_ARGS[@]}"} </dev/tty
|
||||||
fi
|
fi
|
||||||
verified_install_handoff "$handoff_tty"
|
exec "$PANAMA_PATH/install" ${INSTALL_ARGS[@]+"${INSTALL_ARGS[@]}"}
|
||||||
|
|||||||
+3
-2
@@ -22,6 +22,7 @@ alias avante='nvim -c "lua vim.defer_fn(function()require(\"avante.api\").zen_mo
|
|||||||
alias nlc='nvim leetcode.nvim'
|
alias nlc='nvim leetcode.nvim'
|
||||||
alias clauded='claude --allow-dangerously-skip-permissions'
|
alias clauded='claude --allow-dangerously-skip-permissions'
|
||||||
alias claudedr='claude --allow-dangerously-skip-permissions --resume'
|
alias claudedr='claude --allow-dangerously-skip-permissions --resume'
|
||||||
|
alias klaude='CLAUDE_CONFIG_DIR="$HOME/.klaude" claude'
|
||||||
alias commit-repo='git add -A && git commit -m "Update stuff" && git push'
|
alias commit-repo='git add -A && git commit -m "Update stuff" && git push'
|
||||||
|
|
||||||
# Docker Shortcuts
|
# Docker Shortcuts
|
||||||
@@ -36,9 +37,9 @@ alias fprint-on='sudo authselect enable-feature with-fingerprint'
|
|||||||
alias fprint-off='sudo authselect disable-feature with-fingerprint'
|
alias fprint-off='sudo authselect disable-feature with-fingerprint'
|
||||||
|
|
||||||
# File system
|
# File system
|
||||||
alias ls='eza -lh --group-directories-first --icons'
|
alias ls='eza -lh --group-directories-first --icons=auto'
|
||||||
alias lsa='ls -a'
|
alias lsa='ls -a'
|
||||||
alias lt='eza --tree --level=2 --long --icons --git'
|
alias lt='eza --tree --level=2 --long --icons=auto --git'
|
||||||
alias lta='lt -a'
|
alias lta='lt -a'
|
||||||
# Fedora's bat installs /usr/bin/bat; batcat is the Debian name.
|
# Fedora's bat installs /usr/bin/bat; batcat is the Debian name.
|
||||||
alias ff="fzf --preview 'bat --style=numbers --color=always {}'"
|
alias ff="fzf --preview 'bat --style=numbers --color=always {}'"
|
||||||
|
|||||||
@@ -244,10 +244,20 @@ local shipped_mode = "4500x3000@60"
|
|||||||
local shipped_scale = 1.5
|
local shipped_scale = 1.5
|
||||||
local shipped_transform = 0
|
local shipped_transform = 0
|
||||||
|
|
||||||
-- 10-bit output. 4500x3000@60 at 10bpc is ~24 Gbps, right at the edge of DP 1.4
|
-- 8-bit output. 4500x3000@60 at 10bpc is ~24 Gbps, right at the edge of DP 1.4
|
||||||
-- HBR3, so this relies on DSC. If the display fails to light up or falls back to
|
-- HBR3 and reliant on DSC, and this panel's link is marginal: every modeset
|
||||||
-- a lower mode, drop this to 8 first.
|
-- retrains it and blanks the screen. 8bpc keeps headroom on the link.
|
||||||
local shipped_bitdepth = 10
|
--
|
||||||
|
-- Related: directScanoutPolicy is 0 in Panama settings (2026-09-13). With
|
||||||
|
-- scanout on, a fullscreen game whose buffer depth differs from the desktop
|
||||||
|
-- (games ship both 8- and 10-bit swapchains) makes Hyprland change the output
|
||||||
|
-- format, and on amdgpu a format change is a full modeset. Compositing always
|
||||||
|
-- keeps the format fixed, so the link never retrains mid-game.
|
||||||
|
--
|
||||||
|
-- vrrPolicy is also 0 there. VRR on this panel loses sync and blacks out
|
||||||
|
-- (seen on GNOME in July 2026 and again here); a 60Hz panel gains little
|
||||||
|
-- from it anyway.
|
||||||
|
local shipped_bitdepth = 8
|
||||||
|
|
||||||
-- "auto" = sRGB at 8bpc, wide gamut at 10bpc. Not HDR; see header.
|
-- "auto" = sRGB at 8bpc, wide gamut at 10bpc. Not HDR; see header.
|
||||||
local shipped_cm = "auto"
|
local shipped_cm = "auto"
|
||||||
|
|||||||
@@ -125,6 +125,16 @@ hl.window_rule({
|
|||||||
no_dim = true,
|
no_dim = true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
-- Steam games never get true fullscreen. Everything Hyprland does only for a
|
||||||
|
-- real fullscreen window (direct scanout, VRR, tearing, auto HDR) makes this
|
||||||
|
-- panel's marginal DP link retrain and blank (2026-09-13). "1 2" keeps the
|
||||||
|
-- window maximized internally while the game believes it is fullscreen, which
|
||||||
|
-- is what borderless windowed looks like from the game's side.
|
||||||
|
hl.window_rule({
|
||||||
|
match = { class = "^steam_app_\\d+$" },
|
||||||
|
fullscreen_state = "1 2",
|
||||||
|
})
|
||||||
|
|
||||||
-- Steam itself is a normal window, but its transient popups are a mess.
|
-- Steam itself is a normal window, but its transient popups are a mess.
|
||||||
hl.window_rule({
|
hl.window_rule({
|
||||||
match = { class = "^steam$", title = "^(Friends List|Steam Settings|Special Offer.*)$" },
|
match = { class = "^steam$", title = "^(Friends List|Steam Settings|Special Offer.*)$" },
|
||||||
@@ -269,16 +279,28 @@ hl.layer_rule({
|
|||||||
ignore_alpha = 0.2,
|
ignore_alpha = 0.2,
|
||||||
})
|
})
|
||||||
|
|
||||||
-- Overview, capture and local screen-reading UI dim the desktop behind them.
|
-- Overview and local screen-reading UI dim the desktop behind them.
|
||||||
hl.layer_rule({
|
hl.layer_rule({
|
||||||
name = "qs-overlay",
|
name = "qs-overlay",
|
||||||
match = { namespace = "^qs-(overview|capture|screen-intelligence)$" },
|
match = { namespace = "^qs-(overview|screen-intelligence)$" },
|
||||||
blur = true,
|
blur = true,
|
||||||
ignore_alpha = 0.4,
|
ignore_alpha = 0.4,
|
||||||
dim_around = true,
|
dim_around = true,
|
||||||
no_screen_share = true,
|
no_screen_share = true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
-- The screenshot picker paints an opaque frozen frame edge to edge, so blur and
|
||||||
|
-- dim would be compositor work on pixels nobody sees. no_anim is load-bearing:
|
||||||
|
-- services/Capture.qml unmaps the picker and runs grim 90ms later, and the
|
||||||
|
-- 200ms layersOut fade would otherwise put the half-faded picker, dimming and
|
||||||
|
-- all, into every screenshot.
|
||||||
|
hl.layer_rule({
|
||||||
|
name = "qs-capture",
|
||||||
|
match = { namespace = "^qs-capture$" },
|
||||||
|
no_anim = true,
|
||||||
|
no_screen_share = true,
|
||||||
|
})
|
||||||
|
|
||||||
-- Notification toasts. Blurred like every other shell surface -- without this
|
-- Notification toasts. Blurred like every other shell surface -- without this
|
||||||
-- the cards are a near-transparent fill sitting directly on the wallpaper and
|
-- the cards are a near-transparent fill sitting directly on the wallpaper and
|
||||||
-- read as washed out rather than as glass.
|
-- read as washed out rather than as glass.
|
||||||
|
|||||||
@@ -1805,7 +1805,7 @@ Singleton {
|
|||||||
"org.mozilla.thunderbird_esr", "com.slack.Slack",
|
"org.mozilla.thunderbird_esr", "com.slack.Slack",
|
||||||
"app.bluebubbles.BlueBubbles", "rustdesk",
|
"app.bluebubbles.BlueBubbles", "rustdesk",
|
||||||
"io.podman_desktop.PodmanDesktop", "com.anthropic.Claude",
|
"io.podman_desktop.PodmanDesktop", "com.anthropic.Claude",
|
||||||
"codex-desktop", "md.obsidian.Obsidian",
|
"chatgpt", "md.obsidian.Obsidian",
|
||||||
"com.obsproject.Studio", "steam"
|
"com.obsproject.Studio", "steam"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -27,9 +27,13 @@ PanelWindow {
|
|||||||
right: true
|
right: true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fullscreen overlays must not reserve space, or every window on the
|
// Ignore, and nothing else: reserve no space (or every window on the
|
||||||
// workspace gets resized as the picker opens and closes.
|
// workspace resizes as the picker opens and closes) and respect nobody's
|
||||||
exclusiveZone: 0
|
// (or the window starts under the bar, the full-output freeze frame is
|
||||||
|
// squeezed into a shorter box, and every selection lands one bar-height
|
||||||
|
// off in the real capture). Never pair this with exclusiveZone; see
|
||||||
|
// tests/quickshell/exclusion-idiom-contract.
|
||||||
|
exclusionMode: ExclusionMode.Ignore
|
||||||
|
|
||||||
WlrLayershell.namespace: "qs-capture" // matched by a layerrule in hypr/rules.lua
|
WlrLayershell.namespace: "qs-capture" // matched by a layerrule in hypr/rules.lua
|
||||||
WlrLayershell.layer: WlrLayer.Overlay
|
WlrLayershell.layer: WlrLayer.Overlay
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ PanelWindow {
|
|||||||
|
|
||||||
anchors { top: true; bottom: true; left: true; right: true }
|
anchors { top: true; bottom: true; left: true; right: true }
|
||||||
color: "transparent"
|
color: "transparent"
|
||||||
exclusiveZone: 0
|
|
||||||
exclusionMode: ExclusionMode.Ignore
|
exclusionMode: ExclusionMode.Ignore
|
||||||
|
|
||||||
// The `^qs-popover` prefix rule in hypr/rules.lua blurs what is behind
|
// The `^qs-popover` prefix rule in hypr/rules.lua blurs what is behind
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ PanelWindow {
|
|||||||
color: "transparent"
|
color: "transparent"
|
||||||
|
|
||||||
// A dock that reserved space would not be intellihiding.
|
// A dock that reserved space would not be intellihiding.
|
||||||
exclusiveZone: 0
|
|
||||||
exclusionMode: ExclusionMode.Ignore
|
exclusionMode: ExclusionMode.Ignore
|
||||||
|
|
||||||
// Matched by the `qs-dock` layer rule in hypr/rules.lua — do not rename.
|
// Matched by the `qs-dock` layer rule in hypr/rules.lua — do not rename.
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ PanelWindow {
|
|||||||
|
|
||||||
anchors { top: true; bottom: true; left: true; right: true }
|
anchors { top: true; bottom: true; left: true; right: true }
|
||||||
color: "transparent"
|
color: "transparent"
|
||||||
exclusiveZone: 0
|
|
||||||
exclusionMode: ExclusionMode.Ignore
|
exclusionMode: ExclusionMode.Ignore
|
||||||
|
|
||||||
// Blurred by the `^qs-popover` rule in hypr/rules.lua; the scrim is painted
|
// Blurred by the `^qs-popover` rule in hypr/rules.lua; the scrim is painted
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ PanelWindow {
|
|||||||
// Reserve nothing and respect nothing: the glow is drawn over the whole
|
// Reserve nothing and respect nothing: the glow is drawn over the whole
|
||||||
// output including under the bar and the dock, which is what makes it
|
// output including under the bar and the dock, which is what makes it
|
||||||
// visible from wherever the eyes happen to be.
|
// visible from wherever the eyes happen to be.
|
||||||
exclusiveZone: 0
|
|
||||||
exclusionMode: ExclusionMode.Ignore
|
exclusionMode: ExclusionMode.Ignore
|
||||||
color: "transparent"
|
color: "transparent"
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ PanelWindow {
|
|||||||
screen: root.modelData
|
screen: root.modelData
|
||||||
anchors.bottom: true
|
anchors.bottom: true
|
||||||
margins.bottom: Theme.dockIconSize + Theme.dockPadding * 2 + Theme.barGap * 3
|
margins.bottom: Theme.dockIconSize + Theme.dockPadding * 2 + Theme.barGap * 3
|
||||||
exclusiveZone: 0
|
|
||||||
exclusionMode: ExclusionMode.Ignore
|
exclusionMode: ExclusionMode.Ignore
|
||||||
implicitWidth: root.desiredWidth
|
implicitWidth: root.desiredWidth
|
||||||
implicitHeight: 64
|
implicitHeight: 64
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ PanelWindow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
color: "transparent"
|
color: "transparent"
|
||||||
exclusiveZone: 0
|
|
||||||
exclusionMode: ExclusionMode.Ignore
|
exclusionMode: ExclusionMode.Ignore
|
||||||
|
|
||||||
// Matched by the `qs-overlay` layer rule in hypr/rules.lua — do not rename.
|
// Matched by the `qs-overlay` layer rule in hypr/rules.lua — do not rename.
|
||||||
|
|||||||
@@ -34,11 +34,23 @@ Item {
|
|||||||
visible: root.rowCount > 0
|
visible: root.rowCount > 0
|
||||||
implicitHeight: rows.implicitHeight
|
implicitHeight: rows.implicitHeight
|
||||||
|
|
||||||
// Probing I2C takes on the order of a second, so it waits until the panel
|
// Probing I2C takes on the order of a second, so it waits until the quick
|
||||||
// is actually on screen rather than running at shell startup. Monitors do
|
// settings panel is actually on screen rather than running at shell
|
||||||
// not come and go, so once is enough.
|
// startup. Monitors do not come and go, so once is enough.
|
||||||
onVisibleChanged: if (visible && !Brightness.scanned) Brightness.refresh()
|
//
|
||||||
Component.onCompleted: if (root.visible && !Brightness.scanned) Brightness.refresh()
|
// The trigger is the panel opening, not this item becoming visible: on a
|
||||||
|
// machine with no backlight, `visible` stays false until a scan has found
|
||||||
|
// a DDC display, so keying the scan off it would never scan at all.
|
||||||
|
Connections {
|
||||||
|
target: ShellState
|
||||||
|
function onQuickSettingsOpenChanged(): void { root.scanIfOpen(); }
|
||||||
|
}
|
||||||
|
Component.onCompleted: root.scanIfOpen()
|
||||||
|
|
||||||
|
function scanIfOpen(): void {
|
||||||
|
if (ShellState.quickSettingsOpen && !Brightness.scanned)
|
||||||
|
Brightness.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
// `-m` is the machine-readable form: name,class,current,percent,max
|
// `-m` is the machine-readable form: name,class,current,percent,max
|
||||||
Process {
|
Process {
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ Variants {
|
|||||||
implicitWidth: 260
|
implicitWidth: 260
|
||||||
implicitHeight: 172
|
implicitHeight: 172
|
||||||
color: "transparent"
|
color: "transparent"
|
||||||
exclusiveZone: 0
|
|
||||||
exclusionMode: ExclusionMode.Ignore
|
exclusionMode: ExclusionMode.Ignore
|
||||||
mask: Region {}
|
mask: Region {}
|
||||||
|
|
||||||
|
|||||||
@@ -383,6 +383,13 @@ Lua-configured Hyprland, prints the refusal to stdout, and exits 0. `eval` exits
|
|||||||
0 on syntax and runtime errors too. The only trustworthy signal that a write
|
0 on syntax and runtime errors too. The only trustworthy signal that a write
|
||||||
landed is reading the value back.
|
landed is reading the value back.
|
||||||
|
|
||||||
|
**`exclusionMode: ExclusionMode.Ignore` goes alone.** Quickshell's `exclusiveZone`
|
||||||
|
setter flips `exclusionMode` back to `Normal` as a side effect, so a window that
|
||||||
|
declares both is at the mercy of which property the QML engine applies last.
|
||||||
|
The 2026-09-14 Qt update changed that order and every full-screen overlay slid
|
||||||
|
under the bar. `tests/quickshell/exclusion-idiom-contract` fails any file that
|
||||||
|
pairs them.
|
||||||
|
|
||||||
**The Settings window is tiled.** `implicitWidth` is a hint; the layout decides,
|
**The Settings window is tiled.** `implicitWidth` is a hint; the layout decides,
|
||||||
and it ranges from a half-screen split to the full display. `SliderRow` stacks
|
and it ranges from a half-screen split to the full display. `SliderRow` stacks
|
||||||
its control under the label below 520px. Test narrow.
|
its control under the label below 520px. Test narrow.
|
||||||
|
|||||||
@@ -78,7 +78,6 @@ PanelWindow {
|
|||||||
|
|
||||||
anchors { top: true; bottom: true; left: true; right: true }
|
anchors { top: true; bottom: true; left: true; right: true }
|
||||||
color: "transparent"
|
color: "transparent"
|
||||||
exclusiveZone: 0
|
|
||||||
exclusionMode: ExclusionMode.Ignore
|
exclusionMode: ExclusionMode.Ignore
|
||||||
|
|
||||||
WlrLayershell.namespace: "qs-popover-welcome"
|
WlrLayershell.namespace: "qs-popover-welcome"
|
||||||
|
|||||||
@@ -341,7 +341,10 @@ esac'
|
|||||||
property bool record: false
|
property bool record: false
|
||||||
property bool intelligence: false
|
property bool intelligence: false
|
||||||
// A handful of frames at 60Hz, enough for the compositor to recomposite
|
// A handful of frames at 60Hz, enough for the compositor to recomposite
|
||||||
// the output without the overlay on it.
|
// the output without the overlay on it. Only enough because the
|
||||||
|
// qs-capture layer rule in hypr/rules.lua sets no_anim: with the
|
||||||
|
// default 200ms layersOut fade the half-faded picker would still be
|
||||||
|
// on screen when grim reads it.
|
||||||
interval: 90
|
interval: 90
|
||||||
onTriggered: {
|
onTriggered: {
|
||||||
const r = commitDelay.rect;
|
const r = commitDelay.rect;
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ source "$PANAMA_PATH/bin/ascii"
|
|||||||
# time. On an upgrade it is worth running only when its package lists or
|
# time. On an upgrade it is worth running only when its package lists or
|
||||||
# reviewed installer trust inputs changed, so this hashes them and remembers
|
# reviewed installer trust inputs changed, so this hashes them and remembers
|
||||||
# the result. The framed, sorted stream includes top-level package files, the
|
# the result. The framed, sorted stream includes top-level package files, the
|
||||||
# package-stage adapter, the provenance helper, and regular provenance files;
|
# package-stage adapter, every helper it sources, and regular provenance files;
|
||||||
# both relative paths and bytes are part of the state.
|
# both relative paths and bytes are part of the state.
|
||||||
#
|
#
|
||||||
# A content hash rather than a git range, because Panama is developed in place:
|
# A content hash rather than a git range, because Panama is developed in place:
|
||||||
@@ -88,75 +88,42 @@ source "$PANAMA_PATH/bin/ascii"
|
|||||||
STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/panama"
|
STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/panama"
|
||||||
PACKAGES_HASH="$STATE_DIR/packages-hash"
|
PACKAGES_HASH="$STATE_DIR/packages-hash"
|
||||||
|
|
||||||
_collect_package_inputs() {
|
hash_packages() {
|
||||||
local destination="$1" raw="${1}.raw"
|
local file relative size fixed_input digest
|
||||||
[[ -d "$PANAMA_PATH/setup/packages" \
|
|
||||||
&& ! -L "$PANAMA_PATH/setup/packages" \
|
for fixed_input in \
|
||||||
&& -d "$PANAMA_PATH/setup/provenance" \
|
"$PANAMA_PATH/setup/scripts/install-packages" \
|
||||||
&& ! -L "$PANAMA_PATH/setup/provenance" ]] || return 1
|
"$PANAMA_PATH/setup/lib/artifact-provenance" \
|
||||||
{
|
"$PANAMA_PATH/setup/lib/chatgpt-package" \
|
||||||
printf '%s\0' \
|
"$PANAMA_PATH/setup/lib/extras-catalog" \
|
||||||
"$PANAMA_PATH/setup/scripts/install-packages" \
|
"$PANAMA_PATH/setup/lib/machine-role"; do
|
||||||
"$PANAMA_PATH/setup/lib/artifact-provenance" \
|
[[ -f "$fixed_input" && ! -L "$fixed_input" && -r "$fixed_input" ]] || return 1
|
||||||
"$PANAMA_PATH/setup/lib/extras-catalog" \
|
done
|
||||||
"$PANAMA_PATH/setup/lib/machine-role" || exit 1
|
|
||||||
# extras/ is deliberately excluded. A symlink or other non-directory
|
digest="$(
|
||||||
# object at this level is still an input error, not something discovery may
|
{
|
||||||
# silently omit.
|
find "$PANAMA_PATH/setup/packages" -maxdepth 1 -type f -print0 || exit 1
|
||||||
find "$PANAMA_PATH/setup/packages" -mindepth 1 -maxdepth 1 \
|
printf '%s\0' \
|
||||||
! -type d -print0 || exit 1
|
"$PANAMA_PATH/setup/scripts/install-packages" \
|
||||||
find "$PANAMA_PATH/setup/provenance" -mindepth 1 \
|
"$PANAMA_PATH/setup/lib/artifact-provenance" \
|
||||||
! -type d -print0 || exit 1
|
"$PANAMA_PATH/setup/lib/chatgpt-package" \
|
||||||
} >"$raw" || return 1
|
"$PANAMA_PATH/setup/lib/extras-catalog" \
|
||||||
LC_ALL=C sort -z "$raw" >"$destination"
|
"$PANAMA_PATH/setup/lib/machine-role" || exit 1
|
||||||
|
find "$PANAMA_PATH/setup/provenance" -type f -print0 || exit 1
|
||||||
|
} | LC_ALL=C sort -z | while IFS= read -r -d '' file; do
|
||||||
|
relative="${file#"$PANAMA_PATH"/}"
|
||||||
|
size="$(wc -c <"$file")" || exit 1
|
||||||
|
printf '%s\0%s\0' "$relative" "$size" || exit 1
|
||||||
|
cat -- "$file" || exit 1
|
||||||
|
printf '\0' || exit 1
|
||||||
|
done | sha256sum | cut -d' ' -f1
|
||||||
|
)" || return 1
|
||||||
|
printf '%s\n' "$digest"
|
||||||
}
|
}
|
||||||
|
|
||||||
_write_package_manifest() {
|
|
||||||
local inputs="$1" destination="$2" file relative digest
|
|
||||||
: >"$destination" || return 1
|
|
||||||
while IFS= read -r -d '' file; do
|
|
||||||
[[ -f "$file" && ! -L "$file" && -r "$file" ]] || return 1
|
|
||||||
relative="${file#"$PANAMA_PATH"/}"
|
|
||||||
[[ "$relative" != "$file" ]] || return 1
|
|
||||||
digest="$(sha256sum -- "$file" | awk '{ print $1 }')" || return 1
|
|
||||||
[[ "$digest" =~ ^[0-9a-f]{64}$ ]] || return 1
|
|
||||||
printf '%s\0%s\0' "$relative" "$digest" >>"$destination" || return 1
|
|
||||||
done <"$inputs"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Read every input twice from the same enumerated set. A file or path that
|
|
||||||
# changes while the snapshot is built cannot produce a receipt.
|
|
||||||
hash_packages() (
|
|
||||||
local work="" inputs_before inputs_after manifest_before manifest_after
|
|
||||||
trap '[[ -z "$work" ]] || rm -rf -- "$work"' EXIT
|
|
||||||
trap 'exit 130' INT
|
|
||||||
trap 'exit 143' TERM
|
|
||||||
work="$(mktemp -u -d -t panama-packages-hash.XXXXXX)" || exit 1
|
|
||||||
if ! mkdir -m 700 -- "$work"; then
|
|
||||||
work=""
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
inputs_before="$work/inputs-before"
|
|
||||||
inputs_after="$work/inputs-after"
|
|
||||||
manifest_before="$work/manifest-before"
|
|
||||||
manifest_after="$work/manifest-after"
|
|
||||||
|
|
||||||
_collect_package_inputs "$inputs_before" || exit 1
|
|
||||||
_write_package_manifest "$inputs_before" "$manifest_before" || exit 1
|
|
||||||
_collect_package_inputs "$inputs_after" || exit 1
|
|
||||||
cmp -s -- "$inputs_before" "$inputs_after" || exit 1
|
|
||||||
_write_package_manifest "$inputs_after" "$manifest_after" || exit 1
|
|
||||||
cmp -s -- "$manifest_before" "$manifest_after" || exit 1
|
|
||||||
sha256sum -- "$manifest_before" | awk '{ print $1 }'
|
|
||||||
)
|
|
||||||
|
|
||||||
PACKAGE_START_HASH=""
|
|
||||||
|
|
||||||
packages_needed() {
|
packages_needed() {
|
||||||
local current_hash recorded_hash
|
local current_hash="$1" recorded_hash
|
||||||
|
|
||||||
current_hash="$(hash_packages)" || return 2
|
|
||||||
PACKAGE_START_HASH="$current_hash"
|
|
||||||
(( FORCE_PACKAGES )) && return 0
|
(( FORCE_PACKAGES )) && return 0
|
||||||
(( UPGRADE )) || return 0
|
(( UPGRADE )) || return 0
|
||||||
[[ -r "$PACKAGES_HASH" ]] || return 0
|
[[ -r "$PACKAGES_HASH" ]] || return 0
|
||||||
@@ -167,41 +134,52 @@ packages_needed() {
|
|||||||
# Written only after the stage succeeds, mirroring the rule panama-migrate
|
# Written only after the stage succeeds, mirroring the rule panama-migrate
|
||||||
# documents for its markers: a step that did not complete has not happened, and
|
# documents for its markers: a step that did not complete has not happened, and
|
||||||
# recording it as done hides it forever.
|
# recording it as done hides it forever.
|
||||||
record_packages_hash() (
|
record_packages_hash() {
|
||||||
local expected_hash="$1" current_hash temporary_hash=""
|
local starting_hash="$1" current_hash temporary_hash
|
||||||
trap '[[ -z "$temporary_hash" ]] || rm -f -- "$temporary_hash"' EXIT
|
|
||||||
trap 'exit 130' INT
|
|
||||||
trap 'exit 143' TERM
|
|
||||||
[[ "$expected_hash" =~ ^[0-9a-f]{64}$ ]] || return 1
|
|
||||||
current_hash="$(hash_packages)" || return 1
|
current_hash="$(hash_packages)" || return 1
|
||||||
[[ "$current_hash" == "$expected_hash" ]] || return 1
|
[[ "$current_hash" == "$starting_hash" ]] || return 1
|
||||||
mkdir -p "$STATE_DIR"
|
mkdir -p "$STATE_DIR"
|
||||||
temporary_hash="$(mktemp -u "$STATE_DIR/.packages-hash.XXXXXX")" || return 1
|
temporary_hash="$(mktemp "$STATE_DIR/.packages-hash.XXXXXX")" || return 1
|
||||||
umask 077
|
if printf '%s\n' "$starting_hash" >"$temporary_hash"; then
|
||||||
if ! (set -o noclobber; : >"$temporary_hash") 2>/dev/null; then
|
mv -f -- "$temporary_hash" "$PACKAGES_HASH"
|
||||||
temporary_hash=""
|
else
|
||||||
|
rm -f -- "$temporary_hash"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
printf '%s\n' "$expected_hash" >"$temporary_hash" || return 1
|
}
|
||||||
mv -f -- "$temporary_hash" "$PACKAGES_HASH" || return 1
|
|
||||||
temporary_hash=""
|
|
||||||
)
|
|
||||||
|
|
||||||
# Repository trust is checked before the installer can reach its bootstrap DNF.
|
# Repository trust is checked before the installer can reach its bootstrap DNF.
|
||||||
# Status 78 is reserved for a trust-root failure and is propagated unchanged so
|
# Status 78 is reserved for a trust-root failure. It suppresses every stage that
|
||||||
# no later stage, especially install-hardware, can invoke DNF with that repo.
|
# opens DNF -- install-hardware included, which would otherwise pull drivers
|
||||||
|
# through the very repository in doubt -- and the run still exits 78 at the end.
|
||||||
|
#
|
||||||
|
# It suppresses nothing else. Linking dotfiles, skills and user content reads no
|
||||||
|
# repository and installs no package, and a machine whose Terra is in question
|
||||||
|
# still wants its configuration. Refusing the safe work because the unsafe work
|
||||||
|
# is unavailable does not make the machine safer, it just leaves the machine
|
||||||
|
# unconfigured with no way to fix itself. Exiting here instead meant link-skills
|
||||||
|
# never ran on a machine whose Terra was merely unadopted, so ~/.claude/skills
|
||||||
|
# stayed the whole-directory symlink it had been before skills were linked one
|
||||||
|
# by one, and not one shipped skill was reachable.
|
||||||
TERRA_TRUST_FAILURE_STATUS=78
|
TERRA_TRUST_FAILURE_STATUS=78
|
||||||
|
DNF_STAGES=(install-packages change-settings install-hardware)
|
||||||
|
package_trust_refused=0
|
||||||
|
|
||||||
|
stage_opens_dnf() {
|
||||||
|
local candidate="$1" dnf_stage
|
||||||
|
for dnf_stage in "${DNF_STAGES[@]}"; do
|
||||||
|
[[ "$candidate" == "$dnf_stage" ]] && return 0
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
trust_preflight="$PANAMA_PATH/setup/scripts/install-packages"
|
trust_preflight="$PANAMA_PATH/setup/scripts/install-packages"
|
||||||
if [[ ! -x "$trust_preflight" ]]; then
|
if [[ ! -x "$trust_preflight" ]]; then
|
||||||
printf 'install: package repository trust preflight is unavailable\n' >&2
|
printf 'install: package repository trust preflight is unavailable\n' >&2
|
||||||
exit "$TERRA_TRUST_FAILURE_STATUS"
|
package_trust_refused=1
|
||||||
fi
|
elif ! "$trust_preflight" --trust-preflight; then
|
||||||
if "$trust_preflight" --trust-preflight; then
|
|
||||||
:
|
|
||||||
else
|
|
||||||
trust_status=$?
|
|
||||||
printf 'install: package repository trust preflight failed\n' >&2
|
printf 'install: package repository trust preflight failed\n' >&2
|
||||||
exit "$trust_status"
|
package_trust_refused=1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── The interview ────────────────────────────────────────────────────────────
|
# ── The interview ────────────────────────────────────────────────────────────
|
||||||
@@ -221,7 +199,7 @@ fi
|
|||||||
# Gated exactly like the interview itself: under --upgrade no questions are
|
# Gated exactly like the interview itself: under --upgrade no questions are
|
||||||
# asked, so nothing here is used, and a machine that cannot install gum must
|
# asked, so nothing here is used, and a machine that cannot install gum must
|
||||||
# not have that stop an upgrade that never needed it.
|
# not have that stop an upgrade that never needed it.
|
||||||
if (( ! UPGRADE )); then
|
if (( ! UPGRADE && ! package_trust_refused )); then
|
||||||
bootstrap=()
|
bootstrap=()
|
||||||
command -v gum >/dev/null 2>&1 || bootstrap+=(gum)
|
command -v gum >/dev/null 2>&1 || bootstrap+=(gum)
|
||||||
# The probe tools serve only the hardware questions, which a server is never
|
# The probe tools serve only the hardware questions, which a server is never
|
||||||
@@ -236,8 +214,7 @@ if (( ! UPGRADE )); then
|
|||||||
fi
|
fi
|
||||||
if (( ${#bootstrap[@]} > 0 )); then
|
if (( ${#bootstrap[@]} > 0 )); then
|
||||||
echo "Installing what the setup questions are built on: ${bootstrap[*]}"
|
echo "Installing what the setup questions are built on: ${bootstrap[*]}"
|
||||||
sudo dnf install -y --repo=fedora --repo=updates \
|
sudo dnf install -y "${bootstrap[@]}" >/dev/null || {
|
||||||
--from-repo=fedora,updates "${bootstrap[@]}" >/dev/null || {
|
|
||||||
echo "Could not install ${bootstrap[*]}, so the setup questions cannot be asked." >&2
|
echo "Could not install ${bootstrap[*]}, so the setup questions cannot be asked." >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
@@ -300,12 +277,7 @@ gsettings set org.gnome.desktop.session idle-delay 0 2>/dev/null || true
|
|||||||
# is unset and each stage takes the empty-answer path it already documents --
|
# is unset and each stage takes the empty-answer path it already documents --
|
||||||
# which is why this is a flag rather than a rewrite of seven stage scripts.
|
# which is why this is a flag rather than a rewrite of seven stage scripts.
|
||||||
if (( ! UPGRADE )); then
|
if (( ! UPGRADE )); then
|
||||||
PANAMA_ANSWERS="$(mktemp -u -t panama-answers.XXXXXX)" || exit 1
|
PANAMA_ANSWERS="$(mktemp -t panama-answers.XXXXXX)"
|
||||||
umask 077
|
|
||||||
if ! (set -o noclobber; : >"$PANAMA_ANSWERS") 2>/dev/null; then
|
|
||||||
PANAMA_ANSWERS=""
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
export PANAMA_ANSWERS
|
export PANAMA_ANSWERS
|
||||||
|
|
||||||
if ! PANAMA_ROLE_PRESET="$ROLE_PRESET" "$PANAMA_PATH/setup/scripts/interview"; then
|
if ! PANAMA_ROLE_PRESET="$ROLE_PRESET" "$PANAMA_PATH/setup/scripts/interview"; then
|
||||||
@@ -371,7 +343,7 @@ fi
|
|||||||
if [[ "$PANAMA_ROLE" == server ]]; then
|
if [[ "$PANAMA_ROLE" == server ]]; then
|
||||||
STAGES=(install-packages link-dotfiles link-user setup-server link-server setup-identity)
|
STAGES=(install-packages link-dotfiles link-user setup-server link-server setup-identity)
|
||||||
else
|
else
|
||||||
STAGES=(install-packages link-dotfiles link-skills link-user change-settings link-vicinae-scripts setup-identity install-hardware)
|
STAGES=(install-packages link-dotfiles link-skills link-user link-mcp change-settings link-vicinae-scripts setup-identity install-hardware)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# The two an upgrade drops. Both exist only to act on interview answers, and
|
# The two an upgrade drops. Both exist only to act on interview answers, and
|
||||||
@@ -394,11 +366,16 @@ for stage in "${STAGES[@]}"; do
|
|||||||
script="$PANAMA_PATH/setup/scripts/$stage"
|
script="$PANAMA_PATH/setup/scripts/$stage"
|
||||||
[[ -x "$script" ]] || continue
|
[[ -x "$script" ]] || continue
|
||||||
printf '\n=== %s ===\n' "$stage"
|
printf '\n=== %s ===\n' "$stage"
|
||||||
|
if (( package_trust_refused )) && stage_opens_dnf "$stage"; then
|
||||||
|
echo "Skipped: the package repository trust check refused package work."
|
||||||
|
continue
|
||||||
|
fi
|
||||||
if [[ "$stage" == install-packages ]]; then
|
if [[ "$stage" == install-packages ]]; then
|
||||||
package_start_hash=""
|
|
||||||
package_state_status=0
|
package_state_status=0
|
||||||
packages_needed || package_state_status=$?
|
package_start_hash="$(hash_packages)" || package_state_status=2
|
||||||
package_start_hash="$PACKAGE_START_HASH"
|
if (( package_state_status == 0 )); then
|
||||||
|
packages_needed "$package_start_hash" || package_state_status=$?
|
||||||
|
fi
|
||||||
if (( package_state_status == 1 )); then
|
if (( package_state_status == 1 )); then
|
||||||
echo "The package lists have not changed since the last run; skipping."
|
echo "The package lists have not changed since the last run; skipping."
|
||||||
echo "Run with --packages to install them anyway."
|
echo "Run with --packages to install them anyway."
|
||||||
@@ -418,9 +395,12 @@ for stage in "${STAGES[@]}"; do
|
|||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
stage_status=$?
|
stage_status=$?
|
||||||
|
# A configuration change between the preflight and this stage. Suppress the
|
||||||
|
# remaining DNF stages, keep the safe ones, and carry the status to the end.
|
||||||
if [[ "$stage" == install-packages && "$stage_status" -eq "$TERRA_TRUST_FAILURE_STATUS" ]]; then
|
if [[ "$stage" == install-packages && "$stage_status" -eq "$TERRA_TRUST_FAILURE_STATUS" ]]; then
|
||||||
printf '!!! %s stopped on an untrusted package repository\n' "$stage" >&2
|
printf '!!! %s stopped on an untrusted package repository\n' "$stage" >&2
|
||||||
exit "$stage_status"
|
package_trust_refused=1
|
||||||
|
continue
|
||||||
fi
|
fi
|
||||||
failed+=("$stage")
|
failed+=("$stage")
|
||||||
printf '!!! %s failed\n' "$stage" >&2
|
printf '!!! %s failed\n' "$stage" >&2
|
||||||
@@ -446,8 +426,16 @@ done
|
|||||||
# written for -- and baselining would skip every one of them forever. Every
|
# written for -- and baselining would skip every one of them forever. Every
|
||||||
# migration is self-guarding and a no-op where it does not apply, so running
|
# migration is self-guarding and a no-op where it does not apply, so running
|
||||||
# them is the safe direction.
|
# them is the safe direction.
|
||||||
|
#
|
||||||
|
# Held back when package work was refused. A migration is free to run a DNF
|
||||||
|
# transaction -- the ChatGPT package replacement does exactly that -- so the
|
||||||
|
# repositories have to be trustworthy before any of them is allowed to run.
|
||||||
|
# They are not marked applied either, so the next run still has them pending.
|
||||||
migrate="$PANAMA_PATH/bin/panama-migrate"
|
migrate="$PANAMA_PATH/bin/panama-migrate"
|
||||||
if [[ -x "$migrate" ]]; then
|
if (( package_trust_refused )) && [[ -x "$migrate" ]]; then
|
||||||
|
printf '\n=== migrations ===\n'
|
||||||
|
echo "Skipped: the package repository trust check refused package work."
|
||||||
|
elif [[ -x "$migrate" ]]; then
|
||||||
printf '\n=== migrations ===\n'
|
printf '\n=== migrations ===\n'
|
||||||
if (( UPGRADE )) || [[ -d "$STATE_DIR/migrations" ]]; then
|
if (( UPGRADE )) || [[ -d "$STATE_DIR/migrations" ]]; then
|
||||||
"$migrate" run || failed+=(migrations)
|
"$migrate" run || failed+=(migrations)
|
||||||
@@ -495,6 +483,17 @@ else
|
|||||||
retry='./install'
|
retry='./install'
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Reported last and on its own, because it is not an ordinary stage failure:
|
||||||
|
# everything safe did run, and what did not run is named rather than buried in a
|
||||||
|
# list. The exit status stays 78 so a caller can still tell the two apart.
|
||||||
|
if (( package_trust_refused )); then
|
||||||
|
printf 'Package work was refused: the Terra repository configuration on this\n' >&2
|
||||||
|
printf 'machine is not one Panama can verify. Skipped: %s\n' "${DNF_STAGES[*]}" >&2
|
||||||
|
printf 'Everything that touches no repository was still applied.\n' >&2
|
||||||
|
printf 'Inspect it with: panama diagnose\n' >&2
|
||||||
|
exit "$TERRA_TRUST_FAILURE_STATUS"
|
||||||
|
fi
|
||||||
|
|
||||||
if (( ${#failed[@]} == 0 )); then
|
if (( ${#failed[@]} == 0 )); then
|
||||||
if (( UPGRADE )); then
|
if (( UPGRADE )); then
|
||||||
echo "Panama is up to date."
|
echo "Panama is up to date."
|
||||||
|
|||||||
Executable
+64
@@ -0,0 +1,64 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# replace the community ChatGPT Desktop build with the official OpenAI package
|
||||||
|
#
|
||||||
|
# `panama app chatgpt-desktop` used to build a community wrapper (codex-desktop)
|
||||||
|
# from the upstream macOS disk image, complete with a local rebuild daemon.
|
||||||
|
# OpenAI ships an official Linux RPM now, and the installer takes that instead;
|
||||||
|
# this repairs machines still carrying the community build. The official
|
||||||
|
# package goes on before the community one comes off, so a failure part-way
|
||||||
|
# leaves the machine with an app, never without one.
|
||||||
|
#
|
||||||
|
# Rules, because the runner cannot enforce them:
|
||||||
|
#
|
||||||
|
# * Safe to run twice. The marker records success, not intent.
|
||||||
|
# * Tolerant of the repair already being correct -- the user may have fixed
|
||||||
|
# it by hand, or a later ./install may have put it back.
|
||||||
|
# * Root work goes through `panama-sudo --reason "..."`, never bare sudo,
|
||||||
|
# so the password prompt names the repair.
|
||||||
|
# * Exit non-zero to be retried at the next login. Exit zero only when the
|
||||||
|
# machine is genuinely in the state this describes.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
|
||||||
|
|
||||||
|
# Machines that never had the community build are already correct. The official
|
||||||
|
# app is the installer's job, not this one's.
|
||||||
|
rpm -q codex-desktop >/dev/null 2>&1 || exit 0
|
||||||
|
|
||||||
|
# The same verified repository the installer establishes: the pinned signing
|
||||||
|
# key, then a repository that names it, so dnf checks OpenAI's signature before
|
||||||
|
# root installs anything. See setup/lib/chatgpt-package.
|
||||||
|
# shellcheck source=../setup/lib/chatgpt-package
|
||||||
|
source "$PANAMA_PATH/setup/lib/chatgpt-package"
|
||||||
|
|
||||||
|
sudo_cmd=(sudo)
|
||||||
|
if [[ -t 0 && -x "$PANAMA_PATH/bin/panama-sudo" ]]; then
|
||||||
|
sudo_cmd=(
|
||||||
|
"$PANAMA_PATH/bin/panama-sudo" --reason
|
||||||
|
"Replacing the community-built ChatGPT Desktop (codex-desktop) with the official OpenAI package"
|
||||||
|
--
|
||||||
|
)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The official package first, so the machine is never left without one.
|
||||||
|
if ! rpm -q chatgpt >/dev/null 2>&1; then
|
||||||
|
chatgpt_install_repository "${sudo_cmd[@]}"
|
||||||
|
"${sudo_cmd[@]}" dnf install -y chatgpt
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The community package's updater is a user unit; stop it before dnf removes
|
||||||
|
# the unit file out from under it. Removal also takes the app in /opt, both
|
||||||
|
# binaries, and the polkit policy the local rebuilds needed.
|
||||||
|
systemctl --user disable --now codex-update-manager.service 2>/dev/null || true
|
||||||
|
"${sudo_cmd[@]}" dnf remove -y codex-desktop
|
||||||
|
systemctl --user daemon-reload 2>/dev/null || true
|
||||||
|
|
||||||
|
# The rebuild state the updater kept; the official package needs none of it.
|
||||||
|
rm -rf "${XDG_CACHE_HOME:-$HOME/.cache}/codex-update-manager" \
|
||||||
|
"${XDG_CACHE_HOME:-$HOME/.cache}/codex-runtimes" \
|
||||||
|
"${XDG_CONFIG_HOME:-$HOME/.config}/codex-update-manager" \
|
||||||
|
"${XDG_STATE_HOME:-$HOME/.local/state}/codex-update-manager"
|
||||||
|
|
||||||
|
echo "Replaced the community codex-desktop build with the official chatgpt package."
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
# ChatGPT Desktop.
|
|
||||||
#
|
|
||||||
# OpenAI ships macOS and Windows only. This is a community wrapper that converts
|
|
||||||
# the upstream macOS disk image into a Linux Electron app and packages it as an
|
|
||||||
# RPM, so the installed result is again something dnf owns.
|
|
||||||
#
|
|
||||||
# Same exception, same reason: there is no packaged form to prefer. Nothing is
|
|
||||||
# pinned; `bootstrap-native` fetches the current upstream image each time and
|
|
||||||
# fails loudly when it cannot.
|
|
||||||
|
|
||||||
description="ChatGPT Desktop, built into a Fedora RPM"
|
|
||||||
repo="https://github.com/ilysenko/codex-desktop-linux.git"
|
|
||||||
|
|
||||||
# bootstrap-native installs build dependencies, builds, packages, and installs
|
|
||||||
# the newest artifact -- so unlike the Claude build there is no separate install
|
|
||||||
# step to do here.
|
|
||||||
build() {
|
|
||||||
make bootstrap-native
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Pinned signing keys
|
||||||
|
|
||||||
|
A key lands here when a publisher signs what Panama installs but does not
|
||||||
|
publish the key, or its fingerprint, anywhere an install could fetch and check
|
||||||
|
them first. Pinning the key is what lets `dnf` verify a download before root
|
||||||
|
ever sees it.
|
||||||
|
|
||||||
|
Nothing here is a secret. These are public keys, and the reason to track them
|
||||||
|
is that a *changed* one should be a merge request somebody reads, not a silent
|
||||||
|
change of who is trusted.
|
||||||
|
|
||||||
|
## `RPM-GPG-KEY-chatgpt`
|
||||||
|
|
||||||
|
| | |
|
||||||
|
| --- | --- |
|
||||||
|
| Fingerprint | `3BFA0E4AE8B8CC16A2D9BA684A3B4A566C4660E4` |
|
||||||
|
| User ID | `Codex Linux Repository` |
|
||||||
|
| Signs | the `chatgpt` package and the repository metadata at `https://persistent.oaistatic.com/codex-app-prod/linux/rpm/$basearch` |
|
||||||
|
| Used by | `setup/lib/chatgpt-package` |
|
||||||
|
|
||||||
|
Captured on 2026-08-27 from a machine where the official package had been
|
||||||
|
installed, at `/etc/pki/rpm-gpg/RPM-GPG-KEY-chatgpt`, where the package's own
|
||||||
|
root scriptlet writes it. It is the key that signed both the installed
|
||||||
|
`chatgpt` package and the live `repodata/repomd.xml.asc`.
|
||||||
|
|
||||||
|
Be honest about what that is worth: OpenAI's documented instructions
|
||||||
|
(<https://learn.chatgpt.com/docs/linux/linux-app>) are to download an RPM and
|
||||||
|
install it, and they publish no key URL and no fingerprint to compare against.
|
||||||
|
So this is trust established on first use and then held, not trust verified
|
||||||
|
against the publisher. Held is the part that matters -- from here every machine
|
||||||
|
checks the same fingerprint, and a swapped download fails instead of installing.
|
||||||
|
|
||||||
|
To re-derive the fingerprint from the file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gpg --show-keys --with-colons setup/keys/RPM-GPG-KEY-chatgpt \
|
||||||
|
| awk -F: '$1 == "fpr" { print $10; exit }'
|
||||||
|
```
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||||
|
|
||||||
|
mQINBGpypFUBEACi1Vvzq9pIpA6lj7chbqELuxJtVuzUzxrasa6ZU0yF4yhq7jf8
|
||||||
|
3YkJRHwbezBKeQyzJ5lkX0EhXS8aXxUhMAm3PFpAlwcInfKzmV7atJwvaxIw6Rmd
|
||||||
|
GYe9fBWKjTN/SmPIjtyxrTznZY97+TfD1AeGZpLaJ8fsnhrC+HkiN2TACiTocgpe
|
||||||
|
hFiP0OWK7mWZeTWnY2scpIYXP1Ro7nQv4KacmY4JacTQ7m/HM0Qej/3olhuEv2Cw
|
||||||
|
lMVWw57/oHhmTllfLDQOogFQyIVqaaR98y/Eu6cAabSfcsqAAZ2A8vfHYD27z28J
|
||||||
|
vLO2PZEJd5ThlnX4Zqv0eIpZdBj//8Sl/MSqTshFZ1NDsRoqwdqw284X5MpnOJ4k
|
||||||
|
4Sc2Se8tJxt/nCeibH3dJ504Fb1X/mnOqhCAQ6pVJz4RB5HRlFPSkxVPyag1v1m/
|
||||||
|
7T4vie+OR4eqFQNz6mudrOoMmeVIfyL5fbe4cOr4fk/FyvEE2xMgkFatPqXn7vM9
|
||||||
|
og+zremPCfwRAFpBPyX74VowFY7llcdaj/w8K5T8PzM14Hb3E4ZKizMluKmTvTq9
|
||||||
|
WE1/eSQJLLQqXD5VmtmdUaC/VyE/1ZlIxcA1LWqvEQ327UXREvX/nHsrkKrl956W
|
||||||
|
jzkiHFUTsD1NJ0dMfs+csOt8Furb5jZj+HsMmCm9jLdfz5b/4WKLPbvxIwARAQAB
|
||||||
|
tBZDb2RleCBMaW51eCBSZXBvc2l0b3J5iQJRBBMBCgA7FiEEO/oOSui4zBai2bpo
|
||||||
|
SjtKVmxGYOQFAmpypFUCGwMFCwkIBwICIgIGFQoJCAsCBBYCAwECHgcCF4AACgkQ
|
||||||
|
SjtKVmxGYORlCQ/9FyikZo8HQcJBP9E/oXVPds/fQnIFB2qJR2z3DrfYEonNt/ev
|
||||||
|
SAySkPPq4/mEOjaI0pFlDDGSaps+FTcJFgoVRTasBIF7JJivvjW9ap8iWEbhhVLe
|
||||||
|
IrFLbMLpUcTRntUx7R4fVMJ/1/cGn+NWZmNwS9ORorzSyCH0IAgCw1Xc3ZrjuMbF
|
||||||
|
VjdToMC1TiXXCEmlYpQakmQ3Ay1cH0FHC2BBNn1MNVkJdPhpZIZCdhaMPHfYFpyo
|
||||||
|
pg8wFvZ5iIcvlbMgyuy8CPJVRWUcYy2dOhEOGnYJnXRPkE3E1hf8YOHNzRlduH89
|
||||||
|
6lT9qcEK2+fpLfrVGoc4zscLZ+Ey+Ko6iQRdVE1j67+wNR3hX8ukue574v1N/xxu
|
||||||
|
i575jumSE19lEj1sH4+P4gFHOtTbF0JhKKzLctbga0IAwTPKhnt3qzj1U5Yj/MZS
|
||||||
|
uEVjrLhdRauOuFBXUclgyVf2w/lE85UUOdlcollsYA6Huq7xDamqf8SslZQGre3E
|
||||||
|
I+lhpqJR1cOwDMUzzcl40uTyhrxXXd/bk4QSlhZbwHR25Pnt+ZMtWavlQWS0eDEV
|
||||||
|
8djuXAURCmx5WOqAFB/TJe1mn5EvyWg4VFzrY/NVNOpzgY5+Xp7J28z7f637r712
|
||||||
|
Eu9j4imVcdPigwS+jf/0f81i2o9b82Y26TN8+EtDLCY841MJ1lrjDrX/dno=
|
||||||
|
=Y+3h
|
||||||
|
-----END PGP PUBLIC KEY BLOCK-----
|
||||||
@@ -7,23 +7,20 @@
|
|||||||
declare -gA INSTALLER_PROVENANCE=()
|
declare -gA INSTALLER_PROVENANCE=()
|
||||||
|
|
||||||
_primary_key_fingerprints() (
|
_primary_key_fingerprints() (
|
||||||
local home="" gpg_output
|
local home
|
||||||
trap '[[ -z "$home" ]] || rm -rf -- "$home"' EXIT
|
set -o pipefail
|
||||||
|
home="$(mktemp -d)" || exit 1
|
||||||
|
chmod 700 "$home"
|
||||||
|
trap 'rm -rf -- "$home"' EXIT
|
||||||
trap 'exit 130' INT
|
trap 'exit 130' INT
|
||||||
trap 'exit 143' TERM
|
trap 'exit 143' TERM
|
||||||
home="$(mktemp -u -d -t panama-gpg.XXXXXX)" || exit 1
|
GNUPGHOME="$home" gpg --batch --with-colons --import-options show-only --import "$1" 2>/dev/null \
|
||||||
if ! mkdir -m 700 -- "$home"; then
|
| awk -F: '$1 == "pub" { primary = 1; next } primary && $1 == "fpr" { print $10; primary = 0 }'
|
||||||
home=""
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
gpg_output="$(GNUPGHOME="$home" gpg --batch --with-colons \
|
|
||||||
--import-options show-only --import "$1" 2>/dev/null)" || exit 1
|
|
||||||
awk -F: '$1 == "pub" { primary = 1; next } primary && $1 == "fpr" { print $10; primary = 0 }' \
|
|
||||||
<<<"$gpg_output"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
key_fingerprint_matches() {
|
key_fingerprint_matches() {
|
||||||
local file="$1" expected="$2" output
|
local file="$1" expected="$2"
|
||||||
|
local output
|
||||||
local -a primary_fingerprints=()
|
local -a primary_fingerprints=()
|
||||||
output="$(_primary_key_fingerprints "$file")" || return 1
|
output="$(_primary_key_fingerprints "$file")" || return 1
|
||||||
[[ -n "$output" ]] || return 1
|
[[ -n "$output" ]] || return 1
|
||||||
@@ -44,15 +41,11 @@ verify_detached_signature() {
|
|||||||
local key="$1" signature="$2" content="$3" home
|
local key="$1" signature="$2" content="$3" home
|
||||||
_key_has_one_primary "$key" || return 1
|
_key_has_one_primary "$key" || return 1
|
||||||
(
|
(
|
||||||
home=""
|
home="$(mktemp -d)" || exit 1
|
||||||
trap '[[ -z "$home" ]] || rm -rf -- "$home"' EXIT
|
chmod 700 "$home"
|
||||||
|
trap 'rm -rf -- "$home"' EXIT
|
||||||
trap 'exit 130' INT
|
trap 'exit 130' INT
|
||||||
trap 'exit 143' TERM
|
trap 'exit 143' TERM
|
||||||
home="$(mktemp -u -d -t panama-gpg.XXXXXX)" || exit 1
|
|
||||||
if ! mkdir -m 700 -- "$home"; then
|
|
||||||
home=""
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
GNUPGHOME="$home" gpg --batch --quiet --import "$key" >/dev/null 2>&1 \
|
GNUPGHOME="$home" gpg --batch --quiet --import "$key" >/dev/null 2>&1 \
|
||||||
&& GNUPGHOME="$home" gpg --batch --verify "$signature" "$content" >/dev/null 2>&1
|
&& GNUPGHOME="$home" gpg --batch --verify "$signature" "$content" >/dev/null 2>&1
|
||||||
)
|
)
|
||||||
@@ -74,11 +67,7 @@ download_sha256() {
|
|||||||
[[ "$max_bytes" =~ ^[1-9][0-9]*$ ]] || exit 1
|
[[ "$max_bytes" =~ ^[1-9][0-9]*$ ]] || exit 1
|
||||||
[[ -n "$destination" && -d "$directory" ]] || exit 1
|
[[ -n "$destination" && -d "$directory" ]] || exit 1
|
||||||
umask 077
|
umask 077
|
||||||
part="$(mktemp -u "$directory/.${filename}.part.XXXXXX")" || exit 1
|
part="$(mktemp "$directory/.${filename}.part.XXXXXX")" || exit 1
|
||||||
if ! (set -o noclobber; : >"$part") 2>/dev/null; then
|
|
||||||
part=""
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
curl --fail --location --connect-timeout 10 --max-time 600 \
|
curl --fail --location --connect-timeout 10 --max-time 600 \
|
||||||
--max-filesize "$max_bytes" --output "$part" "$url" \
|
--max-filesize "$max_bytes" --output "$part" "$url" \
|
||||||
|| exit 1
|
|| exit 1
|
||||||
@@ -89,25 +78,25 @@ download_sha256() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
rpm_signature_matches() (
|
rpm_signature_matches() {
|
||||||
local package="$1" key="$2" expected="$3" home="" db output
|
local package="$1" key="$2" expected="$3" home db output status
|
||||||
trap '[[ -z "$home" ]] || rm -rf -- "$home"' EXIT
|
|
||||||
trap 'exit 130' INT
|
|
||||||
trap 'exit 143' TERM
|
|
||||||
|
|
||||||
key_fingerprint_matches "$key" "$expected" || exit 1
|
key_fingerprint_matches "$key" "$expected" || return 1
|
||||||
home="$(mktemp -u -d -t panama-rpm-signature.XXXXXX)" || exit 1
|
home="$(mktemp -d)" || return 1
|
||||||
if ! mkdir -m 700 -- "$home"; then
|
chmod 700 "$home"
|
||||||
home=""
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
db="$home/rpmdb"
|
db="$home/rpmdb"
|
||||||
mkdir -m 700 "$db" || exit 1
|
mkdir -m 700 "$db" || {
|
||||||
rpmkeys --dbpath "$db" --import "$key" >/dev/null 2>&1 || exit 1
|
rm -rf -- "$home"
|
||||||
output="$(rpmkeys --dbpath "$db" --checksig --verbose "$package" 2>&1)" \
|
return 1
|
||||||
|| exit 1
|
}
|
||||||
|
|
||||||
|
rpmkeys --dbpath "$db" --import "$key" >/dev/null 2>&1 \
|
||||||
|
&& output="$(rpmkeys --dbpath "$db" --checksig --verbose "$package" 2>&1)"
|
||||||
|
status=$?
|
||||||
|
rm -rf -- "$home"
|
||||||
|
(( status == 0 )) || return 1
|
||||||
grep -Eqi 'OpenPGP.*signature.*: OK' <<<"$output"
|
grep -Eqi 'OpenPGP.*signature.*: OK' <<<"$output"
|
||||||
)
|
}
|
||||||
|
|
||||||
load_installer_provenance() {
|
load_installer_provenance() {
|
||||||
local file="$1" line name value required
|
local file="$1" line name value required
|
||||||
@@ -123,6 +112,7 @@ load_installer_provenance() {
|
|||||||
RUSTDESK_VERSION RUSTDESK_X86_64_URL RUSTDESK_X86_64_SHA256 RUSTDESK_X86_64_MAX_BYTES \
|
RUSTDESK_VERSION RUSTDESK_X86_64_URL RUSTDESK_X86_64_SHA256 RUSTDESK_X86_64_MAX_BYTES \
|
||||||
FEDORA_RELEASE RPMFUSION_FREE_RELEASE_URL RPMFUSION_FREE_RELEASE_MAX_BYTES \
|
FEDORA_RELEASE RPMFUSION_FREE_RELEASE_URL RPMFUSION_FREE_RELEASE_MAX_BYTES \
|
||||||
RPMFUSION_NONFREE_RELEASE_URL RPMFUSION_NONFREE_RELEASE_MAX_BYTES TERRA_BASEURL \
|
RPMFUSION_NONFREE_RELEASE_URL RPMFUSION_NONFREE_RELEASE_MAX_BYTES TERRA_BASEURL \
|
||||||
|
TERRA_METALINK_BASEURL \
|
||||||
HYPRLAND_COPR_BASEURL FLATHUB_DESCRIPTOR_URL FLATHUB_DESCRIPTOR_MAX_BYTES \
|
HYPRLAND_COPR_BASEURL FLATHUB_DESCRIPTOR_URL FLATHUB_DESCRIPTOR_MAX_BYTES \
|
||||||
CLAUDE_CODE_BASEURL CLAUDE_DESKTOP_BASEURL TERRA_FINGERPRINT CLAUDE_CODE_FINGERPRINT \
|
CLAUDE_CODE_BASEURL CLAUDE_DESKTOP_BASEURL TERRA_FINGERPRINT CLAUDE_CODE_FINGERPRINT \
|
||||||
BUN_FINGERPRINT RPMFUSION_FREE_FINGERPRINT RPMFUSION_NONFREE_FINGERPRINT \
|
BUN_FINGERPRINT RPMFUSION_FREE_FINGERPRINT RPMFUSION_NONFREE_FINGERPRINT \
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# Installing OpenAI's ChatGPT Desktop without trusting the download. Sourced,
|
||||||
|
# not run.
|
||||||
|
#
|
||||||
|
# OpenAI signs both its packages and its repository metadata, with one key, and
|
||||||
|
# publishes neither that key nor its fingerprint anywhere a first install could
|
||||||
|
# fetch them. The documented instructions are "download this RPM and install
|
||||||
|
# it" -- and the RPM's own root scriptlet is what writes the repository file and
|
||||||
|
# drops the key into /etc/pki/rpm-gpg. Following them means handing an
|
||||||
|
# unverified download to root and letting it decide afterwards what to trust,
|
||||||
|
# which is the one thing this repository will not do with a network response.
|
||||||
|
#
|
||||||
|
# So the key is pinned here instead. setup/keys/ carries a copy and records
|
||||||
|
# where it came from; this verifies that copy's fingerprint, installs it, and
|
||||||
|
# writes the repository itself with gpgcheck on. dnf then checks the metadata
|
||||||
|
# signature and the package signature against that key before anything runs as
|
||||||
|
# root, and every later upgrade goes through the same repository and the same
|
||||||
|
# key.
|
||||||
|
#
|
||||||
|
# Two callers, which is why this is a library: install-packages, for a machine
|
||||||
|
# being built, and the migration that replaces the community codex-desktop
|
||||||
|
# build on machines that predate the official package.
|
||||||
|
|
||||||
|
# The key that signs the packages and the repository metadata. Pinned, so a
|
||||||
|
# substituted key is a failure here rather than a silent change of publisher.
|
||||||
|
CHATGPT_KEY_FINGERPRINT="3BFA0E4AE8B8CC16A2D9BA684A3B4A566C4660E4"
|
||||||
|
|
||||||
|
# `$basearch` stays literal: dnf expands it, and this is the same base URL the
|
||||||
|
# package's own scriptlet configures.
|
||||||
|
CHATGPT_REPO_BASEURL="https://persistent.oaistatic.com/codex-app-prod/linux/rpm/\$basearch"
|
||||||
|
CHATGPT_REPO_FILE="/etc/yum.repos.d/chatgpt.repo"
|
||||||
|
CHATGPT_KEY_FILE="/etc/pki/rpm-gpg/RPM-GPG-KEY-chatgpt"
|
||||||
|
|
||||||
|
chatgpt_pinned_key() {
|
||||||
|
printf '%s/setup/keys/RPM-GPG-KEY-chatgpt' "${PANAMA_PATH:-$HOME/.local/share/Panama}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# The fingerprint of the pinned copy. Nonzero when it cannot be read at all,
|
||||||
|
# which the caller reports differently from a key that reads but is the wrong
|
||||||
|
# one.
|
||||||
|
chatgpt_pinned_fingerprint() {
|
||||||
|
local key
|
||||||
|
key="$(chatgpt_pinned_key)"
|
||||||
|
[[ -r "$key" ]] || return 1
|
||||||
|
gpg --show-keys --with-colons "$key" 2>/dev/null \
|
||||||
|
| awk -F: '$1 == "fpr" { print $10; exit }'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fails without touching anything when the pinned key is missing, unreadable,
|
||||||
|
# or not the key this repository says it is. Everything below assumes it passed.
|
||||||
|
chatgpt_verify_pinned_key() {
|
||||||
|
local found
|
||||||
|
if ! command -v gpg >/dev/null 2>&1; then
|
||||||
|
printf 'gpg is missing, so the pinned ChatGPT signing key cannot be verified.\n' >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if ! found="$(chatgpt_pinned_fingerprint)"; then
|
||||||
|
printf 'The pinned ChatGPT signing key is missing: %s\n' "$(chatgpt_pinned_key)" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [[ "$found" != "$CHATGPT_KEY_FINGERPRINT" ]]; then
|
||||||
|
printf 'The pinned ChatGPT signing key is %s, not the expected %s.\n' \
|
||||||
|
"${found:-unreadable}" "$CHATGPT_KEY_FINGERPRINT" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Installs the verified key and the repository that names it, so the install
|
||||||
|
# after this one is a signature check rather than an act of faith.
|
||||||
|
#
|
||||||
|
# Takes the command that gets root, because the two callers ask for it
|
||||||
|
# differently: plain `sudo` from the installer, which authenticated once at the
|
||||||
|
# top of the run, and `panama-sudo --reason ...` from a migration, whose prompt
|
||||||
|
# has to say which repair it is for.
|
||||||
|
chatgpt_install_repository() {
|
||||||
|
local -a sudo_cmd=("$@")
|
||||||
|
(( ${#sudo_cmd[@]} > 0 )) || sudo_cmd=(sudo)
|
||||||
|
|
||||||
|
chatgpt_verify_pinned_key || return 1
|
||||||
|
|
||||||
|
"${sudo_cmd[@]}" install -D -m 0644 "$(chatgpt_pinned_key)" "$CHATGPT_KEY_FILE" || return 1
|
||||||
|
"${sudo_cmd[@]}" rpmkeys --import "$CHATGPT_KEY_FILE" || return 1
|
||||||
|
|
||||||
|
# Written here rather than left to the package's scriptlet, because the
|
||||||
|
# point of it is to exist -- with gpgcheck on and this key named -- before
|
||||||
|
# the first install rather than after it. Same base URL and same key the
|
||||||
|
# scriptlet writes, so it finds nothing to change later.
|
||||||
|
printf '%s\n' \
|
||||||
|
'[openai-chatgpt]' \
|
||||||
|
'name=ChatGPT' \
|
||||||
|
"baseurl=$CHATGPT_REPO_BASEURL" \
|
||||||
|
'enabled=1' \
|
||||||
|
'type=rpm-md' \
|
||||||
|
'gpgcheck=1' \
|
||||||
|
'repo_gpgcheck=1' \
|
||||||
|
"gpgkey=file://$CHATGPT_KEY_FILE" \
|
||||||
|
| "${sudo_cmd[@]}" tee "$CHATGPT_REPO_FILE" >/dev/null || return 1
|
||||||
|
}
|
||||||
+31
-52
@@ -11,24 +11,17 @@ content before the applicable verification succeeds.
|
|||||||
Each command below was run in a private temporary directory on 2026-08-27.
|
Each command below was run in a private temporary directory on 2026-08-27.
|
||||||
The resulting armored public key is vendored under `keys/`; each output was
|
The resulting armored public key is vendored under `keys/`; each output was
|
||||||
checked with the listed complete primary fingerprint before it was committed.
|
checked with the listed complete primary fingerprint before it was committed.
|
||||||
The verification commands use Panama's status-preserving helper: it captures
|
|
||||||
GPG's output only after GPG succeeds, then requires exactly one primary key.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
source setup/lib/artifact-provenance
|
|
||||||
key_fingerprint_matches KEY.asc EXPECTED_COMPLETE_PRIMARY_FINGERPRINT
|
|
||||||
```
|
|
||||||
|
|
||||||
| Key | Source URL | Expected primary fingerprint | Verification command |
|
| Key | Source URL | Expected primary fingerprint | Verification command |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| Terra 44 | `https://repos.fyralabs.com/terra44/key.asc` | `AE09157A4DE88B497EA1D5D300CDAB43DE226D6F` | `key_fingerprint_matches terra44.asc AE09157A4DE88B497EA1D5D300CDAB43DE226D6F` |
|
| Terra 44 | `https://repos.fyralabs.com/terra44/key.asc` | `AE09157A4DE88B497EA1D5D300CDAB43DE226D6F` | `gpg --batch --with-colons --import-options show-only --import terra44.asc \| awk -F: '$1 == "fpr" { print $10; exit }'` |
|
||||||
| Anthropic Claude Code | `https://downloads.claude.ai/keys/claude-code.asc` | `31DDDE24DDFAB679F42D7BD2BAA929FF1A7ECACE` | `key_fingerprint_matches claude-code.asc 31DDDE24DDFAB679F42D7BD2BAA929FF1A7ECACE` |
|
| Anthropic Claude Code | `https://downloads.claude.ai/keys/claude-code.asc` | `31DDDE24DDFAB679F42D7BD2BAA929FF1A7ECACE` | `gpg --batch --with-colons --import-options show-only --import claude-code.asc \| awk -F: '$1 == "fpr" { print $10; exit }'` |
|
||||||
| Bun releases | `https://keys.openpgp.org/vks/v1/by-fingerprint/F3DCC08A8572C0749B3E18888EAB4D40A7B22B59` | `F3DCC08A8572C0749B3E18888EAB4D40A7B22B59` | `key_fingerprint_matches bun.asc F3DCC08A8572C0749B3E18888EAB4D40A7B22B59` |
|
| Bun releases | `https://keys.openpgp.org/vks/v1/by-fingerprint/F3DCC08A8572C0749B3E18888EAB4D40A7B22B59` | `F3DCC08A8572C0749B3E18888EAB4D40A7B22B59` | `gpg --batch --with-colons --import-options show-only --import bun.asc \| awk -F: '$1 == "fpr" { print $10; exit }'` |
|
||||||
| RPM Fusion free | `https://download1.rpmfusion.org/free/fedora/RPM-GPG-KEY-rpmfusion-free-fedora-2020` | `E9A491A3DE247814E7E067EAE06F8ECDD651FF2E` | `key_fingerprint_matches rpmfusion-free.asc E9A491A3DE247814E7E067EAE06F8ECDD651FF2E` |
|
| RPM Fusion free | `https://download1.rpmfusion.org/free/fedora/RPM-GPG-KEY-rpmfusion-free-fedora-2020` | `E9A491A3DE247814E7E067EAE06F8ECDD651FF2E` | `gpg --batch --with-colons --import-options show-only --import rpmfusion-free.asc \| awk -F: '$1 == "fpr" { print $10; exit }'` |
|
||||||
| RPM Fusion nonfree | `https://download1.rpmfusion.org/nonfree/fedora/RPM-GPG-KEY-rpmfusion-nonfree-fedora-2020` | `79BDB88F9BBF73910FD4095B6A2AF96194843C65` | `key_fingerprint_matches rpmfusion-nonfree.asc 79BDB88F9BBF73910FD4095B6A2AF96194843C65` |
|
| RPM Fusion nonfree | `https://download1.rpmfusion.org/nonfree/fedora/RPM-GPG-KEY-rpmfusion-nonfree-fedora-2020` | `79BDB88F9BBF73910FD4095B6A2AF96194843C65` | `gpg --batch --with-colons --import-options show-only --import rpmfusion-nonfree.asc \| awk -F: '$1 == "fpr" { print $10; exit }'` |
|
||||||
| lionheartp/Hyprland COPR | `https://download.copr.fedorainfracloud.org/results/lionheartp/Hyprland/pubkey.gpg` | `97E23476C89635135407C7D5E9BA41342C4B2995` | `key_fingerprint_matches hyprland-copr.asc 97E23476C89635135407C7D5E9BA41342C4B2995` |
|
| lionheartp/Hyprland COPR | `https://download.copr.fedorainfracloud.org/results/lionheartp/Hyprland/pubkey.gpg` | `97E23476C89635135407C7D5E9BA41342C4B2995` | `gpg --batch --with-colons --import-options show-only --import hyprland-copr.asc \| awk -F: '$1 == "fpr" { print $10; exit }'` |
|
||||||
| Flathub | `https://flathub.org/repo/flathub.flatpakrepo` | `6E5C05D979C76DAF93C081354184DD4D907A7CAE` | `key_fingerprint_matches flathub.asc 6E5C05D979C76DAF93C081354184DD4D907A7CAE` |
|
| Flathub | `https://flathub.org/repo/flathub.flatpakrepo` | `6E5C05D979C76DAF93C081354184DD4D907A7CAE` | `awk -F= '/^GPGKey=/{print $2}' flathub.flatpakrepo \| base64 --decode \| gpg --batch --with-colons --import-options show-only --import \| awk -F: '$1 == "fpr" { print $10; exit }'` |
|
||||||
| Claude Desktop Extra | `https://patrickjaja.github.io/claude-desktop-extra/gpg-key.asc` | `825A7D15D78BABE45646D5DF382409F597908867` | `key_fingerprint_matches claude-desktop.asc 825A7D15D78BABE45646D5DF382409F597908867` |
|
| Claude Desktop Extra | `https://patrickjaja.github.io/claude-desktop-extra/gpg-key.asc` | `825A7D15D78BABE45646D5DF382409F597908867` | `gpg --batch --with-colons --import-options show-only --import claude-desktop.asc \| awk -F: '$1 == "fpr" { print $10; exit }'` |
|
||||||
|
|
||||||
The retrieval command for every direct key was:
|
The retrieval command for every direct key was:
|
||||||
|
|
||||||
@@ -105,28 +98,16 @@ digest, then update the command and this ledger in a second commit.
|
|||||||
|
|
||||||
Do not replace a key on an automated update. A key rotation is a reviewed
|
Do not replace a key on an automated update. A key rotation is a reviewed
|
||||||
repository change: obtain the new key from the publisher record, independently
|
repository change: obtain the new key from the publisher record, independently
|
||||||
confirm its complete primary fingerprint, and update every independent pin site
|
confirm its complete primary fingerprint, update the vendored key and
|
||||||
in one review:
|
`installers.conf` together, refresh this retrieval record, and add a focused
|
||||||
|
contract case if the verification behavior changes. Until that review lands,
|
||||||
|
verification fails closed and preserves any known-good destination.
|
||||||
|
|
||||||
- the armored key under `setup/provenance/keys/`;
|
## Container-only Terra 44 signed-bootstrap proof
|
||||||
- its fingerprint in `setup/provenance/installers.conf`;
|
|
||||||
- the matching `_require_policy_value` literal in
|
|
||||||
`setup/scripts/install-packages`;
|
|
||||||
- independent fingerprint expectations and command-log fixtures in
|
|
||||||
`tests/setup/package-provenance-contract`;
|
|
||||||
- this retrieval and evidence ledger at `setup/provenance/README.md`.
|
|
||||||
|
|
||||||
Until all sites agree, verification fails closed and preserves any known-good
|
On 2026-08-27, a single disposable rootless Podman container proved the Terra
|
||||||
destination. Add or update a focused contract whenever verification behavior
|
bootstrap path without changing the host package database, host keyring, or
|
||||||
changes.
|
host repository files. Podman reported `rootless=true`, `runtime=crun`, and a
|
||||||
|
|
||||||
## Historical container-only Terra 44 signed-bootstrap proof
|
|
||||||
|
|
||||||
On 2026-08-27, a single disposable rootless Podman container validated Terra's
|
|
||||||
then-reviewed signed bootstrap without changing the host package database,
|
|
||||||
host keyring, or host repository files. This is retained historical publisher
|
|
||||||
evidence; Panama's runtime installer no longer installs `terra-release`.
|
|
||||||
Podman reported `rootless=true`, `runtime=crun`, and a
|
|
||||||
user graph root. The fresh image was
|
user graph root. The fresh image was
|
||||||
`registry.fedoraproject.org/fedora@sha256:62f199d1eb34170a7bb2277485676d89c0e91aae4086151c4043062cce51c77c`
|
`registry.fedoraproject.org/fedora@sha256:62f199d1eb34170a7bb2277485676d89c0e91aae4086151c4043062cce51c77c`
|
||||||
(`sha256:87d8a4a90c0457689db68624cac1026fb2201cbdc1e99cc5455a8f8876118498`).
|
(`sha256:87d8a4a90c0457689db68624cac1026fb2201cbdc1e99cc5455a8f8876118498`).
|
||||||
@@ -134,13 +115,12 @@ The container (`5fc8fa42bb85afb3b57b336ca29b58a32fad50d460583329a4e910cc29fb4d2d
|
|||||||
had no mounts and was removed automatically after `podman stop`.
|
had no mounts and was removed automatically after `podman stop`.
|
||||||
|
|
||||||
Before copying the only host file admitted to the container,
|
Before copying the only host file admitted to the container,
|
||||||
`keys/terra44.asc`, this status-preserving host check accepted the complete
|
`keys/terra44.asc`, this exact host check reported the complete primary
|
||||||
primary fingerprint `AE09157A4DE88B497EA1D5D300CDAB43DE226D6F`:
|
fingerprint `AE09157A4DE88B497EA1D5D300CDAB43DE226D6F`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
source setup/lib/artifact-provenance
|
gpg --batch --with-colons --import-options show-only --import setup/provenance/keys/terra44.asc \
|
||||||
key_fingerprint_matches setup/provenance/keys/terra44.asc \
|
| awk -F: '$1 == "fpr" { print $10; exit }'
|
||||||
AE09157A4DE88B497EA1D5D300CDAB43DE226D6F
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Its SHA-256 was
|
Its SHA-256 was
|
||||||
@@ -164,16 +144,14 @@ podman exec panama-terra-proof-20260827 /bin/bash -lc '
|
|||||||
'
|
'
|
||||||
```
|
```
|
||||||
|
|
||||||
The retained command output records the copied key's SHA-256 and DNF's
|
Inside the container the copied and installed key both had the recorded
|
||||||
successful `terra-release-44-9.noarch` transaction. The command itself pins the
|
SHA-256 before and after installation. `terra-release-44-9.noarch` was
|
||||||
temporary Terra base URL and local staged key and enables package and repository
|
installed. Its effective `terra` configuration reported `gpgcheck = 1`,
|
||||||
signature checks. It does not include a separate post-install fingerprint or
|
`pkg_gpgcheck = 1`, and `repo_gpgcheck = 1`; no GPG-bypass option was used.
|
||||||
effective-repository query, so this ledger makes no independent post-check
|
The package's own `/etc/yum.repos.d/terra.repo` uses its Terra metalink and
|
||||||
claim. Production publishes the reviewed root-staged key/repository pair
|
`RPM-GPG-KEY-terra44`. That differs from Panama's deliberately staged local
|
||||||
directly and commits it only after the effective-repository post-check
|
key/base-URL file in `install-packages`, which replaces the release-generated
|
||||||
succeeds; failure restores the prior pair. Publisher-only package transactions
|
file only after this verified bootstrap step.
|
||||||
use a fresh command-line repository identity, the reviewed base URL, and a
|
|
||||||
newly fingerprint-verified private root key snapshot.
|
|
||||||
|
|
||||||
Although the command runner returned after 30 seconds while DNF was still
|
Although the command runner returned after 30 seconds while DNF was still
|
||||||
loading metadata, Podman's retained event log records the exact command's
|
loading metadata, Podman's retained event log records the exact command's
|
||||||
@@ -187,5 +165,6 @@ podman events --since '2026-08-27T10:55:00-04:00' --until '2026-08-27T11:02:00-0
|
|||||||
|
|
||||||
The first `exec` event, at `timeNano=1787842633591543881`, is the documented
|
The first `exec` event, at `timeNano=1787842633591543881`, is the documented
|
||||||
key-install and DNF command. Its matching first `exec_died` event, at
|
key-install and DNF command. Its matching first `exec_died` event, at
|
||||||
`timeNano=1787842671276003275`, records `ContainerExitCode:0`. No retry or
|
`timeNano=1787842671276003275`, records `ContainerExitCode:0`. The
|
||||||
second container was used, and no stronger post-check evidence is retained.
|
same-container post-check independently confirmed the installed package and
|
||||||
|
effective signature settings above; no retry or second container was used.
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ RPMFUSION_FREE_RELEASE_MAX_BYTES=4194304
|
|||||||
RPMFUSION_NONFREE_RELEASE_URL=https://mirrors.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-44.noarch.rpm
|
RPMFUSION_NONFREE_RELEASE_URL=https://mirrors.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-44.noarch.rpm
|
||||||
RPMFUSION_NONFREE_RELEASE_MAX_BYTES=4194304
|
RPMFUSION_NONFREE_RELEASE_MAX_BYTES=4194304
|
||||||
TERRA_BASEURL=https://repos.fyralabs.com/terra44
|
TERRA_BASEURL=https://repos.fyralabs.com/terra44
|
||||||
|
TERRA_METALINK_BASEURL=https://tetsudou.fyralabs.com/metalink
|
||||||
HYPRLAND_COPR_BASEURL=https://download.copr.fedorainfracloud.org/results/lionheartp/Hyprland/fedora-$releasever-$basearch/
|
HYPRLAND_COPR_BASEURL=https://download.copr.fedorainfracloud.org/results/lionheartp/Hyprland/fedora-$releasever-$basearch/
|
||||||
FLATHUB_DESCRIPTOR_URL=https://flathub.org/repo/flathub.flatpakrepo
|
FLATHUB_DESCRIPTOR_URL=https://flathub.org/repo/flathub.flatpakrepo
|
||||||
FLATHUB_DESCRIPTOR_MAX_BYTES=1048576
|
FLATHUB_DESCRIPTOR_MAX_BYTES=1048576
|
||||||
|
|||||||
@@ -38,13 +38,33 @@ if [[ -f /etc/udev/rules.d/99-panama-gpu.rules ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# The power-key drop-in lands via the same copy; logind reads its config only
|
# The power-key drop-in lands via the same copy; logind reads its config only
|
||||||
# at start, so without this the button keeps hard-powering-off until the next
|
# at start. Restarting logind under a running Wayland or X11 session drops that
|
||||||
# boot. Restarting logind is what Fedora's own updates do and current systemd
|
# session and takes the desktop down with it, so the restart happens only when
|
||||||
# preserves sessions across it.
|
# logind is not already honouring the drop-in AND no graphical session is live.
|
||||||
|
# Otherwise the change waits for the next boot, which is harmless: the answer
|
||||||
|
# is usually already "ignore" from an earlier run.
|
||||||
|
graphical_session_active() {
|
||||||
|
local id
|
||||||
|
for id in $(loginctl list-sessions --no-legend 2>/dev/null | awk '{ print $1 }'); do
|
||||||
|
case "$(loginctl show-session "$id" -p Type --value 2>/dev/null)" in
|
||||||
|
wayland|x11) return 0 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
if [[ -f /etc/systemd/logind.conf.d/panama-power-key.conf ]]; then
|
if [[ -f /etc/systemd/logind.conf.d/panama-power-key.conf ]]; then
|
||||||
log "Reloading logind so the power button opens the menu instead of powering off"
|
power_key="$(busctl get-property org.freedesktop.login1 /org/freedesktop/login1 \
|
||||||
sudo systemctl try-restart systemd-logind 2>/dev/null \
|
org.freedesktop.login1.Manager HandlePowerKey 2>/dev/null)" || power_key=""
|
||||||
|| log "Could not restart logind; the power-button change applies at next boot"
|
if [[ "$power_key" == 's "ignore"' ]]; then
|
||||||
|
log "logind already leaves the power button to the power menu"
|
||||||
|
elif graphical_session_active; then
|
||||||
|
log "A desktop session is running; the power-button change applies at next boot"
|
||||||
|
else
|
||||||
|
log "Reloading logind so the power button opens the menu instead of powering off"
|
||||||
|
sudo systemctl try-restart systemd-logind 2>/dev/null \
|
||||||
|
|| log "Could not restart logind; the power-button change applies at next boot"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# The document-portal drop-in lands via the same copy. A user unit's drop-ins
|
# The document-portal drop-in lands via the same copy. A user unit's drop-ins
|
||||||
|
|||||||
@@ -53,12 +53,7 @@ if [[ "${PANAMA_NVIDIA:-no}" == yes ]]; then
|
|||||||
warn "Secure Boot question, or disable Secure Boot first."
|
warn "Secure Boot question, or disable Secure Boot first."
|
||||||
else
|
else
|
||||||
log "Installing the NVIDIA driver"
|
log "Installing the NVIDIA driver"
|
||||||
if sudo dnf install -y \
|
if sudo dnf install -y akmod-nvidia xorg-x11-drv-nvidia-cuda; then
|
||||||
--repo=fedora --repo=updates \
|
|
||||||
--repo=rpmfusion-free --repo=rpmfusion-free-updates \
|
|
||||||
--repo=rpmfusion-nonfree --repo=rpmfusion-nonfree-updates \
|
|
||||||
--from-repo=rpmfusion-nonfree,rpmfusion-nonfree-updates \
|
|
||||||
akmod-nvidia xorg-x11-drv-nvidia-cuda; then
|
|
||||||
# nouveau has to be out of the way before the kernel would otherwise
|
# nouveau has to be out of the way before the kernel would otherwise
|
||||||
# bind it, which is why these are kernel arguments and not a modprobe
|
# bind it, which is why these are kernel arguments and not a modprobe
|
||||||
# drop-in. modeset=1 is what makes the Wayland session work at all.
|
# drop-in. modeset=1 is what makes the Wayland session work at all.
|
||||||
|
|||||||
+378
-728
File diff suppressed because it is too large
Load Diff
Executable
+99
@@ -0,0 +1,99 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# MCP servers, registered with the agent runtimes on this machine.
|
||||||
|
#
|
||||||
|
# An MCP server is a URL plus a bearer token, and the token is the whole reason
|
||||||
|
# this is a stage rather than a manifest line. Panama is a public repository, so
|
||||||
|
# the tokens cannot live in it, and neither runtime keeps its server list in a
|
||||||
|
# file that could be symlinked anyway: Codex writes them into config.toml beside
|
||||||
|
# dozens of unrelated settings, and Claude Code into ~/.claude.json. There is no
|
||||||
|
# file to point at, so this registers them through the runtime's own CLI.
|
||||||
|
#
|
||||||
|
# What is tracked is user/agents/mcp/servers, which names each server and which
|
||||||
|
# variable carries its token. What is not tracked is user/agents/mcp/env, which
|
||||||
|
# holds the tokens. A new machine gets the servers by dropping its own env file
|
||||||
|
# beside the tracked one and re-running ./install.
|
||||||
|
#
|
||||||
|
# This is personal content, so it obeys the same interview decision link-user
|
||||||
|
# does. A machine that never said yes registers nothing.
|
||||||
|
#
|
||||||
|
# Only Claude Code is handled. Codex stores its servers inside config.toml, and
|
||||||
|
# rewriting a section of somebody's live TOML is a worse failure mode than
|
||||||
|
# leaving two lines for them to paste once.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
log() { echo -e "\033[1;34m[INFO]\033[0m $*"; }
|
||||||
|
warn() { echo -e "\033[1;33m[WARN]\033[0m $*" >&2; }
|
||||||
|
|
||||||
|
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
|
||||||
|
MCP_DIR="$PANAMA_PATH/user/agents/mcp"
|
||||||
|
SERVERS="$MCP_DIR/servers"
|
||||||
|
ENV_FILE="$MCP_DIR/env"
|
||||||
|
STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/panama"
|
||||||
|
DECISION="$STATE_DIR/user-content"
|
||||||
|
|
||||||
|
[[ -r "$SERVERS" ]] || { log "No MCP server list; nothing to register."; exit 0; }
|
||||||
|
|
||||||
|
# The same gate link-user uses, read the same way, so one answer governs all
|
||||||
|
# personal content rather than two stages disagreeing about it.
|
||||||
|
decision="$([[ -r "$DECISION" ]] && cat "$DECISION" || printf 'no')"
|
||||||
|
if [[ "$decision" != "yes" ]]; then
|
||||||
|
log "Personal content is not enabled on this machine; no MCP servers registered."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v claude >/dev/null 2>&1; then
|
||||||
|
log "Claude Code is not installed; nothing to register."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Tokens are optional. A machine without the env file still registers any server
|
||||||
|
# that needs no header, and says which ones it skipped rather than failing.
|
||||||
|
if [[ -r "$ENV_FILE" ]]; then
|
||||||
|
set -a
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
. "$ENV_FILE"
|
||||||
|
set +a
|
||||||
|
else
|
||||||
|
warn "No $ENV_FILE; servers needing a token will be skipped."
|
||||||
|
fi
|
||||||
|
|
||||||
|
registered=0
|
||||||
|
skipped=0
|
||||||
|
|
||||||
|
while read -r name transport url token_var _rest; do
|
||||||
|
case "${name:-}" in ''|'#'*) continue ;; esac
|
||||||
|
if [[ -z "${transport:-}" || -z "${url:-}" ]]; then
|
||||||
|
warn "Ignoring malformed row for '$name'."
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
header=()
|
||||||
|
if [[ -n "${token_var:-}" ]]; then
|
||||||
|
token="${!token_var:-}"
|
||||||
|
if [[ -z "$token" ]]; then
|
||||||
|
warn "Skipping $name: $token_var is not set in $ENV_FILE."
|
||||||
|
skipped=$((skipped + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
header=(-H "Authorization: $token")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Re-registering is how this stays idempotent across upgrades, and how 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 claude mcp add --transport "$transport" "$name" "$url" "${header[@]}" \
|
||||||
|
-s user >/dev/null 2>&1; then
|
||||||
|
log "Registered $name."
|
||||||
|
registered=$((registered + 1))
|
||||||
|
else
|
||||||
|
warn "Could not register $name."
|
||||||
|
skipped=$((skipped + 1))
|
||||||
|
fi
|
||||||
|
done <"$SERVERS"
|
||||||
|
|
||||||
|
log "MCP servers: $registered registered, $skipped skipped."
|
||||||
|
log "Claude Code loads them at start, so restart a running session to pick them up."
|
||||||
+25
-23
@@ -11,12 +11,11 @@
|
|||||||
# this repository, not anybody's personal content, so a stranger who clones
|
# this repository, not anybody's personal content, so a stranger who clones
|
||||||
# Panama wants it for exactly the same reason its author does.
|
# Panama wants it for exactly the same reason its author does.
|
||||||
#
|
#
|
||||||
# ~/.claude/skills was a single symlink into user/agents/skills until now, and
|
# ~/.agents/skills and ~/.claude/skills may each start as a single symlink into
|
||||||
# a directory cannot be two things at once. So the destination becomes a real
|
# user/agents/skills, but a directory cannot point at personal and shipped
|
||||||
# directory and every skill -- shipped here, personal from user/ -- is linked
|
# skills at once. Both destinations become real directories with one link per
|
||||||
# into it one at a time. link-user runs after this stage on purpose: it links
|
# skill. link-user runs after this stage on purpose, so a personal skill named
|
||||||
# last, so a personal skill named like a shipped one wins, which is the
|
# like a shipped one wins in every agent runtime.
|
||||||
# precedence Claude Code itself uses.
|
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -25,7 +24,7 @@ log() { echo -e "\033[1;34m[INFO]\033[0m $*"; }
|
|||||||
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
|
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
|
||||||
SKILLS_DIR="$PANAMA_PATH/skills"
|
SKILLS_DIR="$PANAMA_PATH/skills"
|
||||||
PANAMA_OLD="$PANAMA_PATH/config/old"
|
PANAMA_OLD="$PANAMA_PATH/config/old"
|
||||||
DESTINATION="$HOME/.claude/skills"
|
DESTINATIONS=("$HOME/.agents/skills" "$HOME/.claude/skills")
|
||||||
|
|
||||||
[[ -d "$SKILLS_DIR" ]] || { log "No skills/ in this checkout; nothing to link."; exit 0; }
|
[[ -d "$SKILLS_DIR" ]] || { log "No skills/ in this checkout; nothing to link."; exit 0; }
|
||||||
|
|
||||||
@@ -51,27 +50,30 @@ displace() {
|
|||||||
log "Moved existing $destination to $backup"
|
log "Moved existing $destination to $backup"
|
||||||
}
|
}
|
||||||
|
|
||||||
# The destination itself has to be a real directory before anything can be
|
# Each destination has to be a real directory before anything can be linked
|
||||||
# linked into it. An old whole-directory symlink is removed; a regular file
|
# into it. An old whole-directory symlink is removed; a regular file somebody
|
||||||
# somebody left at this path is kept, in config/old/.
|
# left at the path is kept in config/old/.
|
||||||
mkdir -p "$(dirname "$DESTINATION")"
|
for destination in "${DESTINATIONS[@]}"; do
|
||||||
if [[ -L "$DESTINATION" ]]; then
|
mkdir -p "$(dirname "$destination")"
|
||||||
rm -f "$DESTINATION"
|
if [[ -L "$destination" ]]; then
|
||||||
log "Removed the old $DESTINATION symlink; skills are linked one by one now"
|
rm -f "$destination"
|
||||||
elif [[ -e "$DESTINATION" && ! -d "$DESTINATION" ]]; then
|
log "Removed the old $destination symlink; skills are linked one by one now"
|
||||||
displace "$DESTINATION"
|
elif [[ -e "$destination" && ! -d "$destination" ]]; then
|
||||||
fi
|
displace "$destination"
|
||||||
mkdir -p "$DESTINATION"
|
fi
|
||||||
|
mkdir -p "$destination"
|
||||||
|
done
|
||||||
|
|
||||||
linked=0
|
linked=0
|
||||||
for skill in "$SKILLS_DIR"/*; do
|
for skill in "$SKILLS_DIR"/*; do
|
||||||
[[ -e "$skill" ]] || continue
|
[[ -e "$skill" ]] || continue
|
||||||
name="$(basename "$skill")"
|
name="$(basename "$skill")"
|
||||||
target="$DESTINATION/$name"
|
for destination in "${DESTINATIONS[@]}"; do
|
||||||
|
target="$destination/$name"
|
||||||
displace "$target"
|
displace "$target"
|
||||||
ln -s "$skill" "$target"
|
ln -s "$skill" "$target"
|
||||||
log "Linked skills/$name → $target"
|
log "Linked skills/$name → $target"
|
||||||
|
done
|
||||||
linked=$(( linked + 1 ))
|
linked=$(( linked + 1 ))
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|||||||
@@ -6,75 +6,6 @@
|
|||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
_collect_vicinae_inputs() {
|
|
||||||
local extension="$1" output="$2"
|
|
||||||
[[ -d "$extension" && ! -L "$extension" \
|
|
||||||
&& -f "$extension/package.json" && ! -L "$extension/package.json" \
|
|
||||||
&& -f "$extension/package-lock.json" && ! -L "$extension/package-lock.json" ]] \
|
|
||||||
|| return 1
|
|
||||||
|
|
||||||
# Everything authored below the extension affects its build. npm's
|
|
||||||
# dependency tree is the sole exception and is reproduced from the lock.
|
|
||||||
find "$extension" -mindepth 1 \
|
|
||||||
\( -path "$extension/node_modules" -prune \) -o \
|
|
||||||
! -type d -print0 >"$output" || return 1
|
|
||||||
LC_ALL=C sort -z -o "$output" "$output" || return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
_write_vicinae_manifest() {
|
|
||||||
local extension="$1" inputs="$2" output="$3"
|
|
||||||
local input relative digest
|
|
||||||
: >"$output" || return 1
|
|
||||||
while IFS= read -r -d '' input; do
|
|
||||||
[[ -f "$input" && ! -L "$input" && -r "$input" ]] || return 1
|
|
||||||
relative="${input#"$extension"/}"
|
|
||||||
[[ "$relative" != "$input" && -n "$relative" ]] || return 1
|
|
||||||
digest="$(sha256sum -- "$input" | awk '{ print $1 }')" || return 1
|
|
||||||
[[ "$digest" =~ ^[0-9a-f]{64}$ ]] || return 1
|
|
||||||
printf '%s\0%s\0' "$relative" "$digest" >>"$output" || return 1
|
|
||||||
done <"$inputs"
|
|
||||||
}
|
|
||||||
|
|
||||||
_vicinae_extension_digest() (
|
|
||||||
local extension="${1%/}" work=""
|
|
||||||
trap '[[ -z "$work" ]] || rm -rf -- "$work"' EXIT
|
|
||||||
trap 'exit 130' INT
|
|
||||||
trap 'exit 143' TERM
|
|
||||||
work="$(mktemp -u -d -t panama-vicinae-digest.XXXXXX)" || exit 1
|
|
||||||
if ! mkdir -m 700 -- "$work"; then
|
|
||||||
work=""
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
_collect_vicinae_inputs "$extension" "$work/inputs.before" || exit 1
|
|
||||||
_write_vicinae_manifest \
|
|
||||||
"$extension" "$work/inputs.before" "$work/manifest.before" || exit 1
|
|
||||||
_collect_vicinae_inputs "$extension" "$work/inputs.after" || exit 1
|
|
||||||
_write_vicinae_manifest \
|
|
||||||
"$extension" "$work/inputs.after" "$work/manifest.after" || exit 1
|
|
||||||
cmp -s -- "$work/inputs.before" "$work/inputs.after" || exit 1
|
|
||||||
cmp -s -- "$work/manifest.before" "$work/manifest.after" || exit 1
|
|
||||||
sha256sum -- "$work/manifest.before" | awk '{ print $1 }'
|
|
||||||
)
|
|
||||||
|
|
||||||
_record_vicinae_digest() (
|
|
||||||
local built="$1" digest="$2" receipt temporary=""
|
|
||||||
trap '[[ -z "$temporary" ]] || rm -f -- "$temporary"' EXIT
|
|
||||||
trap 'exit 130' INT
|
|
||||||
trap 'exit 143' TERM
|
|
||||||
[[ -d "$built" && ! -L "$built" ]] || exit 1
|
|
||||||
receipt="$built/.panama-source-sha256"
|
|
||||||
temporary="$(mktemp -u "$built/.panama-source-sha256.XXXXXX")" || exit 1
|
|
||||||
umask 077
|
|
||||||
if ! (set -o noclobber; : >"$temporary") 2>/dev/null; then
|
|
||||||
temporary=""
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
printf '%s\n' "$digest" >"$temporary" || exit 1
|
|
||||||
mv -f -- "$temporary" "$receipt" || exit 1
|
|
||||||
temporary=""
|
|
||||||
)
|
|
||||||
|
|
||||||
panama_path="${PANAMA_PATH:-$HOME/.local/share/Panama}"
|
panama_path="${PANAMA_PATH:-$HOME/.local/share/Panama}"
|
||||||
vicinae_data_dir="${VICINAE_DATA_DIR:-$HOME/.local/share/vicinae}"
|
vicinae_data_dir="${VICINAE_DATA_DIR:-$HOME/.local/share/vicinae}"
|
||||||
source_dir="$panama_path/config/local/share/vicinae/scripts"
|
source_dir="$panama_path/config/local/share/vicinae/scripts"
|
||||||
@@ -150,36 +81,24 @@ if [[ -d "$extensions_source" ]] && command -v npm >/dev/null 2>&1; then
|
|||||||
[[ -f "$extension/package.json" ]] || continue
|
[[ -f "$extension/package.json" ]] || continue
|
||||||
name="$(basename "$extension")"
|
name="$(basename "$extension")"
|
||||||
|
|
||||||
# Skip only when a prior successful build records the digest of both
|
# Skip a build that would produce what is already there. `npm ci`
|
||||||
# manifests and every source byte. Directory mtimes do not change when
|
# alone takes long enough to be worth not repeating on every re-run of
|
||||||
# an existing source file is edited.
|
# a stage that is otherwise nearly instant.
|
||||||
built="$vicinae_data_dir/extensions/$name"
|
built="$vicinae_data_dir/extensions/$name"
|
||||||
receipt="$built/.panama-source-sha256"
|
if [[ -d "$built" ]]; then
|
||||||
if ! source_digest="$(_vicinae_extension_digest "$extension")"; then
|
newer_source=''
|
||||||
printf 'Vicinae extension %s inputs could not be verified; skipping\n' \
|
if newer_source="$(find "$extension/src" -type f -newer "$built" -print -quit)" \
|
||||||
"$name" >&2
|
&& [[ -z "$newer_source" \
|
||||||
continue
|
&& ! "$extension/package.json" -nt "$built" \
|
||||||
fi
|
&& ! "$extension/package-lock.json" -nt "$built" ]]; then
|
||||||
if [[ -f "$receipt" && ! -L "$receipt" ]] \
|
printf 'Vicinae extension %s is already built\n' "$name"
|
||||||
&& cmp -s <(printf '%s\n' "$source_digest") "$receipt"; then
|
continue
|
||||||
printf 'Vicinae extension %s is already built\n' "$name"
|
fi
|
||||||
continue
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
printf 'Building Vicinae extension %s\n' "$name"
|
printf 'Building Vicinae extension %s\n' "$name"
|
||||||
if ! (cd "$extension" && npm ci --silent >/dev/null 2>&1 \
|
if ! (cd "$extension" && npm ci --silent >/dev/null 2>&1 && npm run build >/dev/null 2>&1); then
|
||||||
&& npm run build >/dev/null 2>&1); then
|
|
||||||
printf 'Vicinae extension %s did not build; skipping\n' "$name" >&2
|
printf 'Vicinae extension %s did not build; skipping\n' "$name" >&2
|
||||||
continue
|
|
||||||
fi
|
|
||||||
if ! final_digest="$(_vicinae_extension_digest "$extension")" \
|
|
||||||
|| [[ "$final_digest" != "$source_digest" ]]; then
|
|
||||||
printf 'Vicinae extension %s changed while building; receipt withheld\n' \
|
|
||||||
"$name" >&2
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
if ! _record_vicinae_digest "$built" "$source_digest"; then
|
|
||||||
printf 'Vicinae extension %s receipt could not be recorded\n' "$name" >&2
|
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
elif [[ -d "$extensions_source" ]]; then
|
elif [[ -d "$extensions_source" ]]; then
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ live-desktop tests/quickshell/dock-position-contract
|
|||||||
# Reads Hyprland option descriptions from the live compositor to verify enum
|
# Reads Hyprland option descriptions from the live compositor to verify enum
|
||||||
# mappings.
|
# mappings.
|
||||||
live-compositor tests/quickshell/enum-hypr-map-contract
|
live-compositor tests/quickshell/enum-hypr-map-contract
|
||||||
|
hermetic tests/quickshell/exclusion-idiom-contract
|
||||||
hermetic tests/quickshell/fingerprint-contract
|
hermetic tests/quickshell/fingerprint-contract
|
||||||
# Reads the host firewall state through the production firewall helper.
|
# Reads the host firewall state through the production firewall helper.
|
||||||
live-host tests/quickshell/firewall-contract
|
live-host tests/quickshell/firewall-contract
|
||||||
@@ -247,6 +248,7 @@ hermetic tests/server/containers-shape-contract
|
|||||||
hermetic tests/server/panama-server-contract
|
hermetic tests/server/panama-server-contract
|
||||||
hermetic tests/setup/apps-contract
|
hermetic tests/setup/apps-contract
|
||||||
hermetic tests/setup/boot-contract
|
hermetic tests/setup/boot-contract
|
||||||
|
hermetic tests/setup/chatgpt-package-contract
|
||||||
hermetic tests/setup/contract-manifest-contract
|
hermetic tests/setup/contract-manifest-contract
|
||||||
hermetic tests/setup/crash-watch-contract
|
hermetic tests/setup/crash-watch-contract
|
||||||
hermetic tests/setup/desktop-first-contract
|
hermetic tests/setup/desktop-first-contract
|
||||||
|
|||||||
Executable
+45
@@ -0,0 +1,45 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# A window that wants the whole output must ask for ExclusionMode.Ignore and
|
||||||
|
# nothing else. Quickshell's exclusiveZone setter forces exclusionMode back to
|
||||||
|
# Normal as a side effect, so a window declaring both
|
||||||
|
#
|
||||||
|
# exclusiveZone: 0
|
||||||
|
# exclusionMode: ExclusionMode.Ignore
|
||||||
|
#
|
||||||
|
# ends up with whichever property the QML engine applied last, and that order
|
||||||
|
# is not ours to control: the 2026-09-14 Qt/Quickshell update flipped it, every
|
||||||
|
# full-screen overlay slid down under the bar, and the screenshot picker's
|
||||||
|
# frozen frame stopped lining up with the screen it was a picture of. This pins
|
||||||
|
# the order-independent idiom: Ignore alone, never paired with exclusiveZone.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
qs="$repo_dir/config/dot/quickshell"
|
||||||
|
|
||||||
|
fail() { printf 'exclusion idiom contract: %s\n' "$1" >&2; exit 1; }
|
||||||
|
[[ -d "$qs/modules" ]] || fail "missing $qs/modules"
|
||||||
|
|
||||||
|
python3 - "$qs" <<'PY'
|
||||||
|
import re, sys, pathlib
|
||||||
|
qs = pathlib.Path(sys.argv[1])
|
||||||
|
problems = []
|
||||||
|
ignore = re.compile(r'\bexclusionMode\s*:\s*ExclusionMode\.Ignore\b')
|
||||||
|
zone = re.compile(r'^\s*(?:WlrLayershell\.)?exclusiveZone\s*:', re.M)
|
||||||
|
checked = 0
|
||||||
|
for path in sorted(qs.rglob('*.qml')):
|
||||||
|
text = path.read_text()
|
||||||
|
if not ignore.search(text):
|
||||||
|
continue
|
||||||
|
checked += 1
|
||||||
|
for m in zone.finditer(text):
|
||||||
|
line = text.count('\n', 0, m.start()) + 1
|
||||||
|
problems.append(f"{path.relative_to(qs)}:{line}: exclusiveZone set in a file that uses "
|
||||||
|
"ExclusionMode.Ignore; drop it, Ignore already means -1")
|
||||||
|
if checked == 0:
|
||||||
|
problems.append("no window uses ExclusionMode.Ignore; the overlays this pins are gone")
|
||||||
|
if problems:
|
||||||
|
print('\n'.join(problems), file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
PY
|
||||||
@@ -32,8 +32,6 @@ panama="$repo_dir/bin/panama"
|
|||||||
findings=()
|
findings=()
|
||||||
note() { findings+=("$1"); }
|
note() { findings+=("$1"); }
|
||||||
|
|
||||||
[[ -d "$apps_dir" ]] || { printf 'apps contract: no %s\n' "$apps_dir" >&2; exit 1; }
|
|
||||||
|
|
||||||
shopt -s nullglob
|
shopt -s nullglob
|
||||||
definitions=("$apps_dir"/*)
|
definitions=("$apps_dir"/*)
|
||||||
|
|
||||||
|
|||||||
@@ -15,16 +15,6 @@ note() { findings+=("$1"); }
|
|||||||
|
|
||||||
[[ -x "$boot" ]] || { printf 'boot contract: %s is not executable\n' "$boot" >&2; exit 1; }
|
[[ -x "$boot" ]] || { printf 'boot contract: %s is not executable\n' "$boot" >&2; exit 1; }
|
||||||
|
|
||||||
# Git is the only package boot can install before the verified checkout exists.
|
|
||||||
# Both root-server and ordinary-user paths must exclude ambient third-party
|
|
||||||
# repositories while still allowing Fedora dependencies.
|
|
||||||
for git_install in \
|
|
||||||
'dnf install -y --repo=fedora --repo=updates --from-repo=fedora,updates git' \
|
|
||||||
'sudo dnf install -y --repo=fedora --repo=updates --from-repo=fedora,updates git'; do
|
|
||||||
grep -qF "$git_install" "$boot" \
|
|
||||||
|| note "boot omits reviewed Fedora source binding: $git_install"
|
|
||||||
done
|
|
||||||
|
|
||||||
work="$(mktemp -d)"
|
work="$(mktemp -d)"
|
||||||
trap 'rm -rf "$work"' EXIT
|
trap 'rm -rf "$work"' EXIT
|
||||||
|
|
||||||
@@ -113,16 +103,6 @@ case "\${1:-}" in
|
|||||||
[[ "\$#" -eq 4 && "\$4" == 'HEAD^{commit}' ]] || exit 97
|
[[ "\$#" -eq 4 && "\$4" == 'HEAD^{commit}' ]] || exit 97
|
||||||
cat "$state/head-revision"
|
cat "$state/head-revision"
|
||||||
;;
|
;;
|
||||||
ls-tree)
|
|
||||||
[[ "\$#" -eq 6 && "\$4" == -rz && "\$5" == --full-tree \
|
|
||||||
&& "\$6" == "$revision" ]] || exit 97
|
|
||||||
printf '100755 blob aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\tinstall\0'
|
|
||||||
;;
|
|
||||||
hash-object)
|
|
||||||
[[ "\$#" -eq 6 && "\$4" == --no-filters && "\$5" == -- \
|
|
||||||
&& "\$6" == install ]] || exit 97
|
|
||||||
printf '%s\n' aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
|
||||||
;;
|
|
||||||
*) exit 97 ;;
|
*) exit 97 ;;
|
||||||
esac
|
esac
|
||||||
;;
|
;;
|
||||||
@@ -288,132 +268,6 @@ run_boot "$revision" "$boot_sha"
|
|||||||
(( run_status != 0 )) || note 'existing HEAD mismatch returned success'
|
(( run_status != 0 )) || note 'existing HEAD mismatch returned success'
|
||||||
assert_no_install_or_rewrite 'existing HEAD mismatch'
|
assert_no_install_or_rewrite 'existing HEAD mismatch'
|
||||||
|
|
||||||
# Git's porcelain status deliberately trusts index hints. The bootstrap cannot:
|
|
||||||
# these two flags can hide changed executable bytes while HEAD still names the
|
|
||||||
# reviewed commit. Exercise real Git so the contract cannot accidentally teach
|
|
||||||
# its adapter to expose state that Git itself hides.
|
|
||||||
real_git="$(command -v git)"
|
|
||||||
hidden_root="$work/hidden-index"
|
|
||||||
mkdir -p "$hidden_root/home"
|
|
||||||
"$real_git" init -q "$hidden_root/source"
|
|
||||||
"$real_git" -C "$hidden_root/source" config user.email contract@panama
|
|
||||||
"$real_git" -C "$hidden_root/source" config user.name contract
|
|
||||||
printf '#!/usr/bin/env bash\nexit 0\n' >"$hidden_root/source/install"
|
|
||||||
chmod +x "$hidden_root/source/install"
|
|
||||||
printf 'trusted target bytes\n' >"$hidden_root/source/target"
|
|
||||||
ln -s target "$hidden_root/source/trusted-link"
|
|
||||||
"$real_git" -C "$hidden_root/source" add install target trusted-link
|
|
||||||
"$real_git" -C "$hidden_root/source" commit -qm trusted
|
|
||||||
hidden_revision="$("$real_git" -C "$hidden_root/source" rev-parse HEAD)"
|
|
||||||
"$real_git" clone -q --bare "$hidden_root/source" "$hidden_root/origin.git"
|
|
||||||
|
|
||||||
# Exercise the exact boundary between checkout preparation and handoff. This
|
|
||||||
# test-only copy inserts a same-UID replacement after prepare returns; the
|
|
||||||
# production handoff must perform its complete comparison after that point.
|
|
||||||
post_prepare_checkout="$hidden_root/post-prepare-swap"
|
|
||||||
post_prepare_marker="$hidden_root/post-prepare-executed"
|
|
||||||
post_prepare_hook_marker="$hidden_root/post-prepare-hook-fired"
|
|
||||||
"$real_git" clone -q "$hidden_root/origin.git" "$post_prepare_checkout"
|
|
||||||
post_prepare_hook="$hidden_root/swap-install"
|
|
||||||
cat >"$post_prepare_hook" <<'HOOK'
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
: >"$PANAMA_BOOT_POST_PREPARE_HOOK_MARKER"
|
|
||||||
printf '#!/usr/bin/env bash\nprintf "executed\\n" >%q\n' \
|
|
||||||
"$PANAMA_BOOT_POST_PREPARE_MARKER" >"$PANAMA_PATH/install"
|
|
||||||
chmod +x "$PANAMA_PATH/install"
|
|
||||||
HOOK
|
|
||||||
chmod +x "$post_prepare_hook"
|
|
||||||
hooked_boot="$hidden_root/boot-post-prepare-hook"
|
|
||||||
awk '
|
|
||||||
{
|
|
||||||
print
|
|
||||||
if ($0 == "prepare_panama_checkout \"$PANAMA_PATH\"") {
|
|
||||||
prepare_count++
|
|
||||||
if (prepare_count == 1) print "\"$PANAMA_BOOT_POST_PREPARE_FIXTURE\""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
' "$boot" >"$hooked_boot"
|
|
||||||
hooked_boot_sha="$(sha256sum "$hooked_boot" | cut -d' ' -f1)"
|
|
||||||
post_prepare_status=0
|
|
||||||
HOME="$hidden_root/home" PANAMA_PATH="$post_prepare_checkout" \
|
|
||||||
PANAMA_BOOT_REVISION="$hidden_revision" PANAMA_BOOT_SHA256="$hooked_boot_sha" \
|
|
||||||
PANAMA_BOOT_POST_PREPARE_FIXTURE="$post_prepare_hook" \
|
|
||||||
PANAMA_BOOT_POST_PREPARE_MARKER="$post_prepare_marker" \
|
|
||||||
PANAMA_BOOT_POST_PREPARE_HOOK_MARKER="$post_prepare_hook_marker" \
|
|
||||||
bash "$hooked_boot" </dev/null >"$hidden_root/post-prepare.out" 2>&1 \
|
|
||||||
|| post_prepare_status=$?
|
|
||||||
[[ -e "$post_prepare_hook_marker" ]] \
|
|
||||||
|| note 'post-prepare replacement hook did not exercise the boundary'
|
|
||||||
(( post_prepare_status != 0 )) \
|
|
||||||
|| note 'post-prepare worktree replacement returned success'
|
|
||||||
[[ ! -e "$post_prepare_marker" ]] \
|
|
||||||
|| note 'post-prepare worktree replacement executed unreviewed install bytes'
|
|
||||||
|
|
||||||
# A valid tracked symlink must compare its link text with Git's 120000 blob;
|
|
||||||
# hashing the pathname would follow it and hash the target file instead.
|
|
||||||
symlink_checkout="$hidden_root/tracked-symlink"
|
|
||||||
"$real_git" clone -q "$hidden_root/origin.git" "$symlink_checkout"
|
|
||||||
symlink_status=0
|
|
||||||
HOME="$hidden_root/home" PANAMA_PATH="$symlink_checkout" \
|
|
||||||
PANAMA_BOOT_REVISION="$hidden_revision" PANAMA_BOOT_SHA256="$boot_sha" \
|
|
||||||
bash "$boot" </dev/null >"$hidden_root/tracked-symlink.out" 2>&1 \
|
|
||||||
|| symlink_status=$?
|
|
||||||
(( symlink_status == 0 )) \
|
|
||||||
|| note 'a checkout with a valid tracked symlink was rejected'
|
|
||||||
|
|
||||||
for hidden_flag in assume-unchanged skip-worktree; do
|
|
||||||
hidden_checkout="$hidden_root/$hidden_flag"
|
|
||||||
hidden_marker="$hidden_root/$hidden_flag-executed"
|
|
||||||
"$real_git" clone -q "$hidden_root/origin.git" "$hidden_checkout"
|
|
||||||
printf '#!/usr/bin/env bash\nprintf "executed\\n" >%q\n' "$hidden_marker" \
|
|
||||||
>"$hidden_checkout/install"
|
|
||||||
chmod +x "$hidden_checkout/install"
|
|
||||||
"$real_git" -C "$hidden_checkout" update-index "--$hidden_flag" install
|
|
||||||
[[ -z "$("$real_git" -C "$hidden_checkout" status --porcelain)" ]] \
|
|
||||||
|| note "$hidden_flag fixture was not hidden from porcelain status"
|
|
||||||
|
|
||||||
hidden_status=0
|
|
||||||
HOME="$hidden_root/home" PANAMA_PATH="$hidden_checkout" \
|
|
||||||
PANAMA_BOOT_REVISION="$hidden_revision" PANAMA_BOOT_SHA256="$boot_sha" \
|
|
||||||
bash "$boot" </dev/null >"$hidden_root/$hidden_flag.out" 2>&1 \
|
|
||||||
|| hidden_status=$?
|
|
||||||
(( hidden_status != 0 )) \
|
|
||||||
|| note "$hidden_flag modified checkout returned success"
|
|
||||||
[[ ! -e "$hidden_marker" ]] \
|
|
||||||
|| note "$hidden_flag modified checkout executed unreviewed install bytes"
|
|
||||||
done
|
|
||||||
|
|
||||||
# The same hidden-index state must not conceal a mode change or a different
|
|
||||||
# symlink target; both are part of the reviewed Git tree, not metadata hints.
|
|
||||||
for hidden_flag in assume-unchanged skip-worktree; do
|
|
||||||
for hidden_change in mode symlink-target; do
|
|
||||||
hidden_checkout="$hidden_root/$hidden_flag-$hidden_change"
|
|
||||||
"$real_git" clone -q "$hidden_root/origin.git" "$hidden_checkout"
|
|
||||||
case "$hidden_change" in
|
|
||||||
mode)
|
|
||||||
chmod -x "$hidden_checkout/install"
|
|
||||||
hidden_path=install
|
|
||||||
;;
|
|
||||||
symlink-target)
|
|
||||||
rm -- "$hidden_checkout/trusted-link"
|
|
||||||
ln -s untrusted-target "$hidden_checkout/trusted-link"
|
|
||||||
hidden_path=trusted-link
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
"$real_git" -C "$hidden_checkout" update-index "--$hidden_flag" "$hidden_path"
|
|
||||||
[[ -z "$("$real_git" -C "$hidden_checkout" status --porcelain)" ]] \
|
|
||||||
|| note "$hidden_flag $hidden_change fixture was not hidden from porcelain status"
|
|
||||||
|
|
||||||
hidden_status=0
|
|
||||||
HOME="$hidden_root/home" PANAMA_PATH="$hidden_checkout" \
|
|
||||||
PANAMA_BOOT_REVISION="$hidden_revision" PANAMA_BOOT_SHA256="$boot_sha" \
|
|
||||||
bash "$boot" </dev/null >"$hidden_root/$hidden_flag-$hidden_change.out" 2>&1 \
|
|
||||||
|| hidden_status=$?
|
|
||||||
(( hidden_status != 0 )) \
|
|
||||||
|| note "$hidden_flag concealed a tracked $hidden_change change"
|
|
||||||
done
|
|
||||||
done
|
|
||||||
|
|
||||||
if (( ${#findings[@]} > 0 )); then
|
if (( ${#findings[@]} > 0 )); then
|
||||||
printf 'boot contract: %d finding(s)\n' "${#findings[@]}" >&2
|
printf 'boot contract: %d finding(s)\n' "${#findings[@]}" >&2
|
||||||
printf ' - %s\n' "${findings[@]}" >&2
|
printf ' - %s\n' "${findings[@]}" >&2
|
||||||
|
|||||||
Executable
+195
@@ -0,0 +1,195 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# The one download that gets to run as root, and how it earns that.
|
||||||
|
#
|
||||||
|
# OpenAI publishes no signing key and no fingerprint that a first install could
|
||||||
|
# fetch and compare against: the documented instructions are to download an RPM
|
||||||
|
# and install it, and that RPM's own root scriptlet is what decides afterwards
|
||||||
|
# which repository and which key the machine will trust. Panama pins the key
|
||||||
|
# instead -- setup/keys/ carries it, setup/lib/chatgpt-package verifies the copy
|
||||||
|
# and writes the repository -- so dnf checks a signature before root sees a byte
|
||||||
|
# of it.
|
||||||
|
#
|
||||||
|
# What must hold:
|
||||||
|
#
|
||||||
|
# 1. The pinned key is the key the library says it is. Everything else here
|
||||||
|
# is worthless if this drifts, and a changed key must be a failing test
|
||||||
|
# somebody reads rather than a quiet change of publisher.
|
||||||
|
# 2. A pinned key that is missing, unreadable, or simply not that key stops
|
||||||
|
# the install and leaves the machine untouched. Failing closed is the
|
||||||
|
# whole point; falling back to installing anyway would be worse than
|
||||||
|
# never having checked.
|
||||||
|
# 3. What it writes actually enforces the check: gpgcheck and repo_gpgcheck
|
||||||
|
# on, and the gpgkey pointing at the key it just installed.
|
||||||
|
# 4. Both callers go through it, and neither hands root a downloaded RPM.
|
||||||
|
# The installer and the codex-desktop migration install `chatgpt` by name
|
||||||
|
# from that repository, which is what makes the signature mandatory.
|
||||||
|
#
|
||||||
|
# Hermetic: the key file is read locally, root is a stub that records what it
|
||||||
|
# was asked to do, and the destinations are redirected into a temporary
|
||||||
|
# directory. Nothing here contacts OpenAI or touches /etc.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
library="$repo_dir/setup/lib/chatgpt-package"
|
||||||
|
installer="$repo_dir/setup/scripts/install-packages"
|
||||||
|
migration="$repo_dir/migrations/1787804505.sh"
|
||||||
|
pinned_key="$repo_dir/setup/keys/RPM-GPG-KEY-chatgpt"
|
||||||
|
|
||||||
|
findings=()
|
||||||
|
note() { findings+=("$1"); }
|
||||||
|
|
||||||
|
[[ -r "$library" ]] || {
|
||||||
|
printf 'chatgpt package contract: %s is missing\n' "$library" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
command -v gpg >/dev/null 2>&1 || {
|
||||||
|
printf 'chatgpt package contract: gpg is required to read the pinned key\n' >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
work="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$work"' EXIT
|
||||||
|
|
||||||
|
# Root, as a recording stub. It logs the command and then runs it for real,
|
||||||
|
# which is safe because every destination below is redirected into $work.
|
||||||
|
stub="$work/bin"
|
||||||
|
mkdir -p "$stub"
|
||||||
|
cat >"$stub/sudo" <<'STUB'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
printf '%s\n' "$*" >>"$SUDO_RECORD"
|
||||||
|
exec "$@"
|
||||||
|
STUB
|
||||||
|
cat >"$stub/rpmkeys" <<'STUB'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
printf '%s\n' "$*" >>"$RPMKEYS_RECORD"
|
||||||
|
STUB
|
||||||
|
chmod +x "$stub/sudo" "$stub/rpmkeys"
|
||||||
|
export PATH="$stub:$PATH"
|
||||||
|
|
||||||
|
# ── 1. The pinned key is the pinned key ─────────────────────────────────────
|
||||||
|
|
||||||
|
if [[ ! -r "$pinned_key" ]]; then
|
||||||
|
note 'setup/keys/RPM-GPG-KEY-chatgpt is missing, so nothing can be verified'
|
||||||
|
else
|
||||||
|
declared="$(grep -oP '(?<=^CHATGPT_KEY_FINGERPRINT=")[0-9A-F]+' "$library" | head -1)"
|
||||||
|
actual="$(gpg --show-keys --with-colons "$pinned_key" 2>/dev/null \
|
||||||
|
| awk -F: '$1 == "fpr" { print $10; exit }')"
|
||||||
|
[[ -n "$declared" ]] \
|
||||||
|
|| note 'the library pins no fingerprint, so any key file would be accepted'
|
||||||
|
[[ -n "$actual" ]] \
|
||||||
|
|| note 'the pinned key file does not parse as a public key'
|
||||||
|
[[ "$declared" == "$actual" ]] \
|
||||||
|
|| note "the pinned key is $actual but the library expects $declared"
|
||||||
|
grep -q 'RPM-GPG-KEY-chatgpt' "$repo_dir/setup/keys/README.md" 2>/dev/null \
|
||||||
|
|| note 'setup/keys/README.md does not record where the pinned key came from'
|
||||||
|
grep -q "$actual" "$repo_dir/setup/keys/README.md" 2>/dev/null \
|
||||||
|
|| note 'setup/keys/README.md records a fingerprint other than the key it ships'
|
||||||
|
fi
|
||||||
|
|
||||||
|
# One attempt, against redirected destinations and a recording root. Every
|
||||||
|
# variable the library exposes is set here rather than in the caller's shell,
|
||||||
|
# so a case cannot leak into the next one.
|
||||||
|
attempt() {
|
||||||
|
local dir="$1" panama_path="$2" fingerprint="${3:-}"
|
||||||
|
mkdir -p "$dir"
|
||||||
|
(
|
||||||
|
export SUDO_RECORD="$dir/sudo.log" RPMKEYS_RECORD="$dir/rpmkeys.log"
|
||||||
|
: >"$SUDO_RECORD"
|
||||||
|
: >"$RPMKEYS_RECORD"
|
||||||
|
PANAMA_PATH="$panama_path"
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
source "$library"
|
||||||
|
CHATGPT_KEY_FILE="$dir/pki/RPM-GPG-KEY-chatgpt"
|
||||||
|
CHATGPT_REPO_FILE="$dir/repos/chatgpt.repo"
|
||||||
|
mkdir -p "$dir/repos"
|
||||||
|
[[ -z "$fingerprint" ]] || CHATGPT_KEY_FINGERPRINT="$fingerprint"
|
||||||
|
chatgpt_install_repository sudo
|
||||||
|
) >"$dir/out" 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── 2. It fails closed ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# A checkout with no pinned key at all.
|
||||||
|
empty="$work/no-key"
|
||||||
|
mkdir -p "$empty/checkout/setup/keys"
|
||||||
|
attempt "$empty" "$empty/checkout" \
|
||||||
|
&& note 'a missing pinned key still established the repository'
|
||||||
|
[[ ! -e "$empty/repos/chatgpt.repo" ]] \
|
||||||
|
|| note 'a missing pinned key still wrote a repository file'
|
||||||
|
grep -qi 'missing' "$empty/out" \
|
||||||
|
|| note 'a missing pinned key does not say so'
|
||||||
|
|
||||||
|
# A key file that is not a key.
|
||||||
|
garbage="$work/garbage-key"
|
||||||
|
mkdir -p "$garbage/checkout/setup/keys"
|
||||||
|
printf 'not a key\n' >"$garbage/checkout/setup/keys/RPM-GPG-KEY-chatgpt"
|
||||||
|
attempt "$garbage" "$garbage/checkout" \
|
||||||
|
&& note 'an unreadable pinned key still established the repository'
|
||||||
|
[[ ! -e "$garbage/repos/chatgpt.repo" ]] \
|
||||||
|
|| note 'an unreadable pinned key still wrote a repository file'
|
||||||
|
|
||||||
|
# The real key, against a fingerprint that is not its own -- the shape a
|
||||||
|
# substituted publisher would take.
|
||||||
|
wrong="$work/wrong-fingerprint"
|
||||||
|
attempt "$wrong" "$repo_dir" '0000000000000000000000000000000000000000' \
|
||||||
|
&& note 'a key that does not match the pinned fingerprint was accepted'
|
||||||
|
[[ ! -e "$wrong/repos/chatgpt.repo" ]] \
|
||||||
|
|| note 'a fingerprint mismatch still wrote a repository file'
|
||||||
|
[[ ! -s "$wrong/rpmkeys.log" ]] \
|
||||||
|
|| note 'a fingerprint mismatch still imported the key into the rpm keyring'
|
||||||
|
|
||||||
|
# ── 3. What it writes enforces the check ────────────────────────────────────
|
||||||
|
|
||||||
|
good="$work/verified"
|
||||||
|
if ! attempt "$good" "$repo_dir"; then
|
||||||
|
note "the pinned key was rejected: $(cat "$good/out")"
|
||||||
|
else
|
||||||
|
repo_file="$good/repos/chatgpt.repo"
|
||||||
|
key_file="$good/pki/RPM-GPG-KEY-chatgpt"
|
||||||
|
|
||||||
|
cmp -s "$key_file" "$pinned_key" \
|
||||||
|
|| note 'the installed key is not the pinned key'
|
||||||
|
grep -q 'import' "$good/rpmkeys.log" \
|
||||||
|
|| note 'the verified key was never imported, so dnf has nothing to check against'
|
||||||
|
|
||||||
|
grep -qx 'gpgcheck=1' "$repo_file" \
|
||||||
|
|| note 'the repository does not set gpgcheck=1, so package signatures go unchecked'
|
||||||
|
grep -qx 'repo_gpgcheck=1' "$repo_file" \
|
||||||
|
|| note 'the repository does not set repo_gpgcheck=1, so the metadata goes unchecked'
|
||||||
|
grep -qx "gpgkey=file://$key_file" "$repo_file" \
|
||||||
|
|| note 'the repository does not point gpgkey at the key that was just installed'
|
||||||
|
grep -q 'baseurl=https://' "$repo_file" \
|
||||||
|
|| note 'the repository has no https base URL'
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 4. Both callers go through it ───────────────────────────────────────────
|
||||||
|
|
||||||
|
for caller in "$installer" "$migration"; do
|
||||||
|
name="${caller#"$repo_dir"/}"
|
||||||
|
[[ -r "$caller" ]] || { note "$name is missing"; continue; }
|
||||||
|
|
||||||
|
grep -q 'setup/lib/chatgpt-package' "$caller" \
|
||||||
|
|| note "$name does not source the verified install library"
|
||||||
|
grep -q 'chatgpt_install_repository' "$caller" \
|
||||||
|
|| note "$name does not establish the verified repository before installing"
|
||||||
|
grep -qE 'dnf install -y chatgpt\b' "$caller" \
|
||||||
|
|| note "$name does not install chatgpt by name from that repository"
|
||||||
|
|
||||||
|
# The shape this contract exists to keep out: fetch an RPM, hand it to
|
||||||
|
# root, and let its scriptlet decide what the machine trusts afterwards.
|
||||||
|
grep -qE 'curl.*chatgpt.*\.rpm' "$caller" \
|
||||||
|
&& note "$name downloads a ChatGPT RPM instead of installing it from the verified repository"
|
||||||
|
grep -qE 'dnf install[^|]*\$\{?chatgpt_rpm' "$caller" \
|
||||||
|
&& note "$name installs a downloaded ChatGPT RPM as root"
|
||||||
|
done
|
||||||
|
|
||||||
|
if (( ${#findings[@]} > 0 )); then
|
||||||
|
printf 'chatgpt package contract: %d finding(s)\n' "${#findings[@]}" >&2
|
||||||
|
printf ' - %s\n' "${findings[@]}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'chatgpt package contract: PASS\n'
|
||||||
@@ -11,16 +11,22 @@ manifest="$repo_dir/tests/contracts.manifest"
|
|||||||
|
|
||||||
discover_contracts() {
|
discover_contracts() {
|
||||||
discovered_contracts=()
|
discovered_contracts=()
|
||||||
|
# Byte order, exactly as the runner discovers them. A UTF-8 collation folds
|
||||||
|
# the punctuation away and reorders the pairs that differ only by `-` and
|
||||||
|
# `_`, so a manifest correct here would be wrong on a machine with a
|
||||||
|
# different LANG.
|
||||||
while IFS= read -r path; do
|
while IFS= read -r path; do
|
||||||
[[ -x "$path" || "$path" == *_test.py ]] || continue
|
[[ -x "$path" || "$path" == *_test.py ]] || continue
|
||||||
discovered_contracts+=("tests/${path#"$repo_dir/tests/"}")
|
discovered_contracts+=("tests/${path#"$repo_dir/tests/"}")
|
||||||
done < <(find "$repo_dir/tests" -type f \
|
done < <(find "$repo_dir/tests" -type f \
|
||||||
-not -path '*/fixtures/*' -not -path '*__pycache__*' | sort)
|
-not -path '*/fixtures/*' -not -path '*__pycache__*' | LC_ALL=C sort)
|
||||||
}
|
}
|
||||||
|
|
||||||
validate_manifest() {
|
validate_manifest() {
|
||||||
local candidate="$1"
|
local candidate="$1"
|
||||||
local -n expected_contracts="$2"
|
local -n expected_contracts="$2"
|
||||||
|
# Byte order, for the same reason discover_contracts sorts in it.
|
||||||
|
local LC_ALL=C
|
||||||
local line capabilities path extra previous_comment="" previous_was_comment=0
|
local line capabilities path extra previous_comment="" previous_was_comment=0
|
||||||
local -a capability_list=()
|
local -a capability_list=()
|
||||||
local -A manifest_paths=() capability_counts=()
|
local -A manifest_paths=() capability_counts=()
|
||||||
|
|||||||
@@ -99,10 +99,9 @@ sed -n '/^if \[\[ "\$ROLE" == server \]\]; then/,/^fi/p' "$installer" | grep -q
|
|||||||
# Comments dropped and backslash continuations joined, so a `soft` invocation
|
# Comments dropped and backslash continuations joined, so a `soft` invocation
|
||||||
# wrapped across three lines reads as the one command it is.
|
# wrapped across three lines reads as the one command it is.
|
||||||
uncommented() { grep -vE '^\s*#' "$installer" | sed -e :a -e '/\\$/N; s/\\\n\s*/ /; ta'; }
|
uncommented() { grep -vE '^\s*#' "$installer" | sed -e :a -e '/\\$/N; s/\\\n\s*/ /; ta'; }
|
||||||
uncommented_installer="$(uncommented)"
|
|
||||||
|
|
||||||
while read -r command; do
|
while read -r command; do
|
||||||
grep -q "soft .*$command" <<<"$uncommented_installer" \
|
uncommented | grep -q "soft .*$command" \
|
||||||
|| note "'$command' runs without soft, so its failure still ends the stage"
|
|| note "'$command' runs without soft, so its failure still ends the stage"
|
||||||
done <<'FRAGILE'
|
done <<'FRAGILE'
|
||||||
dnf swap -y 'ffmpeg-free'
|
dnf swap -y 'ffmpeg-free'
|
||||||
@@ -130,7 +129,7 @@ if "rpm -q hyprland" not in after or "exit 1" not in after:
|
|||||||
raise SystemExit(1)
|
raise SystemExit(1)
|
||||||
PY
|
PY
|
||||||
|
|
||||||
grep -q 'soft .*HYPR_PACKAGES' <<<"$uncommented_installer" \
|
uncommented | grep -q 'soft .*HYPR_PACKAGES' \
|
||||||
&& note 'the Hyprland install is tolerated, so a machine with no desktop reports success'
|
&& note 'the Hyprland install is tolerated, so a machine with no desktop reports success'
|
||||||
|
|
||||||
# ── Soft failures are reported ──────────────────────────────────────────────
|
# ── Soft failures are reported ──────────────────────────────────────────────
|
||||||
|
|||||||
@@ -65,6 +65,12 @@ trap 'rm -rf "$work"' EXIT
|
|||||||
|
|
||||||
filter="$(sed -n '/^packages_in()/,/^}/p' "$installer")"
|
filter="$(sed -n '/^packages_in()/,/^}/p' "$installer")"
|
||||||
loop="$(sed -n '/^install_extra_category()/,/^}/p' "$installer")"
|
loop="$(sed -n '/^install_extra_category()/,/^}/p' "$installer")"
|
||||||
|
# install_extra_category verifies the Flathub remote before installing a
|
||||||
|
# flatpak, and records a soft failure when it cannot. Both live outside the
|
||||||
|
# extracted function and have contracts of their own, so they stand in here as
|
||||||
|
# trusted -- what is under test is which targets reach which installer.
|
||||||
|
deps='ensure_flathub_remote() { :; }
|
||||||
|
softly_failed=()'
|
||||||
[[ -n "$filter" && -n "$loop" ]] || {
|
[[ -n "$filter" && -n "$loop" ]] || {
|
||||||
printf 'extras contract: install-packages no longer defines packages_in and install_extra_category\n' >&2
|
printf 'extras contract: install-packages no longer defines packages_in and install_extra_category\n' >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -99,6 +105,7 @@ LIST
|
|||||||
source "$catalog"
|
source "$catalog"
|
||||||
eval "$filter"
|
eval "$filter"
|
||||||
eval "$loop"
|
eval "$loop"
|
||||||
|
eval "$deps"
|
||||||
install_extra_category "$fixture"
|
install_extra_category "$fixture"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -132,6 +139,7 @@ printf 'flatpak:org.example.OnlyFlatpak\n' >"$flatpak_only"
|
|||||||
source "$catalog"
|
source "$catalog"
|
||||||
eval "$filter"
|
eval "$filter"
|
||||||
eval "$loop"
|
eval "$loop"
|
||||||
|
eval "$deps"
|
||||||
install_extra_category "$flatpak_only"
|
install_extra_category "$flatpak_only"
|
||||||
)
|
)
|
||||||
flatpak_only_status=$?
|
flatpak_only_status=$?
|
||||||
@@ -149,6 +157,7 @@ grep -q 'flatpak install -y flathub org.example.OnlyFlatpak' <<<"$(cat "$calls"
|
|||||||
source "$catalog"
|
source "$catalog"
|
||||||
eval "$filter"
|
eval "$filter"
|
||||||
eval "$loop"
|
eval "$loop"
|
||||||
|
eval "$deps"
|
||||||
EXTRAS_DIR="$extras_dir"
|
EXTRAS_DIR="$extras_dir"
|
||||||
for extra in ${PANAMA_EXTRAS:-}; do
|
for extra in ${PANAMA_EXTRAS:-}; do
|
||||||
[[ -f "$EXTRAS_DIR/$extra" ]] && install_extra_category "$EXTRAS_DIR/$extra"
|
[[ -f "$EXTRAS_DIR/$extra" ]] && install_extra_category "$EXTRAS_DIR/$extra"
|
||||||
|
|||||||
@@ -103,13 +103,10 @@ fi
|
|||||||
|
|
||||||
nvidia="$(run_stage PANAMA_NVIDIA=yes)"
|
nvidia="$(run_stage PANAMA_NVIDIA=yes)"
|
||||||
|
|
||||||
called "$nvidia" 'akmod-nvidia' \
|
called "$nvidia" 'dnf install -y akmod-nvidia' \
|
||||||
|| note 'answering yes to NVIDIA does not install akmod-nvidia'
|
|| note 'answering yes to NVIDIA does not install akmod-nvidia'
|
||||||
called "$nvidia" 'xorg-x11-drv-nvidia-cuda' \
|
called "$nvidia" 'xorg-x11-drv-nvidia-cuda' \
|
||||||
|| note 'the CUDA driver is not installed alongside the kernel module'
|
|| note 'the CUDA driver is not installed alongside the kernel module'
|
||||||
expected_nvidia='sudo dnf install -y --repo=fedora --repo=updates --repo=rpmfusion-free --repo=rpmfusion-free-updates --repo=rpmfusion-nonfree --repo=rpmfusion-nonfree-updates --from-repo=rpmfusion-nonfree,rpmfusion-nonfree-updates akmod-nvidia xorg-x11-drv-nvidia-cuda'
|
|
||||||
grep -Fxq -- "$expected_nvidia" <<<"$nvidia" \
|
|
||||||
|| note 'the NVIDIA transaction is not limited to reviewed Fedora and RPM Fusion repositories'
|
|
||||||
called "$nvidia" 'grubby --update-kernel=ALL' \
|
called "$nvidia" 'grubby --update-kernel=ALL' \
|
||||||
|| note 'the kernel arguments are never set'
|
|| note 'the kernel arguments are never set'
|
||||||
called "$nvidia" 'modprobe.blacklist=nouveau' \
|
called "$nvidia" 'modprobe.blacklist=nouveau' \
|
||||||
|
|||||||
@@ -123,7 +123,8 @@ grep -q '/etc/profile.d/nvm.sh' "$stage" \
|
|||||||
|| note 'the extension build never sources nvm, so npm is missing on any machine without a system node'
|
|| note 'the extension build never sources nvm, so npm is missing on any machine without a system node'
|
||||||
|
|
||||||
# node_modules is a dependency tree, not configuration.
|
# node_modules is a dependency tree, not configuration.
|
||||||
git -C "$repo_dir" check-ignore -q "$extension/node_modules/" 2>/dev/null \
|
git -C "$repo_dir" check-ignore --no-index -q \
|
||||||
|
"$extension/node_modules/package.json" 2>/dev/null \
|
||||||
|| note 'the extension node_modules is not gitignored'
|
|| note 'the extension node_modules is not gitignored'
|
||||||
|
|
||||||
# npm must honour the committed dependency graph. This disposable fixture
|
# npm must honour the committed dependency graph. This disposable fixture
|
||||||
@@ -167,130 +168,65 @@ stage_output="$(PATH="$fixture_root/bin:$PATH" PANAMA_PATH="$fixture_root" \
|
|||||||
cmp -s -- "$lock_before" "$lockfile" \
|
cmp -s -- "$lock_before" "$lockfile" \
|
||||||
|| note 'a rejected Vicinae lockfile mismatch changed package-lock.json'
|
|| note 'a rejected Vicinae lockfile mismatch changed package-lock.json'
|
||||||
|
|
||||||
# Successful builds carry a digest receipt over both manifests and every
|
# Editing an existing source file does not change its parent directory's
|
||||||
# source file. Directory mtimes do not change when an existing file is edited,
|
# timestamp, so freshness must inspect files rather than the src directory.
|
||||||
# so each byte class must independently invalidate the build.
|
freshness_root="$fixture_root/freshness"
|
||||||
digest_root="$fixture_root/digest"
|
mkdir -p "$freshness_root/config/local/share/vicinae/scripts" \
|
||||||
digest_extension="$digest_root/config/local/share/vicinae/extensions/panama-search"
|
"$freshness_root/config/local/share/vicinae/extensions/panama-search/src" \
|
||||||
digest_data="$digest_root/vicinae-data"
|
"$freshness_root/bin"
|
||||||
mkdir -p "$digest_root/config/local/share/vicinae/scripts" \
|
freshness_extension="$freshness_root/config/local/share/vicinae/extensions/panama-search"
|
||||||
"$digest_extension/src" "$digest_extension/assets" "$digest_root/bin"
|
cp -- "$manifest" "$freshness_extension/package.json"
|
||||||
cp -- "$manifest" "$digest_extension/package.json"
|
|
||||||
cp -- "$repo_dir/config/local/share/vicinae/extensions/panama-search/package-lock.json" \
|
cp -- "$repo_dir/config/local/share/vicinae/extensions/panama-search/package-lock.json" \
|
||||||
"$digest_extension/package-lock.json"
|
"$freshness_extension/package-lock.json"
|
||||||
cp -- "$repo_dir/config/local/share/vicinae/extensions/panama-search/src/search.tsx" \
|
cp -- "$extension/src/search.tsx" "$freshness_extension/src/search.tsx"
|
||||||
"$digest_extension/src/search.tsx"
|
cat >"$freshness_root/bin/npm" <<'EOF'
|
||||||
cp -- "$repo_dir/config/local/share/vicinae/extensions/panama-search/tsconfig.json" \
|
|
||||||
"$digest_extension/tsconfig.json"
|
|
||||||
cp -- "$repo_dir/config/local/share/vicinae/extensions/panama-search/assets/extension_icon.svg" \
|
|
||||||
"$digest_extension/assets/extension_icon.svg"
|
|
||||||
cat >"$digest_root/bin/npm" <<'EOF'
|
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
printf '%s\n' "$*" >>"${NPM_LOG:?}"
|
printf '%s\n' "$*" >>"${NPM_LOG:?}"
|
||||||
case "${1:-}:${2:-}" in
|
if [[ "${1:-}" == ci ]]; then
|
||||||
ci:--silent) exit 0 ;;
|
exit 0
|
||||||
run:build)
|
fi
|
||||||
mkdir -p "$VICINAE_DATA_DIR/extensions/$(basename "$PWD")"
|
if [[ "${1:-} ${2:-}" == 'run build' ]]; then
|
||||||
printf 'built\n' >"$VICINAE_DATA_DIR/extensions/$(basename "$PWD")/bundle"
|
built="${VICINAE_DATA_DIR:?}/extensions/$(basename "$PWD")"
|
||||||
;;
|
mkdir -p "$built"
|
||||||
*) exit 64 ;;
|
touch "$built"
|
||||||
esac
|
exit 0
|
||||||
|
fi
|
||||||
|
exit 64
|
||||||
EOF
|
EOF
|
||||||
cat >"$digest_root/bin/find" <<'EOF'
|
chmod +x "$freshness_root/bin/npm"
|
||||||
#!/usr/bin/env bash
|
: >"$freshness_root/npm.log"
|
||||||
set -uo pipefail
|
run_freshness_stage() {
|
||||||
status=0
|
PATH="$freshness_root/bin:$PATH" PANAMA_PATH="$freshness_root" \
|
||||||
/usr/bin/find "$@" || status=$?
|
VICINAE_DATA_DIR="$freshness_root/vicinae-data" \
|
||||||
[[ "${STUB_FIND_FAIL:-0}" != 1 ]] || exit 74
|
NPM_LOG="$freshness_root/npm.log" bash "$stage" >/dev/null 2>&1
|
||||||
exit "$status"
|
|
||||||
EOF
|
|
||||||
chmod +x "$digest_root/bin/npm" "$digest_root/bin/find"
|
|
||||||
|
|
||||||
run_digest_stage() {
|
|
||||||
: >"$digest_root/npm.log"
|
|
||||||
PATH="$digest_root/bin:$PATH" PANAMA_PATH="$digest_root" \
|
|
||||||
VICINAE_DATA_DIR="$digest_data" NPM_LOG="$digest_root/npm.log" \
|
|
||||||
STUB_FIND_FAIL="${STUB_FIND_FAIL:-0}" \
|
|
||||||
bash "$stage" >"$digest_root/stage.out" 2>&1
|
|
||||||
}
|
}
|
||||||
|
run_freshness_stage || note 'the Vicinae freshness fixture did not build initially'
|
||||||
|
initial_builds="$(grep -c '^run build$' "$freshness_root/npm.log")"
|
||||||
|
run_freshness_stage || note 'the unchanged Vicinae freshness fixture failed'
|
||||||
|
unchanged_builds="$(grep -c '^run build$' "$freshness_root/npm.log")"
|
||||||
|
[[ "$unchanged_builds" == "$initial_builds" ]] \
|
||||||
|
|| note 'an unchanged Vicinae extension rebuilt unnecessarily'
|
||||||
|
touch -d '2030-01-01 UTC' "$freshness_extension/src/search.tsx"
|
||||||
|
run_freshness_stage || note 'the source-changed Vicinae freshness fixture failed'
|
||||||
|
source_changed_builds="$(grep -c '^run build$' "$freshness_root/npm.log")"
|
||||||
|
[[ "$source_changed_builds" -eq $(( initial_builds + 1 )) ]] \
|
||||||
|
|| note 'editing an existing Vicinae source file did not trigger a rebuild'
|
||||||
|
|
||||||
run_digest_stage || note 'the Vicinae digest fixture initial build failed'
|
built_extension="$freshness_root/vicinae-data/extensions/panama-search"
|
||||||
cmp -s <(printf 'ci --silent\nrun build\n') "$digest_root/npm.log" \
|
touch -r "$built_extension" "$freshness_extension/src/search.tsx"
|
||||||
|| note 'the Vicinae digest fixture did not perform its initial locked build'
|
touch -d '2031-01-01 UTC' "$freshness_extension/package.json"
|
||||||
run_digest_stage || note 'the unchanged Vicinae digest fixture failed'
|
run_freshness_stage || note 'the manifest-changed Vicinae freshness fixture failed'
|
||||||
[[ ! -s "$digest_root/npm.log" ]] \
|
manifest_changed_builds="$(grep -c '^run build$' "$freshness_root/npm.log")"
|
||||||
|| note 'an unchanged Vicinae extension rebuilt despite its matching receipt'
|
[[ "$manifest_changed_builds" -eq $(( source_changed_builds + 1 )) ]] \
|
||||||
|
|| note 'changing a Vicinae package.json did not trigger a rebuild'
|
||||||
|
|
||||||
for digest_input in src/search.tsx package.json package-lock.json tsconfig.json \
|
touch -r "$built_extension" "$freshness_extension/package.json" \
|
||||||
assets/extension_icon.svg; do
|
"$freshness_extension/src/search.tsx"
|
||||||
printf '\n// digest mutation: %s\n' "$digest_input" >>"$digest_extension/$digest_input"
|
touch -d '2032-01-01 UTC' "$freshness_extension/package-lock.json"
|
||||||
run_digest_stage || note "the Vicinae digest fixture failed after changing $digest_input"
|
run_freshness_stage || note 'the lockfile-changed Vicinae freshness fixture failed'
|
||||||
cmp -s <(printf 'ci --silent\nrun build\n') "$digest_root/npm.log" \
|
lockfile_changed_builds="$(grep -c '^run build$' "$freshness_root/npm.log")"
|
||||||
|| note "changing existing $digest_input bytes did not rebuild the Vicinae extension"
|
[[ "$lockfile_changed_builds" -eq $(( manifest_changed_builds + 1 )) ]] \
|
||||||
done
|
|| note 'changing a Vicinae package-lock.json did not trigger a rebuild'
|
||||||
|
|
||||||
# A traversal can emit valid-looking partial output and still fail. Sorting
|
|
||||||
# that output must not hide find's producer status or replace the successful
|
|
||||||
# build receipt with a digest over an incomplete source tree.
|
|
||||||
digest_receipt="$digest_data/extensions/panama-search/.panama-source-sha256"
|
|
||||||
cp -- "$digest_receipt" "$digest_root/receipt.before-find-failure"
|
|
||||||
STUB_FIND_FAIL=1 run_digest_stage \
|
|
||||||
|| note 'the Vicinae stage made a digest traversal failure fatal'
|
|
||||||
[[ ! -s "$digest_root/npm.log" ]] \
|
|
||||||
|| note 'a failed Vicinae digest traversal still rebuilt the extension'
|
|
||||||
grep -q 'inputs could not be verified; skipping' "$digest_root/stage.out" \
|
|
||||||
|| note 'a failed Vicinae digest traversal was accepted as verified input'
|
|
||||||
cmp -s -- "$digest_root/receipt.before-find-failure" "$digest_receipt" \
|
|
||||||
|| note 'a failed Vicinae digest traversal replaced the successful receipt'
|
|
||||||
|
|
||||||
# Helper writes run in conditional contexts in production, where Bash disables
|
|
||||||
# implicit errexit inside the whole function. Each producer therefore has to
|
|
||||||
# return its own write/publication failure and remove its temporary receipt.
|
|
||||||
vicinae_helpers="$digest_root/vicinae-helpers"
|
|
||||||
sed '/^panama_path=/,$d' "$stage" >"$vicinae_helpers"
|
|
||||||
: >"$digest_root/empty-inputs"
|
|
||||||
mkdir "$digest_root/manifest-output-directory"
|
|
||||||
manifest_status=0
|
|
||||||
bash -c 'source "$1"; set +e; _write_vicinae_manifest "$2" "$3" "$4"' bash \
|
|
||||||
"$vicinae_helpers" "$digest_extension" "$digest_root/empty-inputs" \
|
|
||||||
"$digest_root/manifest-output-directory" >/dev/null 2>&1 \
|
|
||||||
|| manifest_status=$?
|
|
||||||
[[ "$manifest_status" -ne 0 ]] \
|
|
||||||
|| note 'a failed Vicinae manifest initialization returned success'
|
|
||||||
|
|
||||||
receipt_failure_root="$digest_root/receipt-publication-failure"
|
|
||||||
mkdir -p "$receipt_failure_root/built" "$receipt_failure_root/bin"
|
|
||||||
printf 'prior receipt\n' >"$receipt_failure_root/built/.panama-source-sha256"
|
|
||||||
cat >"$receipt_failure_root/bin/mv" <<'EOF'
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
destination="${!#}"
|
|
||||||
[[ "$destination" != */.panama-source-sha256 ]] || exit 75
|
|
||||||
exec /usr/bin/mv "$@"
|
|
||||||
EOF
|
|
||||||
chmod +x "$receipt_failure_root/bin/mv"
|
|
||||||
receipt_status=0
|
|
||||||
PATH="$receipt_failure_root/bin:$PATH" bash -c \
|
|
||||||
'source "$1"; set +e; _record_vicinae_digest "$2" "$3"' bash \
|
|
||||||
"$vicinae_helpers" "$receipt_failure_root/built" "$(printf 'a%.0s' {1..64})" \
|
|
||||||
>/dev/null 2>&1 || receipt_status=$?
|
|
||||||
[[ "$receipt_status" -ne 0 ]] \
|
|
||||||
|| note 'a failed Vicinae receipt publication returned success'
|
|
||||||
cmp -s <(printf 'prior receipt\n') \
|
|
||||||
"$receipt_failure_root/built/.panama-source-sha256" \
|
|
||||||
|| note 'a failed Vicinae receipt publication replaced the prior receipt'
|
|
||||||
[[ -z "$(find "$receipt_failure_root/built" \
|
|
||||||
-name '.panama-source-sha256.*' -print -quit)" ]] \
|
|
||||||
|| note 'a failed Vicinae receipt publication left a temporary receipt'
|
|
||||||
|
|
||||||
# Prove the directory-only ignore rule in a repository where node_modules does
|
|
||||||
# not already exist. The trailing slash is part of the query contract.
|
|
||||||
ignore_root="$fixture_root/ignore-repository"
|
|
||||||
mkdir -p "$ignore_root/config/local/share/vicinae/extensions/panama-search"
|
|
||||||
cp -- "$repo_dir/.gitignore" "$ignore_root/.gitignore"
|
|
||||||
git -C "$ignore_root" init -q
|
|
||||||
git -C "$ignore_root" check-ignore -q \
|
|
||||||
'config/local/share/vicinae/extensions/panama-search/node_modules/' \
|
|
||||||
|| note 'a fresh clone with no node_modules directory does not match the ignore rule'
|
|
||||||
|
|
||||||
# ── Report ───────────────────────────────────────────────────────────────────
|
# ── Report ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -187,18 +187,6 @@ case "${1:-}" in
|
|||||||
*) exit 97 ;;
|
*) exit 97 ;;
|
||||||
esac
|
esac
|
||||||
;;
|
;;
|
||||||
ls-tree)
|
|
||||||
[[ "$#" -eq 6 && "$4" == -rz && "$5" == --full-tree \
|
|
||||||
&& "$6" == "$PANAMA_BOOT_REVISION" ]] || exit 97
|
|
||||||
object_id="$(/usr/bin/git hash-object --no-filters -- \
|
|
||||||
"$PANAMA_BOOT_FIXTURE_ROOT/stub-install")" || exit 97
|
|
||||||
printf '100755 blob %s\tinstall\0' "$object_id"
|
|
||||||
;;
|
|
||||||
hash-object)
|
|
||||||
[[ "$#" -eq 6 && "$4" == --no-filters && "$5" == -- \
|
|
||||||
&& "$6" == install ]] || exit 97
|
|
||||||
/usr/bin/git hash-object --no-filters -- "$2/$6"
|
|
||||||
;;
|
|
||||||
*) exit 97 ;;
|
*) exit 97 ;;
|
||||||
esac
|
esac
|
||||||
;;
|
;;
|
||||||
|
|||||||
+41
-20
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# The manual this machine hands an agent.
|
# The manual this machine hands an agent.
|
||||||
#
|
#
|
||||||
# skills/ and .claude/skills/panama exist because an agent asked to do anything
|
# skills/ and .agents/skills/panama exist because an agent asked to do anything
|
||||||
# on a Panama desktop will otherwise infer it from the source and get half of it
|
# on a Panama desktop will otherwise infer it from the source and get half of it
|
||||||
# wrong. That only helps if what the skills say is true -- and a skill is worse
|
# wrong. That only helps if what the skills say is true -- and a skill is worse
|
||||||
# than no skill when it is stale, because an agent believes it verbatim and does
|
# than no skill when it is stale, because an agent believes it verbatim and does
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
# That is a convention on the prose -- name things exactly, in backticks --
|
# That is a convention on the prose -- name things exactly, in backticks --
|
||||||
# and it is how they should be written anyway.
|
# and it is how they should be written anyway.
|
||||||
# 3. The delivery works: link-skills is a stage, in the right place, and the
|
# 3. The delivery works: link-skills is a stage, in the right place, and the
|
||||||
# personal manifest hands ~/.claude/skills over to the linkdir kind.
|
# personal manifest hands both shared skill homes to the linkdir kind.
|
||||||
#
|
#
|
||||||
# Sections 1 and 2 report clearly and keep going when a skill is not written
|
# Sections 1 and 2 report clearly and keep going when a skill is not written
|
||||||
# yet, so this contract is useful while the skills are still being authored.
|
# yet, so this contract is useful while the skills are still being authored.
|
||||||
@@ -32,9 +32,21 @@ manifest="$repo_dir/user/manifest"
|
|||||||
findings=()
|
findings=()
|
||||||
note() { findings+=("$1"); }
|
note() { findings+=("$1"); }
|
||||||
|
|
||||||
# The three, and where each is delivered from. The two under skills/ are shipped
|
# Every directory under skills/ ships to every machine. The project-level
|
||||||
# to every machine; the third is project-level and needs no delivery at all.
|
# panama skill stays in this repository and needs no home-directory delivery.
|
||||||
SKILL_DIRS=(skills/panama-desktop skills/panama-sudo .claude/skills/panama)
|
SKILL_DIRS=()
|
||||||
|
for directory in "$repo_dir"/skills/*; do
|
||||||
|
[[ -d "$directory" ]] && SKILL_DIRS+=("${directory#"$repo_dir"/}")
|
||||||
|
done
|
||||||
|
SKILL_DIRS+=(.agents/skills/panama)
|
||||||
|
|
||||||
|
# The project skill has one agent-neutral source. Claude gets a compatibility
|
||||||
|
# symlink, while Codex and other Agent Skills readers use .agents directly.
|
||||||
|
[[ -L "$repo_dir/.claude/skills/panama" ]] \
|
||||||
|
|| note '.claude/skills/panama is not a compatibility symlink to the agent-neutral source'
|
||||||
|
[[ "$(readlink -f "$repo_dir/.claude/skills/panama" 2>/dev/null)" == \
|
||||||
|
"$(readlink -f "$repo_dir/.agents/skills/panama" 2>/dev/null)" ]] \
|
||||||
|
|| note '.claude and .agents resolve the project panama skill differently'
|
||||||
|
|
||||||
# ── 1. Each skill loads ─────────────────────────────────────────────────────
|
# ── 1. Each skill loads ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -54,7 +66,7 @@ for relative in "${SKILL_DIRS[@]}"; do
|
|||||||
present+=("$directory")
|
present+=("$directory")
|
||||||
|
|
||||||
# Frontmatter is the first --- delimited block, and a skill without one is
|
# Frontmatter is the first --- delimited block, and a skill without one is
|
||||||
# not a skill: Claude Code skips the directory entirely.
|
# not a skill: agent loaders skip the directory entirely.
|
||||||
frontmatter="$(awk 'NR==1 { if ($0 != "---") exit 1; next } $0 == "---" { exit } { print }' "$file")"
|
frontmatter="$(awk 'NR==1 { if ($0 != "---") exit 1; next } $0 == "---" { exit } { print }' "$file")"
|
||||||
if [[ -z "$frontmatter" ]]; then
|
if [[ -z "$frontmatter" ]]; then
|
||||||
note "$relative/SKILL.md does not open with a --- frontmatter block"
|
note "$relative/SKILL.md does not open with a --- frontmatter block"
|
||||||
@@ -207,12 +219,12 @@ else
|
|||||||
work="$(mktemp -d)"
|
work="$(mktemp -d)"
|
||||||
trap 'rm -rf "$work"' EXIT
|
trap 'rm -rf "$work"' EXIT
|
||||||
|
|
||||||
# A checkout and a home of its own. Never the real ones: ~/.claude/skills on
|
# A checkout and a home of its own. Never the real ones: these are somebody's
|
||||||
# this machine is somebody's live agent setup, and a contract that broke it
|
# live agent setup, and a contract that broke them mid-session would be worse
|
||||||
# mid-session would be worse than the bug it was looking for.
|
# than the bug it was looking for.
|
||||||
checkout="$work/Panama"
|
checkout="$work/Panama"
|
||||||
home="$work/home"
|
home="$work/home"
|
||||||
mkdir -p "$checkout/setup/scripts" "$checkout/skills/shipped" "$home/.claude"
|
mkdir -p "$checkout/setup/scripts" "$checkout/skills/shipped" "$home/.claude" "$home/.agents"
|
||||||
cp "$linker" "$checkout/setup/scripts/link-skills"
|
cp "$linker" "$checkout/setup/scripts/link-skills"
|
||||||
printf 'a shipped skill\n' >"$checkout/skills/shipped/SKILL.md"
|
printf 'a shipped skill\n' >"$checkout/skills/shipped/SKILL.md"
|
||||||
|
|
||||||
@@ -220,6 +232,7 @@ else
|
|||||||
# symlink, which is what link-user used to leave here.
|
# symlink, which is what link-user used to leave here.
|
||||||
mkdir -p "$work/personal"
|
mkdir -p "$work/personal"
|
||||||
ln -s "$work/personal" "$home/.claude/skills"
|
ln -s "$work/personal" "$home/.claude/skills"
|
||||||
|
ln -s "$work/personal" "$home/.agents/skills"
|
||||||
|
|
||||||
run() { HOME="$home" PANAMA_PATH="$checkout" "$checkout/setup/scripts/link-skills" >"$work/log" 2>&1; }
|
run() { HOME="$home" PANAMA_PATH="$checkout" "$checkout/setup/scripts/link-skills" >"$work/log" 2>&1; }
|
||||||
|
|
||||||
@@ -231,22 +244,30 @@ else
|
|||||||
|| note 'link-skills left ~/.claude/skills a symlink, so nothing else can be linked into it'
|
|| note 'link-skills left ~/.claude/skills a symlink, so nothing else can be linked into it'
|
||||||
[[ -L "$home/.claude/skills/shipped" ]] \
|
[[ -L "$home/.claude/skills/shipped" ]] \
|
||||||
|| note 'link-skills did not link each shipped skill as a child of ~/.claude/skills'
|
|| note 'link-skills did not link each shipped skill as a child of ~/.claude/skills'
|
||||||
|
[[ -d "$home/.agents/skills" && ! -L "$home/.agents/skills" ]] \
|
||||||
|
|| note 'link-skills left ~/.agents/skills a symlink, so shipped and personal skills cannot coexist'
|
||||||
|
[[ -L "$home/.agents/skills/shipped" ]] \
|
||||||
|
|| note 'link-skills did not link each shipped skill as a child of ~/.agents/skills'
|
||||||
grep -q 'Agent skills: 1 linked' "$work/log" \
|
grep -q 'Agent skills: 1 linked' "$work/log" \
|
||||||
|| note 'link-skills does not report how many skills it linked'
|
|| note 'link-skills does not report how many skills it linked'
|
||||||
|
|
||||||
# A real directory at a shipped skill's name is somebody's work: it moves to
|
# A real directory at a shipped skill's name is somebody's work: it moves to
|
||||||
# config/old rather than being deleted, the same promise the other stages
|
# config/old rather than being deleted, the same promise the other stages
|
||||||
# make. A symlink is not, and must not accumulate there.
|
# make. A symlink is not, and must not accumulate there.
|
||||||
rm "$home/.claude/skills/shipped"
|
for skill_home in "$home/.claude/skills" "$home/.agents/skills"; do
|
||||||
mkdir -p "$home/.claude/skills/shipped"
|
rm -f "$skill_home/shipped"
|
||||||
printf 'installed by hand\n' >"$home/.claude/skills/shipped/SKILL.md"
|
mkdir -p "$skill_home/shipped"
|
||||||
mkdir -p "$home/.claude/skills/untouched"
|
printf 'installed by hand\n' >"$skill_home/shipped/SKILL.md"
|
||||||
|
mkdir -p "$skill_home/untouched"
|
||||||
|
done
|
||||||
|
|
||||||
run
|
run
|
||||||
grep -rq 'installed by hand' "$checkout/config/old" 2>/dev/null \
|
[[ "$(grep -rl 'installed by hand' "$checkout/config/old" 2>/dev/null | wc -l)" == 2 ]] \
|
||||||
|| note 'link-skills destroyed a real skill instead of moving it to config/old'
|
|| note 'link-skills did not preserve real skills from both agent homes'
|
||||||
[[ -d "$home/.claude/skills/untouched" ]] \
|
for skill_home in "$home/.claude/skills" "$home/.agents/skills"; do
|
||||||
|| note 'link-skills removed a skill it does not ship'
|
[[ -d "$skill_home/untouched" ]] \
|
||||||
|
|| note "link-skills removed an unshipped skill from $skill_home"
|
||||||
|
done
|
||||||
|
|
||||||
before="$(find "$checkout/config/old" | wc -l)"
|
before="$(find "$checkout/config/old" | wc -l)"
|
||||||
run
|
run
|
||||||
@@ -287,8 +308,8 @@ fi
|
|||||||
|
|
||||||
grep -qE '^\s*linkdir\s+agents/skills\s+~/\.claude/skills\s*$' "$manifest" \
|
grep -qE '^\s*linkdir\s+agents/skills\s+~/\.claude/skills\s*$' "$manifest" \
|
||||||
|| note 'the manifest does not use linkdir for ~/.claude/skills, so personal skills would replace the directory'
|
|| note 'the manifest does not use linkdir for ~/.claude/skills, so personal skills would replace the directory'
|
||||||
grep -qE '^\s*link\s+agents/skills\s+~/\.agents/skills\s*$' "$manifest" \
|
grep -qE '^\s*linkdir\s+agents/skills\s+~/\.agents/skills\s*$' "$manifest" \
|
||||||
|| note '~/.agents/skills is no longer a whole-directory link, and nothing else claims that path'
|
|| note 'the manifest does not use linkdir for ~/.agents/skills, so personal skills would replace shipped skills'
|
||||||
grep -q 'linkdir)' "$user_linker" \
|
grep -q 'linkdir)' "$user_linker" \
|
||||||
|| note 'link-user does not implement the linkdir kind the manifest asks for'
|
|| note 'link-user does not implement the linkdir kind the manifest asks for'
|
||||||
grep -q 'linkdir' "$repo_dir/user/README.md" \
|
grep -q 'linkdir' "$repo_dir/user/README.md" \
|
||||||
|
|||||||
@@ -36,9 +36,15 @@ note() { findings+=("$1"); }
|
|||||||
tmp="$(mktemp -d -t panama-update-contract.XXXXXX)"
|
tmp="$(mktemp -d -t panama-update-contract.XXXXXX)"
|
||||||
trap 'rm -rf "$tmp"' EXIT
|
trap 'rm -rf "$tmp"' EXIT
|
||||||
|
|
||||||
STAGE_NAMES=(install-packages link-dotfiles link-skills link-user change-settings
|
STAGE_NAMES=(install-packages link-dotfiles link-skills link-user link-mcp change-settings
|
||||||
link-vicinae-scripts setup-server link-server setup-identity
|
link-vicinae-scripts setup-server link-server setup-identity
|
||||||
install-hardware)
|
install-hardware)
|
||||||
|
PACKAGE_BEHAVIOR_INPUTS=(
|
||||||
|
setup/lib/artifact-provenance
|
||||||
|
setup/lib/chatgpt-package
|
||||||
|
setup/lib/extras-catalog
|
||||||
|
setup/lib/machine-role
|
||||||
|
)
|
||||||
|
|
||||||
copy_hash_inputs() {
|
copy_hash_inputs() {
|
||||||
local root="$1" source relative
|
local root="$1" source relative
|
||||||
@@ -50,10 +56,10 @@ copy_hash_inputs() {
|
|||||||
find "$repo_dir/setup/packages" -maxdepth 1 -type f -print0
|
find "$repo_dir/setup/packages" -maxdepth 1 -type f -print0
|
||||||
find "$repo_dir/setup/provenance" -type f -print0
|
find "$repo_dir/setup/provenance" -type f -print0
|
||||||
)
|
)
|
||||||
mkdir -p "$root/setup/lib"
|
for relative in "${PACKAGE_BEHAVIOR_INPUTS[@]}"; do
|
||||||
cp -- "$repo_dir/setup/lib/artifact-provenance" \
|
mkdir -p "$(dirname "$root/$relative")"
|
||||||
"$repo_dir/setup/lib/extras-catalog" \
|
cp -- "$repo_dir/$relative" "$root/$relative"
|
||||||
"$repo_dir/setup/lib/machine-role" "$root/setup/lib/"
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
# A PANAMA_PATH that looks enough like the real one for install to run, and
|
# A PANAMA_PATH that looks enough like the real one for install to run, and
|
||||||
@@ -63,7 +69,7 @@ build_fixture() {
|
|||||||
rm -rf "$root"
|
rm -rf "$root"
|
||||||
mkdir -p "$root/bin" "$root/setup/scripts" "$root/setup/packages" \
|
mkdir -p "$root/bin" "$root/setup/scripts" "$root/setup/packages" \
|
||||||
"$root/setup/lib" "$root/setup/provenance/keys" \
|
"$root/setup/lib" "$root/setup/provenance/keys" \
|
||||||
"$root/config/dot/quickshell/scripts" "$root/tmp"
|
"$root/config/dot/quickshell/scripts"
|
||||||
|
|
||||||
cp "$installer" "$root/install"
|
cp "$installer" "$root/install"
|
||||||
: >"$root/bin/ascii"
|
: >"$root/bin/ascii"
|
||||||
@@ -128,46 +134,6 @@ EOF
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
printf 'dnf-transaction\n' >>"$PANAMA_RAN"
|
printf 'dnf-transaction\n' >>"$PANAMA_RAN"
|
||||||
exit 0
|
exit 0
|
||||||
EOF
|
|
||||||
cat >"$root/shim/mv" <<'EOF'
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
destination="${!#}"
|
|
||||||
if [[ "${STUB_SIGNAL_PACKAGES_HASH:-0}" == 1 \
|
|
||||||
&& "$destination" == */state/panama/packages-hash ]]; then
|
|
||||||
printf 'signal:packages-receipt\n' >>"$PANAMA_RAN"
|
|
||||||
pgid="$(ps -o pgid= -p $$ | tr -d ' ')"
|
|
||||||
kill -TERM -- "-$pgid"
|
|
||||||
sleep 2
|
|
||||||
fi
|
|
||||||
exec /usr/bin/mv "$@"
|
|
||||||
EOF
|
|
||||||
cat >"$root/shim/mktemp" <<'EOF'
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
if [[ "${STUB_SIGNAL_HASH_WORK:-0}" == 1 && "${1:-}" == -d ]]; then
|
|
||||||
directory="$(/usr/bin/mktemp "$@")"
|
|
||||||
printf '%s\n' "$directory"
|
|
||||||
printf 'signal:packages-hash-work\n' >>"$PANAMA_RAN"
|
|
||||||
pgid="$(ps -o pgid= -p $$ | tr -d ' ')"
|
|
||||||
kill -TERM -- "-$pgid"
|
|
||||||
sleep 2
|
|
||||||
fi
|
|
||||||
exec /usr/bin/mktemp "$@"
|
|
||||||
EOF
|
|
||||||
cat >"$root/shim/mkdir" <<'EOF'
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
target="${!#}"
|
|
||||||
if [[ "${STUB_SIGNAL_HASH_WORK:-0}" == 1 \
|
|
||||||
&& "$(basename -- "$target")" == panama-packages-hash.* ]]; then
|
|
||||||
/usr/bin/mkdir "$@"
|
|
||||||
printf 'signal:packages-hash-work\n' >>"$PANAMA_RAN"
|
|
||||||
pgid="$(ps -o pgid= -p $$ | tr -d ' ')"
|
|
||||||
kill -TERM -- "-$pgid"
|
|
||||||
sleep 2
|
|
||||||
fi
|
|
||||||
exec /usr/bin/mkdir "$@"
|
|
||||||
EOF
|
EOF
|
||||||
for prerequisite in gum lspci mokutil fwupdmgr; do
|
for prerequisite in gum lspci mokutil fwupdmgr; do
|
||||||
ln -s gsettings "$root/shim/$prerequisite"
|
ln -s gsettings "$root/shim/$prerequisite"
|
||||||
@@ -181,8 +147,7 @@ run_install() {
|
|||||||
local status=0
|
local status=0
|
||||||
: >"$root/ran"
|
: >"$root/ran"
|
||||||
PATH="$root/shim:$PATH" PANAMA_PATH="$root" PANAMA_RAN="$root/ran" \
|
PATH="$root/shim:$PATH" PANAMA_PATH="$root" PANAMA_RAN="$root/ran" \
|
||||||
XDG_STATE_HOME="$root/state" TMPDIR="$root/tmp" \
|
XDG_STATE_HOME="$root/state" bash "$root/install" "$@" \
|
||||||
/usr/bin/setsid bash "$root/install" "$@" \
|
|
||||||
>"$root/out" 2>&1 || status=$?
|
>"$root/out" 2>&1 || status=$?
|
||||||
cat "$root/ran"
|
cat "$root/ran"
|
||||||
return "$status"
|
return "$status"
|
||||||
@@ -190,8 +155,7 @@ run_install() {
|
|||||||
|
|
||||||
run_hash() {
|
run_hash() {
|
||||||
local root="$1"
|
local root="$1"
|
||||||
sed -n '/^_collect_package_inputs() {/,/^PACKAGE_START_HASH=/p' \
|
sed -n '/^hash_packages() {/,/^}$/p' "$root/install" >"$root/hash-only"
|
||||||
"$root/install" >"$root/hash-only"
|
|
||||||
printf 'set -uo pipefail\nhash_packages\n' >>"$root/hash-only"
|
printf 'set -uo pipefail\nhash_packages\n' >>"$root/hash-only"
|
||||||
PANAMA_PATH="$root" bash "$root/hash-only" 2>"$root/hash-only.err"
|
PANAMA_PATH="$root" bash "$root/hash-only" 2>"$root/hash-only.err"
|
||||||
}
|
}
|
||||||
@@ -227,7 +191,7 @@ fi
|
|||||||
|
|
||||||
# ── 2. Exactly the answer-free stages ────────────────────────────────────────
|
# ── 2. Exactly the answer-free stages ────────────────────────────────────────
|
||||||
|
|
||||||
for stage in install-packages link-dotfiles link-skills link-user change-settings link-vicinae-scripts; do
|
for stage in install-packages link-dotfiles link-skills link-user link-mcp change-settings link-vicinae-scripts; do
|
||||||
grep -qx "$stage" <<<"$ran" || note "install --upgrade did not run $stage"
|
grep -qx "$stage" <<<"$ran" || note "install --upgrade did not run $stage"
|
||||||
done
|
done
|
||||||
for stage in setup-identity install-hardware; do
|
for stage in setup-identity install-hardware; do
|
||||||
@@ -299,8 +263,7 @@ grep -qx 'install-packages' <<<"$ran_forced" \
|
|||||||
# Dynamically discovering them makes this fail when a new reviewed input is
|
# Dynamically discovering them makes this fail when a new reviewed input is
|
||||||
# added but omitted from hash_packages.
|
# added but omitted from hash_packages.
|
||||||
for relative in "${package_inputs[@]}" "${provenance_inputs[@]}" \
|
for relative in "${package_inputs[@]}" "${provenance_inputs[@]}" \
|
||||||
'setup/scripts/install-packages' 'setup/lib/artifact-provenance' \
|
'setup/scripts/install-packages' "${PACKAGE_BEHAVIOR_INPUTS[@]}"; do
|
||||||
'setup/lib/extras-catalog' 'setup/lib/machine-role'; do
|
|
||||||
printf 'changed %s\n' "$relative" >>"$tmp/a/$relative"
|
printf 'changed %s\n' "$relative" >>"$tmp/a/$relative"
|
||||||
install_status=0
|
install_status=0
|
||||||
ran_input_changed="$(run_install "$tmp/a" --upgrade)" || install_status=$?
|
ran_input_changed="$(run_install "$tmp/a" --upgrade)" || install_status=$?
|
||||||
@@ -327,8 +290,7 @@ done
|
|||||||
# Fixed hash inputs must not silently disappear or degrade into a directory or
|
# Fixed hash inputs must not silently disappear or degrade into a directory or
|
||||||
# link. An unreadable package input also proves a failed content read cannot be
|
# link. An unreadable package input also proves a failed content read cannot be
|
||||||
# hidden by the final digest command.
|
# hidden by the final digest command.
|
||||||
for fixed_input in setup/scripts/install-packages setup/lib/artifact-provenance \
|
for fixed_input in setup/scripts/install-packages "${PACKAGE_BEHAVIOR_INPUTS[@]}"; do
|
||||||
setup/lib/extras-catalog setup/lib/machine-role; do
|
|
||||||
for case_name in missing directory symlink unreadable; do
|
for case_name in missing directory symlink unreadable; do
|
||||||
case_root="$tmp/hash-${fixed_input//\//-}-$case_name"
|
case_root="$tmp/hash-${fixed_input//\//-}-$case_name"
|
||||||
build_fixture "$case_root"
|
build_fixture "$case_root"
|
||||||
@@ -351,27 +313,6 @@ build_fixture "$read_failure_root"
|
|||||||
chmod 000 "$read_failure_root/${package_inputs[0]}"
|
chmod 000 "$read_failure_root/${package_inputs[0]}"
|
||||||
assert_hash_failure "$read_failure_root" "${package_inputs[0]} unreadable"
|
assert_hash_failure "$read_failure_root" "${package_inputs[0]} unreadable"
|
||||||
|
|
||||||
# Discovery must reject a symlink instead of silently dropping it from the
|
|
||||||
# receipt while a later consumer follows it.
|
|
||||||
for discovered_root in setup/packages setup/provenance; do
|
|
||||||
case_root="$tmp/hash-${discovered_root//\//-}-symlink"
|
|
||||||
build_fixture "$case_root"
|
|
||||||
printf 'linked installer input\n' >"$case_root/symlink-target"
|
|
||||||
ln -s "$case_root/symlink-target" "$case_root/$discovered_root/symlink-input"
|
|
||||||
assert_hash_failure "$case_root" "$discovered_root symlink input"
|
|
||||||
done
|
|
||||||
|
|
||||||
# Discovery roots are behavior inputs too. GNU find -P treats a symlink passed
|
|
||||||
# as its starting path as an empty traversal, so checking only descendants can
|
|
||||||
# silently erase a whole package or provenance tree from the receipt.
|
|
||||||
for discovered_root in setup/packages setup/provenance; do
|
|
||||||
case_root="$tmp/hash-${discovered_root//\//-}-root-symlink"
|
|
||||||
build_fixture "$case_root"
|
|
||||||
mv -- "$case_root/$discovered_root" "$case_root/$discovered_root.real"
|
|
||||||
ln -s "$case_root/$discovered_root.real" "$case_root/$discovered_root"
|
|
||||||
assert_hash_failure "$case_root" "$discovered_root discovery-root symlink"
|
|
||||||
done
|
|
||||||
|
|
||||||
# A hash failure is an installer failure, not a reason to skip the package
|
# A hash failure is an installer failure, not a reason to skip the package
|
||||||
# stage and retain a stale stamp.
|
# stage and retain a stale stamp.
|
||||||
build_fixture "$tmp/hash-failure"
|
build_fixture "$tmp/hash-failure"
|
||||||
@@ -387,61 +328,26 @@ grep -qx 'install-packages' <<<"$ran_hash_failure" \
|
|||||||
cmp -s -- "$tmp/hash-failure/stamp-before" "$tmp/hash-failure/state/panama/packages-hash" \
|
cmp -s -- "$tmp/hash-failure/stamp-before" "$tmp/hash-failure/state/panama/packages-hash" \
|
||||||
|| note 'a failed package-state hash wrote a new packages-hash stamp'
|
|| note 'a failed package-state hash wrote a new packages-hash stamp'
|
||||||
|
|
||||||
# The stage may race its own input receipt. A successful stage that changes a
|
# Inputs changed while install-packages was running were not the inputs it
|
||||||
# sourced behavior file must not stamp the new digest as though it were the
|
# consumed at the start. Do not stamp the later bytes as successfully applied.
|
||||||
# bytes used to decide this run.
|
build_fixture "$tmp/hash-mid-stage-drift"
|
||||||
build_fixture "$tmp/hash-drift"
|
cat >"$tmp/hash-mid-stage-drift/setup/scripts/install-packages" <<'EOF'
|
||||||
cat >"$tmp/hash-drift/setup/scripts/install-packages" <<'EOF'
|
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
if [[ "${1:-}" == --trust-preflight ]]; then
|
if [[ "${1:-}" == --trust-preflight ]]; then
|
||||||
printf 'trust-preflight\n' >>"$PANAMA_RAN"
|
printf 'trust-preflight\n' >>"$PANAMA_RAN"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
printf 'install-packages\n' >>"$PANAMA_RAN"
|
printf 'install-packages\n' >>"$PANAMA_RAN"
|
||||||
printf '# changed during package stage\n' >>"$PANAMA_PATH/setup/lib/machine-role"
|
printf '\nchanged during package installation\n' >>"$PANAMA_PATH/setup/lib/machine-role"
|
||||||
EOF
|
EOF
|
||||||
chmod +x "$tmp/hash-drift/setup/scripts/install-packages"
|
chmod +x "$tmp/hash-mid-stage-drift/setup/scripts/install-packages"
|
||||||
install_status=0
|
install_status=0
|
||||||
run_install "$tmp/hash-drift" --upgrade >/dev/null || install_status=$?
|
run_install "$tmp/hash-mid-stage-drift" --upgrade >/dev/null || install_status=$?
|
||||||
[[ "$install_status" -ne 0 ]] \
|
[[ "$install_status" -ne 0 ]] \
|
||||||
|| note 'mid-stage package input drift returned success'
|
|| note 'mid-stage package input drift returned success'
|
||||||
[[ ! -e "$tmp/hash-drift/state/panama/packages-hash" ]] \
|
[[ ! -e "$tmp/hash-mid-stage-drift/state/panama/packages-hash" ]] \
|
||||||
|| note 'mid-stage package input drift stamped bytes the stage did not start with'
|
|| note 'mid-stage package input drift stamped bytes the stage did not start with'
|
||||||
|
|
||||||
# The hash workspace exists before command substitution publishes its pathname.
|
|
||||||
# A process-group signal in that window must still remove the private tree.
|
|
||||||
build_fixture "$tmp/hash-work-signal"
|
|
||||||
install_status=0
|
|
||||||
signal_run="$(STUB_SIGNAL_HASH_WORK=1 \
|
|
||||||
run_install "$tmp/hash-work-signal" --upgrade)" || install_status=$?
|
|
||||||
[[ "$install_status" -eq 143 ]] \
|
|
||||||
|| note "package hash workspace signal returned $install_status instead of 143"
|
|
||||||
grep -qx 'signal:packages-hash-work' <<<"$signal_run" \
|
|
||||||
|| note 'package hash workspace adapter did not deliver a real process-group signal'
|
|
||||||
[[ -z "$(find "$tmp/hash-work-signal/tmp" -mindepth 1 -print -quit)" ]] \
|
|
||||||
|| note 'package hash workspace signal left a private temporary directory'
|
|
||||||
|
|
||||||
# A real process-group signal at the final receipt rename must preserve the
|
|
||||||
# prior stamp and remove the private temporary receipt.
|
|
||||||
build_fixture "$tmp/hash-receipt-signal"
|
|
||||||
run_install "$tmp/hash-receipt-signal" --upgrade >/dev/null
|
|
||||||
cp -- "$tmp/hash-receipt-signal/state/panama/packages-hash" \
|
|
||||||
"$tmp/hash-receipt-signal/stamp-before"
|
|
||||||
install_status=0
|
|
||||||
signal_run="$(STUB_SIGNAL_PACKAGES_HASH=1 \
|
|
||||||
run_install "$tmp/hash-receipt-signal" --upgrade --packages)" \
|
|
||||||
|| install_status=$?
|
|
||||||
[[ "$install_status" -eq 143 ]] \
|
|
||||||
|| note "package receipt signal returned $install_status instead of 143"
|
|
||||||
grep -qx 'signal:packages-receipt' <<<"$signal_run" \
|
|
||||||
|| note 'package receipt signal adapter did not deliver a real process-group signal'
|
|
||||||
cmp -s -- "$tmp/hash-receipt-signal/stamp-before" \
|
|
||||||
"$tmp/hash-receipt-signal/state/panama/packages-hash" \
|
|
||||||
|| note 'package receipt signal replaced the prior hash stamp'
|
|
||||||
[[ -z "$(find "$tmp/hash-receipt-signal/state/panama" \
|
|
||||||
-name '.packages-hash.*' -print -quit)" ]] \
|
|
||||||
|| note 'package receipt signal left a temporary hash stamp'
|
|
||||||
|
|
||||||
# A failing stage must not record the hash, or the failure is hidden forever.
|
# A failing stage must not record the hash, or the failure is hidden forever.
|
||||||
build_fixture "$tmp/c" 1
|
build_fixture "$tmp/c" 1
|
||||||
install_status=0
|
install_status=0
|
||||||
@@ -454,30 +360,48 @@ fi
|
|||||||
grep -qx 'link-dotfiles' "$tmp/c/ran" \
|
grep -qx 'link-dotfiles' "$tmp/c/ran" \
|
||||||
|| note 'an ordinary package-stage failure no longer allows later safe stages'
|
|| note 'an ordinary package-stage failure no longer allows later safe stages'
|
||||||
|
|
||||||
# An invalid enabled Terra root is not an ordinary package failure. It must
|
# An untrusted Terra root is not an ordinary package failure, and it is not a
|
||||||
# stop before the installer's bootstrap DNF and before every stage.
|
# reason to abandon the machine either. It suppresses the stages that open DNF
|
||||||
|
# and the migrations, which are free to run a transaction of their own. Every
|
||||||
|
# stage that only links configuration still runs, and the status stays 78.
|
||||||
|
SAFE_STAGES=(link-dotfiles link-skills link-user link-mcp link-vicinae-scripts)
|
||||||
|
DNF_SUPPRESSED=(install-packages change-settings install-hardware)
|
||||||
|
|
||||||
|
assert_trust_refusal() {
|
||||||
|
local root="$1" label="$2" suppressed safe
|
||||||
|
for suppressed in "${DNF_SUPPRESSED[@]}"; do
|
||||||
|
grep -qx "$suppressed" "$root/ran" \
|
||||||
|
&& note "$label still ran $suppressed"
|
||||||
|
done
|
||||||
|
grep -q '^migrate ' "$root/ran" \
|
||||||
|
&& note "$label still ran migrations, which may open a DNF transaction"
|
||||||
|
for safe in "${SAFE_STAGES[@]}"; do
|
||||||
|
grep -qx "$safe" "$root/ran" \
|
||||||
|
|| note "$label suppressed $safe, which touches no repository"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
build_fixture "$tmp/terra-preflight-hard" 0 78
|
build_fixture "$tmp/terra-preflight-hard" 0 78
|
||||||
install_status=0
|
install_status=0
|
||||||
run_install "$tmp/terra-preflight-hard" >/dev/null || install_status=$?
|
run_install "$tmp/terra-preflight-hard" >/dev/null || install_status=$?
|
||||||
[[ "$install_status" -eq 78 ]] \
|
[[ "$install_status" -eq 78 ]] \
|
||||||
|| note "initial Terra trust failure returned $install_status instead of 78"
|
|| note "initial Terra trust failure returned $install_status instead of 78"
|
||||||
asserted_preflight="$(<"$tmp/terra-preflight-hard/ran")"
|
grep -qx 'trust-preflight' "$tmp/terra-preflight-hard/ran" \
|
||||||
[[ "$asserted_preflight" == trust-preflight ]] \
|
|| note 'initial Terra trust fixture never reached the preflight'
|
||||||
|| note "initial Terra trust failure allowed later work: ${asserted_preflight//$'\n'/,}"
|
assert_trust_refusal "$tmp/terra-preflight-hard" 'initial Terra trust failure'
|
||||||
|
|
||||||
# The trust verifier is itself mandatory. Losing its executable adapter must
|
# The trust verifier is itself mandatory. Losing its executable adapter refuses
|
||||||
# fail closed before interview, bootstrap, or stage work.
|
# package work exactly as a failing verdict does, rather than being ignored.
|
||||||
build_fixture "$tmp/terra-preflight-missing"
|
build_fixture "$tmp/terra-preflight-missing"
|
||||||
rm "$tmp/terra-preflight-missing/setup/scripts/install-packages"
|
rm "$tmp/terra-preflight-missing/setup/scripts/install-packages"
|
||||||
install_status=0
|
install_status=0
|
||||||
run_install "$tmp/terra-preflight-missing" >/dev/null || install_status=$?
|
run_install "$tmp/terra-preflight-missing" >/dev/null || install_status=$?
|
||||||
[[ "$install_status" -eq 78 ]] \
|
[[ "$install_status" -eq 78 ]] \
|
||||||
|| note "missing Terra trust verifier returned $install_status instead of 78"
|
|| note "missing Terra trust verifier returned $install_status instead of 78"
|
||||||
[[ ! -s "$tmp/terra-preflight-missing/ran" ]] \
|
assert_trust_refusal "$tmp/terra-preflight-missing" 'missing Terra trust verifier'
|
||||||
|| note 'missing Terra trust verifier allowed later work'
|
|
||||||
|
|
||||||
# The package stage repeats the preflight to close a configuration-change race.
|
# The package stage repeats the preflight to close a configuration-change race.
|
||||||
# Its hard status must also stop link stages and install-hardware immediately.
|
# Its hard status suppresses the DNF stages that would have followed it.
|
||||||
build_fixture "$tmp/terra-stage-hard" 78 0
|
build_fixture "$tmp/terra-stage-hard" 78 0
|
||||||
install_status=0
|
install_status=0
|
||||||
run_install "$tmp/terra-stage-hard" >/dev/null || install_status=$?
|
run_install "$tmp/terra-stage-hard" >/dev/null || install_status=$?
|
||||||
@@ -485,67 +409,15 @@ run_install "$tmp/terra-stage-hard" >/dev/null || install_status=$?
|
|||||||
|| note "stage-time Terra trust failure returned $install_status instead of 78"
|
|| note "stage-time Terra trust failure returned $install_status instead of 78"
|
||||||
grep -qx 'install-packages' "$tmp/terra-stage-hard/ran" \
|
grep -qx 'install-packages' "$tmp/terra-stage-hard/ran" \
|
||||||
|| note 'stage-time Terra trust fixture never reached install-packages'
|
|| note 'stage-time Terra trust fixture never reached install-packages'
|
||||||
for suppressed in link-dotfiles link-skills link-user change-settings install-hardware dnf-transaction; do
|
for suppressed in change-settings install-hardware; do
|
||||||
grep -qx "$suppressed" "$tmp/terra-stage-hard/ran" \
|
grep -qx "$suppressed" "$tmp/terra-stage-hard/ran" \
|
||||||
&& note "stage-time Terra trust failure still ran $suppressed"
|
&& note "stage-time Terra trust failure still ran $suppressed"
|
||||||
done
|
done
|
||||||
|
grep -q '^migrate ' "$tmp/terra-stage-hard/ran" \
|
||||||
# Exercise the complete real package entrypoint at the second boundary. The
|
&& note 'stage-time Terra trust failure still ran migrations'
|
||||||
# outer preflight sees no Terra repository; the same DNF adapter exposes an
|
for safe in "${SAFE_STAGES[@]}"; do
|
||||||
# unsafe enabled Terra identity to the package stage's own preflight. Removing
|
grep -qx "$safe" "$tmp/terra-stage-hard/ran" \
|
||||||
# that production call would reach the transaction marker below.
|
|| note "stage-time Terra trust failure suppressed $safe"
|
||||||
real_preflight_root="$tmp/real-second-preflight"
|
|
||||||
build_fixture "$real_preflight_root"
|
|
||||||
cp -- "$repo_dir/setup/scripts/install-packages" \
|
|
||||||
"$real_preflight_root/setup/scripts/install-packages"
|
|
||||||
chmod +x "$real_preflight_root/setup/scripts/install-packages"
|
|
||||||
mkdir -p "$real_preflight_root/state/panama"
|
|
||||||
printf 'server\n' >"$real_preflight_root/state/panama/role"
|
|
||||||
cat >"$real_preflight_root/shim/dnf" <<'EOF'
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
if [[ "$*" == '--quiet --no-plugins --dump-repo-config=*' ]]; then
|
|
||||||
count=0
|
|
||||||
[[ ! -f "$PANAMA_DNF_DUMP_COUNT" ]] || read -r count <"$PANAMA_DNF_DUMP_COUNT"
|
|
||||||
count=$((count + 1))
|
|
||||||
printf '%s\n' "$count" >"$PANAMA_DNF_DUMP_COUNT"
|
|
||||||
printf 'dnf-dump\n' >>"$PANAMA_RAN"
|
|
||||||
printf '======== "fedora" repository configuration: ========\n'
|
|
||||||
printf 'baseurl = \nenabled = 1\ngpgcheck = 1\n'
|
|
||||||
printf 'gpgkey = file:///etc/pki/rpm-gpg/RPM-GPG-KEY-fedora-44-primary\n'
|
|
||||||
printf 'metalink = https://mirrors.fedoraproject.org/metalink\nmirrorlist\n'
|
|
||||||
printf 'pkg_gpgcheck = 0\nrepo_gpgcheck = 0\n'
|
|
||||||
if (( count == 2 )); then
|
|
||||||
printf '======== "terra" repository configuration: ========\n'
|
|
||||||
printf 'baseurl = https://evil.invalid/terra44\nenabled = 1\ngpgcheck = 0\n'
|
|
||||||
printf 'gpgkey = https://evil.invalid/key\n'
|
|
||||||
printf 'metalink = \nmirrorlist = \npkg_gpgcheck = 0\nrepo_gpgcheck = 0\n'
|
|
||||||
fi
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
printf 'dnf-transaction\n' >>"$PANAMA_RAN"
|
|
||||||
exit 0
|
|
||||||
EOF
|
|
||||||
chmod +x "$real_preflight_root/shim/dnf"
|
|
||||||
printf '0\n' >"$real_preflight_root/dnf-dump-count"
|
|
||||||
: >"$real_preflight_root/ran"
|
|
||||||
real_preflight_status=0
|
|
||||||
PATH="$real_preflight_root/shim:$PATH" \
|
|
||||||
PANAMA_PATH="$real_preflight_root" PANAMA_RAN="$real_preflight_root/ran" \
|
|
||||||
PANAMA_DNF_DUMP_COUNT="$real_preflight_root/dnf-dump-count" \
|
|
||||||
XDG_STATE_HOME="$real_preflight_root/state" \
|
|
||||||
bash "$real_preflight_root/install" --upgrade --packages \
|
|
||||||
>"$real_preflight_root/out" 2>&1 || real_preflight_status=$?
|
|
||||||
[[ "$real_preflight_status" -eq 78 ]] \
|
|
||||||
|| note "real second repository preflight returned $real_preflight_status instead of 78"
|
|
||||||
[[ "$(<"$real_preflight_root/dnf-dump-count")" == 2 ]] \
|
|
||||||
|| note "real package entrypoint executed $(<"$real_preflight_root/dnf-dump-count") repository preflights instead of two: $(tr '\n' ' ' <"$real_preflight_root/out")"
|
|
||||||
[[ "$(grep -c '^dnf-dump$' "$real_preflight_root/ran")" -eq 2 ]] \
|
|
||||||
|| note "real second preflight fixture log was: $(tr '\n' ',' <"$real_preflight_root/ran")"
|
|
||||||
for suppressed in dnf-transaction link-dotfiles link-skills link-user change-settings \
|
|
||||||
install-hardware; do
|
|
||||||
grep -qx "$suppressed" "$real_preflight_root/ran" \
|
|
||||||
&& note "real second repository preflight still ran $suppressed"
|
|
||||||
done
|
done
|
||||||
|
|
||||||
# A full install always runs the stage, whatever any recorded hash says.
|
# A full install always runs the stage, whatever any recorded hash says.
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ cat >"$checkout/user/manifest" <<'FIXTURE'
|
|||||||
# a comment, and a blank line follow
|
# a comment, and a blank line follow
|
||||||
|
|
||||||
link agents/AGENTS.md ~/.claude/CLAUDE.md
|
link agents/AGENTS.md ~/.claude/CLAUDE.md
|
||||||
link agents/skills ~/.agents/skills
|
linkdir agents/skills ~/.agents/skills
|
||||||
linkdir agents/skills ~/.claude/skills
|
linkdir agents/skills ~/.claude/skills
|
||||||
copy plain.txt ~/.config/plain.txt
|
copy plain.txt ~/.config/plain.txt
|
||||||
link missing.txt ~/.config/missing.txt
|
link missing.txt ~/.config/missing.txt
|
||||||
@@ -102,11 +102,12 @@ PANAMA_USER_CONTENT=no run
|
|||||||
|
|
||||||
# ── 2. Saying yes links, and keeps what was there ───────────────────────────
|
# ── 2. Saying yes links, and keeps what was there ───────────────────────────
|
||||||
|
|
||||||
# ~/.claude/skills is shared now: link-skills has already made it a real
|
# Both skill homes are shared now: link-skills has already made them real
|
||||||
# directory and linked Panama's own skills into it. A linkdir entry has to land
|
# directories and linked Panama's own skills into each. Personal linkdir
|
||||||
# beside those rather than replace the directory holding them.
|
# entries land beside those rather than replacing either directory.
|
||||||
mkdir -p "$home/.claude/skills"
|
mkdir -p "$home/.claude/skills" "$home/.agents/skills"
|
||||||
ln -s "$checkout/skills/shipped" "$home/.claude/skills/shipped"
|
ln -s "$checkout/skills/shipped" "$home/.claude/skills/shipped"
|
||||||
|
ln -s "$checkout/skills/shipped" "$home/.agents/skills/shipped"
|
||||||
|
|
||||||
PANAMA_USER_CONTENT=yes run
|
PANAMA_USER_CONTENT=yes run
|
||||||
|
|
||||||
@@ -114,8 +115,12 @@ PANAMA_USER_CONTENT=yes run
|
|||||||
|| note 'CLAUDE.md was not replaced with a symlink into the checkout'
|
|| note 'CLAUDE.md was not replaced with a symlink into the checkout'
|
||||||
[[ "$(cat "$home/.claude/CLAUDE.md")" == "tracked instructions" ]] \
|
[[ "$(cat "$home/.claude/CLAUDE.md")" == "tracked instructions" ]] \
|
||||||
|| note 'the CLAUDE.md link does not resolve to the tracked file'
|
|| note 'the CLAUDE.md link does not resolve to the tracked file'
|
||||||
[[ -L "$home/.agents/skills" && -f "$home/.agents/skills/example/SKILL.md" ]] \
|
[[ -d "$home/.agents/skills" && ! -L "$home/.agents/skills" ]] \
|
||||||
|| note 'the skills directory was not linked as a directory'
|
|| note 'a linkdir entry replaced ~/.agents/skills with a symlink'
|
||||||
|
[[ -L "$home/.agents/skills/example" && -f "$home/.agents/skills/example/SKILL.md" ]] \
|
||||||
|
|| note 'a linkdir entry did not publish the personal skill for agent-neutral readers'
|
||||||
|
[[ -L "$home/.agents/skills/shipped" ]] \
|
||||||
|
|| note 'a linkdir entry removed the shipped skill from ~/.agents/skills'
|
||||||
[[ -d "$home/.claude/skills" && ! -L "$home/.claude/skills" ]] \
|
[[ -d "$home/.claude/skills" && ! -L "$home/.claude/skills" ]] \
|
||||||
|| note 'a linkdir entry replaced its destination directory with a symlink'
|
|| note 'a linkdir entry replaced its destination directory with a symlink'
|
||||||
[[ -L "$home/.claude/skills/example" && -f "$home/.claude/skills/example/SKILL.md" ]] \
|
[[ -L "$home/.claude/skills/example" && -f "$home/.claude/skills/example/SKILL.md" ]] \
|
||||||
|
|||||||
+11
-11
@@ -13,7 +13,7 @@ and one file says where each piece goes.
|
|||||||
| Path | Goes to | Why |
|
| Path | Goes to | Why |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `agents/AGENTS.md` | `~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md` | Two tools, two names, one file. These were byte-identical copies before this, waiting to disagree. |
|
| `agents/AGENTS.md` | `~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md` | Two tools, two names, one file. These were byte-identical copies before this, waiting to disagree. |
|
||||||
| `agents/skills/` | `~/.agents/skills`, `~/.claude/skills` | A skill installed on any machine lands in the checkout. The Claude path is `linkdir` — see below. |
|
| `agents/skills/` | `~/.agents/skills`, `~/.claude/skills` | Personal skills live once in the checkout. Both destinations use `linkdir` so Panama's shipped skills can live beside them. |
|
||||||
| `agents/rules/` | `~/.claude/rules` | |
|
| `agents/rules/` | `~/.claude/rules` | |
|
||||||
| `ssh/config` | `~/.ssh/config` | Host aliases only. Keys are per-machine and are never tracked. |
|
| `ssh/config` | `~/.ssh/config` | Host aliases only. Keys are per-machine and are never tracked. |
|
||||||
| `espanso/identity.yml` | `~/.config/espanso/match/identity.yml` | Copied, not linked, because a machine may add its own triggers. |
|
| `espanso/identity.yml` | `~/.config/espanso/match/identity.yml` | Copied, not linked, because a machine may add its own triggers. |
|
||||||
@@ -27,22 +27,22 @@ destination is empty. `linkdir` is the third, and it exists because one
|
|||||||
destination is no longer only ours.
|
destination is no longer only ours.
|
||||||
|
|
||||||
Panama ships its own agent skills now (`skills/`, linked by
|
Panama ships its own agent skills now (`skills/`, linked by
|
||||||
`setup/scripts/link-skills`), and they go to `~/.claude/skills` — the same
|
`setup/scripts/link-skills`), and they go to both skill homes. A directory
|
||||||
directory the personal ones went to as a single symlink. A directory cannot be
|
cannot be a symlink to two places, so both personal entries use `linkdir`: each
|
||||||
a symlink to two places, so that entry became `linkdir`: the destination is a
|
destination is a real directory, and each child of `user/agents/skills/` is
|
||||||
real directory, and each child of `user/agents/skills/` is linked into it
|
linked into it individually.
|
||||||
individually. `~/.agents/skills` is still a whole-directory `link`, because
|
|
||||||
nothing else claims it.
|
|
||||||
|
|
||||||
Two consequences, recorded rather than fixed:
|
Two consequences, recorded rather than fixed:
|
||||||
|
|
||||||
- **A personal skill named like a shipped one shadows it.** `link-user` runs
|
- **A personal skill named like a shipped one shadows it.** `link-user` runs
|
||||||
after `link-skills` and displaces what it finds, so the personal one wins.
|
after `link-skills` and displaces what it finds, so the personal one wins in
|
||||||
That is the intent, and it is the precedence Claude Code uses anyway.
|
both skill homes.
|
||||||
- **A new personal skill needs a re-link to appear.** The whole-directory link
|
- **A new personal skill needs a re-link to appear.** The whole-directory link
|
||||||
showed a newly created skill instantly; per-child links do not know about a
|
showed a newly created skill instantly; per-child links do not know about a
|
||||||
child that did not exist when they were made. Run `panama update` or
|
child that did not exist when they were made. Put new shared skills in
|
||||||
`setup/scripts/link-user` after adding one.
|
`user/agents/skills/`, then run `panama update` or `setup/scripts/link-user`.
|
||||||
|
A tool that installs directly into a home skill directory creates a
|
||||||
|
machine-local skill until it is moved into the checkout.
|
||||||
|
|
||||||
## It is off unless you say yes
|
## It is off unless you say yes
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ I'm Gabriel. You're my agent. We will be working together a lot, so I thought I
|
|||||||
|
|
||||||
I'm a software developer at Ksense Technology Group. At Ksense, we basically sell our services as developers to clients to create web applications or web servers. Basically whatever the client wants, but for the most part, we create web applications with Next.js. I am the primary developer for internal tools & web applications. I mainly work on an application called Command Center, which serves as a portal for our project managers to interact with our clients.
|
I'm a software developer at Ksense Technology Group. At Ksense, we basically sell our services as developers to clients to create web applications or web servers. Basically whatever the client wants, but for the most part, we create web applications with Next.js. I am the primary developer for internal tools & web applications. I mainly work on an application called Command Center, which serves as a portal for our project managers to interact with our clients.
|
||||||
|
|
||||||
|
My Ksense work lives in ~/Documents/Code/Ksense. The Ksense-specific sections below (Merge Requests, Coworkers) mostly matter when I am working inside that folder. Anywhere else, assume a personal project.
|
||||||
|
|
||||||
I love to build. I focus on building complex things as simple as possible. I love to find ways to reduce complexity when solving problems.
|
I love to build. I focus on building complex things as simple as possible. I love to find ways to reduce complexity when solving problems.
|
||||||
|
|
||||||
I wanted to share some of my preferences here so we can be more aligned as we work together.
|
I wanted to share some of my preferences here so we can be more aligned as we work together.
|
||||||
@@ -62,6 +64,40 @@ I wanted to share some of my preferences here so we can be more aligned as we wo
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
# The prose bar
|
||||||
|
|
||||||
|
Anything a person other than me will read gets a pattern pass before it ships. MR and PR
|
||||||
|
descriptions, ticket fields, deliverable documents, reports, anything handed to a
|
||||||
|
teammate or a client. Invoke the `unslop` skill and apply its pattern detection.
|
||||||
|
|
||||||
|
- Skip its "Adding soul" section. Opinions and deliberate mess are right for an essay and
|
||||||
|
wrong for a risk table or a status field. These want plain, factual, and specific.
|
||||||
|
- No em dashes, en dashes, semicolons, or arrow glyphs in prose. This is stricter than
|
||||||
|
unslop's own punctuation pattern, and mine wins. Grep the file before calling it done.
|
||||||
|
- Passive voice is the usual offender in technical writing, not puffery. "X was verified"
|
||||||
|
wants to be "I verified X", which is shorter and says who did it.
|
||||||
|
- Short reference cells still count. Three sentences wrapped around a filename is prose.
|
||||||
|
- Never rewrite generated output. If a tool produced a block, an audit verdict or a test
|
||||||
|
report, regenerate it rather than editing its words.
|
||||||
|
- Write it as me, in first person. Never use my name or a stand-in for it. Not "Gib
|
||||||
|
decided", not "the author", not "per the developer". If a sentence needs an actor, it
|
||||||
|
is "I".
|
||||||
|
- Never flag one of my own decisions as a deviation. Don't write that something was out
|
||||||
|
of scope, that an AC or dev review said otherwise, that an earlier story should have
|
||||||
|
caught it, or that a direction was reversed. I write the tickets, so I am allowed to
|
||||||
|
change them, and a dev review is a proposal rather than a contract. Calling it out
|
||||||
|
reads as my own AI tattling on me. It waves a red flag at a nothing burger, costs the
|
||||||
|
PM time investigating a non-issue, and leaves them thinking I did something wrong.
|
||||||
|
State what the change is and why it belongs where it is, then stop.
|
||||||
|
- If a decision feels big enough that you want to narrate it, ask me about it before
|
||||||
|
writing rather than annotating it in the document. Being aligned first is the point,
|
||||||
|
and the writing should read as though we always were.
|
||||||
|
|
||||||
|
Files only I read are exempt: plans, scratch notes, working records. Those can name me
|
||||||
|
and record who decided what, because that history is useful.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
# Merge Requests
|
# Merge Requests
|
||||||
|
|
||||||
- Make sure titles follow conventions from the repo.
|
- Make sure titles follow conventions from the repo.
|
||||||
@@ -84,5 +120,8 @@ I wanted to share some of my preferences here so we can be more aligned as we wo
|
|||||||
- Henry reviews all of my code & up until recently, would also write all the dev reviews for all the stories I completed as well. Nowadays, I write them myself & he reviews those too.
|
- Henry reviews all of my code & up until recently, would also write all the dev reviews for all the stories I completed as well. Nowadays, I write them myself & he reviews those too.
|
||||||
- Henry likes work to be very considerate & he always prefers solutions that result in 0 downtime.
|
- Henry likes work to be very considerate & he always prefers solutions that result in 0 downtime.
|
||||||
- Despite the fact that Command Center does not have many users & the impact of it being down for a few minutes is small, Henry still leans on the side of solutions to problems that don't result in prod being down ever, even for just a few minutes during the build process. So our solutions should always keep that in mind. Any code that isn't considering everything & could result in a bug will probably be flagged by him, so its worthwhile to do right the first time!
|
- Despite the fact that Command Center does not have many users & the impact of it being down for a few minutes is small, Henry still leans on the side of solutions to problems that don't result in prod being down ever, even for just a few minutes during the build process. So our solutions should always keep that in mind. Any code that isn't considering everything & could result in a bug will probably be flagged by him, so its worthwhile to do right the first time!
|
||||||
|
- Josh - Tech Lead for the APSCA project (apsca_next)
|
||||||
|
- On APSCA, Josh is my lead instead of Henry. He reviews my code and writes the dev reviews, spikes, and proposed schemas on the APSCA stories.
|
||||||
|
- His dev reviews are detailed and often include draft code, schema, and state machines. Treat them as the starting point for a story, not as a finished spec.
|
||||||
- Hunter Southworth - Engineering Manager / Senior Developer
|
- Hunter Southworth - Engineering Manager / Senior Developer
|
||||||
- Kelson - Owner of Ksense
|
- Kelson - Owner of Ksense
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# MCP servers registered with the local agent runtimes.
|
||||||
|
#
|
||||||
|
# <name> <transport> <url> <token variable>
|
||||||
|
#
|
||||||
|
# The token variable names an entry in user/agents/mcp/env, which is ignored by
|
||||||
|
# git. This file carries no secret, so it is tracked like the rest of user/ and
|
||||||
|
# a new machine gets the same servers by dropping its own env file beside it.
|
||||||
|
#
|
||||||
|
# A row with no token variable registers without an Authorization header.
|
||||||
|
#
|
||||||
|
# Lines beginning with # are comments and blank lines are ignored.
|
||||||
|
|
||||||
|
# The NanoKVM boards. These give an agent full console control of each machine,
|
||||||
|
# including the parts SSH cannot reach: firmware, a locked screen, and the GUI
|
||||||
|
# prompts Xcode and the keychain raise during a signed iOS build.
|
||||||
|
nanokvm-fedora http https://nano.gbrown.org/api/mcp NANOKVM_FEDORA_TOKEN
|
||||||
|
nanokvm-mac http https://macnano.gbrown.org/api/mcp NANOKVM_MAC_TOKEN
|
||||||
|
|
||||||
|
# Bill Tracker's hosted MCP server. No token: it is an OAuth server of its own
|
||||||
|
# and Claude Code signs in through Gib's Auth on first use.
|
||||||
|
bill-tracker http https://mcp.billtracker.gbrown.org/mcp
|
||||||
|
|
||||||
|
# Atlassian's hosted MCP server for Jira and Confluence. No token: it is an
|
||||||
|
# OAuth server and Claude Code signs in through /mcp on first use.
|
||||||
|
atlassian http https://mcp.atlassian.com/v1/mcp
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
# The ticket page
|
||||||
|
|
||||||
|
Every ticket this skill works has a **ticket page**, and every epic has an **epic index**. The
|
||||||
|
page is where Gib follows the work: the ticket as Jira has it, the plan, live progress, proof
|
||||||
|
beside the mocks, and the review. It updates while you work, so a write you skip is a gap he
|
||||||
|
sees. It is a personal working file, exempt from the prose bar like the rest of `.claude/docs/`.
|
||||||
|
|
||||||
|
```
|
||||||
|
.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>/
|
||||||
|
index.html, ticket.data.js # the ticket page
|
||||||
|
resources/ proof/ deliverables/ mr.md
|
||||||
|
tickets/<TICKET-KEY>/ # same shape, for a ticket with no epic, and no index
|
||||||
|
```
|
||||||
|
|
||||||
|
## Write through `ticket-page`, always
|
||||||
|
|
||||||
|
`~/.agents/skills/ticket/scripts/ticket-page` is the only writer of the data files. It validates
|
||||||
|
before every write and refuses a broken one, writes atomically so the open page never reads half
|
||||||
|
a file, appends each change to the page's live log, and keeps the ticket's row on the epic index
|
||||||
|
current. Editing a `.data.js` file by hand skips all four. `ticket-page help` lists every
|
||||||
|
command and its flags.
|
||||||
|
|
||||||
|
It finds the ticket by key under the current repo's `.claude/docs/epics/`, so run it from inside
|
||||||
|
the repo.
|
||||||
|
|
||||||
|
## What to record, and when
|
||||||
|
|
||||||
|
| 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. |
|
||||||
|
| 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` |
|
||||||
|
| Plan written | `plan <KEY> --file plan.json`, then one `mock` per artboard |
|
||||||
|
| Plan approved, Phase 2 begins | `approve <KEY>` |
|
||||||
|
| 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>"` |
|
||||||
|
| 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>` |
|
||||||
|
| Anything else Gib would want to see happen: suite green, a surprise, a blocker | `event <KEY> "<text>"` |
|
||||||
|
|
||||||
|
A step is done when its commit exists and `ticket-page show <KEY>` lists that commit beside it.
|
||||||
|
`step done` refuses without `--commit`. A step with nothing to commit, such as a verification
|
||||||
|
pass, takes `--no-commit`.
|
||||||
|
|
||||||
|
## `plan.json`
|
||||||
|
|
||||||
|
The plan is one JSON file, written with the Write tool and loaded with `ticket-page plan`. Prose
|
||||||
|
fields are HTML fragments: `<p>`, `<b>`, `<code>`, `<ul><li>`. `plan` replaces any earlier plan
|
||||||
|
and clears its progress and proof, because a rewritten plan is a restart. To change a plan
|
||||||
|
mid-build (a new step, a reworded criterion), print it with `plan-json <KEY> > plan.json`, edit
|
||||||
|
that, and load it with `plan --amend`: steps whose number and text are unchanged, and
|
||||||
|
criteria whose id remains, keep their progress, and proof keeps the criteria that still exist.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"criteria": [
|
||||||
|
{ "id": "AC1", "text": "Every company can carry a five character canonical ID.", "steps": [1, 2] },
|
||||||
|
{ "id": "AC2", "text": "A duplicate canonical ID is rejected with a readable message.", "steps": [3] }
|
||||||
|
],
|
||||||
|
"questions": [],
|
||||||
|
"approach": "<p><b>Company.canonicalId</b> is the only new state. The engagement ID is derived by <code>formatEngagementCanonicalId</code>, so the two cannot drift.</p>",
|
||||||
|
"decisions": [["Engagement canonical ID", "Derived at read time", "Matches the spreadsheet formula, with no second column to backfill."]],
|
||||||
|
"steps": [
|
||||||
|
{ "n": 1, "text": "Add the canonicalId module, tests first.", "tdd": true },
|
||||||
|
{ "n": 2, "text": "Schema, migration and backfill." },
|
||||||
|
{ "n": 3, "text": "Map the unique violation to CONFLICT.", "tdd": true },
|
||||||
|
{ "n": 4, "text": "Canonical ID field on the company form.", "kind": "mock" }
|
||||||
|
],
|
||||||
|
"risks": [
|
||||||
|
{ "risk": "Two companies given the same code", "handling": "The unique index blocks it and step 3 returns a readable CONFLICT.", "criteria": ["AC2"], "fromTicket": true }
|
||||||
|
],
|
||||||
|
"tests": [
|
||||||
|
{ "kind": "integration", "text": "companies.update with a taken code throws CONFLICT.", "criteria": ["AC2"] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Steps are numbered 1 to N in order. `kind` is `code` (the default), `mock`, or `deliverable`.
|
||||||
|
- Every criterion names the steps that satisfy it. Risks and tests name the criteria they cover.
|
||||||
|
- A risk or test that comes from the ticket's own table carries `"fromTicket": true`. Your own
|
||||||
|
additions leave it off, so the page shows which rows the ticket asked for.
|
||||||
|
- `questions` holds only what genuinely needs Gib's answer, each as a direct question. Empty
|
||||||
|
means none.
|
||||||
|
|
||||||
|
## Mocks
|
||||||
|
|
||||||
|
When a step changes what a user sees (a new screen, a changed layout, a new control, copy a
|
||||||
|
user reads), the plan carries mock artboards:
|
||||||
|
|
||||||
|
```
|
||||||
|
ticket-page mock <KEY> <id> --title "<screen>" --criteria AC3,AC8 --note "<what the build will not do>" --file artboard.html
|
||||||
|
```
|
||||||
|
|
||||||
|
- An artboard is a complete small HTML document with its own `<style>`, rendered in a sandboxed
|
||||||
|
frame beside the proof. Draw the screen state the plan introduces, with the app's real
|
||||||
|
navigation and density around it rather than a wireframe of boxes.
|
||||||
|
- One artboard per new screen state. When the direction is Gib's call, draw each candidate as
|
||||||
|
its own artboard (`option-a`, `option-b`) and ask the choice in `questions`, naming the
|
||||||
|
artboards. This is how the house rule of several static mocks before touching real components
|
||||||
|
is met inside the ticket flow.
|
||||||
|
- `--criteria` names what the artboard illustrates. The Progress tab shows it under those
|
||||||
|
criteria, and replaces the empty half with the proof once a `proof ... --mock <id>` lands.
|
||||||
|
- `--note` states what the artboard shows that the implementation will not do.
|
||||||
|
- A spike deliverable's polished mocks (see **Mocks and datamodel changes**) can start from the
|
||||||
|
approved artboard. The built screens are still screenshotted for real.
|
||||||
|
|
||||||
|
## The epic index
|
||||||
|
|
||||||
|
The index lists the epic's stories in build order: the order they should be completed in, which
|
||||||
|
is not key order and not always the Jira link graph. Each worked ticket keeps its own row current.
|
||||||
|
The rest of the index is yours to seed and keep true.
|
||||||
|
|
||||||
|
- **Seed it** the first time a ticket under the epic is worked, and refresh it on later runs.
|
||||||
|
Search the epic's children (`parent = <EPIC> ORDER BY rank`). For each, record the title, the
|
||||||
|
estimate as `--hours`, the Story Risk Level as `--risk`, the stories it is blocked by as
|
||||||
|
`--after`, and `--status done` if Jira says Done: `ticket-page epic <EPIC> story <KEY> ...`.
|
||||||
|
Closed spikes go in with `epic <EPIC> closed <KEY> --title T`.
|
||||||
|
- **Set the order** with `ticket-page epic <EPIC> order <KEY> <KEY> ...`, listing every story. If
|
||||||
|
the epic already records a build order (a `summary.md` or plan in its folder), that order wins.
|
||||||
|
Otherwise order by the blocked-by links, then Jira rank. Write the reasons the order is what it
|
||||||
|
is with `epic <EPIC> why --file reasons.txt`, one reason per line.
|
||||||
|
- **Gib reorders by asking.** When he does, rerun `order` and update `why`.
|
||||||
|
- A story is marked done only by `--status done`, once it has merged or Jira says Done. The index
|
||||||
|
never guesses that.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## Tickets from before the page
|
||||||
|
|
||||||
|
Tickets planned before the page existed have `resources/ticket.md` and `plan.md` and no
|
||||||
|
`ticket.data.js`. Leave those files alone. When Phase 0 resumes such a ticket, convert it
|
||||||
|
first: `init` from `resources/issue.raw.json`, `plan` from the plan's content, then `approve` and
|
||||||
|
`step ... done` for each step `git log` shows committed. From then on the page is the record.
|
||||||
+285
-217
@@ -1,14 +1,14 @@
|
|||||||
---
|
---
|
||||||
name: ticket
|
name: ticket
|
||||||
description: End-to-end Jira ticket workflow — fetch a ticket into .claude/docs/epics/, write a plan, implement it with clean commits, run pre-mr-review to convergence, 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, 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.
|
||||||
disable-model-invocation: true
|
disable-model-invocation: true
|
||||||
---
|
---
|
||||||
|
|
||||||
# Ticket Workflow
|
# Ticket Workflow
|
||||||
|
|
||||||
Turns a Jira ticket key into: a saved ticket record, a reviewed plan, implemented and
|
Turns a Jira ticket key into: a live ticket page carrying the ticket, a reviewed plan,
|
||||||
committed code, a converged `pre-mr-review` audit, and an MR doc ready to paste into
|
progress, and proof, implemented and committed code, a converged `pre-mr-review` audit, and
|
||||||
GitLab/GitHub. This skill is user-level (`~/.agents/skills/ticket/`) and works the same
|
an MR doc ready to paste into GitLab/GitHub. This skill is user-level (`~/.agents/skills/ticket/`) and works the same
|
||||||
way in any repo that has a `.claude/docs/` directory and a `pre-mr-review` skill.
|
way in any repo that has a `.claude/docs/` directory and a `pre-mr-review` skill.
|
||||||
|
|
||||||
Invoked as `/ticket KACP-11111`. The ticket key is passed as the skill's arguments. Called
|
Invoked as `/ticket KACP-11111`. The ticket key is passed as the skill's arguments. Called
|
||||||
@@ -19,36 +19,48 @@ Jira story, the estimate, risk level, and testing tables, as part of authoring a
|
|||||||
before anyone builds it. It has nothing to do with reviewing code. The code review in
|
before anyone builds it. It has nothing to do with reviewing code. The code review in
|
||||||
this skill is Phase 2 step 5 and is performed by a dispatched agent, not by a skill.
|
this skill is Phase 2 step 5 and is performed by a dispatched agent, not by a skill.
|
||||||
|
|
||||||
|
## The ticket page
|
||||||
|
|
||||||
|
Every ticket gets a **ticket page**, a web page Gib keeps open while you work: the ticket as
|
||||||
|
Jira has it, the plan with its mocks, live progress, proof beside the mocks, and the review.
|
||||||
|
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.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
## Directory layout this skill maintains
|
## Directory layout this skill maintains
|
||||||
|
|
||||||
```
|
```
|
||||||
.claude/docs/epics/
|
.claude/docs/epics/
|
||||||
|
_site/ # the shared renderer, refreshed by ticket-page
|
||||||
<EPIC-KEY>/
|
<EPIC-KEY>/
|
||||||
epic.md # brief epic context, written once, best-effort
|
index.html, epic.data.js # the epic index
|
||||||
<TICKET-KEY>/
|
<TICKET-KEY>/
|
||||||
|
index.html, ticket.data.js # the ticket page
|
||||||
resources/
|
resources/
|
||||||
ticket.md # title, description, dev review instructions
|
issue.raw.json # the fetched issue, kept for re-imports
|
||||||
<attachments...> # images, videos, other files as downloaded
|
<attachments...> # images, videos, other files as downloaded
|
||||||
<video>.transcript.txt # only for videos that got transcribed
|
<video>.transcript.txt # only for videos that got transcribed
|
||||||
<video>.transcript.srt
|
<video>.transcript.srt
|
||||||
plan.md
|
<video>.frames/
|
||||||
deliverables/ # only for tickets whose output is documents, not code
|
deliverables/ # only for tickets whose output is documents, not code
|
||||||
<deliverable-slug>/
|
<deliverable-slug>/
|
||||||
<deliverable-slug>.md
|
<deliverable-slug>.md
|
||||||
<deliverable-slug>.typ
|
<deliverable-slug>.typ
|
||||||
<deliverable-slug>.pdf
|
<deliverable-slug>.pdf
|
||||||
proof/ # captured working feature proof, uploaded to Jira by the user
|
proof/ # captured working feature proof, uploaded to Jira by the user
|
||||||
mr.md # the MR description only, written once pre-mr-review says Ready to Open MR
|
mr.md # the MR description only, written once pre-mr-review says Ready to Open MR
|
||||||
tickets/
|
tickets/
|
||||||
<TICKET-KEY>/ # same shape, for tickets with no epic
|
<TICKET-KEY>/ # same shape, for tickets with no epic, and no index
|
||||||
resources/...
|
|
||||||
plan.md
|
|
||||||
deliverables/...
|
|
||||||
mr.md
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Scripts this skill uses
|
## Scripts this skill uses
|
||||||
|
|
||||||
|
- `~/.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/jira-fetch-issue.sh <KEY> <OUT.json>` — fetches one
|
- `~/.agents/skills/ticket/scripts/jira-fetch-issue.sh <KEY> <OUT.json>` — fetches one
|
||||||
issue (`fields=*all`, rendered HTML description, field-name map) via `JIRA_BASE_URL` /
|
issue (`fields=*all`, rendered HTML description, field-name map) via `JIRA_BASE_URL` /
|
||||||
`JIRA_CREDENTIALS`.
|
`JIRA_CREDENTIALS`.
|
||||||
@@ -99,8 +111,8 @@ rerun it, and verify the device log instead of accepting the fallback transcript
|
|||||||
## House style (applies to everything this skill writes or commits)
|
## House style (applies to everything this skill writes or commits)
|
||||||
|
|
||||||
- No code comments unless the user explicitly asks for one in that spot.
|
- No code comments unless the user explicitly asks for one in that spot.
|
||||||
- No em dashes, en dashes, semicolons, or arrow glyphs in commit messages, `ticket.md`,
|
- No em dashes, en dashes, semicolons, or arrow glyphs in commit messages, the ticket page,
|
||||||
`plan.md`, or `mr.md`. Plain punctuation only.
|
or `mr.md`. Plain punctuation only.
|
||||||
- Commits are `git commit -m "<short imperative message>"` only — no body, no
|
- Commits are `git commit -m "<short imperative message>"` only — no body, no
|
||||||
co-author line, no `Claude` / `Codex` / `AI` trailer of any kind.
|
co-author line, no `Claude` / `Codex` / `AI` trailer of any kind.
|
||||||
- Never `git push`. Never open an MR/PR. This skill prepares everything needed to open
|
- Never `git push`. Never open an MR/PR. This skill prepares everything needed to open
|
||||||
@@ -108,12 +120,69 @@ rerun it, and verify the device log instead of accepting the fallback transcript
|
|||||||
- Don't check a checklist box (in `mr.md` or anywhere else) unless you actually verified
|
- Don't check a checklist box (in `mr.md` or anywhere else) unless you actually verified
|
||||||
it. Leave it unchecked and say why in the notes rather than guessing.
|
it. Leave it unchecked and say why in the notes rather than guessing.
|
||||||
|
|
||||||
|
## The prose bar (anything another person reads)
|
||||||
|
|
||||||
|
Everything this skill produces for someone else gets a pattern pass before it ships:
|
||||||
|
`mr.md`'s Summary and Additional Notes, every Jira field including the proof and
|
||||||
|
mitigation cells, and every deliverable document. Call the Skill tool with "unslop" and
|
||||||
|
apply its pattern detection.
|
||||||
|
|
||||||
|
- **Skip unslop's "Adding soul" section.** It asks for opinions, varied rhythm, and some
|
||||||
|
mess. That is right for an essay and wrong for a mitigation cell Henry is auditing.
|
||||||
|
These want plain, factual, and specific.
|
||||||
|
- **Never edit generated output.** The pre-MR handoff pasted into `mr.md` is the audit's
|
||||||
|
own words. If it reads badly, regenerate it, don't rewrite it.
|
||||||
|
- **Proof cells are prose too.** A cell that wraps three sentences around a filename is
|
||||||
|
writing, not a citation, and it picks up passive voice and jargon like any other
|
||||||
|
paragraph. Real examples caught this way: "affordance" for button, "vacuously" for
|
||||||
|
"for the wrong reason", "the test was confirmed able to fail" for "I emptied the
|
||||||
|
legacy render and confirmed the test fails".
|
||||||
|
- **Passive voice is the usual offender here,** not puffery. Technical writing drifts
|
||||||
|
into "X was verified" when "I verified X" is shorter and says who did it.
|
||||||
|
|
||||||
|
### Write as Gabriel, and never flag his own decisions
|
||||||
|
|
||||||
|
Everything that ships under his name is written **as him**, in first person. Never write
|
||||||
|
his name or any stand-in for it. Not "Gib decided", not "the author", not "per the
|
||||||
|
developer". When a sentence needs an actor, it is "I".
|
||||||
|
|
||||||
|
**Never frame one of his decisions as a deviation.** Do not write that something "was not
|
||||||
|
in this story's scope", that an AC or dev review said otherwise, that an earlier story
|
||||||
|
"should have caught it", or that a direction was "reversed". State what the change is and
|
||||||
|
why it belongs where it is, then stop.
|
||||||
|
|
||||||
|
He writes the tickets himself, so he is entitled to change them, and a dev review is a
|
||||||
|
proposal rather than a contract. Calling the change out reads as his own AI tattling on
|
||||||
|
him. It waves a red flag at a nothing burger, costs the PM time investigating a
|
||||||
|
non-issue, and leaves them assuming he did something wrong. The real story is normally
|
||||||
|
mundane and good: the better component already existed and cost almost nothing to use.
|
||||||
|
|
||||||
|
Compare, on KACP-23058:
|
||||||
|
|
||||||
|
> Bad: "This was not in this story's scope. KACP-23059 shipped that page still on the
|
||||||
|
> legacy tables, and Gib reversed the reuse-legacy direction on 2026-09-02."
|
||||||
|
>
|
||||||
|
> Good: "Both detail pages now share one set of grid components instead of two divergent
|
||||||
|
> implementations, so the move ships with the components it shares."
|
||||||
|
|
||||||
|
Same information, no flag attached. **If a decision feels big enough that you want to
|
||||||
|
narrate it, that is the signal to ask him about it before writing, not to annotate it in
|
||||||
|
the document.** Being aligned first is the point, and the writing should read as though
|
||||||
|
you always were.
|
||||||
|
|
||||||
|
This governs `mr.md`, `customfield_10260` and `10261`, every other Jira field, and PM
|
||||||
|
deliverables. It does not govern files only he reads. The ticket page, `bugs.md`, audits and
|
||||||
|
working notes can name him and record who decided what, because that history is useful.
|
||||||
|
|
||||||
|
The House style punctuation rule above is the stricter one where the two overlap. Keep
|
||||||
|
it. The ticket page is a personal working file and exempt, nobody else reads it.
|
||||||
|
|
||||||
## Verification tools available
|
## Verification tools available
|
||||||
|
|
||||||
The goal isn't "a plausible-sounding plan" or "code that compiles" — it's a plan and
|
The goal isn't "a plausible-sounding plan" or "code that compiles" — it's a plan and
|
||||||
an implementation that are actually correct, checked against the real system rather
|
an implementation that are actually correct, checked against the real system rather
|
||||||
than assumed from the ticket text. Use whichever of these actually verify the thing
|
than assumed from the ticket text. Use whichever of these actually verify the thing
|
||||||
in question, before writing `plan.md` (Phase 1) and again whenever Phase 2's
|
in question, before writing the plan (Phase 1) and again whenever Phase 2's
|
||||||
implementation makes a claim that's checkable:
|
implementation makes a claim that's checkable:
|
||||||
|
|
||||||
- **The codebase itself.** Don't take the ticket's description of "how things work
|
- **The codebase itself.** Don't take the ticket's description of "how things work
|
||||||
@@ -139,7 +208,7 @@ implementation makes a claim that's checkable:
|
|||||||
about explicitly rather than assume the file's DB credential is fair game by default.
|
about explicitly rather than assume the file's DB credential is fair game by default.
|
||||||
|
|
||||||
If something would need access you don't have (a different repo, a system you can't
|
If something would need access you don't have (a different repo, a system you can't
|
||||||
reach), say so explicitly in `plan.md`'s Open questions instead of silently skipping
|
reach), say so explicitly in the plan's open questions instead of silently skipping
|
||||||
the check.
|
the check.
|
||||||
|
|
||||||
**How to write what you found, in a deliverable.** State it as a plain confirmed
|
**How to write what you found, in a deliverable.** State it as a plain confirmed
|
||||||
@@ -153,7 +222,7 @@ codebase equally.
|
|||||||
|
|
||||||
Some tickets (spikes, research, migration proposals, the KACP-22764 kind of ticket)
|
Some tickets (spikes, research, migration proposals, the KACP-22764 kind of ticket)
|
||||||
don't produce code, they produce documents for a PM or another engineer to read. When
|
don't produce code, they produce documents for a PM or another engineer to read. When
|
||||||
a `plan.md` Step produces a document rather than a code change, it becomes its own
|
a plan step produces a document rather than a code change, it becomes its own
|
||||||
subfolder under `<target-dir>/deliverables/<deliverable-slug>/` with three files:
|
subfolder under `<target-dir>/deliverables/<deliverable-slug>/` with three files:
|
||||||
|
|
||||||
- `<slug>.md` — the source of truth for content. Write this first, get the content
|
- `<slug>.md` — the source of truth for content. Write this first, get the content
|
||||||
@@ -168,11 +237,10 @@ subfolder under `<target-dir>/deliverables/<deliverable-slug>/` with three files
|
|||||||
- `<slug>.pdf` — compiled from the `.typ` file.
|
- `<slug>.pdf` — compiled from the `.typ` file.
|
||||||
|
|
||||||
**Write for the actual audience, not for yourself.** The reader is a PM or another
|
**Write for the actual audience, not for yourself.** The reader is a PM or another
|
||||||
engineer who has never seen `.claude/docs/`, doesn't know what `plan.md` or
|
engineer who has never seen `.claude/docs/` or the ticket page, and doesn't have this
|
||||||
`ticket.md` are, and doesn't have this repo (or any other local repo) checked out.
|
repo (or any other local repo) checked out. Never reference `.claude/docs/` or any local
|
||||||
Never reference `.claude/docs/` or any local file path in a deliverable's content.
|
file path in a deliverable's content. Never point the reader at the ticket page or the
|
||||||
Never say "see plan.md" or "see ticket.md", those are personal working files, not
|
plan, those are personal working files, not things a reader can open. Refer to "this ticket" or a plain Jira link, not a local
|
||||||
things a reader can open. Refer to "this ticket" or a plain Jira link, not a local
|
|
||||||
path. If a fact came from another repo on this machine, state the fact, don't cite
|
path. If a fact came from another repo on this machine, state the fact, don't cite
|
||||||
the local checkout path it came from.
|
the local checkout path it came from.
|
||||||
|
|
||||||
@@ -226,15 +294,19 @@ method for producing accurate screenshots, not part of the deliverable's story.
|
|||||||
Process, per deliverable:
|
Process, per deliverable:
|
||||||
|
|
||||||
1. Write `<slug>.md`.
|
1. Write `<slug>.md`.
|
||||||
2. Write `<slug>.typ` from it, per the styling notes above.
|
2. Run the prose bar over `<slug>.md` before building anything from it. A spike
|
||||||
3. Compile: `typst compile <slug>.typ <slug>.pdf`. `typst` should already be on PATH;
|
deliverable is the most prose-heavy thing this skill produces and it goes straight to
|
||||||
|
Conrad, who is skeptical of AI. Fixing it here means fixing it once, rather than in
|
||||||
|
the markdown and again in the Typst.
|
||||||
|
3. Write `<slug>.typ` from it, per the styling notes above.
|
||||||
|
4. Compile: `typst compile <slug>.typ <slug>.pdf`. `typst` should already be on PATH;
|
||||||
if it isn't, tell the user rather than silently skipping the PDF.
|
if it isn't, tell the user rather than silently skipping the PDF.
|
||||||
4. Actually look at the compiled PDF using the Read tool (it reads PDFs directly, page
|
5. Actually look at the compiled PDF using the Read tool (it reads PDFs directly, page
|
||||||
by page for longer documents). Check every page for real layout problems: text or
|
by page for longer documents). Check every page for real layout problems: text or
|
||||||
a table overflowing a page, an awkward page break splitting a table or diagram,
|
a table overflowing a page, an awkward page break splitting a table or diagram,
|
||||||
cramped or excessive spacing, a diagram that rendered wrong. A successful compile
|
cramped or excessive spacing, a diagram that rendered wrong. A successful compile
|
||||||
only means valid Typst, not that it looks right — actually look.
|
only means valid Typst, not that it looks right — actually look.
|
||||||
5. If anything looks wrong, fix `<slug>.typ` and go back to step 3. Repeat until the
|
6. If anything looks wrong, fix `<slug>.typ` and go back to step 4. Repeat until the
|
||||||
PDF genuinely looks right, not just until it compiles without erroring.
|
PDF genuinely looks right, not just until it compiles without erroring.
|
||||||
|
|
||||||
If `.claude/docs/` is tracked by git in this repo (check with
|
If `.claude/docs/` is tracked by git in this repo (check with
|
||||||
@@ -254,7 +326,7 @@ describe it only as a quick UI mockup.
|
|||||||
|
|
||||||
When a ticket calls for mocks or datamodel changes (a spike proposing new screens or a
|
When a ticket calls for mocks or datamodel changes (a spike proposing new screens or a
|
||||||
new schema, for instance), don't stop at wireframes or a schema proposal document.
|
new schema, for instance), don't stop at wireframes or a schema proposal document.
|
||||||
Build it for real, on the ticket's branch. Mark these steps `(mock)` in `plan.md`.
|
Build it for real, on the ticket's branch. Give these steps `"kind": "mock"` in the plan.
|
||||||
|
|
||||||
A mock deliverable is **two artifacts**, not one, and they answer different questions.
|
A mock deliverable is **two artifacts**, not one, and they answer different questions.
|
||||||
The code proves the thing is buildable and surfaces the problems a picture hides. The
|
The code proves the thing is buildable and surfaces the problems a picture hides. The
|
||||||
@@ -287,7 +359,7 @@ what was quick to wire up. Ship both, and don't let either stand in for the othe
|
|||||||
feature. Don't chase every edge case and don't fix unrelated pre-existing bugs you
|
feature. Don't chase every edge case and don't fix unrelated pre-existing bugs you
|
||||||
happen to notice along the way, just implement enough of the real thing to be
|
happen to notice along the way, just implement enough of the real thing to be
|
||||||
screenshotted and evaluated. If doing it for real would take significantly longer
|
screenshotted and evaluated. If doing it for real would take significantly longer
|
||||||
than a static mock would, say so in `plan.md` rather than silently scoping it down
|
than a static mock would, say so in the plan's approach rather than silently scoping it down
|
||||||
without mentioning the tradeoff.
|
without mentioning the tradeoff.
|
||||||
- **Screenshot the real thing.** Once it's built and running, use the `run` skill to
|
- **Screenshot the real thing.** Once it's built and running, use the `run` skill to
|
||||||
get the app up and a browser automation tool (e.g. `claude-in-chrome`) to capture
|
get the app up and a browser automation tool (e.g. `claude-in-chrome`) to capture
|
||||||
@@ -351,7 +423,7 @@ through, and the branch needs to already exist when that happens.
|
|||||||
|
|
||||||
Once this resolves you're on the correct branch, and Phase 1/Phase 2 work happens
|
Once this resolves you're on the correct branch, and Phase 1/Phase 2 work happens
|
||||||
there. `.claude/docs/` itself is gitignored in this repo, so which branch you're on
|
there. `.claude/docs/` itself is gitignored in this repo, so which branch you're on
|
||||||
doesn't affect `ticket.md`/`plan.md`/`mr.md` directly — this is about making sure any
|
doesn't affect the ticket page or `mr.md` directly — this is about making sure any
|
||||||
actual code (mockup prototypes, spike code, real implementation) lands in the right
|
actual code (mockup prototypes, spike code, real implementation) lands in the right
|
||||||
place from the start.
|
place from the start.
|
||||||
|
|
||||||
@@ -361,20 +433,23 @@ place from the start.
|
|||||||
correct key.
|
correct key.
|
||||||
2. Look for an existing directory for this ticket without hitting the network:
|
2. Look for an existing directory for this ticket without hitting the network:
|
||||||
`find .claude/docs/epics -mindepth 2 -maxdepth 2 -type d -name "<KEY>"`
|
`find .claude/docs/epics -mindepth 2 -maxdepth 2 -type d -name "<KEY>"`
|
||||||
(this matches both `epics/<EPIC>/<KEY>` and `epics/tickets/<KEY>`).
|
(this matches both `epics/<EPIC>/<KEY>` and `epics/tickets/<KEY>`). If it holds a
|
||||||
|
`plan.md` but no `ticket.data.js`, it predates the ticket page: convert it first, as
|
||||||
|
[SITE.md](SITE.md) describes under **Tickets from before the page**, then continue.
|
||||||
|
`ticket-page show <KEY>` says where the ticket stands.
|
||||||
3. Branch on what you find:
|
3. Branch on what you find:
|
||||||
- **Nothing found** — this is a fresh ticket. Go to **Phase 1**.
|
- **Nothing found** — this is a fresh ticket. Go to **Phase 1**.
|
||||||
- **Directory exists, no `plan.md`** — a previous run was interrupted before writing
|
- **A page with no plan** — a previous run was interrupted before writing a plan. Go
|
||||||
a plan. Go to **Phase 1** and regenerate from scratch (re-fetch, overwrite
|
to **Phase 1** and regenerate from scratch (re-fetch, re-import, re-check
|
||||||
`ticket.md`, re-check attachments); it's idempotent and cheap.
|
attachments); it's idempotent and cheap.
|
||||||
- **`plan.md` exists, no `mr.md`** — ask the user (AskUserQuestion) what they want:
|
- **A plan, no `mr.md`** — ask the user (AskUserQuestion) what they want:
|
||||||
- Re-fetch the ticket and rewrite the plan from scratch (they want to start over)
|
- Re-fetch the ticket and rewrite the plan from scratch (they want to start over)
|
||||||
- Proceed to implementing the existing `plan.md` as-is (they reviewed and approved it)
|
- Proceed to implementing the existing plan as-is (they reviewed and approved it)
|
||||||
- Resume implementation (some plan steps are already checked off / some commits
|
- Resume implementation (some steps are already done on the page / some commits
|
||||||
already exist on the ticket branch — pick up from the first unchecked step)
|
already exist on the ticket branch — pick up from the first open step)
|
||||||
- Run `pre-mr-review` now (implementation looks done, just need the audit + MR doc)
|
- 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)
|
||||||
Route to **Phase 1** or **Phase 2** accordingly.
|
Route to **Phase 1** or **Phase 2** accordingly.
|
||||||
- **Both `plan.md` and `mr.md` exist** — this ticket looks finished. Tell the user
|
- **A plan and `mr.md`** — this ticket looks finished. Tell the user
|
||||||
`mr.md` already exists at its path and ask whether they want you to refresh it
|
`mr.md` already exists at its path and ask whether they want you to refresh it
|
||||||
(e.g. they made more changes since) or leave it alone. Only re-enter Phase 2's
|
(e.g. they made more changes since) or leave it alone. Only re-enter Phase 2's
|
||||||
verification/pre-mr-review loop if they say the code changed since `mr.md` was
|
verification/pre-mr-review loop if they say the code changed since `mr.md` was
|
||||||
@@ -385,83 +460,39 @@ place from the start.
|
|||||||
## Phase 1 — fetch, scaffold, and plan
|
## Phase 1 — fetch, scaffold, and plan
|
||||||
|
|
||||||
1. Do **Branch setup** above first, unconditionally.
|
1. Do **Branch setup** above first, unconditionally.
|
||||||
2. Run `jira-fetch-issue.sh <KEY> <tmp-path>` (a scratch path is fine here — you don't
|
2. Run `jira-fetch-issue.sh <KEY> <tmp-path>` (a scratch path is fine here — the next
|
||||||
yet know the final directory). Read the resulting JSON.
|
step knows the final directory). Read the resulting JSON.
|
||||||
3. Determine the epic: `fields.parent.key`, if present. If absent, this ticket has no
|
3. Import it: `ticket-page init <KEY> --issue <tmp-path>`. This files the ticket under its
|
||||||
epic.
|
epic (or `tickets/` without one), imports every rich-text field verbatim as sanitized
|
||||||
4. Resolve the target directory:
|
HTML, writes the page, and prints the ticket folder. Copy the fetched JSON to
|
||||||
- With epic: `.claude/docs/epics/<EPIC-KEY>/<KEY>/`
|
`<target-dir>/resources/issue.raw.json`, which later re-imports and the attachment
|
||||||
- Without epic: `.claude/docs/epics/tickets/<KEY>/`
|
script read. Record the branch from **Branch setup** with `ticket-page meta`, then open
|
||||||
Create it and its `resources/` subdirectory.
|
the page with `ticket-page open <KEY>` so Gib can follow from here on.
|
||||||
5. Move/copy the fetched JSON to `<target-dir>/resources/issue.raw.json` for your own
|
4. `init` ends by listing populated Jira fields the page does not show. Read each one and
|
||||||
reference while writing `ticket.md` — this raw file is scratch, not part of the
|
add any that bear on implementing or verifying the ticket as a note. This sweep exists
|
||||||
deliverable; feel free to leave it (it's harmless context for later) or delete it
|
because real content sometimes hides in per-project custom fields with unpredictable
|
||||||
once `ticket.md` is written, your call.
|
names, and a name-pattern miss must not silently drop it.
|
||||||
6. If there's an epic and `.claude/docs/epics/<EPIC-KEY>/epic.md` doesn't already exist:
|
5. Read what the Ticket tab now holds, and treat its tables as the ticket's contract.
|
||||||
best-effort fetch the epic issue too (`jira-fetch-issue.sh <EPIC-KEY> <tmp>`) and
|
- **Test cases** (KACP's "Test Cases & Working Feature Proof") and **risk mitigation**
|
||||||
write a short `epic.md` (title + description, converted to markdown, a couple
|
are imported with every row verbatim. Often a field is just the bare instruction
|
||||||
paragraphs at most). If this fetch fails for any reason, skip it and continue — it's
|
panel and an empty table, which needs no further thought. When it has real rows,
|
||||||
context, not a blocker.
|
those are the scenarios the developer must prove and the mitigations the developer
|
||||||
7. Write `resources/ticket.md` by reading the fetched issue JSON yourself:
|
must implement: the plan names each one (`"fromTicket": true`), Phase 2 proves each
|
||||||
- Title, type, status, priority, assignee, epic key (or "None"), and a link
|
one, and Phase 2 step 9 fills its proof column back into the Jira field. The PM and
|
||||||
(`<JIRA_BASE_URL>/browse/<KEY>`).
|
Lead write the risks. Implementing and proving each is the developer's job.
|
||||||
- Description: convert `renderedFields.description` (HTML) to clean markdown. If
|
- Never substitute your own invented test cases or risks for the ticket's, in the plan
|
||||||
empty, say so.
|
or in the Jira fields. Your own additional cases are welcome alongside the ticket's
|
||||||
- Developer review instructions: the field name varies by project and isn't a fixed
|
rows, never in place of them.
|
||||||
custom field ID. Look at the `names` map in the response for any field whose name
|
6. If the ticket has an epic, seed or refresh its epic index as [SITE.md](SITE.md)
|
||||||
matches something like "dev review instructions" / "review instructions"
|
describes under **The epic index**. This is best-effort context: if the epic search
|
||||||
(case-insensitive substring match), then render that field's value the same way as
|
fails, say so and continue.
|
||||||
the description. If nothing matches, write "None provided" — don't guess a field.
|
7. Run `jira-download-attachments.sh <issue.raw.json> <target-dir>/resources/`. For each
|
||||||
- Test cases: same approach for any field whose name matches something like
|
|
||||||
"test cases" / "test table" / "working feature proof" / "qa" (case-insensitive
|
|
||||||
substring match), e.g. KACP's "Test Cases & Working Feature Proof" field. This
|
|
||||||
is part of the ticket's testing section and MUST be captured into `ticket.md`
|
|
||||||
whenever it holds anything beyond an empty template. Often it's just the bare
|
|
||||||
instruction panel and empty table, note that and move on. But when it has real
|
|
||||||
content, that content is part of the implementation and has to be known from
|
|
||||||
the start: render every row verbatim as a markdown table under its own
|
|
||||||
`## Test cases and working feature proof` heading, keeping the ticket's own
|
|
||||||
column headers, and carry any prose or instructions in the field alongside the
|
|
||||||
table. These are the scenarios the developer must prove, so Phase 1's plan must
|
|
||||||
already say how each row gets proven, Phase 2's verification must actually
|
|
||||||
prove each row, and Phase 2 step 9 fills the proof column back into the Jira
|
|
||||||
field itself. If nothing matches, write "None provided".
|
|
||||||
- Risk mitigation: same approach for any field whose name matches something like
|
|
||||||
"risk mitigation" / "risk management" / "risks" (case-insensitive substring
|
|
||||||
match), e.g. KACP's "Risk Mitigation" field. Same rule as test cases: an empty
|
|
||||||
template gets noted, real content gets captured in full because it shapes the
|
|
||||||
implementation from the start. Render it as a markdown table under its own
|
|
||||||
`## Risk mitigation` heading, keeping the ticket's own column headers and
|
|
||||||
every row verbatim. The PM and Lead Developer write the risks and mitigation
|
|
||||||
strategies; implementing those strategies and proving each one is the developer's
|
|
||||||
job, so every row needs a real mitigation in the code and its proof filled into
|
|
||||||
the Jira field in Phase 2 step 9. If nothing matches, write "None provided".
|
|
||||||
- Developer fill-in tables, in general: any ticket table with a column the developer
|
|
||||||
is meant to complete (Working Feature Proof, Mitigation Proof, and the like) is
|
|
||||||
copied into `ticket.md` in full, with that column left showing where its answer
|
|
||||||
goes rather than dropped. `ticket.md` is the record of what the ticket actually
|
|
||||||
asks for. Never substitute your own invented test cases or risks for the ticket's,
|
|
||||||
in `ticket.md` or in the Jira fields. Your own additional cases are welcome, but
|
|
||||||
they go alongside the ticket's rows, clearly marked as additional, never in place
|
|
||||||
of them.
|
|
||||||
- Sweep for anything else populated: list every key in the `names` map whose field
|
|
||||||
actually has a non-null, non-empty value on this issue (jq over `.fields` joined
|
|
||||||
with `.names`), and skim any populated field not already captured above. Capture
|
|
||||||
the ones relevant to implementing or verifying the ticket; ignore workflow
|
|
||||||
plumbing (ranks, sprints boards, reviewer assignments, rich-field duplicates).
|
|
||||||
This exists because real content sometimes hides in per-project custom fields
|
|
||||||
with unpredictable names — a name-pattern miss must not silently drop content.
|
|
||||||
- Any other fields on the ticket that look clearly relevant to implementing it
|
|
||||||
(acceptance criteria field, story points, labels, components) are worth a short
|
|
||||||
line each; don't dump every custom field verbatim.
|
|
||||||
8. Run `jira-download-attachments.sh <issue.raw.json> <target-dir>/resources/`. For each
|
|
||||||
attachment with a `video/*` mime type, check whether another attachment already looks
|
attachment with a `video/*` mime type, check whether another attachment already looks
|
||||||
like its transcript (same base filename with `.txt`/`.srt`/`.vtt`, or a filename
|
like its transcript (same base filename with `.txt`/`.srt`/`.vtt`, or a filename
|
||||||
containing "transcript"). Collect any videos with no matching transcript.
|
containing "transcript"). Collect any videos with no matching transcript.
|
||||||
9. If there are untranscribed videos, ask the user (AskUserQuestion, one question,
|
8. If there are untranscribed videos, ask the user (AskUserQuestion, one question,
|
||||||
multiSelect if more than one video) whether to transcribe them now. For each they
|
multiSelect if more than one video) whether to transcribe them now. For each they
|
||||||
approve, run `transcribe.sh <video-path> <target-dir>/resources/`. Note in `ticket.md`
|
approve, run `transcribe.sh <video-path> <target-dir>/resources/`.
|
||||||
under Attachments which videos have a transcript and which were skipped.
|
|
||||||
For every video that gets transcribed, also extract frames — ticket videos are
|
For every video that gets transcribed, also extract frames — ticket videos are
|
||||||
almost always screen recordings, and the transcript alone misses what was on
|
almost always screen recordings, and the transcript alone misses what was on
|
||||||
screen (the UI being pointed at, the annotation, the row that's wrong). Extract to
|
screen (the UI being pointed at, the annotation, the row that's wrong). Extract to
|
||||||
@@ -474,6 +505,10 @@ place from the start.
|
|||||||
and treat what's visible on screen as part of the ticket's content the same way
|
and treat what's visible on screen as part of the ticket's content the same way
|
||||||
the transcript is. Skip frame extraction only when the video is confirmed
|
the transcript is. Skip frame extraction only when the video is confirmed
|
||||||
audio-only or the user says the visuals don't matter.
|
audio-only or the user says the visuals don't matter.
|
||||||
|
9. Run `ticket-page scan <KEY>` so the page carries the attachments, each video with its
|
||||||
|
transcript and frames. When a video, spreadsheet, or linked document holds the
|
||||||
|
ticket's real requirements, write what it says as a note (`ticket-page note`), the
|
||||||
|
way "What the video says" summarizes a recording point by point.
|
||||||
10. Now actually understand the ticket in the context of this codebase: read whatever
|
10. Now actually understand the ticket in the context of this codebase: read whatever
|
||||||
source files, tests, or docs are relevant to what's being asked. Use Explore/grep
|
source files, tests, or docs are relevant to what's being asked. Use Explore/grep
|
||||||
as needed — this is normal engineering research, not scripted. Use the
|
as needed — this is normal engineering research, not scripted. Use the
|
||||||
@@ -488,54 +523,33 @@ place from the start.
|
|||||||
**architectural** is a new subsystem, a schema change other stories depend on, or
|
**architectural** is a new subsystem, a schema change other stories depend on, or
|
||||||
anything that alters an interface neighbouring work relies on. Bounded is the common
|
anything that alters an interface neighbouring work relies on. Bounded is the common
|
||||||
case and goes straight to step 11. For architectural, call the Skill tool with
|
case and goes straight to step 11. For architectural, call the Skill tool with
|
||||||
"grilling" and resolve its frontier with Gib BEFORE writing `plan.md` — those
|
"grilling" and resolve its frontier with Gib BEFORE writing the plan — those
|
||||||
questions get answered in conversation, not deferred into the plan's Open questions
|
questions get answered in conversation, not deferred into the plan's Open questions
|
||||||
section, because an architectural plan built on a wrong assumption is the expensive
|
section, because an architectural plan built on a wrong assumption is the expensive
|
||||||
one to discover in Phase 2. Bounded measures the repo, not your familiarity: if the
|
one to discover in Phase 2. Bounded measures the repo, not your familiarity: if the
|
||||||
flow being changed isn't already there to read, it isn't bounded. In doubt, take the
|
flow being changed isn't already there to read, it isn't bounded. In doubt, take the
|
||||||
heavier path.
|
heavier path. Either way, record the classification with `ticket-page meta`.
|
||||||
11. Write `plan.md` in `<target-dir>/plan.md` (a sibling of `resources/`, not inside
|
11. Write the plan as `plan.json` in a scratch path, in the shape [SITE.md](SITE.md) gives,
|
||||||
it). Structure:
|
and load it with `ticket-page plan <KEY> --file plan.json`. Its parts: acceptance
|
||||||
```
|
criteria restated precisely from the ticket, each naming the steps that satisfy it;
|
||||||
# Plan: <KEY> — <short title>
|
open questions that genuinely need Gib's answer, empty if there really aren't any
|
||||||
|
(don't manufacture questions to look thorough); the approach, meaning what you're
|
||||||
|
going to do and why, which files, modules and functions are involved, and how it fits
|
||||||
|
the existing patterns; decisions already settled with Gib; steps, each roughly one
|
||||||
|
commit's worth of work, with `"kind": "deliverable"` for a step that produces a
|
||||||
|
document via **Deliverable documents** and `"kind": "mock"` for a real schema or UI
|
||||||
|
change built to demonstrate a mock (see **Mocks and datamodel changes**); risks and
|
||||||
|
edge cases, each with how the plan handles it; and the test plan, meaning how each
|
||||||
|
criterion gets verified, the existing suites to run, and any new tests.
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
- [ ] <criterion from the ticket, restated precisely>
|
|
||||||
...
|
|
||||||
|
|
||||||
## Open questions
|
|
||||||
<Anything genuinely blocking or ambiguous that needs the user's answer before or
|
|
||||||
during implementation. "None" if there really aren't any — don't manufacture
|
|
||||||
questions to look thorough.>
|
|
||||||
|
|
||||||
## Approach
|
|
||||||
<Narrative: what you're going to do and why, which files/modules/functions are
|
|
||||||
involved, how it fits the existing patterns in this codebase.>
|
|
||||||
|
|
||||||
## Steps
|
|
||||||
- [ ] <step 1, roughly one commit's worth of work — mark it `(deliverable)` if it
|
|
||||||
produces a document via the Deliverable documents process instead of code, or
|
|
||||||
`(mock)` if it's a real schema/UI change built to demonstrate a mock rather than
|
|
||||||
ship a finished feature, see Mocks and datamodel changes>
|
|
||||||
- [ ] <step 2>
|
|
||||||
...
|
|
||||||
|
|
||||||
## Risks and edge cases considered
|
|
||||||
- <edge case> — <how the plan handles it>
|
|
||||||
...
|
|
||||||
|
|
||||||
## Test plan
|
|
||||||
- <how you'll verify each acceptance criterion, including existing test suites to
|
|
||||||
run and any new tests to add>
|
|
||||||
```
|
|
||||||
Be thorough: address every acceptance criterion, resolve as many open questions as
|
Be thorough: address every acceptance criterion, resolve as many open questions as
|
||||||
you reasonably can by reading the code first, and don't leave logic gaps. Steps
|
you reasonably can by reading the code first, and don't leave logic gaps. Steps
|
||||||
should be concrete enough that Phase 2 can execute them without re-deriving the
|
should be concrete enough that Phase 2 can execute them without re-deriving the
|
||||||
approach. The Test plan must name every test case `ticket.md` captured and say how
|
approach. The test plan must name every test case on the Ticket tab and say how
|
||||||
that exact case gets proven, and the Risks section must name every risk
|
that exact case gets proven, and the risks must name every risk on the Ticket tab
|
||||||
`ticket.md` captured and say which code enforces its mitigation. Read the notes
|
and say which code enforces its mitigation. Read the notes column of a ticket test
|
||||||
column of a ticket test case as part of the case, not as commentary: a note like
|
case as part of the case, not as commentary: a note like "confirm the certificate
|
||||||
"confirm the certificate matches the emailed version" is its own thing to prove.
|
matches the emailed version" is its own thing to prove.
|
||||||
|
|
||||||
**Where the shape is in question, borrow the vocabulary.** When the plan has to
|
**Where the shape is in question, borrow the vocabulary.** When the plan has to
|
||||||
decide how deep a module should be, where a seam belongs, or what an interface
|
decide how deep a module should be, where a seam belongs, or what an interface
|
||||||
@@ -545,7 +559,7 @@ place from the start.
|
|||||||
written for real.
|
written for real.
|
||||||
|
|
||||||
**The Test plan is a design decision, not a formality.** Beyond the cases
|
**The Test plan is a design decision, not a formality.** Beyond the cases
|
||||||
`ticket.md` already captured, call the Skill tool with "tdd" to decide which
|
the ticket already carries, call the Skill tool with "tdd" to decide which
|
||||||
additional tests are worth writing, where the seam under test goes, and whether a
|
additional tests are worth writing, where the seam under test goes, and whether a
|
||||||
mock is warranted. That judgment is what keeps this section focused instead of
|
mock is warranted. That judgment is what keeps this section focused instead of
|
||||||
padded: a test plan listing one case per changed file is slop, and three tests that
|
padded: a test plan listing one case per changed file is slop, and three tests that
|
||||||
@@ -562,10 +576,14 @@ place from the start.
|
|||||||
step stays green because the old form still exists until the last one. Say in the
|
step stays green because the old form still exists until the last one. Say in the
|
||||||
Approach that this is what you're doing and why, since the step count looks inflated
|
Approach that this is what you're doing and why, since the step count looks inflated
|
||||||
otherwise.
|
otherwise.
|
||||||
12. Stop here. Tell the user `plan.md` is ready at its path, summarize the approach in
|
12. When a step changes what a user sees, draw its mock artboards with `ticket-page mock`,
|
||||||
a couple of sentences, and mention any open questions that need their input before
|
as [SITE.md](SITE.md) describes under **Mocks**.
|
||||||
you'd implement it. Do not start implementing in this same run — wait for them to
|
13. Stop here. Tell the user the plan is on the ticket page, give its path from
|
||||||
review the plan (editing it directly if they want) and invoke `/ticket <KEY>` again.
|
`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
|
||||||
|
`/ticket <KEY>` again. They change the plan by asking, and you rewrite it with
|
||||||
|
`ticket-page plan`.
|
||||||
|
|
||||||
## Phase 2 — implement, verify, and hand off
|
## Phase 2 — implement, verify, and hand off
|
||||||
|
|
||||||
@@ -574,14 +592,16 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
|
|||||||
1. Do **Branch setup** above first, unconditionally — even if Phase 1 already did this
|
1. Do **Branch setup** above first, unconditionally — even if Phase 1 already did this
|
||||||
for the same ticket earlier, confirm you're still actually on that branch now (the
|
for the same ticket earlier, confirm you're still actually on that branch now (the
|
||||||
user may have switched branches between runs).
|
user may have switched branches between runs).
|
||||||
2. Read `plan.md` fresh — the user may have hand-edited it.
|
2. Read the plan fresh with `ticket-page md <KEY>`, then mark it approved with
|
||||||
3. Work through `plan.md`'s Steps checklist in order. For each step: implement it (a
|
`ticket-page approve <KEY>`.
|
||||||
step marked `(deliverable)` follows the **Deliverable documents** process above
|
3. Work through the plan's steps in order. For each step: implement it (a
|
||||||
instead of writing code; a step marked `(mock)` follows **Mocks and datamodel
|
`deliverable` step follows the **Deliverable documents** process above instead of
|
||||||
changes** — real schema/UI work, not a static wireframe), then check it off
|
writing code; a `mock` step follows **Mocks and datamodel changes** — real schema/UI work, not a static wireframe), and make one commit for it (or
|
||||||
(`- [x]`) in `plan.md`, then make one commit for it (or a few, if the step
|
a few, if the step naturally splits into independent units). Commit messages are short
|
||||||
naturally splits into independent units). Commit messages are short and imperative,
|
and imperative, describing what changed, following House style above. Mark the step
|
||||||
describing what changed, following House style above.
|
running on the page when you start it and done with its commits when they exist, and
|
||||||
|
mark each acceptance criterion met the moment it is (SITE.md has the commands). A step
|
||||||
|
is done when `ticket-page show <KEY>` lists its commit.
|
||||||
|
|
||||||
**Write the test first for steps that carry real logic**, meaning a router
|
**Write the test first for steps that carry real logic**, meaning a router
|
||||||
procedure, a derivation, a permission or authorization check, a migration, or a
|
procedure, a derivation, a permission or authorization check, a migration, or a
|
||||||
@@ -593,11 +613,13 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
|
|||||||
House style rejects. When unsure whether a step qualifies, ask whether the test could
|
House style rejects. When unsure whether a step qualifies, ask whether the test could
|
||||||
ever fail for a reason worth knowing about. If it couldn't, skip it.
|
ever fail for a reason worth knowing about. If it couldn't, skip it.
|
||||||
|
|
||||||
If a step fails in a way `plan.md` didn't predict, and the cause isn't obvious within
|
If a step fails in a way the plan didn't predict, and the cause isn't obvious within
|
||||||
a couple of minutes, call the Skill tool with "diagnosing-bugs" rather than trying
|
a couple of minutes, call the Skill tool with "diagnosing-bugs" rather than trying
|
||||||
fixes to see what sticks. Note anything it turns up that changes the plan.
|
fixes to see what sticks. Note anything it turns up that changes the plan as an event on
|
||||||
|
the page. When it changes the steps, amend the plan as [SITE.md](SITE.md) describes, which
|
||||||
|
keeps the progress already made.
|
||||||
4. Do not run the full verification suite after every commit (checks-at-the-end mode).
|
4. Do not run the full verification suite after every commit (checks-at-the-end mode).
|
||||||
Once every step is implemented and checked off, find and run this project's standard
|
Once every step is implemented and marked done, find and run this project's standard
|
||||||
verification (check `package.json` scripts for something like `ci:check`,
|
verification (check `package.json` scripts for something like `ci:check`,
|
||||||
`typecheck`, `lint`, `test`, or fall back to whatever the repo's README documents).
|
`typecheck`, `lint`, `test`, or fall back to whatever the repo's README documents).
|
||||||
Fix anything broken, committing fixes as their own commits, until it's clean. If you
|
Fix anything broken, committing fixes as their own commits, until it's clean. If you
|
||||||
@@ -620,8 +642,8 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
|
|||||||
Order matters and is not just convenience. `pre-mr-review`'s output is a readiness
|
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
|
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
|
invalidates that verdict by construction, and you end up rerunning it and rewriting
|
||||||
the handoff. Review first, fix, commit, and only then run `pre-mr-review` over the
|
the handoff. Review first, fix, commit, and only then have the user
|
||||||
final tree.
|
run `pre-mr-review` over the final tree.
|
||||||
|
|
||||||
- **Skip this step for genuinely trivial changes**: a copy tweak, a styling fix, a
|
- **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
|
one line correction with no logic in it. Run it whenever the change adds or
|
||||||
@@ -633,8 +655,9 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
|
|||||||
change cannot provide it.
|
change cannot provide it.
|
||||||
- **Give the reviewer its reading order explicitly**, or it produces noise about
|
- **Give the reviewer its reading order explicitly**, or it produces noise about
|
||||||
conventions the project already settled: the epic summary if there is one
|
conventions the project already settled: the epic summary if there is one
|
||||||
(`.claude/docs/epics/<EPIC-KEY>/summary.md`), then this ticket's `resources/` and
|
(`.claude/docs/epics/<EPIC-KEY>/summary.md`), then the ticket and plan as
|
||||||
`plan.md`, then `AGENTS.md`, and only then the diff. Give it the exact commit range.
|
`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
|
- **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
|
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,
|
`file:line` plus a concrete failure scenario. Tell it explicitly not to write,
|
||||||
@@ -645,12 +668,13 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
|
|||||||
from checking.
|
from checking.
|
||||||
- **Ask the spec questions explicitly**, or the review only finds bugs in what you
|
- **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
|
did write and never notices what you didn't. Three questions, each answered against
|
||||||
`ticket.md` and `plan.md` rather than the diff alone: what the ticket asked for that
|
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;
|
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
|
and what looks implemented but is implemented wrongly. Have it quote the AC ID or
|
||||||
the `plan.md` line each finding traces to.
|
the plan step each finding traces to.
|
||||||
|
|
||||||
Then respond to the review, in writing:
|
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
|
- **Findings are not automatically true.** Verify each one against the codebase
|
||||||
before acting on it. Reviewers do produce confident wrong answers. Say plainly
|
before acting on it. Reviewers do produce confident wrong answers. Say plainly
|
||||||
@@ -671,11 +695,54 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
|
|||||||
in the current repo). If it doesn't, stop here, tell the user implementation and
|
in the current repo). If it doesn't, stop here, tell the user implementation and
|
||||||
local verification are done but this repo has no `pre-mr-review` skill to run, and
|
local verification are done but this repo has no `pre-mr-review` skill to run, and
|
||||||
let them decide how to proceed.
|
let them decide how to proceed.
|
||||||
7. Invoke the `pre-mr-review` skill. Read its verdict.
|
7. **Do NOT invoke the `pre-mr-review` skill yourself.** The org asks that this
|
||||||
- **Ready to Open MR**: continue to step 8.
|
review run from the developer's separate work Claude account, so the audit on
|
||||||
- **Almost Ready / Not Ready Yet**: fix what it flagged (each meaningful fix as its
|
record must come from there, not from this session. Instead:
|
||||||
own commit), then invoke `pre-mr-review` again. Repeat until the verdict is Ready
|
1. Tell the user the branch is ready for its pre-mr-review and ask them to run
|
||||||
to Open MR. Don't write `mr.md` before that verdict is reached.
|
`/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`.
|
||||||
|
- **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.
|
||||||
|
Findings that need no code change (mentions, disclosures) get addressed in
|
||||||
|
`mr.md`'s Additional Notes or the Jira fields as usual.
|
||||||
|
|
||||||
|
**The verdict is the gate and it is not negotiable.** Ready to Open MR is what
|
||||||
|
goes on record with the Lead, so never reason your way past it because the
|
||||||
|
remaining items look harmless. That judgment is not yours to make.
|
||||||
|
|
||||||
|
**Don't confuse the verdict with the handoff's recommendation.** They are
|
||||||
|
different fields in different files for different readers. The audit's
|
||||||
|
`Verdict` says whether the branch is ready to become an MR, and that is the
|
||||||
|
gate. The handoff's `Recommendation` (`Merge`, `Merge after conditions`) is
|
||||||
|
advice to the maintainer about merging it, and `Merge after conditions` is
|
||||||
|
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.
|
||||||
8. Read `~/.agents/skills/ticket/templates/mr.md` — this is the org's MR template,
|
8. Read `~/.agents/skills/ticket/templates/mr.md` — this is the org's MR template,
|
||||||
copied into this skill so it still works even though the original
|
copied into this skill so it still works even though the original
|
||||||
`.claude/docs/mr/template.md` no longer exists in the command-center repo. `mr.md`
|
`.claude/docs/mr/template.md` no longer exists in the command-center repo. `mr.md`
|
||||||
@@ -690,7 +757,7 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
|
|||||||
- New ENVs: list any env vars actually introduced; "None" if none.
|
- New ENVs: list any env vars actually introduced; "None" if none.
|
||||||
- Additional Notes: a bulleted checklist of the changes you actually made, not a
|
- Additional Notes: a bulleted checklist of the changes you actually made, not a
|
||||||
dumping ground. Between Summary and this section, every acceptance criterion from
|
dumping ground. Between Summary and this section, every acceptance criterion from
|
||||||
`plan.md` (or, for a bug ticket, the bug itself) needs to be directly addressed,
|
the plan (or, for a bug ticket, the bug itself) needs to be directly addressed,
|
||||||
not just gestured at. If there's genuinely unrelated work worth mentioning (e.g.
|
not just gestured at. If there's genuinely unrelated work worth mentioning (e.g.
|
||||||
an incidental fix along the way), it goes after everything ticket-relevant, still
|
an incidental fix along the way), it goes after everything ticket-relevant, still
|
||||||
as short bullets. Five bullet points max total, ticket-relevant and incidental
|
as short bullets. Five bullet points max total, ticket-relevant and incidental
|
||||||
@@ -700,16 +767,18 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
|
|||||||
verified" if you actually ran a build and it passed).
|
verified" if you actually ran a build and it passed).
|
||||||
- Replace `<!-- Paste the Pre-MR handoff here -->` with the exact content of the
|
- Replace `<!-- Paste the Pre-MR handoff here -->` with the exact content of the
|
||||||
latest `pre-mr-review` handoff file (the one with verdict Ready to Open MR) —
|
latest `pre-mr-review` handoff file (the one with verdict Ready to Open MR) —
|
||||||
paste it verbatim, don't summarize it.
|
paste it verbatim, don't summarize it. Its `Recommendation` line is the
|
||||||
|
audit's advice to the maintainer and is left exactly as written, including
|
||||||
|
`Merge after conditions`.
|
||||||
|
|
||||||
The handoff section of `mr.md` is GENERATED OUTPUT, not something you author.
|
The handoff section of `mr.md` is GENERATED OUTPUT, not something you author.
|
||||||
It is the block that states whether the branch is ready to merge, so it must be
|
It is the block that states whether the branch is ready to merge, so it must be
|
||||||
the audit's own words, not a retelling of them. Concretely:
|
the audit's own words, not a retelling of them. Concretely:
|
||||||
|
|
||||||
- Never hand-write a handoff section, and never edit one in place. If it is
|
- Never hand-write a handoff section, and never edit one in place. If it is
|
||||||
wrong, thin, or stale, fix the handoff file by rerunning `/pre-mr-review`,
|
wrong, thin, or stale, ask the user to rerun `/pre-mr-review` from their work
|
||||||
then re-paste.
|
account to regenerate the handoff file, then re-paste.
|
||||||
- On EVERY rerun, replace the whole existing handoff block with the whole
|
- On EVERY rerun of theirs, replace the whole existing handoff block with the whole
|
||||||
regenerated one. Do not patch the copy sitting in `mr.md` to match the new
|
regenerated one. Do not patch the copy sitting in `mr.md` to match the new
|
||||||
head — that is how the two silently diverge, and the version the reviewer
|
head — that is how the two silently diverge, and the version the reviewer
|
||||||
reads stops being the version the audit actually produced.
|
reads stops being the version the audit actually produced.
|
||||||
@@ -728,7 +797,8 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
|
|||||||
(`customfield_10260`, "For The Engineer: Patch Notes"). Step 9 writes it.
|
(`customfield_10260`, "For The Engineer: Patch Notes"). Step 9 writes it.
|
||||||
Scattered coverage across notes does not count as having answered a named
|
Scattered coverage across notes does not count as having answered a named
|
||||||
requirement, and neither does burying it in the MR description.
|
requirement, and neither does burying it in the MR description.
|
||||||
Save this as `<target-dir>/mr.md` (next to `plan.md`, not under `resources/`).
|
Save this as `<target-dir>/mr.md` (next to `index.html`, not under `resources/`), and
|
||||||
|
record it with `ticket-page mr <KEY>`.
|
||||||
|
|
||||||
**`mr.md` is the template plus the pasted handoff, and NOTHING ELSE.** This is a
|
**`mr.md` is the template plus the pasted handoff, and NOTHING ELSE.** This is a
|
||||||
hard shape, not a starting point. Its only headings are the template's own numbered
|
hard shape, not a starting point. Its only headings are the template's own numbered
|
||||||
@@ -742,32 +812,29 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
|
|||||||
content. A reviewer should be able to read the whole file in about a minute; if it
|
content. A reviewer should be able to read the whole file in about a minute; if it
|
||||||
has grown past roughly 120 lines including the handoff, it has drifted.
|
has grown past roughly 120 lines including the handoff, it has drifted.
|
||||||
|
|
||||||
Before saving, call the Skill tool with "unslop" and apply its pattern detection to
|
Before saving, run the prose bar (see **The prose bar** above) over the Summary and
|
||||||
the Summary and Additional Notes ONLY: puffery, superficial -ing phrases, "not just
|
Additional Notes ONLY. The pasted handoff is generated output and is never edited.
|
||||||
X but Y", vague attributions, rule-of-three padding. The pasted handoff is generated
|
Henry reads every one of these, and prose that reads as machine-generated costs the
|
||||||
output and is never edited, per the rule above. Skip unslop's "Adding soul" section
|
MR its credibility before anyone looks at the diff.
|
||||||
too — an MR description wants plain and factual, not voice. Henry reads every one of
|
|
||||||
these, and prose that reads as machine-generated costs the MR its credibility before
|
|
||||||
anyone looks at the diff.
|
|
||||||
9. Fill the Jira ticket fields directly, by issue type. Rich text fields are ADF:
|
9. Fill the Jira ticket fields directly, by issue type. Rich text fields are ADF:
|
||||||
render markdown with `python3 ~/.agents/skills/review-ticket/scripts/review2adf.py
|
render markdown with `python3 ~/.agents/skills/review-ticket/scripts/review2adf.py
|
||||||
render <file.md>` and PUT via `{"fields": {...}}` to `/rest/api/3/issue/<KEY>`.
|
render <file.md>` and PUT via `{"fields": {...}}` to `/rest/api/3/issue/<KEY>`.
|
||||||
After every PUT, re-fetch and verify the field landed (compare extracted text,
|
After every PUT, re-fetch and verify the field landed, then record it with
|
||||||
|
`ticket-page jira <KEY> "<field name>"` (compare extracted text,
|
||||||
treating empty `attrs` objects and `localId`/`colwidth`/`width` as noise). The
|
treating empty `attrs` objects and `localId`/`colwidth`/`width` as noise). The
|
||||||
field-to-type map below is verified against the project's edit screens, don't PUT
|
field-to-type map below is verified against the project's edit screens, don't PUT
|
||||||
a field to a type that doesn't carry it.
|
a field to a type that doesn't carry it.
|
||||||
|
|
||||||
**Same prose bar as `mr.md`.** Every field authored here is read by the Lead and the
|
**Run the prose bar** (see **The prose bar** above) over every field before rendering
|
||||||
PM. Run the `unslop` skill's pattern detection over the markdown before rendering it
|
it to ADF, proof and mitigation cells included. Everything here is read by the Lead
|
||||||
to ADF, skipping its "Adding soul" section — Jira fields want plain, factual, and
|
and the PM.
|
||||||
specific. This does not apply to proof cells, which are references to artifacts and
|
|
||||||
test names rather than prose.
|
|
||||||
|
|
||||||
**Proof first.** Before filling any proof column, capture working feature proof
|
**Proof first.** Before filling any proof column, capture working feature proof
|
||||||
yourself wherever possible: run the app (`run` skill) and screenshot the real
|
yourself wherever possible: run the app (`run` skill) and screenshot the real
|
||||||
feature with browser automation, or capture test output for behavior with no UI.
|
feature with browser automation, or capture test output for behavior with no UI.
|
||||||
Save artifacts under `<target-dir>/proof/` with names that match the test-case
|
Save artifacts under `<target-dir>/proof/` with names that match the test-case
|
||||||
rows they prove. A proof cell references its artifact by filename plus "attached"
|
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"
|
||||||
(Gib uploads every attachment, this skill NEVER uploads files to Jira) or names
|
(Gib uploads every attachment, this skill NEVER uploads files to Jira) or names
|
||||||
the passing test. When proof genuinely can't be captured here (needs prod, a real
|
the passing test. When proof genuinely can't be captured here (needs prod, a real
|
||||||
inbox, a flag flip), the cell says exactly what's needed and step 10 lists it for
|
inbox, a flag flip), the cell says exactly what's needed and step 10 lists it for
|
||||||
@@ -777,7 +844,8 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
|
|||||||
**Cap the proof at five files, and aim for two or three.** Gib uploads every
|
**Cap the proof at five files, and aim for two or three.** Gib uploads every
|
||||||
attachment by hand, so each file has a real cost and a wall of near-duplicate
|
attachment by hand, so each file has a real cost and a wall of near-duplicate
|
||||||
screenshots buries the two that matter. Before filling the proof column, pick the
|
screenshots buries the two that matter. Before filling the proof column, pick the
|
||||||
smallest set that actually carries the evidence and delete the rest from `proof/`.
|
smallest set that actually carries the evidence, delete the rest from `proof/`, and
|
||||||
|
drop them from the page with `ticket-page unproof`.
|
||||||
|
|
||||||
- One screenshot can prove several rows at once. A company page that shows the
|
- One screenshot can prove several rows at once. A company page that shows the
|
||||||
navigation, the tabs, and the absent section proves three criteria in one image,
|
navigation, the tabs, and the absent section proves three criteria in one image,
|
||||||
@@ -821,25 +889,25 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
|
|||||||
handled and what covers it. Deliver the number asked for, not more, and pick the
|
handled and what covers it. Deliver the number asked for, not more, and pick the
|
||||||
ones a reviewer most benefits from. This field, not `mr.md`.
|
ones a reviewer most benefits from. This field, not `mr.md`.
|
||||||
- `customfield_10261` User Story Patch Notes: the same change in user-facing words.
|
- `customfield_10261` User Story Patch Notes: the same change in user-facing words.
|
||||||
- `customfield_10142` Working Feature Proof: ALWAYS fill this on a Story, never
|
- `customfield_10142` Working Feature Proof: leave it alone. Write nothing here.
|
||||||
leave it empty. It renders as its own panel in the ticket's testing section, so
|
This field is where Gib drops the proof attachments, and dragging files into it
|
||||||
an empty field reads as unfilled testing even when every test-table cell is
|
replaces whatever text is sitting there, so a written list is destroyed by the
|
||||||
complete (this happened on KACP-23143). At minimum it lists the files staged in
|
upload it was describing. Naming each artifact is already the job of the Test
|
||||||
`proof/` for Gib to attach, one line each saying what the artifact shows, plus a
|
Cases proof cells, which reference it by filename. Step 10 tells Gib which files
|
||||||
pointer that per-row proof lives in the Test Cases table. A headline artifact (a
|
to upload. Do not PUT this field on a Story.
|
||||||
demo capture, a before/after pair) leads the list when one exists.
|
|
||||||
|
|
||||||
**Spike** (deliverables, not code): no test or proof fields to fill. The output is
|
**Spike** (deliverables, not code): no test or proof fields to fill. The output is
|
||||||
the deliverables directory, and step 10 tells Gib which files to upload where (the
|
the deliverables directory, and step 10 tells Gib which files to upload where (the
|
||||||
ticket, Drive, or both, per the ticket's instructions). Fill `customfield_10260`
|
ticket, Drive, or both, per the ticket's instructions). Fill `customfield_10260`
|
||||||
only if the spike actually shipped code.
|
only if the spike actually shipped code.
|
||||||
|
|
||||||
Before moving on, re-read `ticket.md` row by row: every test case and every risk
|
Before moving on, re-read the Ticket tab's test case and risk tables row by row: every
|
||||||
the ticket carries now has its developer column filled in Jira. Leave none
|
test case and every risk the ticket carries now has its developer column filled in
|
||||||
|
Jira. Leave none
|
||||||
unanswered.
|
unanswered.
|
||||||
10. Tell the user: implementation is committed on `<branch>`, `pre-mr-review` verdict is
|
10. Tell the user: implementation is committed on `<branch>`, `pre-mr-review` verdict is
|
||||||
Ready to Open MR, the Jira fields are filled (name which), and `mr.md` is ready at
|
Ready to Open MR, the Jira fields are filled (name which), `mr.md` is ready at its
|
||||||
its path. Then the manual list, which should only ever be:
|
path, and the ticket page shows the finished work beside its mocks. Then the manual list, which should only ever be:
|
||||||
- push the branch, open the MR, and paste `mr.md` in as the description
|
- push the branch, open the MR, and paste `mr.md` in as the description
|
||||||
- upload the named proof or deliverable files (from `proof/` or `deliverables/`)
|
- upload the named proof or deliverable files (from `proof/` or `deliverables/`)
|
||||||
to the ticket, plus anything flagged as proof only Gib can capture
|
to the ticket, plus anything flagged as proof only Gib can capture
|
||||||
@@ -847,8 +915,8 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
|
|||||||
|
|
||||||
## Resuming mid-implementation
|
## Resuming mid-implementation
|
||||||
|
|
||||||
If Phase 0 routes here because some steps in `plan.md` are already checked off: check
|
If Phase 0 routes here because some steps are already done on the page: check
|
||||||
`git log` on the current branch against the plan's steps to sanity-check they actually
|
`git log` on the current branch against the page's step commits to sanity-check they actually
|
||||||
match reality (a checked-off step should have a corresponding commit), then continue
|
match reality (a checked-off step should have a corresponding commit), then continue
|
||||||
from the first unchecked step. If the plan and the git history disagree, stop and ask
|
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.
|
the user rather than guessing which one is right.
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
// Turns a fetched Jira issue (jira-fetch-issue.sh output) and the ticket's resources/
|
||||||
|
// directory into the source half of a Ticket: its metadata, every rich-text field as
|
||||||
|
// sanitized HTML, and its attachments with transcripts and frames.
|
||||||
|
|
||||||
|
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||||
|
import { extname, join } from "node:path";
|
||||||
|
import type { Attachment, Section, Segment, Ticket } from "./ticket-data";
|
||||||
|
import { now } from "./ticket-data";
|
||||||
|
|
||||||
|
type Named = { name?: string; value?: string; displayName?: string };
|
||||||
|
interface JiraLink { type: { inward: string; outward: string }; inwardIssue?: LinkedIssue; outwardIssue?: LinkedIssue }
|
||||||
|
interface LinkedIssue { key: string; fields?: { summary?: string } }
|
||||||
|
|
||||||
|
/** The parts of jira-fetch-issue.sh's response this importer reads. Every other field
|
||||||
|
* stays `unknown` and only ever passes through `plain()`. */
|
||||||
|
export interface JiraIssue {
|
||||||
|
key: string;
|
||||||
|
fields: {
|
||||||
|
summary: string;
|
||||||
|
issuetype?: Named; status?: Named; priority?: Named; assignee?: Named; reporter?: Named;
|
||||||
|
timeoriginalestimate?: number | null;
|
||||||
|
parent?: LinkedIssue;
|
||||||
|
issuelinks?: JiraLink[];
|
||||||
|
attachment?: { id: string | number; filename: string }[];
|
||||||
|
[field: string]: unknown;
|
||||||
|
};
|
||||||
|
renderedFields: Record<string, unknown>;
|
||||||
|
names: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rich-text fields in the order a reader wants them. Anything populated that matches
|
||||||
|
* none of these still gets imported, after them, so a field with an unexpected name
|
||||||
|
* is never dropped. */
|
||||||
|
const FIELD_ORDER = [/^description$/i, /acceptance criteria/i, /review instructions/i, /test cases|working feature proof/i, /risk mitigation$|^risk mitigation/i];
|
||||||
|
|
||||||
|
/** Strips anything executable and rewrites attachment links to the downloaded copies,
|
||||||
|
* so the page renders Jira's HTML offline without running any of it. */
|
||||||
|
export function sanitize(html: string, attachments: Map<string, string>): string {
|
||||||
|
return html
|
||||||
|
.replace(/<(script|style|iframe|object|embed)\b[\s\S]*?<\/\1\s*>/gi, "")
|
||||||
|
.replace(/<(script|style|iframe|object|embed|link|meta)\b[^>]*\/?>/gi, "")
|
||||||
|
.replace(/\son[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, "")
|
||||||
|
.replace(/(href|src)\s*=\s*(["'])\s*javascript:[^"']*\2/gi, '$1="#"')
|
||||||
|
.replace(/<img\b[^>]*class="icon"[^>]*>/gi, "")
|
||||||
|
.replace(/https?:\/\/[^"'\s]+\/(?:rest\/api\/\d\/attachment\/content|secure\/attachment)\/(\d+)(?:\/[^"'\s]*)?/g,
|
||||||
|
(url, id: string) => attachments.get(id) ?? url)
|
||||||
|
.replace(/<a\s+(?=[^>]*href="https?:)/gi, '<a target="_blank" rel="noreferrer" ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Workflow bookkeeping, matched against both field ids and display names. */
|
||||||
|
const PLUMBING = /^(created|updated|last ?viewed|status ?category|watches|votes|progress|aggregate|work ?ratio|creator|project|time ?tracking|worklog|comment|subtasks|security|environment|resolution|due ?date|rank|epic link|development|children count|remaining estimate|time spent|sprint|flagged)/i;
|
||||||
|
|
||||||
|
const hours = (seconds?: number | null) => (seconds ? `${+(seconds / 3600).toFixed(1)}h` : undefined);
|
||||||
|
|
||||||
|
/** A plain value for the details table, or null when the field is empty or plumbing. */
|
||||||
|
function plain(value: unknown): string | null {
|
||||||
|
if (value == null || value === "" || (Array.isArray(value) && !value.length)) return null;
|
||||||
|
if (typeof value === "string" || typeof value === "number") return String(value);
|
||||||
|
if (Array.isArray(value)) return value.map(plain).filter(Boolean).join(", ") || null;
|
||||||
|
if (typeof value === "object") { const v = value as Named; return v.displayName ?? v.value ?? v.name ?? null; }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function importIssue(issue: JiraIssue, baseUrl: string): Pick<Ticket, "key" | "title" | "type" | "status" | "priority" | "estimate" | "assignee" | "epic" | "jira" | "links" | "source"> & { unplaced: string[] } {
|
||||||
|
const f = issue.fields;
|
||||||
|
const files = new Map((f.attachment ?? []).map(a => [String(a.id), `resources/${a.filename}`]));
|
||||||
|
const rich = Object.entries(issue.renderedFields)
|
||||||
|
.filter((e): e is [string, string] => typeof e[1] === "string" && /<[a-z]/i.test(e[1]))
|
||||||
|
.map(([id, html]) => ({ id, title: issue.names[id] ?? id, html }));
|
||||||
|
const rank = (title: string) => { const i = FIELD_ORDER.findIndex(r => r.test(title)); return i < 0 ? FIELD_ORDER.length : i; };
|
||||||
|
rich.sort((a, b) => rank(a.title) - rank(b.title));
|
||||||
|
const sections: Section[] = rich.map(r => ({ id: r.id, title: r.title, html: sanitize(r.html, files), origin: "jira" }));
|
||||||
|
|
||||||
|
const shown: [string, string | undefined | null][] = [
|
||||||
|
["Type", f.issuetype?.name], ["Status", f.status?.name], ["Priority", f.priority?.name],
|
||||||
|
["Assignee", f.assignee?.displayName], ["Reporter", f.reporter?.displayName],
|
||||||
|
["Estimate", hours(f.timeoriginalestimate)], ["Labels", plain(f.labels)], ["Components", plain(f.components)],
|
||||||
|
["Fix versions", plain(f.fixVersions)], ["Story points", plain(f.customfield_10016)],
|
||||||
|
];
|
||||||
|
const fields = shown.filter((x): x is [string, string] => !!x[1]);
|
||||||
|
|
||||||
|
// Populated plain fields the page does not show, named for the agent's sweep.
|
||||||
|
const known = new Set(["summary", "issuetype", "status", "priority", "assignee", "reporter", "timeoriginalestimate", "labels",
|
||||||
|
"components", "fixVersions", "customfield_10016", "parent", "issuelinks", "attachment", "description", ...rich.map(r => r.id)]);
|
||||||
|
const unplaced = Object.entries(f)
|
||||||
|
.filter(([k, v]) => !known.has(k) && plain(v) && !PLUMBING.test(k) && !PLUMBING.test(issue.names[k] ?? ""))
|
||||||
|
.map(([k, v]) => `${issue.names[k] ?? k}: ${String(plain(v)).slice(0, 80)}`);
|
||||||
|
|
||||||
|
const links = (f.issuelinks ?? []).flatMap(l => {
|
||||||
|
const other = l.outwardIssue ?? l.inwardIssue;
|
||||||
|
return other ? [{ type: l.outwardIssue ? l.type.outward : l.type.inward, key: other.key, title: other.fields?.summary ?? "" }] : [];
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: issue.key,
|
||||||
|
title: f.summary,
|
||||||
|
type: f.issuetype?.name ?? "Issue",
|
||||||
|
status: f.status?.name ?? "",
|
||||||
|
priority: f.priority?.name ?? "",
|
||||||
|
estimate: hours(f.timeoriginalestimate),
|
||||||
|
assignee: f.assignee?.displayName,
|
||||||
|
epic: f.parent ? { key: f.parent.key, title: f.parent.fields?.summary ?? "" } : null,
|
||||||
|
jira: `${baseUrl.replace(/\/$/, "")}/browse/${issue.key}`,
|
||||||
|
links,
|
||||||
|
source: { importedAt: now(), fields, sections },
|
||||||
|
unplaced,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSrt(srt: string): Segment[] {
|
||||||
|
const secs = (t: string) => { const [h, m, s] = t.replace(",", ".").split(":"); return Math.round((+h * 3600 + +m * 60 + +s) * 10) / 10; };
|
||||||
|
return srt.trim().split(/\r?\n\s*\r?\n/).flatMap(block => {
|
||||||
|
const lines = block.trim().split(/\r?\n/);
|
||||||
|
const time = lines.find(l => l.includes("-->"));
|
||||||
|
if (!time) return [];
|
||||||
|
const text = lines.slice(lines.indexOf(time) + 1).join(" ").trim();
|
||||||
|
return text.length > 1 ? [{ t: secs(time.split("-->")[0].trim()), text }] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const KIND: Record<string, Attachment["kind"]> = {
|
||||||
|
".png": "image", ".jpg": "image", ".jpeg": "image", ".gif": "image", ".webp": "image", ".svg": "image",
|
||||||
|
".mp4": "video", ".webm": "video", ".mov": "video", ".mkv": "video",
|
||||||
|
".mp3": "audio", ".m4a": "audio", ".wav": "audio", ".ogg": "audio",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Every file in resources/, with each video's transcript and frames attached to it. */
|
||||||
|
export function scanResources(ticketDir: string): Attachment[] {
|
||||||
|
const dir = join(ticketDir, "resources");
|
||||||
|
let names: string[];
|
||||||
|
try { names = readdirSync(dir); } catch { return []; }
|
||||||
|
const skip = (n: string) => n === "issue.raw.json" || /\.transcript\.(txt|srt|vtt)$/.test(n) || n.endsWith(".frames") || n.startsWith(".");
|
||||||
|
return names.filter(n => !skip(n) && statSync(join(dir, n)).isFile()).sort().map(name => {
|
||||||
|
const kind = KIND[extname(name).toLowerCase()] ?? "file";
|
||||||
|
const base = name.slice(0, -extname(name).length);
|
||||||
|
const att: Attachment = { name, path: `resources/${name}`, kind, bytes: statSync(join(dir, name)).size };
|
||||||
|
if (kind === "video" || kind === "audio") {
|
||||||
|
try { att.transcript = parseSrt(readFileSync(join(dir, `${base}.transcript.srt`), "utf8")); } catch {}
|
||||||
|
try { att.frames = readdirSync(join(dir, `${base}.frames`)).filter(n => /\.(png|jpe?g)$/i.test(n)).sort().map(n => `resources/${base}.frames/${n}`); } catch {}
|
||||||
|
}
|
||||||
|
return att;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Readable text from Jira-ish HTML, for the markdown export an agent reads. */
|
||||||
|
export function htmlToText(html: string): string {
|
||||||
|
return html
|
||||||
|
.replace(/<(br|\/p|\/div|\/h\d|\/li|\/tr)\b[^>]*>/gi, "\n")
|
||||||
|
.replace(/<\/t[dh]>/gi, " | ")
|
||||||
|
.replace(/<li\b[^>]*>/gi, "- ")
|
||||||
|
.replace(/<[^>]+>/g, "")
|
||||||
|
.replace(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'")
|
||||||
|
.replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
// 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
|
||||||
|
// goes through this module, so the shape is defined once and validated on every write.
|
||||||
|
|
||||||
|
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
|
||||||
|
export type StepStatus = "todo" | "running" | "done";
|
||||||
|
export type StepKind = "code" | "mock" | "deliverable";
|
||||||
|
|
||||||
|
export interface Section {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
html: string;
|
||||||
|
/** "jira" is imported verbatim from the ticket; "note" is written by the agent. */
|
||||||
|
origin: "jira" | "note";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Segment { t: number; text: string }
|
||||||
|
|
||||||
|
export interface Attachment {
|
||||||
|
name: string;
|
||||||
|
/** Relative to the ticket directory. */
|
||||||
|
path: string;
|
||||||
|
kind: "image" | "video" | "audio" | "file";
|
||||||
|
bytes: number;
|
||||||
|
transcript?: Segment[];
|
||||||
|
frames?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Criterion {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
steps: number[];
|
||||||
|
status: "todo" | "done";
|
||||||
|
doneAt?: string;
|
||||||
|
evidence?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Step {
|
||||||
|
n: number;
|
||||||
|
text: string;
|
||||||
|
kind: StepKind;
|
||||||
|
tdd?: boolean;
|
||||||
|
status: StepStatus;
|
||||||
|
commits: string[];
|
||||||
|
doneAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Risk { risk: string; handling: string; criteria?: string[]; fromTicket?: boolean }
|
||||||
|
export interface Test { text: string; kind: string; criteria?: string[]; fromTicket?: boolean }
|
||||||
|
/** A design artboard drawn at plan time. `criteria` names what it illustrates, so the
|
||||||
|
* Progress tab can show it beside the proof for those criteria. */
|
||||||
|
export interface Mock { id: string; title: string; note: string; html: string; criteria: string[] }
|
||||||
|
|
||||||
|
export interface Plan {
|
||||||
|
writtenAt: string;
|
||||||
|
approvedAt?: string;
|
||||||
|
criteria: Criterion[];
|
||||||
|
/** HTML fragments. Empty means none. */
|
||||||
|
questions: string[];
|
||||||
|
/** HTML. */
|
||||||
|
approach: string;
|
||||||
|
decisions: [question: string, choice: string, why: string][];
|
||||||
|
steps: Step[];
|
||||||
|
risks: Risk[];
|
||||||
|
tests: Test[];
|
||||||
|
mocks: Mock[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Proof {
|
||||||
|
kind: "image" | "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. */
|
||||||
|
text?: string;
|
||||||
|
/** The passing test's name, for test proof. */
|
||||||
|
test?: string;
|
||||||
|
proves: string[];
|
||||||
|
caption: string;
|
||||||
|
mock?: string;
|
||||||
|
at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Finding {
|
||||||
|
id: string;
|
||||||
|
severity: "CONFIRMED" | "PLAUSIBLE";
|
||||||
|
text: string;
|
||||||
|
where?: string;
|
||||||
|
status: "open" | "fixed" | "rejected";
|
||||||
|
commit?: string;
|
||||||
|
reason?: string;
|
||||||
|
at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Event { at: string; kind: string; text: string; commit?: string }
|
||||||
|
|
||||||
|
export interface Ticket {
|
||||||
|
schema: 1;
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
type: string;
|
||||||
|
status: string;
|
||||||
|
priority: string;
|
||||||
|
estimate?: string;
|
||||||
|
assignee?: string;
|
||||||
|
epic: { key: string; title: string } | null;
|
||||||
|
jira: string;
|
||||||
|
branch?: string;
|
||||||
|
classified?: "bounded" | "architectural";
|
||||||
|
links: { type: string; key: string; title: string }[];
|
||||||
|
source: { importedAt: string; fields: [string, string][]; sections: Section[] };
|
||||||
|
attachments: Attachment[];
|
||||||
|
plan: Plan | null;
|
||||||
|
proof: Proof[];
|
||||||
|
review: {
|
||||||
|
findings: Finding[];
|
||||||
|
preMr: { verdict: string; recommendation?: string; head?: string; runs: number; at: string } | null;
|
||||||
|
};
|
||||||
|
jiraFields: { name: string; at: string }[];
|
||||||
|
mr: { at: string } | null;
|
||||||
|
events: Event[];
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StoryStatus = "todo" | "planned" | "building" | "verifying" | "ready" | "done";
|
||||||
|
|
||||||
|
export interface Story {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
hours?: number;
|
||||||
|
risk?: string;
|
||||||
|
after: string[];
|
||||||
|
status: StoryStatus;
|
||||||
|
/** Set once a ticket page exists for the story, so the index can link to it. */
|
||||||
|
page?: boolean;
|
||||||
|
progress?: { criteria: [number, number]; steps: [number, number] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Epic {
|
||||||
|
schema: 1;
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
jira: string;
|
||||||
|
/** Why the build order is what it is, one reason per entry. */
|
||||||
|
why: string[];
|
||||||
|
/** In build order. */
|
||||||
|
stories: Story[];
|
||||||
|
closed: { key: string; title: string }[];
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const now = () => new Date().toISOString();
|
||||||
|
|
||||||
|
const GLOBAL = { ticket: "TICKET", epic: "EPIC" } as const;
|
||||||
|
|
||||||
|
export function readData(file: string, kind: "ticket"): Ticket;
|
||||||
|
export function readData(file: string, kind: "epic"): Epic;
|
||||||
|
export function readData(file: string, kind: keyof typeof GLOBAL): Ticket | Epic {
|
||||||
|
const raw = readFileSync(file, "utf8");
|
||||||
|
const prefix = `window.${GLOBAL[kind]} = `;
|
||||||
|
if (!raw.startsWith(prefix)) throw new Error(`${file} does not start with ${prefix.trim()}`);
|
||||||
|
return JSON.parse(raw.slice(prefix.length).trim().replace(/;$/, ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validates, then writes through a temp file and a rename so a page reloading
|
||||||
|
* the file mid-write never sees half of it. */
|
||||||
|
export function writeData(file: string, kind: "ticket", data: Ticket): void;
|
||||||
|
export function writeData(file: string, kind: "epic", data: Epic): void;
|
||||||
|
export function writeData(file: string, kind: keyof typeof GLOBAL, data: Ticket | Epic) {
|
||||||
|
const problems = kind === "ticket" ? validateTicket(data as Ticket) : validateEpic(data as Epic);
|
||||||
|
if (problems.length) throw new Error(`refusing to write ${file}:\n - ${problems.join("\n - ")}`);
|
||||||
|
data.updatedAt = now();
|
||||||
|
mkdirSync(dirname(file), { recursive: true });
|
||||||
|
const tmp = join(dirname(file), `.${Date.now()}.${process.pid}.tmp`);
|
||||||
|
writeFileSync(tmp, `window.${GLOBAL[kind]} = ${JSON.stringify(data, null, 1)};\n`);
|
||||||
|
renameSync(tmp, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const isStr = (x: unknown): x is string => typeof x === "string";
|
||||||
|
|
||||||
|
export function validateTicket(t: Ticket): string[] {
|
||||||
|
const p: string[] = [];
|
||||||
|
if (t.schema !== 1) p.push("schema must be 1");
|
||||||
|
for (const k of ["key", "title", "type", "status", "jira"] as const) if (!isStr(t[k]) || !t[k]) p.push(`${k} is required`);
|
||||||
|
if (!Array.isArray(t.events)) p.push("events must be a list");
|
||||||
|
if (!t.plan) return p;
|
||||||
|
const plan = t.plan;
|
||||||
|
const ids = new Set<string>();
|
||||||
|
plan.criteria.forEach((c, i) => {
|
||||||
|
if (!c.id || !c.text) p.push(`criterion ${i + 1} needs an id and text`);
|
||||||
|
if (ids.has(c.id)) p.push(`criterion id ${c.id} is used twice`);
|
||||||
|
ids.add(c.id);
|
||||||
|
for (const n of c.steps) if (!plan.steps.some(s => s.n === n)) p.push(`${c.id} names step ${n}, which does not exist`);
|
||||||
|
});
|
||||||
|
plan.steps.forEach((s, i) => {
|
||||||
|
if (s.n !== i + 1) p.push(`steps must be numbered 1..${plan.steps.length} in order, found ${s.n} at position ${i + 1}`);
|
||||||
|
if (!["code", "mock", "deliverable"].includes(s.kind)) p.push(`step ${s.n} kind must be code, mock or deliverable`);
|
||||||
|
if (!["todo", "running", "done"].includes(s.status)) p.push(`step ${s.n} status must be todo, running or done`);
|
||||||
|
});
|
||||||
|
const mockIds = new Set(plan.mocks.map(m => m.id));
|
||||||
|
for (const m of plan.mocks) for (const c of m.criteria) if (!ids.has(c)) p.push(`mock ${m.id} names unknown criterion ${c}`);
|
||||||
|
for (const r of plan.risks) for (const c of r.criteria ?? []) if (!ids.has(c)) p.push(`risk "${r.risk}" names unknown criterion ${c}`);
|
||||||
|
for (const x of plan.tests) for (const c of x.criteria ?? []) if (!ids.has(c)) p.push(`test "${x.text}" names unknown criterion ${c}`);
|
||||||
|
for (const pr of t.proof) {
|
||||||
|
for (const c of pr.proves) if (!ids.has(c)) p.push(`proof ${pr.file ?? pr.test} names unknown criterion ${c}`);
|
||||||
|
if (pr.mock && !mockIds.has(pr.mock)) p.push(`proof ${pr.file} names unknown mock ${pr.mock}`);
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateEpic(e: Epic): string[] {
|
||||||
|
const p: string[] = [];
|
||||||
|
if (e.schema !== 1) p.push("schema must be 1");
|
||||||
|
if (!e.key || !e.title) p.push("key and title are required");
|
||||||
|
const keys = new Set<string>();
|
||||||
|
for (const s of e.stories) {
|
||||||
|
if (keys.has(s.key)) p.push(`${s.key} appears twice in the order`);
|
||||||
|
keys.add(s.key);
|
||||||
|
}
|
||||||
|
for (const s of e.stories) for (const a of s.after) if (!keys.has(a)) p.push(`${s.key} waits on ${a}, which is not in the epic`);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where a ticket stands, derived from its data rather than stored, so the page,
|
||||||
|
* the epic index and `show` can never disagree. */
|
||||||
|
export function storyStatus(t: Ticket): StoryStatus {
|
||||||
|
if (!t.plan) return "todo";
|
||||||
|
if (t.review.preMr?.verdict === "Ready to Open MR") return "ready";
|
||||||
|
if (!t.plan.approvedAt) return "planned";
|
||||||
|
return t.plan.steps.every(s => s.status === "done") ? "verifying" : "building";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function progress(t: Ticket): Story["progress"] {
|
||||||
|
const pl = t.plan;
|
||||||
|
if (!pl) return { criteria: [0, 0], steps: [0, 0] };
|
||||||
|
return {
|
||||||
|
criteria: [pl.criteria.filter(c => c.status === "done").length, pl.criteria.length],
|
||||||
|
steps: [pl.steps.filter(s => s.status === "done").length, pl.steps.length],
|
||||||
|
};
|
||||||
|
}
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Runs the ticket page CLI under bun. See ../SITE.md, or: ticket-page help
|
||||||
|
set -euo pipefail
|
||||||
|
here="$(dirname "$(readlink -f "$0")")"
|
||||||
|
command -v bun >/dev/null 2>&1 || { echo "ticket-page: bun is not on PATH" >&2; exit 127; }
|
||||||
|
exec bun "$here/ticket-page.ts" "$@"
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
// Run with: bun test (from this directory)
|
||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { importIssue, type JiraIssue, parseSrt, sanitize } from "./jira-import";
|
||||||
|
import { readData, type Ticket, writeData } from "./ticket-data";
|
||||||
|
|
||||||
|
const issue = (over: Partial<JiraIssue["fields"]> = {}, rendered: Record<string, string> = {}): JiraIssue => ({
|
||||||
|
key: "KACP-1",
|
||||||
|
fields: { summary: "Add canonical IDs", issuetype: { name: "Story" }, status: { name: "To Do" }, priority: { name: "Medium" },
|
||||||
|
parent: { key: "KACP-9", fields: { summary: "Engagement epic" } }, attachment: [{ id: "42830", filename: "shot.png" }], ...over },
|
||||||
|
renderedFields: { description: "<p>Do the thing</p>", ...rendered },
|
||||||
|
names: { description: "Description", customfield_1: "Risk Mitigation", customfield_2: "Some Oddly Named Field", customfield_3: "Developer Review Instructions" },
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sanitize", () => {
|
||||||
|
test("drops anything executable and points attachments at the downloaded copy", () => {
|
||||||
|
const out = sanitize(
|
||||||
|
`<p onclick="x()">hi</p><script>alert(1)</script><a href="javascript:evil()">x</a>` +
|
||||||
|
`<img src="https://ksense-tech.atlassian.net/rest/api/3/attachment/content/42830" alt="shot.png">`,
|
||||||
|
new Map([["42830", "resources/shot.png"]]));
|
||||||
|
expect(out).not.toMatch(/onclick|<script|javascript:/i);
|
||||||
|
expect(out).toContain('src="resources/shot.png"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("importIssue", () => {
|
||||||
|
test("keeps every rich field, reader order first and unexpected names after", () => {
|
||||||
|
const t = importIssue(issue({}, { customfield_2: "<p>odd</p>", customfield_1: "<table><tr><td>r</td></tr></table>", customfield_3: "<p>dev</p>" }), "https://j");
|
||||||
|
expect(t.source.sections.map(s => s.title)).toEqual(["Description", "Developer Review Instructions", "Risk Mitigation", "Some Oddly Named Field"]);
|
||||||
|
expect(t.epic).toEqual({ key: "KACP-9", title: "Engagement epic" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseSrt skips cues with no words", () => {
|
||||||
|
const segs = parseSrt("1\n00:00:01,500 --> 00:00:03,000\n Hello there\n\n2\n00:00:03,000 --> 00:00:04,000\n I\n\n3\n00:01:02,000 --> 00:01:05,000\nBye");
|
||||||
|
expect(segs).toEqual([{ t: 1.5, text: "Hello there" }, { t: 62, text: "Bye" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("writeData refuses a plan that names a missing step and leaves the file as it was", () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "tp-"));
|
||||||
|
const file = join(dir, "ticket.data.js");
|
||||||
|
const base = { schema: 1, key: "KACP-1", title: "t", type: "Story", status: "To Do", priority: "", epic: null, jira: "j", links: [],
|
||||||
|
source: { importedAt: "", fields: [], sections: [] }, attachments: [], plan: null, proof: [], review: { findings: [], preMr: null },
|
||||||
|
jiraFields: [], mr: null, events: [], updatedAt: "" } satisfies Ticket;
|
||||||
|
writeData(file, "ticket", base);
|
||||||
|
const before = readFileSync(file, "utf8");
|
||||||
|
const broken: Ticket = { ...base, plan: { writtenAt: "", criteria: [{ id: "AC1", text: "x", steps: [2], status: "todo" }], questions: [], approach: "",
|
||||||
|
decisions: [], steps: [{ n: 1, text: "s", kind: "code", status: "todo", commits: [] }], risks: [], tests: [], mocks: [] } };
|
||||||
|
expect(() => writeData(file, "ticket", broken)).toThrow(/AC1 names step 2/);
|
||||||
|
expect(readFileSync(file, "utf8")).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the CLI, end to end", () => {
|
||||||
|
const docs = mkdtempSync(join(tmpdir(), "tp-docs-"));
|
||||||
|
const cli = (...args: string[]) => spawnSync("bun", [join(import.meta.dir, "ticket-page.ts"), ...args], { encoding: "utf8", env: { ...process.env, TICKET_DOCS: docs } });
|
||||||
|
const scratch = mkdtempSync(join(tmpdir(), "tp-in-"));
|
||||||
|
const issuePath = join(scratch, "issue.json");
|
||||||
|
writeFileSync(issuePath, JSON.stringify(issue()));
|
||||||
|
const dir = join(docs, "KACP-9", "KACP-1");
|
||||||
|
const ticket = () => readData(join(dir, "ticket.data.js"), "ticket");
|
||||||
|
const epic = () => readData(join(docs, "KACP-9", "epic.data.js"), "epic");
|
||||||
|
|
||||||
|
test("init files the ticket under its epic and writes both pages", () => {
|
||||||
|
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}"/);
|
||||||
|
expect(epic().stories.map(s => [s.key, s.status])).toEqual([["KACP-1", "todo"]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
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" }] }));
|
||||||
|
expect(cli("plan", "KACP-1", "--file", plan).status).toBe(0);
|
||||||
|
expect(epic().stories[0].status).toBe("planned");
|
||||||
|
cli("approve", "KACP-1");
|
||||||
|
const refused = cli("step", "KACP-1", "1", "done");
|
||||||
|
expect(refused.status).toBe(2);
|
||||||
|
expect(refused.stderr).toMatch(/--commit/);
|
||||||
|
expect(cli("step", "KACP-1", "1", "done", "--commit", "abc1234").status).toBe(0);
|
||||||
|
expect(ticket().plan!.steps[0]).toMatchObject({ status: "done", commits: ["abc1234"] });
|
||||||
|
expect(epic().stories[0]).toMatchObject({ status: "building", progress: { steps: [1, 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);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("--amend keeps progress for the steps that remain, a rewrite starts over", () => {
|
||||||
|
const plan = join(scratch, "plan2.json");
|
||||||
|
const exported = JSON.parse(cli("plan-json", "KACP-1").stdout);
|
||||||
|
exported.steps.push({ n: 3, text: "a review fix" });
|
||||||
|
writeFileSync(plan, JSON.stringify(exported));
|
||||||
|
expect(cli("plan", "KACP-1", "--file", plan, "--amend").status).toBe(0);
|
||||||
|
expect(ticket().plan!.steps.map(s => s.status)).toEqual(["done", "todo", "todo"]);
|
||||||
|
expect(ticket().plan!.approvedAt).toBeDefined();
|
||||||
|
expect(ticket().proof).toHaveLength(1);
|
||||||
|
expect(cli("plan", "KACP-1", "--file", plan).status).toBe(0);
|
||||||
|
expect(ticket().plan!.steps.every(s => s.status === "todo")).toBe(true);
|
||||||
|
expect(ticket().proof).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("re-importing keeps the plan unless --reset", () => {
|
||||||
|
cli("init", "KACP-1", "--issue", issuePath);
|
||||||
|
expect(ticket().plan).not.toBeNull();
|
||||||
|
cli("init", "KACP-1", "--issue", issuePath, "--reset");
|
||||||
|
expect(ticket().plan).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,436 @@
|
|||||||
|
// 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 { spawnSync } from "node:child_process";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { htmlToText, importIssue, type JiraIssue, scanResources } from "./jira-import";
|
||||||
|
import {
|
||||||
|
type Criterion, type Epic, type Finding, type Mock, now, type Plan, progress, readData, type Step,
|
||||||
|
storyStatus, type Ticket, writeData,
|
||||||
|
} from "./ticket-data";
|
||||||
|
|
||||||
|
const SITE_TEMPLATES = resolve(import.meta.dir, "../templates/site");
|
||||||
|
|
||||||
|
class UsageError extends Error {}
|
||||||
|
function fail(msg: string): never { throw new UsageError(msg); }
|
||||||
|
|
||||||
|
/** --flag value pairs and bare --switches after the positional arguments. */
|
||||||
|
function parse(argv: string[]) {
|
||||||
|
const pos: string[] = [];
|
||||||
|
const opt = new Map<string, string[]>();
|
||||||
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
const a = argv[i];
|
||||||
|
if (!a.startsWith("--")) { pos.push(a); continue; }
|
||||||
|
const name = a.slice(2);
|
||||||
|
const next = argv[i + 1];
|
||||||
|
const value = next === undefined || next.startsWith("--") ? "true" : (i++, next);
|
||||||
|
opt.set(name, [...(opt.get(name) ?? []), value]);
|
||||||
|
}
|
||||||
|
return { pos, one: (k: string) => opt.get(k)?.at(-1), all: (k: string) => opt.get(k) ?? [], has: (k: string) => opt.has(k) };
|
||||||
|
}
|
||||||
|
const list = (s?: string) => (s ? s.split(",").map(x => x.trim()).filter(Boolean) : []);
|
||||||
|
|
||||||
|
// ── Where things live ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
function ticketDir(key: string): string {
|
||||||
|
const root = docsRoot();
|
||||||
|
const hit = existsSync(root) && readdirSync(root).map(d => join(root, d, key)).find(d => existsSync(join(d, "ticket.data.js")));
|
||||||
|
return hit || fail(`no ticket page for ${key} under ${root}. Run: ticket-page init ${key} --issue <issue.raw.json>`);
|
||||||
|
}
|
||||||
|
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 === "<" ? "<" : "&")));
|
||||||
|
const page = join(pageDir, "index.html");
|
||||||
|
if (!existsSync(page) || readFileSync(page, "utf8") !== shell) writeFileSync(page, shell);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Ticket reads and writes ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function load(key: string) {
|
||||||
|
const dir = ticketDir(key);
|
||||||
|
return { dir, t: readData(ticketFile(dir), "ticket") };
|
||||||
|
}
|
||||||
|
|
||||||
|
function save(dir: string, t: Ticket) {
|
||||||
|
writeData(ticketFile(dir), "ticket", t);
|
||||||
|
refreshSite(dir, "ticket", `${t.key}: ${t.title}`);
|
||||||
|
if (t.epic) syncStory(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
function log(t: Ticket, kind: string, text: string, commit?: string) {
|
||||||
|
t.events.push({ at: now(), kind, text, ...(commit ? { commit } : {}) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keeps this ticket's row on its epic index current. Creates the epic page if the
|
||||||
|
* ticket is the first one worked under it. A story marked done stays done. */
|
||||||
|
function syncStory(t: Ticket) {
|
||||||
|
const epic = t.epic!;
|
||||||
|
const file = epicFile(epic.key);
|
||||||
|
const e: Epic = existsSync(file) ? readData(file, "epic")
|
||||||
|
: { schema: 1, key: epic.key, title: epic.title, jira: t.jira.replace(t.key, epic.key), why: [], stories: [], closed: [], updatedAt: now() };
|
||||||
|
let s = e.stories.find(x => x.key === t.key);
|
||||||
|
if (!s) { s = { key: t.key, title: t.title, after: [], status: "todo" }; e.stories.push(s); }
|
||||||
|
s.title = t.title;
|
||||||
|
s.page = true;
|
||||||
|
s.progress = progress(t);
|
||||||
|
if (s.status !== "done") s.status = storyStatus(t);
|
||||||
|
writeData(file, "epic", e);
|
||||||
|
refreshSite(dirname(file), "epic", `${e.key}: ${e.title}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Commands ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type Args = ReturnType<typeof parse>;
|
||||||
|
const commands: Record<string, { usage: string; run: (a: Args) => void }> = {};
|
||||||
|
const command = (name: string, usage: string, run: (a: Args) => void) => { commands[name] = { usage, run }; };
|
||||||
|
|
||||||
|
command("init", "init <KEY> --issue <issue.raw.json> [--reset] import the ticket, keeping any plan unless --reset", a => {
|
||||||
|
const [key] = a.pos;
|
||||||
|
const issuePath = a.one("issue") ?? fail("init needs --issue <issue.raw.json>");
|
||||||
|
const issue: JiraIssue = JSON.parse(readFileSync(issuePath, "utf8"));
|
||||||
|
if (issue.key !== key) fail(`${issuePath} holds ${issue.key}, not ${key}`);
|
||||||
|
const imported = importIssue(issue, process.env.JIRA_BASE_URL ?? "https://ksense-tech.atlassian.net");
|
||||||
|
const { unplaced, ...meta } = imported;
|
||||||
|
const dir = join(docsRoot(), meta.epic?.key ?? "tickets", key);
|
||||||
|
mkdirSync(join(dir, "resources"), { recursive: true });
|
||||||
|
const prior = existsSync(ticketFile(dir)) ? readData(ticketFile(dir), "ticket") : null;
|
||||||
|
const keep = prior && !a.has("reset") ? prior : null;
|
||||||
|
const t: Ticket = {
|
||||||
|
schema: 1, ...meta,
|
||||||
|
branch: keep?.branch, classified: keep?.classified,
|
||||||
|
// Notes the agent wrote survive a re-import. Jira's own sections are replaced.
|
||||||
|
source: { ...meta.source, sections: [...meta.source.sections, ...(keep?.source.sections.filter(s => s.origin === "note") ?? [])] },
|
||||||
|
attachments: scanResources(dir),
|
||||||
|
plan: keep?.plan ?? null, proof: keep?.proof ?? [], review: keep?.review ?? { findings: [], preMr: null },
|
||||||
|
jiraFields: keep?.jiraFields ?? [], mr: keep?.mr ?? null, events: keep?.events ?? [], updatedAt: now(),
|
||||||
|
};
|
||||||
|
log(t, "fetch", prior ? (keep ? "Re-imported the ticket from Jira, plan kept" : "Re-imported the ticket from Jira, plan cleared") : "Imported the ticket from Jira");
|
||||||
|
save(dir, t);
|
||||||
|
console.log(dir);
|
||||||
|
if (unplaced.length) console.log(`\nPopulated fields not on the page. Add any that matter with \`ticket-page note\`:\n ${unplaced.join("\n ")}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
command("scan", "scan <KEY> re-read resources/ after downloads, transcripts or frames change", a => {
|
||||||
|
const { dir, t } = load(a.pos[0]);
|
||||||
|
t.attachments = scanResources(dir);
|
||||||
|
const withTranscript = t.attachments.filter(x => x.transcript?.length).length;
|
||||||
|
log(t, "fetch", `Scanned ${t.attachments.length} attachments, ${withTranscript} with a transcript`);
|
||||||
|
save(dir, t);
|
||||||
|
});
|
||||||
|
|
||||||
|
command("meta", "meta <KEY> [--branch B] [--classified bounded|architectural]", a => {
|
||||||
|
const { dir, t } = load(a.pos[0]);
|
||||||
|
if (a.has("branch")) t.branch = a.one("branch");
|
||||||
|
const c = a.one("classified");
|
||||||
|
if (c) t.classified = c === "bounded" || c === "architectural" ? c : fail("--classified is bounded or architectural");
|
||||||
|
save(dir, t);
|
||||||
|
});
|
||||||
|
|
||||||
|
command("note", "note <KEY> <title> --file <notes.html> add or replace an agent-written section on the Ticket tab", a => {
|
||||||
|
const [key, title] = a.pos;
|
||||||
|
if (!title) fail("note needs a title");
|
||||||
|
const { dir, t } = load(key);
|
||||||
|
const html = readFileSync(a.one("file") ?? fail("note needs --file"), "utf8");
|
||||||
|
const id = "note-" + title.toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
||||||
|
t.source.sections = [...t.source.sections.filter(s => s.id !== id), { id, title, html, origin: "note" }];
|
||||||
|
save(dir, t);
|
||||||
|
});
|
||||||
|
|
||||||
|
command("plan", "plan <KEY> --file <plan.json> [--amend] write the plan. A rewrite resets progress and proof; --amend keeps both for the steps and criteria that remain", a => {
|
||||||
|
const { dir, t } = load(a.pos[0]);
|
||||||
|
type StepIn = Omit<Step, "status" | "commits" | "doneAt"> & Partial<Pick<Step, "kind">>;
|
||||||
|
type CritIn = Omit<Criterion, "status" | "doneAt" | "evidence">;
|
||||||
|
const input = JSON.parse(readFileSync(a.one("file") ?? fail("plan needs --file"), "utf8")) as
|
||||||
|
Partial<Omit<Plan, "steps" | "criteria">> & { steps: StepIn[]; criteria: CritIn[] };
|
||||||
|
const prior = t.plan;
|
||||||
|
const amend = a.has("amend");
|
||||||
|
if (amend && !prior) fail("--amend needs an existing plan");
|
||||||
|
const was = <T,>(list: T[] | undefined, match: (x: T) => boolean) => (amend ? list?.find(match) : undefined);
|
||||||
|
const plan: Plan = {
|
||||||
|
writtenAt: now(),
|
||||||
|
...(amend && prior?.approvedAt ? { approvedAt: prior.approvedAt } : {}),
|
||||||
|
criteria: (input.criteria ?? fail("plan.json needs criteria")).map(c => {
|
||||||
|
const old = was(prior?.criteria, x => x.id === c.id);
|
||||||
|
return { ...c, steps: c.steps ?? [], status: old?.status ?? "todo", ...(old?.doneAt ? { doneAt: old.doneAt } : {}), ...(old?.evidence ? { evidence: old.evidence } : {}) };
|
||||||
|
}),
|
||||||
|
questions: input.questions ?? [],
|
||||||
|
approach: input.approach ?? "",
|
||||||
|
decisions: input.decisions ?? [],
|
||||||
|
steps: (input.steps ?? fail("plan.json needs steps")).map(s => {
|
||||||
|
const old = was(prior?.steps, x => x.n === s.n && x.text === s.text);
|
||||||
|
return { ...s, kind: s.kind ?? "code", status: old?.status ?? "todo", commits: old?.commits ?? [], ...(old?.doneAt ? { doneAt: old.doneAt } : {}) };
|
||||||
|
}),
|
||||||
|
risks: input.risks ?? [],
|
||||||
|
tests: input.tests ?? [],
|
||||||
|
mocks: (input.mocks ?? prior?.mocks ?? []).map(m => ({ ...m, criteria: m.criteria ?? [] })),
|
||||||
|
};
|
||||||
|
t.plan = plan;
|
||||||
|
const ids = new Set(plan.criteria.map(c => c.id));
|
||||||
|
t.proof = amend ? t.proof.map(p => ({ ...p, proves: p.proves.filter(c => ids.has(c)) })).filter(p => p.proves.length) : [];
|
||||||
|
log(t, "plan", amend ? `Amended the plan: ${plan.criteria.length} criteria, ${plan.steps.length} steps`
|
||||||
|
: `Wrote the plan: ${plan.criteria.length} criteria, ${plan.steps.length} steps${t.classified ? `, classified ${t.classified}` : ""}`);
|
||||||
|
save(dir, t);
|
||||||
|
});
|
||||||
|
|
||||||
|
command("plan-json", "plan-json <KEY> print the current plan as plan.json, to edit and reload with plan --amend", a => {
|
||||||
|
const pl = load(a.pos[0]).t.plan ?? fail("there is no plan yet");
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
criteria: pl.criteria.map(({ id, text, steps }) => ({ id, text, steps })),
|
||||||
|
questions: pl.questions, approach: pl.approach, decisions: pl.decisions,
|
||||||
|
steps: pl.steps.map(({ n, text, kind, tdd }) => ({ n, text, kind, ...(tdd ? { tdd } : {}) })),
|
||||||
|
risks: pl.risks, tests: pl.tests,
|
||||||
|
}, null, 2));
|
||||||
|
});
|
||||||
|
|
||||||
|
command("mock", "mock <KEY> <id> --title T --criteria AC1,AC2 --file <artboard.html> [--note N] add or replace a mock artboard", a => {
|
||||||
|
const [key, id] = a.pos;
|
||||||
|
const { dir, t } = load(key);
|
||||||
|
if (!t.plan) fail("write the plan before its mocks");
|
||||||
|
const m: Mock = { id: id ?? fail("mock needs an id"), title: a.one("title") ?? fail("mock needs --title"), note: a.one("note") ?? "", html: readFileSync(a.one("file") ?? fail("mock needs --file"), "utf8"),
|
||||||
|
criteria: list(a.one("criteria")).length ? list(a.one("criteria")) : fail("mock needs --criteria, the criteria it illustrates") };
|
||||||
|
t.plan!.mocks = [...t.plan!.mocks.filter(x => x.id !== id), m];
|
||||||
|
save(dir, t);
|
||||||
|
});
|
||||||
|
|
||||||
|
command("approve", "approve <KEY> mark the plan approved; Phase 2 starts", a => {
|
||||||
|
const { dir, t } = load(a.pos[0]);
|
||||||
|
if (!t.plan) fail("there is no plan to approve");
|
||||||
|
t.plan!.approvedAt = now();
|
||||||
|
log(t, "approve", "Plan approved");
|
||||||
|
save(dir, t);
|
||||||
|
});
|
||||||
|
|
||||||
|
command("step", "step <KEY> <n> running|done|todo [--commit SHA]... done needs at least one commit, or --no-commit for a step with none", a => {
|
||||||
|
const [key, n, status] = a.pos;
|
||||||
|
const { dir, t } = load(key);
|
||||||
|
const step = t.plan?.steps.find(s => s.n === Number(n)) ?? fail(`no step ${n}`);
|
||||||
|
if (status !== "running" && status !== "done" && status !== "todo") fail("status is running, done or todo");
|
||||||
|
const commits = a.all("commit");
|
||||||
|
if (status === "done" && !commits.length && !step.commits.length && !a.has("no-commit")) fail(`step ${n} is done only once its commit exists: pass --commit <sha>`);
|
||||||
|
if (status === "running") t.plan!.steps.forEach(s => { if (s.status === "running") s.status = "todo"; });
|
||||||
|
step.status = status;
|
||||||
|
step.commits = [...new Set([...step.commits, ...commits])];
|
||||||
|
step.doneAt = status === "done" ? now() : undefined;
|
||||||
|
if (status === "running") log(t, "start", `Started step ${n}: ${step.text}`);
|
||||||
|
if (status === "done") log(t, "commit", `Finished step ${n}`, commits.at(-1));
|
||||||
|
save(dir, t);
|
||||||
|
});
|
||||||
|
|
||||||
|
command("criterion", "criterion <KEY> <id> done|todo [--evidence TEXT]", a => {
|
||||||
|
const [key, id, status] = a.pos;
|
||||||
|
const { dir, t } = load(key);
|
||||||
|
const c = t.plan?.criteria.find(x => x.id === id) ?? fail(`no criterion ${id}`);
|
||||||
|
if (status !== "done" && status !== "todo") fail("status is done or todo");
|
||||||
|
c.status = status;
|
||||||
|
c.doneAt = status === "done" ? now() : undefined;
|
||||||
|
if (a.has("evidence")) c.evidence = a.one("evidence");
|
||||||
|
if (status === "done") log(t, "criterion", `Met ${id}${c.evidence ? `: ${c.evidence}` : ""}`);
|
||||||
|
save(dir, t);
|
||||||
|
});
|
||||||
|
|
||||||
|
command("proof", "proof <KEY> (--file proof/<name> | --test NAME) --proves AC1,AC2 --caption TEXT [--mock ID]", a => {
|
||||||
|
const { dir, t } = load(a.pos[0]);
|
||||||
|
const file = a.one("file"), test = a.one("test");
|
||||||
|
if (!file === !test) fail("proof takes exactly one of --file or --test");
|
||||||
|
const proves = list(a.one("proves"));
|
||||||
|
if (!proves.length) fail("proof needs --proves");
|
||||||
|
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);
|
||||||
|
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 }),
|
||||||
|
...(isText ? { text: readFileSync(join(dir, file!), "utf8") } : {}),
|
||||||
|
proves, caption, ...(a.one("mock") ? { mock: a.one("mock") } : {}), at: now(),
|
||||||
|
});
|
||||||
|
log(t, "proof", `Captured proof for ${proves.join(", ")}: ${caption}`);
|
||||||
|
save(dir, t);
|
||||||
|
});
|
||||||
|
|
||||||
|
command("unproof", "unproof <KEY> <file-or-test> drop a proof entry, for files cut from proof/", a => {
|
||||||
|
const [key, what] = a.pos;
|
||||||
|
const { dir, t } = load(key);
|
||||||
|
t.proof = t.proof.filter(p => p.file !== what && p.test !== what);
|
||||||
|
save(dir, t);
|
||||||
|
});
|
||||||
|
|
||||||
|
command("finding", "finding <KEY> add TEXT --severity CONFIRMED|PLAUSIBLE [--where file:line] | finding <KEY> <id> fixed --commit SHA | rejected --reason TEXT", a => {
|
||||||
|
const [key, idOrAdd, arg] = a.pos;
|
||||||
|
const { dir, t } = load(key);
|
||||||
|
if (idOrAdd === "add") {
|
||||||
|
const severity = a.one("severity");
|
||||||
|
if (severity !== "CONFIRMED" && severity !== "PLAUSIBLE") fail("--severity is CONFIRMED or PLAUSIBLE");
|
||||||
|
const f: Finding = { id: `F${t.review.findings.length + 1}`, severity, text: arg ?? fail("finding add needs its text"), status: "open", at: now(), ...(a.one("where") ? { where: a.one("where") } : {}) };
|
||||||
|
t.review.findings.push(f);
|
||||||
|
log(t, "review", `Review finding ${f.id}: ${f.text}`);
|
||||||
|
console.log(f.id);
|
||||||
|
} else {
|
||||||
|
const f = t.review.findings.find(x => x.id === idOrAdd) ?? fail(`no finding ${idOrAdd}`);
|
||||||
|
if (arg === "fixed") { f.status = "fixed"; f.commit = a.one("commit") ?? fail("fixed needs --commit"); log(t, "fix", `Fixed ${f.id}: ${f.text}`, f.commit); }
|
||||||
|
else if (arg === "rejected") { f.status = "rejected"; f.reason = a.one("reason") ?? fail("rejected needs --reason"); log(t, "review", `Rejected ${f.id}: ${f.reason}`); }
|
||||||
|
else fail("finding <id> takes fixed or rejected");
|
||||||
|
f.at = now();
|
||||||
|
}
|
||||||
|
save(dir, t);
|
||||||
|
});
|
||||||
|
|
||||||
|
command("verdict", "verdict <KEY> <verdict> [--recommendation R] [--head SHA] record a pre-mr-review run", a => {
|
||||||
|
const [key, ...words] = a.pos;
|
||||||
|
const { dir, t } = load(key);
|
||||||
|
const verdict = words.join(" ") || fail("verdict needs the verdict text");
|
||||||
|
t.review.preMr = { verdict, recommendation: a.one("recommendation"), head: a.one("head"), runs: (t.review.preMr?.runs ?? 0) + 1, at: now() };
|
||||||
|
log(t, "verdict", `pre-mr-review: ${verdict}`);
|
||||||
|
save(dir, t);
|
||||||
|
});
|
||||||
|
|
||||||
|
command("jira", "jira <KEY> <field name> record a Jira field as filled", a => {
|
||||||
|
const [key, ...name] = a.pos;
|
||||||
|
const { dir, t } = load(key);
|
||||||
|
const field = name.join(" ") || fail("jira needs the field name");
|
||||||
|
t.jiraFields = [...t.jiraFields.filter(f => f.name !== field), { name: field, at: now() }];
|
||||||
|
log(t, "jira", `Filled ${field} in Jira`);
|
||||||
|
save(dir, t);
|
||||||
|
});
|
||||||
|
|
||||||
|
command("mr", "mr <KEY> record that mr.md is written", a => {
|
||||||
|
const { dir, t } = load(a.pos[0]);
|
||||||
|
t.mr = { at: now() };
|
||||||
|
log(t, "mr", "Wrote mr.md");
|
||||||
|
save(dir, t);
|
||||||
|
});
|
||||||
|
|
||||||
|
command("event", "event <KEY> TEXT [--kind K] [--commit SHA] add a line to the live log", a => {
|
||||||
|
const [key, ...text] = a.pos;
|
||||||
|
const { dir, t } = load(key);
|
||||||
|
log(t, a.one("kind") ?? "note", text.join(" ") || fail("event needs text"), a.one("commit"));
|
||||||
|
save(dir, t);
|
||||||
|
});
|
||||||
|
|
||||||
|
command("show", "show <KEY> print where the ticket stands", a => {
|
||||||
|
const { dir, t } = load(a.pos[0]);
|
||||||
|
const p = progress(t)!;
|
||||||
|
console.log(`${t.key} ${storyStatus(t)} criteria ${p.criteria.join("/")} steps ${p.steps.join("/")} proof ${t.proof.length}\n${join(dir, "index.html")}`);
|
||||||
|
for (const s of t.plan?.steps ?? []) console.log(` ${s.status === "done" ? "x" : s.status === "running" ? ">" : " "} ${s.n}. ${s.text}${s.commits.length ? ` ${s.commits.join(" ")}` : ""}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
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("md", "md <KEY> print the ticket and plan as markdown, for an agent's reading order", a => {
|
||||||
|
const { t } = load(a.pos[0]);
|
||||||
|
const out: string[] = [`# ${t.key}: ${t.title}`, "", `${t.type}, ${t.status}. ${t.jira}`, ""];
|
||||||
|
for (const [k, v] of t.source.fields) out.push(`- ${k}: ${v}`);
|
||||||
|
for (const s of t.source.sections) out.push("", `## ${s.title}${s.origin === "note" ? " (notes)" : ""}`, "", htmlToText(s.html));
|
||||||
|
const pl = t.plan;
|
||||||
|
if (pl) {
|
||||||
|
out.push("", "# Plan", "", "## Acceptance criteria");
|
||||||
|
for (const c of pl.criteria) out.push(`- [${c.status === "done" ? "x" : " "}] ${c.id}: ${c.text} (steps ${c.steps.join(", ")})`);
|
||||||
|
out.push("", "## Open questions", ...(pl.questions.length ? pl.questions.map(q => `- ${htmlToText(q)}`) : ["None"]));
|
||||||
|
out.push("", "## Approach", "", htmlToText(pl.approach));
|
||||||
|
if (pl.decisions.length) out.push("", "## Decisions", ...pl.decisions.map(([q, c, w]) => `- ${q}: ${c}. ${w}`));
|
||||||
|
out.push("", "## Steps", ...pl.steps.map(s => `- [${s.status === "done" ? "x" : " "}] ${s.n}. ${s.text}${s.kind !== "code" ? ` (${s.kind})` : ""}${s.commits.length ? ` ${s.commits.join(" ")}` : ""}`));
|
||||||
|
out.push("", "## Risks", ...pl.risks.map(r => `- ${r.risk}: ${r.handling}${r.fromTicket ? " (from the ticket)" : ""}`));
|
||||||
|
out.push("", "## Test plan", ...pl.tests.map(x => `- ${x.kind}: ${x.text}${x.fromTicket ? " (from the ticket)" : ""}`));
|
||||||
|
}
|
||||||
|
if (t.proof.length) out.push("", "# Proof", ...t.proof.map(p => `- ${p.file ?? p.test}: ${p.caption} (proves ${p.proves.join(", ")})`));
|
||||||
|
console.log(out.join("\n"));
|
||||||
|
});
|
||||||
|
|
||||||
|
command("check", "check <KEY> validate the data file", a => {
|
||||||
|
const { t } = load(a.pos[0]);
|
||||||
|
console.log(`${t.key} is valid`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Epic commands work on the epic's own data file.
|
||||||
|
function withEpic(key: string, fn: (e: Epic) => void) {
|
||||||
|
const file = epicFile(key);
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 => {
|
||||||
|
const [key, sub, ...rest] = a.pos;
|
||||||
|
if (sub === "init") {
|
||||||
|
const file = epicFile(key);
|
||||||
|
const e: Epic = existsSync(file) ? readData(file, "epic") : { schema: 1, key, title: "", jira: "", why: [], stories: [], closed: [], updatedAt: now() };
|
||||||
|
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}`);
|
||||||
|
return console.log(join(dirname(file), "index.html"));
|
||||||
|
}
|
||||||
|
withEpic(key, e => {
|
||||||
|
if (sub === "story") {
|
||||||
|
const [storyKey] = rest;
|
||||||
|
let s = e.stories.find(x => x.key === storyKey);
|
||||||
|
if (!s) { s = { key: storyKey ?? fail("story needs a key"), title: "", after: [], status: "todo" }; e.stories.push(s); }
|
||||||
|
if (a.has("title")) s.title = a.one("title")!;
|
||||||
|
if (a.has("hours")) s.hours = Number(a.one("hours"));
|
||||||
|
if (a.has("risk")) s.risk = a.one("risk");
|
||||||
|
if (a.has("after")) s.after = list(a.one("after"));
|
||||||
|
const st = a.one("status");
|
||||||
|
if (st) s.status = (["todo", "planned", "building", "verifying", "ready", "done"] as const).find(x => x === st) ?? fail("unknown status");
|
||||||
|
if (!s.title) fail(`${s.key} needs --title`);
|
||||||
|
} else if (sub === "order") {
|
||||||
|
const missing = e.stories.filter(s => !rest.includes(s.key)).map(s => s.key);
|
||||||
|
if (missing.length || rest.length !== e.stories.length) fail(`order must list every story exactly once. Missing: ${missing.join(", ") || "none"}`);
|
||||||
|
e.stories = rest.map(k => e.stories.find(s => s.key === k) ?? fail(`${k} is not in the epic`));
|
||||||
|
} else if (sub === "why") {
|
||||||
|
e.why = readFileSync(a.one("file") ?? fail("why needs --file"), "utf8").split("\n").map(x => x.trim()).filter(Boolean);
|
||||||
|
} else if (sub === "closed") {
|
||||||
|
const [k] = rest;
|
||||||
|
e.closed = [...e.closed.filter(c => c.key !== k), { key: k ?? fail("closed needs a key"), title: a.one("title") ?? fail("closed needs --title") }];
|
||||||
|
e.stories = e.stories.filter(s => s.key !== k);
|
||||||
|
} else fail("epic takes init, story, order, why or closed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function help() {
|
||||||
|
console.log("ticket-page <command> ...\n");
|
||||||
|
for (const c of Object.values(commands)) console.log(" " + c.usage);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.main) {
|
||||||
|
const [name, ...rest] = process.argv.slice(2);
|
||||||
|
if (!name || name === "help" || !commands[name]) { help(); process.exit(name && name !== "help" ? 2 : 0); }
|
||||||
|
try {
|
||||||
|
const args = parse(rest);
|
||||||
|
if (name !== "epic" && !/^[A-Z][A-Z0-9]+-\d+$/.test(args.pos[0] ?? "")) fail(`${name} needs a ticket key like KACP-12345 first`);
|
||||||
|
commands[name].run(args);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`ticket-page ${name}: ${err instanceof Error ? err.message : err}`);
|
||||||
|
process.exit(err instanceof UsageError ? 2 : 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<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}}">
|
||||||
|
</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>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
/* Shared stylesheet for ticket and epic pages. Copied into .claude/docs/epics/_site/ by ticket-page. */
|
||||||
|
:root {
|
||||||
|
--bg: #f6f6f3; --surface: #ffffff; --sunk: #efefea; --ink: #1c1c1a; --muted: #6b6b66;
|
||||||
|
--line: #e2e2dd; --accent: #2f5fd0; --accent-ink: #fff; --accent-soft: #e8eeff;
|
||||||
|
--ok: #1f7a3d; --ok-soft: #e4f4e8; --warn: #8a6100; --warn-soft: #fff4d6;
|
||||||
|
--run: #2f5fd0; --run-soft: #e8eeff; --todo: #9b9a93;
|
||||||
|
--mono: ui-monospace, "JetBrains Mono", "Cascadia Mono", Menlo, monospace;
|
||||||
|
--sans: system-ui, -apple-system, "Segoe UI", Roboto, "Inter", sans-serif;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not([data-theme="light"]) {
|
||||||
|
--bg: #141513; --surface: #1d1e1b; --sunk: #181917; --ink: #ecebe6; --muted: #9b9a93;
|
||||||
|
--line: #2d2e2a; --accent: #86a4f3; --accent-ink: #0f1420; --accent-soft: #1f2940;
|
||||||
|
--ok: #6fcf8e; --ok-soft: #173222; --warn: #e6c36b; --warn-soft: #2e2713;
|
||||||
|
--run: #86a4f3; --run-soft: #1f2940; --todo: #6b6b66;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
--bg: #141513; --surface: #1d1e1b; --sunk: #181917; --ink: #ecebe6; --muted: #9b9a93;
|
||||||
|
--line: #2d2e2a; --accent: #86a4f3; --accent-ink: #0f1420; --accent-soft: #1f2940;
|
||||||
|
--ok: #6fcf8e; --ok-soft: #173222; --warn: #e6c36b; --warn-soft: #2e2713;
|
||||||
|
--run: #86a4f3; --run-soft: #1f2940; --todo: #6b6b66;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; background: var(--bg); color: var(--ink); font: 15px/1.55 var(--sans); }
|
||||||
|
a { color: var(--accent); }
|
||||||
|
code, .mono { font-family: var(--mono); font-size: .9em; }
|
||||||
|
h1, h2, h3 { line-height: 1.25; }
|
||||||
|
h2 { font-size: 1.1em; margin: 0 0 12px; }
|
||||||
|
h3 { font-size: .95em; margin: 18px 0 8px; }
|
||||||
|
p { margin: 0 0 10px; max-width: 74ch; }
|
||||||
|
ul { margin: 0; padding-left: 20px; }
|
||||||
|
li { margin: 0 0 6px; max-width: 74ch; }
|
||||||
|
table { border-collapse: collapse; width: 100%; font-size: .92em; }
|
||||||
|
th, td { text-align: left; vertical-align: top; padding: 8px 10px; border-bottom: 1px solid var(--line); }
|
||||||
|
th { font-size: .78em; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); font-weight: 600; }
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
.card { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; padding: 16px 18px; }
|
||||||
|
.stack > * + * { margin-top: 14px; }
|
||||||
|
|
||||||
|
/* Status marks: done by the agent, running, not started. */
|
||||||
|
.mark { display: inline-grid; place-items: center; width: 18px; height: 18px; border-radius: 50%;
|
||||||
|
flex: none; font-size: 11px; font-weight: 700; border: 1.5px solid var(--todo); color: transparent; }
|
||||||
|
.mark.done { background: var(--ok); border-color: var(--ok); color: var(--surface); }
|
||||||
|
.mark.run { border-color: var(--run); background: var(--run-soft); }
|
||||||
|
.mark.done::after { content: "✓"; }
|
||||||
|
.pill { display: inline-block; font-size: .75em; font-weight: 600; padding: 2px 9px; border-radius: 99px;
|
||||||
|
background: var(--sunk); color: var(--muted); white-space: nowrap; }
|
||||||
|
.pill.ok { background: var(--ok-soft); color: var(--ok); }
|
||||||
|
.pill.run { background: var(--run-soft); color: var(--run); }
|
||||||
|
.pill.warn { background: var(--warn-soft); color: var(--warn); }
|
||||||
|
.bar { height: 6px; border-radius: 99px; background: var(--sunk); overflow: hidden; }
|
||||||
|
.bar > i { display: block; height: 100%; background: var(--ok); border-radius: 99px; transition: width .3s; }
|
||||||
|
.sha { font-family: var(--mono); font-size: .82em; color: var(--muted); }
|
||||||
|
|
||||||
|
/* The review tick is the viewer's own, separate from the agent's done mark. */
|
||||||
|
.seen { font-size: .78em; color: var(--muted); display: inline-flex; gap: 5px; align-items: center; cursor: pointer; user-select: none; }
|
||||||
|
.seen input { margin: 0; }
|
||||||
|
|
||||||
|
|
||||||
|
/* Video with a transcript that seeks the player. */
|
||||||
|
.video { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr); gap: 14px; }
|
||||||
|
.video video { width: 100%; border-radius: 8px; background: #000; display: block; }
|
||||||
|
.transcript { max-height: 340px; overflow: auto; border: 1px solid var(--line); border-radius: 8px; background: var(--surface); font-size: .88em; }
|
||||||
|
.transcript button { display: flex; gap: 10px; width: 100%; text-align: left; font: inherit; color: inherit; background: none; border: 0;
|
||||||
|
border-bottom: 1px solid var(--line); padding: 6px 10px; cursor: pointer; }
|
||||||
|
.transcript button:hover { background: var(--sunk); }
|
||||||
|
.transcript button.now { background: var(--accent-soft); }
|
||||||
|
.transcript .t { font-family: var(--mono); color: var(--muted); flex: none; }
|
||||||
|
.frames { display: flex; gap: 8px; margin-top: 10px; overflow-x: auto; }
|
||||||
|
.frames img { height: 64px; border-radius: 5px; border: 1px solid var(--line); cursor: zoom-in; }
|
||||||
|
@media (max-width: 860px) { .video { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
/* Mock artboard and proof, side by side. */
|
||||||
|
.compare { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||||
|
.compare figure { margin: 0; }
|
||||||
|
.compare figcaption { font-size: .78em; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); margin-bottom: 6px; }
|
||||||
|
.artboard { width: 100%; height: 300px; border: 1px solid var(--line); border-radius: 8px; background: #fff; display: block; }
|
||||||
|
.shot { width: 100%; border: 1px solid var(--line); border-radius: 8px; display: block; background: var(--sunk); }
|
||||||
|
.placeholder { height: 300px; border: 1.5px dashed var(--line); border-radius: 8px; display: grid; place-items: center; color: var(--muted); font-size: .9em; text-align: center; padding: 20px; }
|
||||||
|
pre.textproof { margin: 0; max-height: 300px; overflow: auto; font-family: var(--mono); font-size: .8em; background: var(--surface);
|
||||||
|
border: 1px solid var(--line); border-radius: 8px; padding: 12px; }
|
||||||
|
@media (max-width: 860px) { .compare { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
.timeline { list-style: none; padding: 0; margin: 0; position: relative; }
|
||||||
|
.timeline li { display: grid; grid-template-columns: 74px 18px 1fr; gap: 10px; align-items: start; margin: 0 0 10px; max-width: none; }
|
||||||
|
.timeline time { font-family: var(--mono); font-size: .8em; color: var(--muted); padding-top: 1px; white-space: nowrap; }
|
||||||
|
.timeline .dot { width: 9px; height: 9px; border-radius: 50%; margin: 6px auto 0; background: var(--todo); }
|
||||||
|
.timeline .dot.commit { background: var(--ok); } .timeline .dot.fix { background: var(--warn); }
|
||||||
|
.timeline .dot.verdict, .timeline .dot.approve { background: var(--accent); }
|
||||||
|
|
||||||
|
/* Page chrome */
|
||||||
|
.loading, .error { max-width: 720px; margin: 60px auto; padding: 0 16px; color: var(--muted); }
|
||||||
|
.error { color: var(--warn); }
|
||||||
|
.top { background: var(--surface); border-bottom: 1px solid var(--line); }
|
||||||
|
.wrap { max-width: 1180px; margin: 0 auto; padding: 0 16px; }
|
||||||
|
.head { padding: 22px 0 14px; display: grid; grid-template-columns: 1fr 300px; gap: 24px; align-items: end; }
|
||||||
|
.head h1 { margin: 4px 0 8px; font-size: 1.55em; }
|
||||||
|
.crumbs { font-size: .85em; color: var(--muted); }
|
||||||
|
.meta { display: flex; flex-wrap: wrap; gap: 6px 16px; font-size: .86em; color: var(--muted); align-items: center; }
|
||||||
|
.live { font-size: .8em; color: var(--muted); }
|
||||||
|
.live.stale { color: var(--warn); }
|
||||||
|
.meters { display: grid; gap: 10px; font-size: .84em; }
|
||||||
|
.meters .row { display: grid; grid-template-columns: 1fr auto; gap: 4px 10px; }
|
||||||
|
.meters .bar { grid-column: 1 / -1; }
|
||||||
|
.tabs { display: flex; gap: 2px; overflow-x: auto; }
|
||||||
|
.tabs a { padding: 10px 14px; text-decoration: none; color: var(--muted); border-bottom: 2px solid transparent; white-space: nowrap; font-size: .93em; }
|
||||||
|
.tabs a[aria-current] { color: var(--ink); border-color: var(--accent); font-weight: 600; }
|
||||||
|
.tabs .n { margin-left: 6px; font-size: .78em; }
|
||||||
|
main.wrap { padding-top: 24px; padding-bottom: 80px; }
|
||||||
|
.grid2 { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr); gap: 16px; align-items: start; }
|
||||||
|
.check { display: flex; gap: 10px; align-items: flex-start; padding: 10px 0; border-bottom: 1px solid var(--line); }
|
||||||
|
.check:last-child { border-bottom: 0; }
|
||||||
|
.check .body { flex: 1; min-width: 0; }
|
||||||
|
.check .sub { font-size: .82em; color: var(--muted); margin-top: 2px; }
|
||||||
|
.callout { background: var(--warn-soft); border: 1px solid var(--warn); border-radius: 8px; padding: 10px 14px; }
|
||||||
|
.empty { color: var(--muted); padding: 30px 0; text-align: center; }
|
||||||
|
.kv td:first-child { color: var(--muted); width: 140px; }
|
||||||
|
|
||||||
|
/* Jira's own HTML, sanitized at import. */
|
||||||
|
.jira { overflow-x: auto; }
|
||||||
|
.jira img { max-width: 100%; height: auto; border-radius: 6px; }
|
||||||
|
.jira table { margin: 8px 0; }
|
||||||
|
.jira td, .jira th { border: 1px solid var(--line); }
|
||||||
|
/* Jira hard-codes light panel colors inline, which would be unreadable in dark mode. */
|
||||||
|
.jira *:not(img) { background-color: transparent !important; color: inherit !important; border-color: var(--line) !important; }
|
||||||
|
.jira .panel, .jira .panelHeader, .jira .panelContent { background-color: var(--sunk) !important; border-radius: 6px; }
|
||||||
|
.jira .panel { border: 1px solid var(--line); margin: 8px 0; }
|
||||||
|
.jira .panelHeader { font-weight: 600; padding: 6px 10px; }
|
||||||
|
.jira .panelContent { padding: 6px 10px; }
|
||||||
|
.note-tag { font-size: .72em; font-weight: 600; text-transform: uppercase; letter-spacing: .05em; color: var(--accent); margin-left: 8px; }
|
||||||
|
.images { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 10px; }
|
||||||
|
.images a { display: block; } .images img { width: 100%; border-radius: 6px; border: 1px solid var(--line); }
|
||||||
|
|
||||||
|
/* Progress: criteria down the left, everything that traces to the picked one on the right. */
|
||||||
|
.tiles { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 16px; }
|
||||||
|
.tile { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; padding: 10px 14px; }
|
||||||
|
.tile .n { font-size: 1.35em; font-weight: 700; }
|
||||||
|
.tile .l { font-size: .8em; color: var(--muted); }
|
||||||
|
.split { display: grid; grid-template-columns: 380px minmax(0, 1fr); gap: 16px; align-items: start; margin-bottom: 16px; }
|
||||||
|
.spine { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; overflow: hidden; position: sticky; top: 16px; }
|
||||||
|
.spine button { display: flex; gap: 10px; width: 100%; text-align: left; font: inherit; font-size: .9em; color: inherit; background: none; border: 0;
|
||||||
|
border-bottom: 1px solid var(--line); padding: 10px 14px; cursor: pointer; align-items: flex-start; }
|
||||||
|
.spine button:last-child { border-bottom: 0; }
|
||||||
|
.spine button[aria-current] { background: var(--accent-soft); }
|
||||||
|
.spine .id { font-family: var(--mono); font-size: .82em; color: var(--muted); width: 40px; flex: none; }
|
||||||
|
.spine .dots { display: flex; gap: 3px; margin-top: 5px; }
|
||||||
|
.spine .dots i { width: 7px; height: 7px; border-radius: 50%; background: var(--line); }
|
||||||
|
.spine .dots i.on { background: var(--ok); } .spine .dots i.run { background: var(--run); }
|
||||||
|
.trace { display: grid; grid-template-columns: 110px 1fr; gap: 8px 14px; font-size: .92em; margin: 12px 0 18px; }
|
||||||
|
.trace dt { color: var(--muted); font-size: .85em; padding-top: 2px; }
|
||||||
|
.trace dd { margin: 0; }
|
||||||
|
|
||||||
|
/* Epic index */
|
||||||
|
.epic h1 { margin: 2px 0 6px; font-size: 1.6em; }
|
||||||
|
.epic main.wrap { max-width: 1040px; }
|
||||||
|
.sum { display: flex; flex-wrap: wrap; gap: 8px 22px; color: var(--muted); font-size: .9em; margin: 4px 0 18px; align-items: center; }
|
||||||
|
.sum .bar { width: 220px; }
|
||||||
|
.why { margin-bottom: 18px; } .why ol { margin: 6px 0 0; padding-left: 20px; }
|
||||||
|
.seq { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; }
|
||||||
|
.seq .row { display: grid; grid-template-columns: 44px 1fr 170px 120px; gap: 14px; align-items: center; padding: 12px 16px; border-bottom: 1px solid var(--line); }
|
||||||
|
.seq .row:last-child { border-bottom: 0; }
|
||||||
|
.seq .n { font-family: var(--mono); font-weight: 700; color: var(--muted); font-size: 1.05em; text-align: right; }
|
||||||
|
.seq .row.done .n { color: var(--ok); }
|
||||||
|
.seq .row.next { background: var(--accent-soft); } .seq .row.next .n { color: var(--accent); }
|
||||||
|
.seq .t a { color: inherit; text-decoration: none; font-weight: 600; } .seq .t a[href]:hover { color: var(--accent); }
|
||||||
|
.seq .k { font-family: var(--mono); font-size: .8em; color: var(--muted); }
|
||||||
|
.seq .after { font-size: .8em; color: var(--muted); margin-top: 2px; }
|
||||||
|
.seq .prog { font-size: .78em; color: var(--muted); display: grid; gap: 4px; }
|
||||||
|
.seq .meta { text-align: right; font-size: .82em; color: var(--muted); display: grid; gap: 4px; justify-items: end; }
|
||||||
|
.risk-HIGH { color: #c2410c; font-weight: 700; }
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.head, .grid2, .split { grid-template-columns: 1fr; }
|
||||||
|
.tiles { grid-template-columns: 1fr 1fr; }
|
||||||
|
.spine { position: static; }
|
||||||
|
.seq .row { grid-template-columns: 34px 1fr; }
|
||||||
|
.seq .prog, .seq .meta { grid-column: 2; text-align: left; justify-items: start; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
// 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.
|
||||||
|
// @ts-check
|
||||||
|
/** @typedef {import('../../scripts/ticket-data').Ticket} Ticket */
|
||||||
|
/** @typedef {import('../../scripts/ticket-data').Epic} Epic */
|
||||||
|
(() => {
|
||||||
|
const PAGE = document.body.dataset.page;
|
||||||
|
const SRC = document.body.dataset.src || '';
|
||||||
|
const GLOBAL = PAGE === 'epic' ? 'EPIC' : 'TICKET';
|
||||||
|
|
||||||
|
// ── DOM helpers ──────────────────────────────────────────────────────────
|
||||||
|
/** h('div.card#id', {attrs}, ...children). Strings become text; html() parses markup. */
|
||||||
|
function h(sel, attrs, ...kids) {
|
||||||
|
const [, tag, rest] = /** @type {RegExpMatchArray} */ (sel.match(/^([a-z0-9]*)(.*)$/i));
|
||||||
|
const node = document.createElement(tag || 'div');
|
||||||
|
(rest.match(/[.#][^.#]+/g) || []).forEach(p => p[0] === '.' ? node.classList.add(p.slice(1)) : (node.id = p.slice(1)));
|
||||||
|
if (attrs && (typeof attrs !== 'object' || attrs instanceof Node || Array.isArray(attrs))) { kids.unshift(attrs); attrs = null; }
|
||||||
|
for (const [k, v] of Object.entries(attrs || {})) {
|
||||||
|
if (v == null || v === false) continue;
|
||||||
|
if (k.startsWith('on')) node.addEventListener(k.slice(2), v); else node.setAttribute(k, v === true ? '' : String(v));
|
||||||
|
}
|
||||||
|
kids.flat(Infinity).forEach(k => k != null && k !== false && node.append(k instanceof Node ? k : document.createTextNode(String(k))));
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
/** Markup from the data file: sanitized Jira HTML at import, or the agent's own notes. */
|
||||||
|
const html = s => { const t = document.createElement('template'); t.innerHTML = s || ''; return t.content; };
|
||||||
|
// Nodes that must survive a redraw (a playing video, a loaded iframe) are built once per key.
|
||||||
|
const kept = new Map();
|
||||||
|
const keep = (key, make) => { if (!kept.has(key)) kept.set(key, make()); return kept.get(key); };
|
||||||
|
|
||||||
|
const clock = iso => iso ? new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
|
||||||
|
const day = iso => iso ? new Date(iso).toLocaleDateString([], { month: 'short', day: 'numeric' }) : '';
|
||||||
|
const mmss = s => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`;
|
||||||
|
const size = b => b > 1e6 ? `${(b / 1e6).toFixed(1)} MB` : `${Math.max(1, Math.round(b / 1e3))} KB`;
|
||||||
|
const pct = ([a, b]) => b ? Math.round(100 * a / b) : 0;
|
||||||
|
const bar = pair => h('div.bar', h('i', { style: `width:${pct(pair)}%` }));
|
||||||
|
const mark = st => h('span.mark' + (st === 'done' ? '.done' : st === 'running' ? '.run' : ''), { title: st });
|
||||||
|
const pill = (text, tone) => h('span.pill' + (tone ? '.' + tone : ''), text);
|
||||||
|
|
||||||
|
/** 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 {}
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Loading and live reload ──────────────────────────────────────────────
|
||||||
|
let last = '', loadedAt = 0, failed = false;
|
||||||
|
function load() {
|
||||||
|
const s = document.createElement('script');
|
||||||
|
s.src = `${SRC}?t=${Date.now()}`;
|
||||||
|
s.onload = () => {
|
||||||
|
s.remove(); failed = false; loadedAt = Date.now();
|
||||||
|
const next = JSON.stringify(window[GLOBAL]);
|
||||||
|
if (next !== last) { last = next; draw(); } else liveNote();
|
||||||
|
};
|
||||||
|
s.onerror = () => { s.remove(); failed = true; if (!last) fatal(`Could not load ${SRC}. Run ticket-page check for this ticket.`); else liveNote(); };
|
||||||
|
document.head.append(s);
|
||||||
|
}
|
||||||
|
function fatal(msg) { document.getElementById('app')?.replaceChildren(h('p.error', msg)); }
|
||||||
|
function liveNote() {
|
||||||
|
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())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
const data = window[GLOBAL];
|
||||||
|
const y = scrollY;
|
||||||
|
try {
|
||||||
|
document.getElementById('app')?.replaceChildren(PAGE === 'epic' ? epicPage(data) : ticketPage(data));
|
||||||
|
} catch (err) { fatal('The page could not render this data: ' + err.message); console.error(err); }
|
||||||
|
liveNote();
|
||||||
|
scrollTo(0, y);
|
||||||
|
}
|
||||||
|
addEventListener('hashchange', () => last && draw());
|
||||||
|
|
||||||
|
// ── Ticket page ──────────────────────────────────────────────────────────
|
||||||
|
const TABS = ['Ticket', 'Plan', 'Progress', 'Proof', 'Review'];
|
||||||
|
const route = () => { const [tab, pick] = location.hash.slice(1).split('/'); return { tab: TABS.includes(tab) ? tab : null, pick }; };
|
||||||
|
|
||||||
|
/** @param {Ticket} t */
|
||||||
|
function phase(t) {
|
||||||
|
const pl = t.plan;
|
||||||
|
if (t.review.preMr?.verdict === 'Ready to Open MR') return ['Ready to Open MR', 'ok'];
|
||||||
|
if (!pl) return ['Not planned', ''];
|
||||||
|
if (!pl.approvedAt) return ['Plan ready for review', 'warn'];
|
||||||
|
const run = pl.steps.find(s => s.status === 'running');
|
||||||
|
if (run) return [`Building step ${run.n}`, 'run'];
|
||||||
|
if (pl.steps.every(s => s.status === 'done')) return [t.review.preMr ? `pre-mr-review: ${t.review.preMr.verdict}` : 'Verifying', 'run'];
|
||||||
|
return ['Building', 'run'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {Ticket} t */
|
||||||
|
function ticketPage(t) {
|
||||||
|
const r = route();
|
||||||
|
const tab = r.tab || (t.plan ? (t.plan.approvedAt ? 'Progress' : 'Plan') : 'Ticket');
|
||||||
|
const pl = t.plan;
|
||||||
|
const count = xs => [xs.filter(x => x.status === 'done').length, xs.length];
|
||||||
|
const crit = pl ? count(pl.criteria) : [0, 0], steps = pl ? count(pl.steps) : [0, 0];
|
||||||
|
const [phaseText, tone] = phase(t);
|
||||||
|
const openFindings = t.review.findings.filter(f => f.status === 'open').length;
|
||||||
|
const badge = { Plan: pl && !pl.approvedAt ? 'review' : null, Progress: pl ? `${steps[0]}/${steps[1]}` : null, Proof: t.proof.length || null,
|
||||||
|
Review: t.review.preMr?.verdict === 'Ready to Open MR' ? 'Ready' : openFindings || null };
|
||||||
|
const header = h('div.top', h('div.wrap',
|
||||||
|
h('div.head',
|
||||||
|
h('div',
|
||||||
|
h('div.crumbs', t.epic ? [h('a', { href: '../index.html' }, `${t.epic.key} ${t.epic.title}`), ' / '] : null, t.key),
|
||||||
|
h('h1', t.title),
|
||||||
|
h('div.meta', pill(phaseText, tone), h('span', [t.type, t.estimate].filter(Boolean).join(', ')),
|
||||||
|
t.branch ? h('span', 'Branch ', h('code', t.branch)) : null, h('a', { href: t.jira, target: '_blank', rel: 'noreferrer' }, 'Open in Jira'), h('span.live'))),
|
||||||
|
pl ? h('div.meters',
|
||||||
|
h('div.row', h('span', 'Acceptance criteria'), h('b', `${crit[0]} of ${crit[1]}`), bar(crit)),
|
||||||
|
h('div.row', h('span', 'Steps'), h('b', `${steps[0]} of ${steps[1]}`), bar(steps))) : h('div')),
|
||||||
|
h('nav.tabs', TABS.map(x => h('a', { href: '#' + x, 'aria-current': x === tab ? 'page' : null }, x, badge[x] ? h('span.pill.n', badge[x]) : null)))));
|
||||||
|
return h('div', header, h('main.wrap', TICKET_TABS[tab](t, r.pick)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function videoBlock(a) {
|
||||||
|
return keep('video:' + a.path, () => {
|
||||||
|
const player = /** @type {HTMLVideoElement} */ (h('video', { src: a.path, poster: a.frames?.[0], controls: true, preload: 'metadata' }));
|
||||||
|
const segs = a.transcript || [];
|
||||||
|
const rows = segs.map(seg => h('button', { onclick: () => { player.currentTime = seg.t; player.play().catch(() => {}); } }, h('span.t', mmss(seg.t)), h('span', seg.text)));
|
||||||
|
const follow = () => {
|
||||||
|
let i = segs.findIndex(s => s.t > player.currentTime) - 1;
|
||||||
|
if (i < -1) i = segs.length - 1;
|
||||||
|
rows.forEach((row, j) => row.classList.toggle('now', j === i));
|
||||||
|
};
|
||||||
|
player.addEventListener('timeupdate', follow);
|
||||||
|
player.addEventListener('seeked', follow);
|
||||||
|
const frames = a.frames?.length ? h('div.frames', a.frames.map(f => h('a', { href: f, target: '_blank' }, h('img', { src: f, alt: 'Frame', loading: 'lazy' })))) : null;
|
||||||
|
return h('div.card', h('h2', a.name),
|
||||||
|
h('div.video', h('div', player, frames), segs.length ? h('div.transcript', rows) : h('p.muted', 'No transcript. Transcribe it and run ticket-page scan.')),
|
||||||
|
segs.length ? h('p.muted', { style: 'margin-top:8px;font-size:.85em' }, 'Click a transcript line to jump there.') : null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function artboard(m) {
|
||||||
|
return keep('mock:' + m.id + ':' + m.html.length + ':' + m.html.slice(0, 64), () => {
|
||||||
|
const f = /** @type {HTMLIFrameElement} */ (h('iframe.artboard', { title: m.title, sandbox: '' }));
|
||||||
|
f.srcdoc = m.html;
|
||||||
|
return f;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @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 }));
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
const pending = m => h('div.compare',
|
||||||
|
h('figure', h('figcaption', 'Mock, drawn at plan time'), artboard(m)),
|
||||||
|
h('figure', h('figcaption', 'Proof, captured from the build'), h('div.placeholder', 'Not captured yet. It appears here when the proof is recorded.')));
|
||||||
|
|
||||||
|
function timeline(events) {
|
||||||
|
if (!events.length) return h('p.muted', 'Nothing yet.');
|
||||||
|
let lastDay = '';
|
||||||
|
return h('ul.timeline', events.slice().reverse().map(e => {
|
||||||
|
const d = day(e.at), label = d !== lastDay ? d : ''; lastDay = d;
|
||||||
|
return h('li', h('time', { title: new Date(e.at).toLocaleString() }, label ? h('div', label) : null, clock(e.at)), h('span.dot.' + e.kind), h('span', e.text, e.commit ? [' ', h('span.sha', e.commit)] : null));
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const TICKET_TABS = {
|
||||||
|
/** @param {Ticket} t */
|
||||||
|
Ticket: t => {
|
||||||
|
const media = t.attachments.filter(a => a.kind === 'video' || a.kind === 'audio');
|
||||||
|
const images = t.attachments.filter(a => a.kind === 'image');
|
||||||
|
const files = t.attachments.filter(a => a.kind === 'file');
|
||||||
|
return h('div.stack',
|
||||||
|
h('div.grid2',
|
||||||
|
h('div.stack', t.source.sections.length ? t.source.sections.map(s => h('div.card', h('h2', s.title, s.origin === 'note' ? h('span.note-tag', 'notes') : null), h('div.jira', html(s.html))))
|
||||||
|
: h('div.card', h('p.muted', 'The ticket has no written content.'))),
|
||||||
|
h('div.stack',
|
||||||
|
h('div.card', h('h2', 'Details'), h('table.kv', t.source.fields.map(([k, v]) => h('tr', h('td', k), h('td', v))))),
|
||||||
|
t.links.length ? h('div.card', h('h2', 'Linked issues'), h('ul', t.links.map(l => h('li', l.type, ' ', h('code', l.key), ' ', l.title)))) : null,
|
||||||
|
files.length ? h('div.card', h('h2', 'Files'), h('ul', files.map(f => h('li', h('a', { href: f.path }, f.name), h('span.muted', ' ' + size(f.bytes)))))) : null)),
|
||||||
|
media.map(videoBlock),
|
||||||
|
images.length ? h('div.card', h('h2', 'Images'), h('div.images', images.map(i => h('a', { href: i.path, target: '_blank' }, h('img', { src: i.path, alt: i.name, loading: 'lazy' }))))) : null);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** @param {Ticket} t */
|
||||||
|
Plan: t => {
|
||||||
|
const pl = t.plan;
|
||||||
|
if (!pl) return h('p.empty', 'No plan yet.');
|
||||||
|
return h('div.grid2',
|
||||||
|
h('div.stack',
|
||||||
|
h('div.card', h('h2', 'Acceptance criteria'), pl.criteria.map(c => h('div.check', mark(c.status),
|
||||||
|
h('div.body', h('div', h('b', c.id + ' '), c.text), h('div.sub', c.steps.length ? 'Steps ' + c.steps.join(', ') : 'No step named')), seen(t.key, c.id)))),
|
||||||
|
h('div.card', h('h2', 'Approach'), h('div.jira', html(pl.approach))),
|
||||||
|
pl.mocks.length ? h('div.card', h('h2', 'Mocks'), pl.mocks.map(m => h('div', h('h3', m.title), artboard(m), m.note ? h('p.muted', { style: 'font-size:.85em;margin-top:6px' }, m.note) : null))) : null),
|
||||||
|
h('div.stack',
|
||||||
|
h('div.card', h('h2', 'Open questions'), pl.questions.length ? h('div.callout', h('ul', pl.questions.map(q => h('li', html(q))))) : h('p.muted', 'None.')),
|
||||||
|
pl.decisions.length ? h('div.card', h('h2', 'Decisions'), h('table', pl.decisions.map(([q, c, why]) => h('tr', h('td', h('b', q), h('div.muted', { style: 'font-size:.9em' }, why)), h('td', c))))) : null,
|
||||||
|
h('div.card', h('h2', 'Steps'), h('ol', { style: 'padding-left:22px;margin:0' }, pl.steps.map(s => h('li', s.text, s.kind !== 'code' ? [' ', pill(s.kind)] : null, s.tdd ? [' ', pill('test first')] : null)))),
|
||||||
|
h('div.card', h('h2', 'Risks and edge cases'), pl.risks.length ? h('ul', pl.risks.map(r => h('li', h('b', r.risk + '. '), r.handling, r.fromTicket ? [' ', pill('ticket')] : null))) : h('p.muted', 'None listed.')),
|
||||||
|
h('div.card', h('h2', 'Test plan'), pl.tests.length ? h('ul', pl.tests.map(x => h('li', pill(x.kind), ' ', x.text, x.fromTicket ? [' ', pill('ticket')] : null))) : h('p.muted', 'None listed.'))));
|
||||||
|
},
|
||||||
|
|
||||||
|
/** @param {Ticket} t @param {string} pick */
|
||||||
|
Progress: (t, pick) => {
|
||||||
|
const pl = t.plan;
|
||||||
|
if (!pl) return h('p.empty', 'No plan yet.');
|
||||||
|
const count = xs => [xs.filter(x => x.status === 'done').length, xs.length];
|
||||||
|
const [cd, ct] = count(pl.criteria), [sd, st] = count(pl.steps);
|
||||||
|
const open = t.review.findings.filter(f => f.status === 'open').length;
|
||||||
|
const review = t.review.preMr ? t.review.preMr.verdict : t.review.findings.length ? `${open} open` : 'Not yet';
|
||||||
|
const tiles = h('div.tiles', [[`${cd}/${ct}`, 'Criteria met'], [`${sd}/${st}`, 'Steps committed'], [String(t.proof.length), 'Proof captured'], [review, 'Review']]
|
||||||
|
.map(([n, l]) => h('div.tile', h('div.n', n), h('div.l', l))));
|
||||||
|
const step = n => pl.steps.find(s => s.n === n);
|
||||||
|
const c = pl.criteria.find(x => x.id === pick) || pl.criteria.find(x => x.status !== 'done') || pl.criteria[0];
|
||||||
|
const spine = h('div.spine', pl.criteria.map(x => h('button', { 'aria-current': x === c ? 'true' : null, onclick: () => { location.hash = `Progress/${x.id}`; } },
|
||||||
|
mark(x.status), h('span.id', x.id),
|
||||||
|
h('span', x.text, h('div.dots', { title: 'Steps behind this criterion' }, x.steps.map(n => h('i' + (step(n)?.status === 'done' ? '.on' : step(n)?.status === 'running' ? '.run' : ''))))))));
|
||||||
|
let detail = h('div.card', h('p.muted', 'The plan has no acceptance criteria.'));
|
||||||
|
if (c) {
|
||||||
|
const proofs = t.proof.filter(p => p.proves.includes(c.id));
|
||||||
|
const risks = pl.risks.filter(r => r.criteria?.includes(c.id));
|
||||||
|
const tests = pl.tests.filter(x => x.criteria?.includes(c.id));
|
||||||
|
const mocks = pl.mocks.filter(m => m.criteria.includes(c.id) && !proofs.some(p => p.mock === m.id));
|
||||||
|
detail = h('div.card',
|
||||||
|
h('div', { style: 'display:flex;justify-content:space-between;gap:12px;align-items:baseline' }, h('h2', c.id, ' ', c.status === 'done' ? pill('met ' + clock(c.doneAt), 'ok') : pill('open')), seen(t.key, c.id)),
|
||||||
|
h('p', c.text), c.evidence ? h('p.muted', c.evidence) : null,
|
||||||
|
h('dl.trace',
|
||||||
|
h('dt', 'Steps'), h('dd', c.steps.length ? c.steps.map(n => { const s = step(n); return s && h('div', { style: 'display:flex;gap:8px;margin-bottom:4px' }, mark(s.status), h('span', `${s.n}. ${s.text} `, s.commits.map(x => h('span.sha', x + ' ')))); }) : h('span.muted', 'None named')),
|
||||||
|
h('dt', 'Tests'), h('dd', tests.length ? tests.map(x => h('div', pill(x.kind), ' ', x.text)) : h('span.muted', 'None tied to this one')),
|
||||||
|
h('dt', 'Risks'), h('dd', risks.length ? risks.map(r => h('div', h('b', r.risk + '. '), r.handling)) : h('span.muted', 'None tied to this one'))),
|
||||||
|
proofs.map(p => h('div', { style: 'margin-bottom:14px' }, h('h3', p.caption), compare(t, p))),
|
||||||
|
mocks.map(m => h('div', h('h3', m.title), pending(m))),
|
||||||
|
!proofs.length && !mocks.length ? h('p.muted', 'No proof recorded for this criterion yet.') : null);
|
||||||
|
}
|
||||||
|
const steps = h('div.card', h('h2', 'Steps'), pl.steps.map(s => h('div.check', mark(s.status),
|
||||||
|
h('div.body', h('div', h('b', s.n + '. '), s.text, s.kind !== 'code' ? [' ', pill(s.kind)] : null),
|
||||||
|
h('div.sub', s.status === 'done' ? [s.commits.map(x => h('span.sha', x + ' ')), 'at ', clock(s.doneAt)] : s.status === 'running' ? 'In progress' : 'Not started')))));
|
||||||
|
return h('div', tiles, h('div.split', spine, detail), h('div.grid2', steps, h('div.card', h('h2', 'Live log'), timeline(t.events))));
|
||||||
|
},
|
||||||
|
|
||||||
|
/** @param {Ticket} t */
|
||||||
|
Proof: t => {
|
||||||
|
const mocks = t.plan?.mocks || [];
|
||||||
|
const waiting = mocks.filter(m => !t.proof.some(p => p.mock === m.id));
|
||||||
|
if (!t.proof.length && !waiting.length) return h('p.empty', 'No proof yet.');
|
||||||
|
return h('div.stack',
|
||||||
|
t.proof.map(p => h('div.card', h('h2', p.caption),
|
||||||
|
h('p.muted', 'Proves ', p.proves.join(', '), '. ', p.file ? ['File ', h('code', p.file), ', for you to attach in Jira.'] : 'Named test.'), compare(t, p))),
|
||||||
|
waiting.map(m => h('div.card', h('h2', m.title), pending(m))));
|
||||||
|
},
|
||||||
|
|
||||||
|
/** @param {Ticket} t */
|
||||||
|
Review: t => {
|
||||||
|
const R = t.review;
|
||||||
|
return h('div.grid2',
|
||||||
|
h('div.card', h('h2', 'Code review'), R.findings.length ? R.findings.map(f => h('div.check', mark(f.status === 'open' ? 'running' : 'done'),
|
||||||
|
h('div.body', h('div', pill(f.severity, f.severity === 'CONFIRMED' ? 'warn' : ''), ' ', h('b', f.id + ' '), f.text),
|
||||||
|
h('div.sub', f.where ? [h('code', f.where), ' '] : null, f.status === 'fixed' ? ['Fixed in ', h('span.sha', f.commit)] : f.status === 'rejected' ? 'Rejected: ' + f.reason : 'Open'))))
|
||||||
|
: h('p.muted', 'No reviewer has run yet.')),
|
||||||
|
h('div.stack',
|
||||||
|
h('div.card', h('h2', 'pre-mr-review'), R.preMr ? [h('p', pill(R.preMr.verdict, R.preMr.verdict === 'Ready to Open MR' ? 'ok' : 'warn')),
|
||||||
|
h('p', R.preMr.recommendation ? ['Recommendation: ', h('b', R.preMr.recommendation), '. '] : null, `Run ${R.preMr.runs} time${R.preMr.runs === 1 ? '' : 's'}`, R.preMr.head ? [' at ', h('span.sha', R.preMr.head)] : null, '.')]
|
||||||
|
: h('p.muted', 'Not run yet. You run it from your work account.')),
|
||||||
|
h('div.card', h('h2', 'Jira fields'), t.jiraFields.length ? h('ul', t.jiraFields.map(f => h('li', f.name, h('span.muted', ' ' + clock(f.at))))) : h('p.muted', 'Filled after the verdict.')),
|
||||||
|
h('div.card', h('h2', 'MR description'), t.mr ? h('p', h('code', 'mr.md'), ' written ', day(t.mr.at), ' ', clock(t.mr.at), '.') : h('p.muted', 'Written once the verdict is Ready to Open MR.'))));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Epic page ────────────────────────────────────────────────────────────
|
||||||
|
const STATUS = { todo: ['To do', ''], planned: ['Planned', 'warn'], building: ['Building', 'run'], verifying: ['Verifying', 'run'], ready: ['Ready for MR', 'ok'], done: ['Done', 'ok'] };
|
||||||
|
|
||||||
|
/** @param {Epic} e */
|
||||||
|
function epicPage(e) {
|
||||||
|
document.body.classList.add('epic');
|
||||||
|
const byKey = Object.fromEntries(e.stories.map((s, i) => [s.key, { ...s, n: i + 1 }]));
|
||||||
|
const done = e.stories.filter(s => s.status === 'done');
|
||||||
|
const hours = e.stories.reduce((a, s) => a + (s.hours || 0), 0), doneH = done.reduce((a, s) => a + (s.hours || 0), 0);
|
||||||
|
const ready = s => s.status === 'todo' && s.after.every(k => byKey[k]?.status === 'done');
|
||||||
|
const next = e.stories.find(s => ['planned', 'building', 'verifying', 'ready'].includes(s.status)) || e.stories.find(ready);
|
||||||
|
return h('div',
|
||||||
|
h('div.top', h('div.wrap', h('div.head', h('div',
|
||||||
|
h('div.crumbs', e.key), h('h1', e.title),
|
||||||
|
h('div.meta', h('span', `${done.length} of ${e.stories.length} stories done`), hours ? h('span', `${doneH} of ${hours}h`) : null,
|
||||||
|
h('a', { href: e.jira, target: '_blank', rel: 'noreferrer' }, 'Open in Jira'), h('span.live'))),
|
||||||
|
h('div.meters', h('div.row', h('span', 'Stories done'), h('b', `${done.length} of ${e.stories.length}`), bar([done.length, e.stories.length])))))),
|
||||||
|
h('main.wrap',
|
||||||
|
e.why.length ? h('div.card.why', h('b', 'Build order, and why it is this order'), h('ol', e.why.map(w => h('li', w)))) : null,
|
||||||
|
e.stories.length ? h('div.seq', e.stories.map((s, i) => {
|
||||||
|
const [label, tone] = STATUS[s.status] || [s.status, ''];
|
||||||
|
const p = s.progress;
|
||||||
|
return h('div.row.' + s.status + (s === next ? '.next' : ''),
|
||||||
|
h('div.n', i + 1),
|
||||||
|
h('div.t', h('a', { href: s.page ? `${s.key}/index.html` : null, title: s.page ? 'Open the ticket page' : 'No ticket page yet' }, s.title), ' ', h('span.k', s.key),
|
||||||
|
s.after.length ? h('div.after', 'After ', s.after.map(k => '#' + (byKey[k]?.n ?? '?')).join(', ')) : null),
|
||||||
|
h('div.prog', s.status === 'todo' || !p ? h('span', ready(s) ? 'Unblocked, ready to start' : s.after.length ? 'Waiting on earlier stories' : '') : [h('span', `Criteria ${p.criteria.join('/')} · Steps ${p.steps.join('/')}`), bar(p.steps)]),
|
||||||
|
h('div.meta', pill(s === next && s.status === 'todo' ? 'Up next' : label, s === next && s.status === 'todo' ? 'run' : tone),
|
||||||
|
h('span', [s.hours ? s.hours + 'h' : '', s.risk ? h('span.risk-' + s.risk, s.risk) : ''].filter(Boolean).flatMap((x, j) => j ? [' · ', x] : [x]))));
|
||||||
|
})) : h('p.empty', 'No stories yet.'),
|
||||||
|
e.closed.length ? h('p.muted', { style: 'margin-top:18px;font-size:.9em' }, 'Closed: ', e.closed.map(c => `${c.key} ${c.title}`).join(', '), '.') : null));
|
||||||
|
}
|
||||||
|
|
||||||
|
load();
|
||||||
|
setInterval(load, 2000);
|
||||||
|
})();
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<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}}">
|
||||||
|
</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>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -55,26 +55,30 @@ Removing patterns is half the job. Sterile, voiceless writing is just as obvious
|
|||||||
18. **Decorative emojis.** Remove from headings and bullets.
|
18. **Decorative emojis.** Remove from headings and bullets.
|
||||||
19. **Curly quotes.** Replace with straight quotes.
|
19. **Curly quotes.** Replace with straight quotes.
|
||||||
|
|
||||||
|
### Spelling
|
||||||
|
|
||||||
|
20. **British spelling.** The author is American. Catch -our (behaviour, flavour, colour, favour, honour), -ise and -ised (recognise, organise, authorise, prioritise, summarise, initialise), -yse (analyse), -re (centre, metre), -logue (catalogue, dialogue), doubled l (travelling, labelled, modelling), and the odd ones (licence as a noun, judgement, programme, grey, whilst). Fix them to the American form. Two exceptions: a word that is the project's own established vocabulary, spelled that way in the client's documents, the ticket titles and the codebase alike (on APSCA, "enrolment" and "cancelled"), and an existing code identifier, enum value, event type or template key, which is never renamed to fix spelling. Check the codebase once before deciding a word is project vocabulary, and do not flip it back and forth.
|
||||||
|
|
||||||
### Communication artifacts
|
### Communication artifacts
|
||||||
|
|
||||||
20. **Chatbot phrases.** "I hope this helps!", "Let me know if...", "Of course!", "Certainly!", "Found the smoking gun!" Remove.
|
21. **Chatbot phrases.** "I hope this helps!", "Let me know if...", "Of course!", "Certainly!", "Found the smoking gun!" Remove.
|
||||||
21. **Cutoff disclaimers.** "While specific details are limited..." Find sources or remove.
|
22. **Cutoff disclaimers.** "While specific details are limited..." Find sources or remove.
|
||||||
22. **Sycophantic tone.** "Great question! You're absolutely right!" Respond directly.
|
23. **Sycophantic tone.** "Great question! You're absolutely right!" Respond directly.
|
||||||
|
|
||||||
### Filler
|
### Filler
|
||||||
|
|
||||||
23. **Filler phrases.** "In order to" becomes "To". "Due to the fact that" becomes "Because". "It is important to note that" gets deleted.
|
24. **Filler phrases.** "In order to" becomes "To". "Due to the fact that" becomes "Because". "It is important to note that" gets deleted.
|
||||||
24. **Excessive hedging.** "could potentially possibly be argued that it might" becomes "may".
|
25. **Excessive hedging.** "could potentially possibly be argued that it might" becomes "may".
|
||||||
25. **Generic conclusions.** "The future looks bright." State specific plans or facts.
|
26. **Generic conclusions.** "The future looks bright." State specific plans or facts.
|
||||||
|
|
||||||
### Jargon
|
### Jargon
|
||||||
|
|
||||||
26. **Abstract metaphor nouns.** Substrate, wedge, vector, locus, vantage, nexus, primitive (as noun), harness (as metaphor), surface (as in "API surface"), bedrock, scaffolding (as metaphor), modality, paradigm, gold-plating, ratchet (as metaphor), evacuate (for moving code), endgame, north star, flywheel. These read as technical but usually have a plainer concrete word. "Substrate" becomes "base". "Wedge in" becomes "add". "Vector" becomes "way" or "method". "Gold-plating" becomes "more than the job needs". "Ratchet" becomes the mechanism's real name or "a limit that only tightens". "Evacuate" becomes "move out". "Endgame" becomes "the last phase". Pick the concrete word.
|
27. **Abstract metaphor nouns.** Substrate, wedge, vector, locus, vantage, nexus, primitive (as noun), harness (as metaphor), surface (as in "API surface"), bedrock, scaffolding (as metaphor), modality, paradigm, gold-plating, ratchet (as metaphor), evacuate (for moving code), endgame, north star, flywheel. These read as technical but usually have a plainer concrete word. "Substrate" becomes "base". "Wedge in" becomes "add". "Vector" becomes "way" or "method". "Gold-plating" becomes "more than the job needs". "Ratchet" becomes the mechanism's real name or "a limit that only tightens". "Evacuate" becomes "move out". "Endgame" becomes "the last phase". Pick the concrete word.
|
||||||
|
|
||||||
### Plain speech
|
### Plain speech
|
||||||
|
|
||||||
27. **Say what it does, not how it feels.** "the database stays close at hand", "SQL you can read", "types that follow your schema" name a feeling. The fix names the mechanism or a number: "`.toSQL()` returns the exact string sent to the database", "a column rename fails the build". Ask what the sentence tells the reader to do or know, then write that. If you can't restate it as a concrete instruction, fact, or number, cut it. One more check: if the sentence could appear unchanged in another project's docs, it says nothing about this one. Cut it.
|
28. **Say what it does, not how it feels.** "the database stays close at hand", "SQL you can read", "types that follow your schema" name a feeling. The fix names the mechanism or a number: "`.toSQL()` returns the exact string sent to the database", "a column rename fails the build". Ask what the sentence tells the reader to do or know, then write that. If you can't restate it as a concrete instruction, fact, or number, cut it. One more check: if the sentence could appear unchanged in another project's docs, it says nothing about this one. Cut it.
|
||||||
28. **Shorten or split dense sentences.** If the reader has to backtrack to parse a sentence, break it in two or drop clauses. One idea per sentence.
|
29. **Shorten or split dense sentences.** If the reader has to backtrack to parse a sentence, break it in two or drop clauses. One idea per sentence.
|
||||||
29. **Active voice.** Prefer it. Catch "is/are/was/were + past participle" and name the actor: "queries are validated" becomes "the compiler validates queries", "the file is parsed by the loader" becomes "the loader parses the file". Passive is fine only when the actor is unknown or genuinely doesn't matter.
|
30. **Active voice.** Prefer it. Catch "is/are/was/were + past participle" and name the actor: "queries are validated" becomes "the compiler validates queries", "the file is parsed by the loader" becomes "the loader parses the file". Passive is fine only when the actor is unknown or genuinely doesn't matter.
|
||||||
30. **Cut adverbs, or use a stronger verb.** "runs quickly" becomes "is fast" or the number. "significantly improves" becomes the measured delta. An adverb propping up a weak verb means the verb is wrong.
|
31. **Cut adverbs, or use a stronger verb.** "runs quickly" becomes "is fast" or the number. "significantly improves" becomes the measured delta. An adverb propping up a weak verb means the verb is wrong.
|
||||||
31. **Prefer the plain word.** "utilize" becomes "use", "leverage" becomes "use", "facilitate" becomes "help", "numerous" becomes "many", "in the event that" becomes "if". The fancier synonym is rarely clearer.
|
32. **Prefer the plain word.** "utilize" becomes "use", "leverage" becomes "use", "facilitate" becomes "help", "numerous" becomes "many", "in the event that" becomes "if". The fancier synonym is rarely clearer.
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ ln -s ../../.agents/skills/<name> ~/.claude/skills/<name>
|
|||||||
Writing a second copy into `~/.claude/skills/` gives you two files that drift, and the drift is
|
Writing a second copy into `~/.claude/skills/` gives you two files that drift, and the drift is
|
||||||
silent because each harness only ever reads its own. One home, one symlink per harness that needs it.
|
silent because each harness only ever reads its own. One home, one symlink per harness that needs it.
|
||||||
|
|
||||||
A skill that only makes sense inside one repo belongs in that repo, at `<repo>/.claude/skills/`.
|
A skill that only makes sense inside one repo lives once at
|
||||||
|
`<repo>/.agents/skills/<name>/`. Point Claude Code at that source with a
|
||||||
|
`<repo>/.claude/skills/<name>` symlink.
|
||||||
|
|
||||||
## Frontmatter
|
## Frontmatter
|
||||||
|
|
||||||
|
|||||||
+6
-7
@@ -28,14 +28,13 @@
|
|||||||
link agents/AGENTS.md ~/.claude/CLAUDE.md
|
link agents/AGENTS.md ~/.claude/CLAUDE.md
|
||||||
link agents/AGENTS.md ~/.codex/AGENTS.md
|
link agents/AGENTS.md ~/.codex/AGENTS.md
|
||||||
|
|
||||||
# Skills, at both paths that look for them. Linked so that a skill installed by
|
# Skills, at both paths that look for them. Each tracked skill is linked from
|
||||||
# any tool lands in the checkout and `panama update` offers to commit it.
|
# this checkout so edits stay shared and `panama update` offers to commit them.
|
||||||
#
|
#
|
||||||
# ~/.agents/skills is this directory and nothing else, so it stays one link.
|
# Both destinations also hold skills Panama itself ships, put there by the
|
||||||
# ~/.claude/skills also holds the skills Panama itself ships, put there by the
|
# link-skills stage, so both are real directories with one link per skill. This
|
||||||
# link-skills stage, so it is a real directory with one link per skill -- and
|
# stage runs after that one, so a personal skill wins a name collision.
|
||||||
# this stage runs after that one, so a personal skill wins a name collision.
|
linkdir agents/skills ~/.agents/skills
|
||||||
link agents/skills ~/.agents/skills
|
|
||||||
linkdir agents/skills ~/.claude/skills
|
linkdir agents/skills ~/.claude/skills
|
||||||
|
|
||||||
link agents/rules ~/.claude/rules
|
link agents/rules ~/.claude/rules
|
||||||
|
|||||||
@@ -14,3 +14,10 @@ Host git.gbrown.org
|
|||||||
User git
|
User git
|
||||||
IdentityFile ~/.ssh/id_ed25519
|
IdentityFile ~/.ssh/id_ed25519
|
||||||
IdentitiesOnly yes
|
IdentitiesOnly yes
|
||||||
|
|
||||||
|
# Agent harnesses (Claude Code, Codex) start their shells without
|
||||||
|
# SSH_AUTH_SOCK, and id_ed25519 has a passphrase, so batch ssh from them fails
|
||||||
|
# with "Permission denied (publickey)" even though the key is right. The
|
||||||
|
# desktop keyring agent already holds the unlocked key; point every host at it.
|
||||||
|
Host *
|
||||||
|
IdentityAgent ${XDG_RUNTIME_DIR}/keyring/ssh
|
||||||
|
|||||||
Reference in New Issue
Block a user