Merge branch 'main' into codex/repo-audit-remediation-package-2

# Conflicts:
#	README.md
#	setup/scripts/install-packages
This commit is contained in:
Gabriel Brown
2026-08-27 16:51:53 -04:00
24 changed files with 605 additions and 114 deletions
@@ -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
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
- `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.
Successful stdout stays quiet. Successful stderr is surfaced as a warning.
- 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-commands` (writes the launcher deep links).
Both take `--check`; `tests/quickshell/settings-docs-contract` fails when stale.
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/panama
+7 -4
View 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 |
| `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-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) |
| `change-settings` | Copies `config/copy/` over `/`, applies gsettings, enables user services |
| `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
server/README.md
skills/ Agent skills for operating this desktop, linked into
~/.claude/skills
~/.agents/skills and ~/.claude/skills
setup/
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
scripts/ Run in order by ./install
tests/ Contracts. See below
@@ -247,7 +250,7 @@ docs/ Settings reference, and the design specs behind the work
## Tests
188 of them, under `tests/`. `tests/contracts.manifest` classifies every
189 of them, under `tests/`. `tests/contracts.manifest` classifies every
contract by the capabilities it needs. Run the hermetic set, or grant a
specific external capability when automation needs it:
+8 -2
View File
@@ -134,7 +134,6 @@ ${BOLD}Examples:${RESET}
$PROGRAM upgrade
$PROGRAM apps
$PROGRAM app
$PROGRAM app chatgpt-desktop
EOF
}
@@ -451,11 +450,16 @@ CONTRACT_CAPABILITIES=(hermetic live-host live-compositor live-desktop network p
contract_paths() {
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
[[ -x "$candidate" || "$candidate" == *_test.py ]] || continue
printf 'tests/%s\n' "${candidate#"$PANAMA_DIR/tests/"}"
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() {
@@ -478,6 +482,8 @@ validate_contract_manifest() {
require_contract_manifest || return 1
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 previous_path="" capability discovered
local -a capability_list=() findings=()
+1
View File
@@ -22,6 +22,7 @@ alias avante='nvim -c "lua vim.defer_fn(function()require(\"avante.api\").zen_mo
alias nlc='nvim leetcode.nvim'
alias clauded='claude --allow-dangerously-skip-permissions'
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'
# Docker Shortcuts
@@ -1805,7 +1805,7 @@ Singleton {
"org.mozilla.thunderbird_esr", "com.slack.Slack",
"app.bluebubbles.BlueBubbles", "rustdesk",
"io.podman_desktop.PodmanDesktop", "com.anthropic.Claude",
"codex-desktop", "md.obsidian.Obsidian",
"chatgpt", "md.obsidian.Obsidian",
"com.obsproject.Studio", "steam"
]
},
+64
View File
@@ -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."
-19
View File
@@ -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
}
+38
View File
@@ -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 }'
```
+28
View File
@@ -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-----
+97
View File
@@ -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
}
+27
View File
@@ -89,6 +89,11 @@ source "$PANAMA_PATH/setup/lib/extras-catalog"
source "$PANAMA_PATH/setup/lib/machine-role"
ROLE="$(panama_role)"
# Establishing the verified ChatGPT repository, shared with the migration that
# replaces the community build, so neither can install it a less careful way.
# shellcheck source=../lib/chatgpt-package
source "$PANAMA_PATH/setup/lib/chatgpt-package"
# One list, installed the way every list is installed: --skip-unavailable so a
# single rotted name cannot cost the transaction, then report_missing so a
# skipped name is a warning somebody reads.
@@ -1299,6 +1304,28 @@ if ! install_claude_desktop_if_trusted; then
softly_failed+=("Claude Desktop")
fi
# ChatGPT Desktop: OpenAI ships an official Linux RPM now. Panama used to build
# a community wrapper from the macOS disk image -- it was `panama app
# chatgpt-desktop` -- because no packaged form existed; that build froze often
# and carried its own local rebuild daemon. The official package is strictly
# better: it comes from a repository, so it upgrades with every other package
# from then on.
#
# The repository and its signing key are established first, from the copy
# pinned in setup/keys/, so dnf verifies the metadata and the package before
# either reaches root. Upstream's own instructions do not allow that -- see
# setup/lib/chatgpt-package for why they are not followed here.
if rpm -q chatgpt >/dev/null 2>&1; then
log "ChatGPT Desktop already installed"
elif ! chatgpt_install_repository sudo; then
log "Could not establish the verified ChatGPT repository; skipping"
softly_failed+=("ChatGPT Desktop")
else
log "Installing ChatGPT Desktop..."
sudo dnf install -y chatgpt > /dev/null \
|| { log "ChatGPT Desktop install failed; skipping"; softly_failed+=("ChatGPT Desktop"); }
fi
# The RPM ships rustdesk.service already enabled, which is what provides
# unattended access; Panama deliberately does not start it a second time.
install_rustdesk || true
+25 -23
View File
@@ -11,12 +11,11 @@
# this repository, not anybody's personal content, so a stranger who clones
# Panama wants it for exactly the same reason its author does.
#
# ~/.claude/skills was a single symlink into user/agents/skills until now, and
# a directory cannot be two things at once. So the destination becomes a real
# directory and every skill -- shipped here, personal from user/ -- is linked
# into it one at a time. link-user runs after this stage on purpose: it links
# last, so a personal skill named like a shipped one wins, which is the
# precedence Claude Code itself uses.
# ~/.agents/skills and ~/.claude/skills may each start as a single symlink into
# user/agents/skills, but a directory cannot point at personal and shipped
# skills at once. Both destinations become real directories with one link per
# skill. link-user runs after this stage on purpose, so a personal skill named
# like a shipped one wins in every agent runtime.
set -euo pipefail
@@ -25,7 +24,7 @@ log() { echo -e "\033[1;34m[INFO]\033[0m $*"; }
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
SKILLS_DIR="$PANAMA_PATH/skills"
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; }
@@ -51,27 +50,30 @@ displace() {
log "Moved existing $destination to $backup"
}
# The destination itself has to be a real directory before anything can be
# linked into it. An old whole-directory symlink is removed; a regular file
# somebody left at this path is kept, in config/old/.
mkdir -p "$(dirname "$DESTINATION")"
if [[ -L "$DESTINATION" ]]; then
rm -f "$DESTINATION"
log "Removed the old $DESTINATION symlink; skills are linked one by one now"
elif [[ -e "$DESTINATION" && ! -d "$DESTINATION" ]]; then
displace "$DESTINATION"
fi
mkdir -p "$DESTINATION"
# Each destination has to be a real directory before anything can be linked
# into it. An old whole-directory symlink is removed; a regular file somebody
# left at the path is kept in config/old/.
for destination in "${DESTINATIONS[@]}"; do
mkdir -p "$(dirname "$destination")"
if [[ -L "$destination" ]]; then
rm -f "$destination"
log "Removed the old $destination symlink; skills are linked one by one now"
elif [[ -e "$destination" && ! -d "$destination" ]]; then
displace "$destination"
fi
mkdir -p "$destination"
done
linked=0
for skill in "$SKILLS_DIR"/*; do
[[ -e "$skill" ]] || continue
name="$(basename "$skill")"
target="$DESTINATION/$name"
displace "$target"
ln -s "$skill" "$target"
log "Linked skills/$name → $target"
for destination in "${DESTINATIONS[@]}"; do
target="$destination/$name"
displace "$target"
ln -s "$skill" "$target"
log "Linked skills/$name → $target"
done
linked=$(( linked + 1 ))
done
+1
View File
@@ -247,6 +247,7 @@ hermetic tests/server/containers-shape-contract
hermetic tests/server/panama-server-contract
hermetic tests/setup/apps-contract
hermetic tests/setup/boot-contract
hermetic tests/setup/chatgpt-package-contract
hermetic tests/setup/contract-manifest-contract
hermetic tests/setup/crash-watch-contract
hermetic tests/setup/desktop-first-contract
-2
View File
@@ -32,8 +32,6 @@ panama="$repo_dir/bin/panama"
findings=()
note() { findings+=("$1"); }
[[ -d "$apps_dir" ]] || { printf 'apps contract: no %s\n' "$apps_dir" >&2; exit 1; }
shopt -s nullglob
definitions=("$apps_dir"/*)
+195
View File
@@ -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'
+7 -1
View File
@@ -11,16 +11,22 @@ manifest="$repo_dir/tests/contracts.manifest"
discover_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
[[ -x "$path" || "$path" == *_test.py ]] || continue
discovered_contracts+=("tests/${path#"$repo_dir/tests/"}")
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() {
local candidate="$1"
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 -a capability_list=()
local -A manifest_paths=() capability_counts=()
+3 -2
View File
@@ -336,8 +336,9 @@ expect_failure load_installer_provenance "$parser_fixture"
installer_fixture="$test_tmp/installer-fixture"
mkdir -p "$installer_fixture/setup/lib" "$installer_fixture/setup/provenance/keys" \
"$installer_fixture/setup/scripts"
cp "$repo_dir/setup/lib/artifact-provenance" "$repo_dir/setup/lib/extras-catalog" \
"$repo_dir/setup/lib/machine-role" "$installer_fixture/setup/lib/"
cp "$repo_dir/setup/lib/artifact-provenance" "$repo_dir/setup/lib/chatgpt-package" \
"$repo_dir/setup/lib/extras-catalog" "$repo_dir/setup/lib/machine-role" \
"$installer_fixture/setup/lib/"
cp "$config" "$installer_fixture/setup/provenance/installers.conf"
cp "$repo_dir"/setup/provenance/keys/*.asc "$installer_fixture/setup/provenance/keys/"
sed '/^# --- The server path/,$d' "$repo_dir/setup/scripts/install-packages" \
+41 -20
View File
@@ -2,7 +2,7 @@
# 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
# 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
@@ -16,7 +16,7 @@
# That is a convention on the prose -- name things exactly, in backticks --
# and it is how they should be written anyway.
# 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
# yet, so this contract is useful while the skills are still being authored.
@@ -32,9 +32,21 @@ manifest="$repo_dir/user/manifest"
findings=()
note() { findings+=("$1"); }
# The three, and where each is delivered from. The two under skills/ are shipped
# to every machine; the third is project-level and needs no delivery at all.
SKILL_DIRS=(skills/panama-desktop skills/panama-sudo .claude/skills/panama)
# Every directory under skills/ ships to every machine. The project-level
# panama skill stays in this repository and needs no home-directory delivery.
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 ─────────────────────────────────────────────────────
@@ -54,7 +66,7 @@ for relative in "${SKILL_DIRS[@]}"; do
present+=("$directory")
# 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")"
if [[ -z "$frontmatter" ]]; then
note "$relative/SKILL.md does not open with a --- frontmatter block"
@@ -207,12 +219,12 @@ else
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
# A checkout and a home of its own. Never the real ones: ~/.claude/skills on
# this machine is somebody's live agent setup, and a contract that broke it
# mid-session would be worse than the bug it was looking for.
# A checkout and a home of its own. Never the real ones: these are somebody's
# live agent setup, and a contract that broke them mid-session would be worse
# than the bug it was looking for.
checkout="$work/Panama"
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"
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.
mkdir -p "$work/personal"
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; }
@@ -231,22 +244,30 @@ else
|| note 'link-skills left ~/.claude/skills a symlink, so nothing else can be linked into it'
[[ -L "$home/.claude/skills/shipped" ]] \
|| 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" \
|| 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
# config/old rather than being deleted, the same promise the other stages
# make. A symlink is not, and must not accumulate there.
rm "$home/.claude/skills/shipped"
mkdir -p "$home/.claude/skills/shipped"
printf 'installed by hand\n' >"$home/.claude/skills/shipped/SKILL.md"
mkdir -p "$home/.claude/skills/untouched"
for skill_home in "$home/.claude/skills" "$home/.agents/skills"; do
rm -f "$skill_home/shipped"
mkdir -p "$skill_home/shipped"
printf 'installed by hand\n' >"$skill_home/shipped/SKILL.md"
mkdir -p "$skill_home/untouched"
done
run
grep -rq 'installed by hand' "$checkout/config/old" 2>/dev/null \
|| note 'link-skills destroyed a real skill instead of moving it to config/old'
[[ -d "$home/.claude/skills/untouched" ]] \
|| note 'link-skills removed a skill it does not ship'
[[ "$(grep -rl 'installed by hand' "$checkout/config/old" 2>/dev/null | wc -l)" == 2 ]] \
|| note 'link-skills did not preserve real skills from both agent homes'
for skill_home in "$home/.claude/skills" "$home/.agents/skills"; do
[[ -d "$skill_home/untouched" ]] \
|| note "link-skills removed an unshipped skill from $skill_home"
done
before="$(find "$checkout/config/old" | wc -l)"
run
@@ -287,8 +308,8 @@ fi
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'
grep -qE '^\s*link\s+agents/skills\s+~/\.agents/skills\s*$' "$manifest" \
|| note '~/.agents/skills is no longer a whole-directory link, and nothing else claims that path'
grep -qE '^\s*linkdir\s+agents/skills\s+~/\.agents/skills\s*$' "$manifest" \
|| note 'the manifest does not use linkdir for ~/.agents/skills, so personal skills would replace shipped skills'
grep -q 'linkdir)' "$user_linker" \
|| note 'link-user does not implement the linkdir kind the manifest asks for'
grep -q 'linkdir' "$repo_dir/user/README.md" \
+12 -7
View File
@@ -73,7 +73,7 @@ cat >"$checkout/user/manifest" <<'FIXTURE'
# a comment, and a blank line follow
link agents/AGENTS.md ~/.claude/CLAUDE.md
link agents/skills ~/.agents/skills
linkdir agents/skills ~/.agents/skills
linkdir agents/skills ~/.claude/skills
copy plain.txt ~/.config/plain.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 ───────────────────────────
# ~/.claude/skills is shared now: link-skills has already made it a real
# directory and linked Panama's own skills into it. A linkdir entry has to land
# beside those rather than replace the directory holding them.
mkdir -p "$home/.claude/skills"
# Both skill homes are shared now: link-skills has already made them real
# directories and linked Panama's own skills into each. Personal linkdir
# entries land beside those rather than replacing either directory.
mkdir -p "$home/.claude/skills" "$home/.agents/skills"
ln -s "$checkout/skills/shipped" "$home/.claude/skills/shipped"
ln -s "$checkout/skills/shipped" "$home/.agents/skills/shipped"
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'
[[ "$(cat "$home/.claude/CLAUDE.md")" == "tracked instructions" ]] \
|| note 'the CLAUDE.md link does not resolve to the tracked file'
[[ -L "$home/.agents/skills" && -f "$home/.agents/skills/example/SKILL.md" ]] \
|| note 'the skills directory was not linked as a directory'
[[ -d "$home/.agents/skills" && ! -L "$home/.agents/skills" ]] \
|| 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" ]] \
|| note 'a linkdir entry replaced its destination directory with a symlink'
[[ -L "$home/.claude/skills/example" && -f "$home/.claude/skills/example/SKILL.md" ]] \
+11 -11
View File
@@ -13,7 +13,7 @@ and one file says where each piece goes.
| 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/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` | |
| `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. |
@@ -27,22 +27,22 @@ destination is empty. `linkdir` is the third, and it exists because one
destination is no longer only ours.
Panama ships its own agent skills now (`skills/`, linked by
`setup/scripts/link-skills`), and they go to `~/.claude/skills` — the same
directory the personal ones went to as a single symlink. A directory cannot be
a symlink to two places, so that entry became `linkdir`: the destination is a
real directory, and each child of `user/agents/skills/` is linked into it
individually. `~/.agents/skills` is still a whole-directory `link`, because
nothing else claims it.
`setup/scripts/link-skills`), and they go to both skill homes. A directory
cannot be a symlink to two places, so both personal entries use `linkdir`: each
destination is a real directory, and each child of `user/agents/skills/` is
linked into it individually.
Two consequences, recorded rather than fixed:
- **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.
That is the intent, and it is the precedence Claude Code uses anyway.
after `link-skills` and displaces what it finds, so the personal one wins in
both skill homes.
- **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
child that did not exist when they were made. Run `panama update` or
`setup/scripts/link-user` after adding one.
child that did not exist when they were made. Put new shared skills in
`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
+26 -12
View File
@@ -1,6 +1,6 @@
---
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 .claude/docs/epics/, write a plan, 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
---
@@ -372,7 +372,7 @@ place from the start.
- Proceed to implementing the existing `plan.md` as-is (they reviewed and approved it)
- Resume implementation (some plan steps are already checked off / some commits
already exist on the ticket branch — pick up from the first unchecked 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.
- **Both `plan.md` and `mr.md` exist** — this ticket looks finished. Tell the user
`mr.md` already exists at its path and ask whether they want you to refresh it
@@ -620,8 +620,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
verdict that gets pasted verbatim into `mr.md`. Anything that runs after it
invalidates that verdict by construction, and you end up rerunning it and rewriting
the handoff. Review first, fix, commit, and only then run `pre-mr-review` over the
final tree.
the handoff. Review first, fix, commit, and only then have the user
run `pre-mr-review` over the final tree.
- **Skip this step for genuinely trivial changes**: a copy tweak, a styling fix, a
one line correction with no logic in it. Run it whenever the change adds or
@@ -671,11 +671,25 @@ 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
local verification are done but this repo has no `pre-mr-review` skill to run, and
let them decide how to proceed.
7. Invoke the `pre-mr-review` skill. Read its 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 invoke `pre-mr-review` again. Repeat until the verdict is Ready
to Open MR. Don't write `mr.md` before that verdict is reached.
7. **Do NOT invoke the `pre-mr-review` skill yourself.** The org asks that this
review run from the developer's separate work Claude account, so the audit on
record must come from there, not from this session. Instead:
1. Tell the user the branch is ready for its pre-mr-review and ask them to run
`/pre-mr-review` from their work account, then let you know when it has
finished. Stop and wait, this is a hard gate.
2. When they say it ran, read the audit and handoff files it wrote under
`.claude/audits/pre-mr/` (the context script from the repo's `pre-mr-review`
skill prints the exact paths). Check the `last_reviewed_head` in the audit
header matches the current HEAD; if the branch moved after their run, say so
and ask them to rerun before acting on a stale audit.
3. Read the verdict.
- **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.
8. Read `~/.agents/skills/ticket/templates/mr.md` — this is the org's MR template,
copied into this skill so it still works even though the original
`.claude/docs/mr/template.md` no longer exists in the command-center repo. `mr.md`
@@ -707,9 +721,9 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
the audit's own words, not a retelling of them. Concretely:
- Never hand-write a handoff section, and never edit one in place. If it is
wrong, thin, or stale, fix the handoff file by rerunning `/pre-mr-review`,
then re-paste.
- On EVERY rerun, replace the whole existing handoff block with the whole
wrong, thin, or stale, ask the user to rerun `/pre-mr-review` from their work
account to regenerate the handoff file, then re-paste.
- On EVERY rerun of theirs, replace the whole existing handoff block with the whole
regenerated one. Do not patch the copy sitting in `mr.md` to match the new
head — that is how the two silently diverge, and the version the reviewer
reads stops being the version the audit actually produced.
@@ -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
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
+6 -7
View File
@@ -28,14 +28,13 @@
link agents/AGENTS.md ~/.claude/CLAUDE.md
link agents/AGENTS.md ~/.codex/AGENTS.md
# Skills, at both paths that look for them. Linked so that a skill installed by
# any tool lands in the checkout and `panama update` offers to commit it.
# Skills, at both paths that look for them. Each tracked skill is linked from
# 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.
# ~/.claude/skills also holds the skills Panama itself ships, put there by the
# link-skills stage, so it is a real directory with one link per skill -- and
# this stage runs after that one, so a personal skill wins a name collision.
link agents/skills ~/.agents/skills
# Both destinations also hold skills Panama itself ships, put there by the
# link-skills stage, so both are real directories with one link per skill. This
# stage runs after that one, so a personal skill wins a name collision.
linkdir agents/skills ~/.agents/skills
linkdir agents/skills ~/.claude/skills
link agents/rules ~/.claude/rules