The machine now carries its own manual for AI hands

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-25 10:09:38 -04:00
parent beed44dd87
commit 045a774847
16 changed files with 1321 additions and 30 deletions
+91
View File
@@ -0,0 +1,91 @@
---
name: panama
description: Use when working on the Panama repository itself — editing anything under config/, setup/, bin/, tests/, or the Quickshell shell and Hyprland Lua it ships. Covers the live-desktop hot-reload rules, the contract-test discipline, the fixture seams, and where the real documentation lives.
---
# panama
You are editing a running desktop, not a codebase that gets deployed later. Every dotfile in this
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:
- `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
thing to know first" and "Never use `hyprctl keyword`" before touching it
- `config/dot/quickshell/modules/settings/README.md` — adding a setting, setting ownership,
"Things that will bite you", and where state lives; the most load-bearing document here
- `docs/settings.md` — generated reference for every settings key (never edit by hand)
## The live-desktop covenant
Editing `config/dot/quickshell/**` hot-reloads the live shell on every save.
1. Every save must leave valid QML. An intermediate broken save is a broken desktop, not a broken
build. Convert a component and its body in ONE edit, never two.
2. After each batch of saves, check the journal for errors AND "Unable to assign" warnings:
`journalctl --user -u panama-quickshell.service --since '-2 minutes' --no-pager`
3. Every `.qml` file in `config/dot/quickshell/modules/*` must be registered in that directory's
`qmldir` in the same save batch. An unregistered component fails the whole configuration and
takes down the bar, dock, and settings together. `tests/quickshell/qmldir-registration-contract`
is pure file inspection — run it before the change lands.
4. Never `qs kill` from a copied configuration: Quickshell derives the shell ID from config
content, so a content-identical harness can share the live shell's ID and kill the desktop.
Harnesses use a distinct entry file, `qs -p`, the PID from `qs list --all`, and `kill` that PID.
5. Never `hyprctl keyword` — Lua-configured Hyprland refuses it, prints to stdout, and exits 0.
`hyprctl eval` also exits 0 on errors. The only proof a write landed is `hyprctl getoption`
reading it back. Batch `hyprctl reload` to one per verified change-set, and say when you do it.
6. IPC: annotate every parameter and return type or Quickshell silently skips registration; never
duplicate an `IpcHandler` target — one silently shadows the other.
7. In `config/dot/quickshell/config/PreferenceSchema.qml`, `readAs` describes the compositor's
ANSWER shape, not the setting's; a wrong one makes every successful write look rejected.
8. No continuously repainting animations (pulse, shimmer, spinners) — they peg the GPU on
high-refresh displays.
9. `hyprlock.conf`, `hypridle.conf`, `hyprpaper.conf`, `hyprtoolkit.conf` never moved to Lua. Do
not "fix" them. Do not install a notification daemon — the shell is one.
10. Scripts carry no `.sh` extension (shebang + `chmod +x`), with one deliberate exception:
`migrations/*.sh`, which `bin/panama-migrate` globs. No secrets under `user/` — it is
world-readable and `tests/setup/user-content-contract` greps for key material.
## The contract discipline
~180 executable contracts under `tests/` pin behavior. The rules:
- `panama contracts <file>` names the contracts that mention a file; run those after touching it.
- During a desktop session, run `panama test --safe [pattern]` — it skips everything listed in
`tests/desktop-hijacking` (contracts that restart the shell, open real overlays, or write
through the live compositor). Run the full `panama test` only with the user's go-ahead, and say
so first: it visibly hijacks the desktop.
- Contracts run directly too: `tests/setup/interview-contract`.
- After changing `PreferenceSchema.qml` or `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.
- `README.md` pins the contract count and the `panama` subcommand list
(`tests/setup/readme-contract`); adding a contract or subcommand means updating it.
## Seams for testing
Helpers are built hermetic: validated inputs, JSON out, secrets on stdin only (never argv).
Contracts reach them through env seams — `PANAMA_NETWORK_HELPER`, `PANAMA_FINGERPRINT_FIXTURE`,
`PANAMA_EXTRAS_DIR`, and the `PANAMA_*_STATIC_ONLY` family that cuts a contract to its
file-inspection half. Prefer exercising a helper through its contract's stubbed PATH over running
it against the real system.
## Safety idioms (contract-enforced)
- Destructive actions are two-stage: `ConfirmAction` with a unique `actionId`, arbitrated through
`ShellState.armedConfirm` — one armed confirm app-wide. Danger tone marks only the confirming
press.
- Failures render through `ErrorRow`; honest empty states through `NotMeasuredRow` with a
`because:`; paragraph-length notes through `SettingsNote`.
- Stored user actions (custom shortcuts, gestures, window rules) are DATA — an enum kind and a
validated target resolved through whitelist tables in `config/dot/hypr/actions.lua`. Nothing in
`~/.config/panama/settings.json` may ever be an executable command; the contracts
(`tests/quickshell/settings-idiom-contract`, `tests/quickshell/keybind-rebind-contract`) pin all
of this.
## Root work
Never bare `sudo` — load the `panama-sudo` skill. Migrations already follow the rule.
+6 -1
View File
@@ -37,6 +37,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-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 |
@@ -156,6 +157,8 @@ config/
extensions, linked into ~/.local/share extensions, linked into ~/.local/share
old/ Backups of whatever was replaced (gitignored) old/ Backups of whatever was replaced (gitignored)
wallpapers/ Copied into ~/Pictures/Wallpapers when absent wallpapers/ Copied into ~/Pictures/Wallpapers when absent
skills/ Agent skills for operating this desktop, linked into
~/.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 lib/ Shared by more than one stage; the extras catalog reader
@@ -167,7 +170,7 @@ docs/ Settings reference, and the design specs behind the work
## Tests ## Tests
177 of them, under `tests/`. Run the lot, or a subset by pattern: 179 of them, under `tests/`. Run the lot, or a subset by pattern:
```sh ```sh
panama test # everything panama test # everything
@@ -210,6 +213,8 @@ panama sync # review, commit and push your changes to this repo
panama edit # open it in Neovim panama edit # open it in Neovim
panama doctor # what is actually running, not what was installed panama doctor # what is actually running, not what was installed
panama test # every contract, or a subset by pattern panama test # every contract, or a subset by pattern
panama test --safe # the same, minus the ones that take over the desktop
panama contracts <file> # which contracts mention a file, and can they be run
panama migrate # apply repairs this machine has not had yet panama migrate # apply repairs this machine has not had yet
panama upgrade # re-run ./install from anywhere, interview and all panama upgrade # re-run ./install from anywhere, interview and all
panama apps # choose applications to install, by category panama apps # choose applications to install, by category
+184 -13
View File
@@ -5,16 +5,17 @@
# Author: Gabriel Brown # Author: Gabriel Brown
# #
# Commands: # Commands:
# update Bring this machine up to date: pull, then install --upgrade # update Bring this machine up to date: pull, then install --upgrade
# sync Review, commit & push local changes to this repo # sync Review, commit & push local changes to this repo
# edit Open the Panama repo in Neovim # edit Open the Panama repo in Neovim
# doctor Report what is actually running on this machine # doctor Report what is actually running on this machine
# test Run every contract under tests/ # test Run every contract under tests/ (--safe skips the hijacking ones)
# upgrade Re-run the installer from anywhere, interview included # contracts Name the contracts that mention a given file
# migrate Apply repairs this machine has not had yet # upgrade Re-run the installer from anywhere, interview included
# apps Choose applications to install, by category # migrate Apply repairs this machine has not had yet
# app Build and install an application that no repository packages # apps Choose applications to install, by category
# help Show this help # app Build and install an application that no repository packages
# help Show this help
# #
# update and sync are deliberately separate verbs. One acts on the machine, the # update and sync are deliberately separate verbs. One acts on the machine, the
# other on the repository, and a single command that guessed between them by # other on the repository, and a single command that guessed between them by
@@ -82,6 +83,11 @@ ${BOLD}Commands:${RESET}
than what was installed. Takes --summary for one line per check. than what was installed. Takes --summary for one line per check.
${GREEN}test${RESET} Run every contract under tests/. Give it a pattern to run ${GREEN}test${RESET} Run every contract under tests/. Give it a pattern to run
a subset: 'panama test dock' runs the ones matching 'dock'. a subset: 'panama test dock' runs the ones matching 'dock'.
--safe skips the ones that take over the live desktop; what
they are and why is tests/desktop-hijacking.
${GREEN}contracts${RESET} Name the contracts that mention a given file, each marked
safe or desktop. A heuristic over the text of tests/, so it
answers "what should I run" rather than "what covers this".
${GREEN}upgrade${RESET} Re-run ./install from anywhere, interview and all. For a new ${GREEN}upgrade${RESET} Re-run ./install from anywhere, interview and all. For a new
machine, or to change an answer you gave. Routine updates are machine, or to change an answer you gave. Routine updates are
'$PROGRAM update', which asks nothing. '$PROGRAM update', which asks nothing.
@@ -106,6 +112,8 @@ ${BOLD}Examples:${RESET}
$PROGRAM edit $PROGRAM edit
$PROGRAM doctor --summary $PROGRAM doctor --summary
$PROGRAM test dock $PROGRAM test dock
$PROGRAM test --safe
$PROGRAM contracts config/dot/quickshell/services/Displays.qml
$PROGRAM upgrade $PROGRAM upgrade
$PROGRAM apps $PROGRAM apps
$PROGRAM app $PROGRAM app
@@ -340,6 +348,32 @@ cmd_doctor() {
exec "$doctor" "$@" exec "$doctor" "$@"
} }
# ----------------------------------------------------------------------------
# The desktop-hijacking ledger
# ----------------------------------------------------------------------------
#
# tests/desktop-hijacking lists, one repo-relative path per line with a '#'
# comment saying what it does to the live session, the contracts that drive the
# real shell, compositor or machine rather than a harness. Read by `test --safe`
# to decide what to skip, and by `contracts` to mark each hit.
#
# Prints the paths, comments and blank lines stripped. A missing ledger prints
# nothing: no ledger means nothing is known to hijack, which is the honest
# reading of an absent file and keeps `--safe` from failing on a fresh checkout.
DESKTOP_HIJACKING_LEDGER="tests/desktop-hijacking"
hijacking_entries() {
local ledger="$PANAMA_DIR/$DESKTOP_HIJACKING_LEDGER" line
[[ -r "$ledger" ]] || return 0
while IFS= read -r line || [[ -n "$line" ]]; do
line="${line%%#*}"
line="${line#"${line%%[![:space:]]*}"}"
line="${line%"${line##*[![:space:]]}"}"
[[ -n "$line" ]] && printf '%s\n' "$line"
done < "$ledger"
return 0
}
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
# Command: test # Command: test
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
@@ -351,9 +385,34 @@ cmd_doctor() {
# Each runs in its own process and a failure does not stop the rest, because the # Each runs in its own process and a failure does not stop the rest, because the
# useful output is the whole list of what is broken rather than the first thing # useful output is the whole list of what is broken rather than the first thing
# that broke. The exit code is what a caller can act on. # that broke. The exit code is what a caller can act on.
#
# --safe exists because a fair number of these contracts ARE the desktop: they
# open overlays, restart the shell, move your windows. Running the suite while
# sitting in front of the machine used to mean losing the session for a few
# minutes, so the honest options were "run everything" or "run nothing". --safe
# is the third: skip exactly what tests/desktop-hijacking names, and say how
# many were skipped, so the gap is stated rather than implied.
cmd_test() { cmd_test() {
local pattern="${1:-}" local pattern="" safe=0 arg
# Position-independent, because 'panama test --safe dock' and
# 'panama test dock --safe' are the same intent and nobody should have to
# remember which one this accepts.
for arg in "$@"; do
case "$arg" in
--safe) safe=1 ;;
*) pattern="$arg" ;;
esac
done
local -a suite=() local -a suite=()
local -A hijacking=()
local skipped=0 entry rel
if (( safe )); then
while IFS= read -r entry; do
hijacking["$entry"]=1
done < <(hijacking_entries)
fi
# Executables, plus the Python suites. Those are unittest files rather than # Executables, plus the Python suites. Those are unittest files rather than
# executables, and collecting only what has the executable bit would skip them # executables, and collecting only what has the executable bit would skip them
@@ -361,11 +420,25 @@ cmd_test() {
# A runner with a blind spot is worse than no runner, because it reports PASS. # A runner with a blind spot is worse than no runner, because it reports PASS.
while IFS= read -r path; do while IFS= read -r path; do
[[ -x "$path" || "$path" == *_test.py ]] || continue [[ -x "$path" || "$path" == *_test.py ]] || continue
[[ -z "$pattern" || "$path" == *"$pattern"* ]] && suite+=("$path") [[ -z "$pattern" || "$path" == *"$pattern"* ]] || continue
rel="tests/${path#"$PANAMA_DIR"/tests/}"
if (( safe )) && [[ -n "${hijacking[$rel]:-}" ]]; then
(( ++skipped ))
continue
fi
suite+=("$path")
done < <(find "$PANAMA_DIR/tests" -type f -not -path '*/fixtures/*' -not -path '*__pycache__*' | sort) done < <(find "$PANAMA_DIR/tests" -type f -not -path '*/fixtures/*' -not -path '*__pycache__*' | sort)
if (( ${#suite[@]} == 0 )); then if (( ${#suite[@]} == 0 )); then
err "No contracts match '${pattern}'" # "Nothing matched" and "everything that matched was skipped" are different
# answers, and reporting the first for the second is how --safe would come
# to look like a broken pattern.
if (( skipped > 0 )); then
err "Every contract matching '${pattern}' is desktop-hijacking; --safe skipped all ${skipped}."
printf ' What they do to the session: %s/%s\n' "$PANAMA_DIR" "$DESKTOP_HIJACKING_LEDGER" >&2
else
err "No contracts match '${pattern}'"
fi
exit 1 exit 1
fi fi
@@ -391,14 +464,111 @@ cmd_test() {
header "Result" header "Result"
if (( ${#failed[@]} == 0 )); then if (( ${#failed[@]} == 0 )); then
ok "${#suite[@]} contract(s) passed" ok "${#suite[@]} contract(s) passed"
if (( safe )); then
printf 'Skipped %d desktop-hijacking contract(s) (%s).\n' "$skipped" "$DESKTOP_HIJACKING_LEDGER"
fi
return 0 return 0
fi fi
err "${#failed[@]} of ${#suite[@]} failed:" err "${#failed[@]} of ${#suite[@]} failed:"
printf ' %s\n' "${failed[@]}" >&2 printf ' %s\n' "${failed[@]}" >&2
warn "Run one on its own to see why: ${BOLD}${PANAMA_DIR}/tests/<name>${RESET}" warn "Run one on its own to see why: ${BOLD}${PANAMA_DIR}/tests/<name>${RESET}"
if (( safe )); then
printf 'Skipped %d desktop-hijacking contract(s) (%s).\n' "$skipped" "$DESKTOP_HIJACKING_LEDGER"
fi
return 1 return 1
} }
# ----------------------------------------------------------------------------
# Command: contracts
# ----------------------------------------------------------------------------
#
# "I changed this file -- what should I run?" There are 177 contracts and no
# index, so the honest answers were "all of them" (minutes, and half of them
# take the desktop away) or "the ones whose name sounds related" (which is how
# a covering contract gets skipped).
#
# This is a grep, and says so. A contract that names the file, or a
# parent-trimmed suffix of it, or just its basename, is a contract worth
# running; one that reaches the file through a harness or a generated artifact
# is not found, which is why the empty answer says "coverage may be indirect"
# rather than "nothing covers this". Naming a file the suite does not mention is
# a real answer -- exit 1 so a script can tell the difference -- but it is a
# statement about this search, not about the file.
#
# Each hit is marked from tests/desktop-hijacking, so the output also answers
# "and can I run them right now".
cmd_contracts() {
local target="${1:-}"
if [[ -z "$target" ]]; then
err "Which file? Usage: ${BOLD}$PROGRAM contracts <file>${RESET}"
exit 1
fi
# Absolute, relative to where you are standing, or repo-relative -- all three
# are how somebody refers to a file in this tree, and readlink resolves the
# symlinked dotfile in ~/.config back into the checkout it points at.
local absolute=""
if [[ -e "$target" ]]; then
absolute="$(readlink -f "$target")"
elif [[ -e "$PANAMA_DIR/$target" ]]; then
absolute="$(readlink -f "$PANAMA_DIR/$target")"
else
err "No such file: '$target'"
printf 'Give a path, absolute or relative to here or to %s.\n' "$PANAMA_DIR" >&2
exit 1
fi
local path
case "$absolute" in
"$PANAMA_DIR"/*) path="${absolute#"$PANAMA_DIR"/}" ;;
*)
err "'$target' is outside the Panama repo (${PANAMA_DIR})."
exit 1
;;
esac
# The repo-relative path, then each parent trimmed off in turn, ending at the
# basename. Contracts refer to their subject every one of these ways: by the
# full path from the repo root, by the path from the shell directory, and by
# name alone.
local -a patterns=()
local suffix="$path"
while :; do
patterns+=(-e "$suffix")
[[ "$suffix" == */* ]] || break
suffix="${suffix#*/}"
done
local -A hijacking=()
local entry
while IFS= read -r entry; do
hijacking["$entry"]=1
done < <(hijacking_entries)
# The same collection `test` runs, so anything named here is something the
# runner would actually execute.
local -a hits=()
local candidate rel
while IFS= read -r candidate; do
[[ -x "$candidate" || "$candidate" == *_test.py ]] || continue
grep -qF "${patterns[@]}" "$candidate" 2>/dev/null || continue
hits+=("tests/${candidate#"$PANAMA_DIR"/tests/}")
done < <(find "$PANAMA_DIR/tests" -type f -not -path '*/fixtures/*' -not -path '*__pycache__*' | sort)
if (( ${#hits[@]} == 0 )); then
printf 'No contract mentions %s — coverage may be indirect (a harness or a generated artifact); nothing verified.\n' "$path" >&2
exit 1
fi
for rel in "${hits[@]}"; do
if [[ -n "${hijacking[$rel]:-}" ]]; then
printf '%s [desktop]\n' "$rel"
else
printf '%s [safe]\n' "$rel"
fi
done
}
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
# Command: upgrade # Command: upgrade
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
@@ -636,6 +806,7 @@ main() {
edit) shift; cmd_edit "$@" ;; edit) shift; cmd_edit "$@" ;;
doctor) shift; cmd_doctor "$@" ;; doctor) shift; cmd_doctor "$@" ;;
test) shift; cmd_test "$@" ;; test) shift; cmd_test "$@" ;;
contracts) shift; cmd_contracts "$@" ;;
upgrade) shift; cmd_upgrade "$@" ;; upgrade) shift; cmd_upgrade "$@" ;;
migrate) shift; cmd_migrate "$@" ;; migrate) shift; cmd_migrate "$@" ;;
app) shift; cmd_app "$@" ;; app) shift; cmd_app "$@" ;;
@@ -0,0 +1,206 @@
# Agent integration: the machine carries its own manual for AI hands
Approved direction (2026-08-25 interview): three Claude-Code skills, delivery approach A
(per-skill symlinks under a real `~/.claude/skills`), a ledger-backed `panama test --safe`,
and `panama contracts <file>`. Factual content only — Gabriel's personal preferences stay in
his own CLAUDE.md and are out of scope. Everything the skills claim is contract-pinned,
because an agent believes the manual verbatim.
## Decisions log
| Decision | Choice |
|---|---|
| Audience | Claude Code skills; no AGENTS.md (YAGNI until another tool is in use) |
| Skill count | Three: `panama` (develop), `panama-desktop` (operate), `panama-sudo` (privilege) |
| Gating | Shipped to every machine, un-gated; personal content stays gated as today |
| Dev-skill home | In-repo `.claude/skills/panama/` (project-level, auto-discovered in the checkout) |
| Global delivery | Approach A: real `~/.claude/skills` dir + per-skill symlinks; new `linkdir` manifest kind for personal skills |
| CLI expansion | Moderate: `panama contracts <file>` + `panama test --safe`; `doctor` already speaks JSON |
| Test safety | `tests/desktop-hijacking` ledger + `--safe` flag + honesty contract |
| Coverage lookup | Heuristic grep, tier-marked from the ledger, honest when empty |
| Skill claims | Pinned by a new `skills-contract` |
## The three skills
House format per `user/agents/skills/writing-for-agents/`: frontmatter is `name` +
`description` only, H1 matches the name, ~100-column wrap, sibling files via relative links.
The rule for all three: **point, don't duplicate**. A skill orients and warns; the facts live
in the documents and commands the repo already maintains, so the rot surface is the pointer,
not the prose. Skills are written by the orchestrator (not fanned out) — they are the heart
of the feature and draw on session knowledge.
### `.claude/skills/panama/` — developing Panama (project-level)
Discovered automatically for any session inside the checkout; needs no delivery machinery and
works in forks. Description triggers on working in the Panama repo. Contents:
1. **Orientation** — one paragraph on what Panama is; pointers: `README.md` (layout, the
`panama` command), `config/dot/hypr/README.md` (Lua-not-hyprlang, the one thing to know
first), `config/dot/quickshell/modules/settings/README.md` (adding a setting, ownership,
"things that will bite you", where state lives), `docs/settings.md` (generated reference).
2. **The live-desktop covenant** — the distilled never-list, each item one line with its
source pointer: every save of `config/dot/quickshell/**` hot-reloads the live shell, so
every save must leave valid QML; check the journal for errors AND "Unable to assign" after
each batch (command form included); components in `modules/*` require same-save qmldir
registration; never `qs kill` from a copied configuration; never `hyprctl keyword` — and
`eval` exits 0 on error, so read back with `getoption`; no duplicate IPC targets; annotate
every IPC parameter and return type; no continuously-repainting animations; batch
compositor reloads and announce them; `readAs` describes the answer, not the setting;
don't touch the hyprlang configs that never moved to Lua; no new notification daemon;
scripts carry no `.sh` extension except `migrations/*.sh`; no secrets under `user/`.
3. **The contract discipline** — contracts live under `tests/`, run via `panama test
[pattern]` or directly; run the covering contracts for what you touched
(`panama contracts <file>` names them); during a desktop session run
`panama test --safe` — the `tests/desktop-hijacking` ledger is the list of what it skips
and why; regenerate `docs/settings.md` and the settings commands after schema/route
changes (`panama-settings-docs`, `panama-settings-commands`, both `--check`-able); the
README pins the contract count and subcommand list.
4. **Seams for testing** — fixture env vars (`PANAMA_*_HELPER`, `PANAMA_*_FIXTURE`,
`PANAMA_*_STATIC_ONLY`), the harness discipline (distinct semantic entry file, `qs -p`,
PID from `qs list --all`, kill only that PID), hermetic-by-construction helpers
(validated inputs, JSON out, stdin-only secrets).
5. **Safety idioms** — ConfirmAction/ErrorRow/NotMeasuredRow/SettingsNote exist; destructive
actions are two-stage; stored actions are data resolved through whitelists, never
commands (`settings-idiom-contract`, `keybind-rebind-contract` pin these).
### `skills/panama-desktop/` — operating the desktop (global)
For an agent doing *any* task on a Panama machine: how to drive the desktop honestly.
Description triggers on desktop interaction (opening settings, checking system health,
screenshots, notifications, audio/network/display questions) on a Panama machine. Contents:
1. `panama doctor` — JSON by default (`schemaVersion` 1), `--summary`, `check <id>`,
`--repair <id>`; the 30 check ids by group.
2. `panama-action <verb>` as the stable boundary for desktop actions (the verb list), and
`qs ipc call <target> <fn>` beneath it for what `panama-action` doesn't cover — pointer
to `shell.qml` for the target table rather than duplicating it.
3. Settings: open pages via `panama-action settings-page <id>`; change values through the
Settings UI or IPC, not by editing `~/.config/panama/settings.json` (the shell's
in-memory model wins for keys it has touched); where state lives (the state-paths table
pointer).
4. `panama-launch --class` for focus-or-launch; `panama test --safe` if asked to verify the
desktop; `panama update` for keeping the machine current.
5. Privilege: one line — load `panama-sudo` before anything needing root.
### `skills/panama-sudo/` — privilege etiquette (global)
Small. Description triggers on any need for root/sudo/pkexec on a Panama machine. Contents:
never bare `sudo`/`pkexec`; use `panama-sudo --reason "why" -- cmd args`; `--reason` must be
first; the reason appears beside (never instead of) polkitd's own action text; degrades to
plain pkexec with no shell running; migrations already follow this rule
(`bin/panama-migrate`). Include the one-line trust model so agents phrase reasons honestly:
state what the command does, not why it is safe.
## Delivery: approach A
### New root `skills/`
`skills/panama-desktop/SKILL.md`, `skills/panama-sudo/SKILL.md` (+ sibling files if needed).
Repo-root `README.md` layout table gains the row (readme-contract pins the layout block).
### New stage `setup/scripts/link-skills`
Un-gated. Behavior:
1. If `~/.claude/skills` is a symlink (today: → `user/agents/skills`), remove it (symlink
removal never displaces to `config/old`, matching `link-user`'s rule) and `mkdir -p` a
real directory.
2. For each child of `$PANAMA_PATH/skills/`, displace any existing real entry at
`~/.claude/skills/<name>` to `config/old/` (house `displace` semantics; symlinks just
removed) and link `~/.claude/skills/<name>` → `$PANAMA_PATH/skills/<name>`.
3. Report `Agent skills: N linked.`
`install` STAGES gains `link-skills` **after `link-dotfiles`, before `link-user`** (personal
must win name collisions by linking last, mirroring Claude Code's personal-over-project
precedence), in both the full list and `upgrade_stages`. `tests/setup/update-command-contract`
pins STAGE_NAMES — its list gets the new entry as part of this work.
### `linkdir` manifest kind (link-user)
`user/manifest` line `link agents/skills ~/.claude/skills` becomes
`linkdir agents/skills ~/.claude/skills`: for each child of the source, displace and link it
as a direct child of the destination (creating the destination as a real directory if
needed). Whole-dir `link` entries (`~/.agents/skills`, CLAUDE.md, rules) are untouched.
`user/README.md` documents the new kind. Known behavior, recorded not fixed: a personal
skill named like a shipped one shadows it (deliberate — personal wins); a *new* personal
skill needs a re-link (`panama update` or `setup/scripts/link-user`) to appear, where the
old whole-dir link showed it instantly.
## CLI additions (`bin/panama`)
House three-touchpoint rule: header comment, `usage()`, `main()` — plus the README
subcommand list (readme-contract cross-checks it).
### `panama contracts <file>`
Heuristic: resolve the argument to a repo-relative path; scan `tests/` (excluding
`fixtures/`, `__pycache__`) for contracts whose text contains the path, any parent-trimmed
suffix of it, or the basename. Output one line per hit: `tests/<name> [safe|desktop]`,
tier read from the ledger. No hits → `No contract mentions <path> — coverage may be
indirect (a harness or a generated artifact); nothing verified.` and exit 1. No options.
### `panama test --safe [pattern]`
`--safe` (position-independent, before or after the pattern): filter out every contract
listed in `tests/desktop-hijacking`, run the rest exactly as today, and end the summary with
`Skipped N desktop-hijacking contract(s) (tests/desktop-hijacking).` Plain `panama test`
is unchanged.
## The ledger: `tests/desktop-hijacking`
Package-list style: one repo-relative contract path per line, `#` comments; each entry's
comment says what it does to the session (restarts the shell; latches caffeine; opens live
overlays; writes every schema key against the real compositor; touches real xdg-mime
defaults; …). Seeded from the tier-3 inventory in the exploration (focus-session-restart,
focus-session-expiry, settings-window, overview-live-actions, switcher, welcome, cheatsheet,
status-events, activity-*, accessibility, calendar-agenda, screen-intelligence,
panama-action-ipc, panama-commands, panama-doctor, polkit-reason, signal-glass,
settings-write-sweep, settings-system, default-apps-*, gnome-handoff — verified individually
during the build, not copied blind).
## New contracts
### `tests/setup/skills-contract`
1. The two shipped skills and `.claude/skills/panama` exist with valid frontmatter (`name`
matches the directory, non-empty `description`, nothing else required).
2. Claim pinning: every `` `panama <sub>` `` subcommand a skill names exists in
`bin/panama`'s dispatcher; every `` `panama-<tool>` `` exists in `bin/` or the
quickshell scripts dir; every backticked repo path exists; every `PANAMA_*` env var it
names appears somewhere in the tree; every `panama-action` verb it names is in
`panama-action`'s dispatch. (Convention this imposes on skill prose: commands, paths and
variables are always backticked and exact — which is how they should be written anyway.)
3. `link-skills` exists, is in STAGES and `upgrade_stages` ordered after `link-dotfiles`
and before `link-user`; the manifest uses `linkdir` for `agents/skills`; `link-user`
implements `linkdir`.
### `tests/setup/desktop-hijacking-contract`
1. Every ledger entry names an existing contract.
2. Honesty sweep: every contract under `tests/` that drives the live shell — matches
`qs ipc call` without booting its own `qs -p` harness, or restarts
`panama-quickshell.service`, or calls `qs kill` — must be listed; the sweep's heuristics
live in this contract so a new hijacking contract cannot stay unlisted.
3. `cmd_test` honors the ledger: static check that `--safe` reads the file.
README test count: +2 (the "N of them" line — orchestrator's, updated at the end).
## Build plan and ownership
- **Orchestrator**: this spec; the three skills' content; final seam audit; README count.
- **Agent A**: `bin/panama` (`cmd_contracts`, `--safe`), `tests/desktop-hijacking` (verifying
each seeded entry by reading the contract), `tests/setup/desktop-hijacking-contract`,
README subcommand-list rows.
- **Agent B**: `setup/scripts/link-skills`, `linkdir` in `setup/scripts/link-user`,
`user/manifest`, `user/README.md`, `install` STAGES + `upgrade_stages`,
`tests/setup/skills-contract`, updates to `tests/setup/update-command-contract` and
`tests/setup/user-content-contract` where their pins move, README layout row.
Disjoint by construction; the skills (orchestrator) reference what A and B build, so the
skills land last and `skills-contract` runs after both.
## Out of scope (recorded)
Generic `AGENTS.md`; a Claude Code plugin; `panama map`/`agent` namespace; JSON output for
`panama test`; shipping any of Gabriel's personal preferences; per-contract header
annotations (the ledger supersedes); auto-relink on personal-skill creation.
+4 -3
View File
@@ -17,7 +17,7 @@
# What --upgrade changes, and nothing else: # What --upgrade changes, and nothing else:
# #
# * The interview is skipped, so every PANAMA_* answer is unset and each # * The interview is skipped, so every PANAMA_* answer is unset and each
# stage takes its documented empty-answer path. Five of the seven need no # stage takes its documented empty-answer path. Six of the eight need no
# answer at all; link-user falls back to the decision it recorded. # answer at all; link-user falls back to the decision it recorded.
# * setup-identity and install-hardware are dropped. They exist only to # * setup-identity and install-hardware are dropped. They exist only to
# consume interview answers -- git identity, NVIDIA, Secure Boot, firmware # consume interview answers -- git identity, NVIDIA, Secure Boot, firmware
@@ -166,7 +166,8 @@ gsettings set org.gnome.desktop.session idle-delay 0 2>/dev/null || true
# which needs both vicinae itself (installed by install-packages) and the # which needs both vicinae itself (installed by install-packages) and the
# theme files it selects among (symlinked into place by link-dotfiles); # theme files it selects among (symlinked into place by link-dotfiles);
# link-user runs before setup-identity so tracked personal content wins over # link-user runs before setup-identity so tracked personal content wins over
# what the interview would otherwise seed; setup-identity needs the gh and # what the interview would otherwise seed, and after link-skills so a personal
# skill wins a name collision with a shipped one; setup-identity needs the gh and
# git-all that install-packages provides; and # git-all that install-packages provides; and
# install-hardware is last because MOK enrollment arms a prompt consumed at the # install-hardware is last because MOK enrollment arms a prompt consumed at the
# next boot and a firmware update may ask for a reboot -- a machine that reboots # next boot and a firmware update may ask for a reboot -- a machine that reboots
@@ -222,7 +223,7 @@ if [[ -n "${PANAMA_HOSTNAME:-}" ]]; then
echo "Hostname set to: $(hostname)" echo "Hostname set to: $(hostname)"
fi fi
STAGES=(install-packages link-dotfiles link-user change-settings link-vicinae-scripts setup-identity install-hardware) STAGES=(install-packages link-dotfiles link-skills link-user change-settings link-vicinae-scripts setup-identity install-hardware)
# 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
# both are first-run decisions: who you are and what hardware this is. Filtered # both are first-run decisions: who you are and what hardware this is. Filtered
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
# Agent skills, shipped to every machine.
#
# Panama is a desktop that an agent is frequently asked to operate: open the
# settings, check what is running, take a screenshot, run something as root. An
# agent that has to infer all of that from the source reliably invents half of
# it, so the machine carries its own manual and skills/ is where it lives.
#
# This stage is un-gated, unlike link-user. What it links is documentation of
# 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.
set -euo pipefail
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"
[[ -d "$SKILLS_DIR" ]] || { log "No skills/ in this checkout; nothing to link."; exit 0; }
mkdir -p "$PANAMA_OLD"
# Moves whatever is already at a destination out of the way, once -- the same
# promise link-dotfiles and link-user make. A symlink is removed rather than
# backed up: it is a pointer this installer made, and keeping copies of it
# would grow config/old/ by one entry on every upgrade.
displace() {
local destination="$1" backup
if [[ -L "$destination" ]]; then
rm -f "$destination"
return 0
fi
[[ -e "$destination" ]] || return 0
backup="$PANAMA_OLD/skills-$(printf '%s' "${destination#"$HOME"/}" | tr '/' '-')"
if [[ -e "$backup" ]]; then
backup="$backup.$(date +%s)"
fi
mv "$destination" "$backup"
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"
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"
linked=$(( linked + 1 ))
done
log "Agent skills: $linked linked."
+25
View File
@@ -95,6 +95,31 @@ while read -r kind source destination; do
log "Linked $source → $dst" log "Linked $source → $dst"
linked=$(( linked + 1 )) linked=$(( linked + 1 ))
;; ;;
linkdir)
# A destination that has to hold more than this one source. The
# only one today is ~/.claude/skills, which now carries Panama's
# own shipped skills as well as these -- so the directory itself
# cannot be a symlink, and each child is linked into it instead.
#
# Whatever link-skills put there is kept; only same-named entries
# are displaced, which is how a personal skill deliberately
# shadows a shipped one. This stage runs last for that reason.
if [[ -L "$dst" ]]; then
rm -f "$dst"
elif [[ -e "$dst" && ! -d "$dst" ]]; then
displace "$dst"
fi
mkdir -p "$dst"
for child in "$src"/*; do
[[ -e "$child" ]] || continue
child_name="$(basename "$child")"
displace "$dst/$child_name"
ln -s "$child" "$dst/$child_name"
log "Linked $source/$child_name → $dst/$child_name"
linked=$(( linked + 1 ))
done
;;
copy) copy)
if [[ -e "$dst" ]]; then if [[ -e "$dst" ]]; then
log "Keeping existing $dst" log "Keeping existing $dst"
+63
View File
@@ -0,0 +1,63 @@
---
name: panama-desktop
description: Use when interacting with the desktop on a Panama machine (Hyprland + the Panama Quickshell shell) during any task — opening settings, checking system health, taking screenshots, toggling Do Not Disturb, launching or focusing applications, or diagnosing audio, network, or display questions.
---
# panama-desktop
This machine runs Panama: Hyprland with a shell that replaces GNOME's bar, dock, notifications,
settings, and lock screen. The desktop has a command surface built for exactly what you are about
to do — use it instead of poking at processes and config files.
## Reading the machine
`panama doctor` prints a JSON health snapshot (`schemaVersion` 1): a `summary` with an overall
status and per-check counts, and `checks[]` with `{id, group, title, status, detail, action}`.
Groups: `desktop-foundation`, `input-media`, `integrations`, `panama-tools`. Check ids follow
`group.name` (`desktop.quickshell`, `input.pipewire`, `integration.kdeconnect`,
`panama.updates` — the full list is in any snapshot). Useful forms:
- `panama doctor --summary` — one human line
- `panama doctor check <id>` — a single check, same envelope
- `panama doctor --repair <id>` — run a check's repair; exit code is the verdict
It reports what is actually running, not what was installed — trust it over `ps` archaeology.
## Driving the desktop
`panama-action <verb>` (on PATH via `~/.config/quickshell/scripts/panama-action`) is the stable
boundary for desktop actions. Verbs: `control-center`, `notifications`, `calendar`, `clipboard`,
`overview`, `settings`, `settings-page <id>`, `dock-pin`, `health`, `dnd`, `caffeine`,
`night-light`, `focus-start`, `focus-end`, `capture`, `intelligence`, `screenshot`, `microphone`,
`gallery`, `restart-shell`.
Beneath it sits `qs ipc call <target> <function>` — the shell's full IPC surface, one
`IpcHandler` per target in `config/dot/quickshell/shell.qml` (status verbs are free to call;
anything else changes live state). Prefer `panama-action` when a verb exists.
Other tools that beat raw commands:
- `panama-launch --class '<regex>' -- cmd…` — focus the window if it exists, launch otherwise
- `panama-sudo --reason "why" -- cmd…` — root work; load the `panama-sudo` skill first
- `panama update` — bring the machine current (pull, repairs, unattended stages); asks nothing
- `panama test --safe` — verify the desktop's contracts without hijacking the session
## Settings
Open a page for the user with `panama-action settings-page <id>` (leaf ids like `displays`,
`sound`, `connectivity`; `docs/settings.md` in the repo documents every key and page).
Change values through the Settings window or the shell's IPC — do NOT hand-edit
`~/.config/panama/settings.json` while the shell runs: the shell's writes merge-and-win for any
key it has touched, so your edit silently loses. The file is the user's to read and back up, not
an API.
## Where state lives (read, don't write)
- `~/.config/panama/settings.json` — every setting; also read by the Hyprland Lua config
- `$XDG_STATE_HOME/panama/` — generated idle/lock configs, settings backups, migration markers
- The repo itself is at `~/.local/share/Panama` (or `$PANAMA_PATH`), and `~/.config` entries are
symlinks into it — a change to either is a change to both
If you find yourself editing files under `~/.config/quickshell` or `~/.config/hypr`, you are
editing the Panama repository on a live desktop — stop and load the `panama` skill first.
+29
View File
@@ -0,0 +1,29 @@
---
name: panama-sudo
description: Use whenever a command needs root on a Panama machine — any sudo, pkexec, or privileged system change. Panama routes privilege through a wrapper that shows the user your reason beside the password prompt.
---
# panama-sudo
Never run bare `sudo` or `pkexec` on a Panama machine. Use:
```sh
panama-sudo --reason "why this needs root" -- command args…
```
`--reason` must be the first argument; the `--` separator is optional. The reason is displayed on
the desktop's own polkit prompt **beside** — never instead of — polkitd's description of the
action, because anything can claim any reason. That is the trust model, and it shapes how you
write the reason: state what the command does, not an assurance that it is safe.
- Good: `--reason "Install the fwupd package so firmware updates can be checked"`
- Bad: `--reason "Safe maintenance task"`
With no `--reason`, no running shell, or no `qs` on PATH, it degrades to plain `pkexec` — so it
is always the right call, even in a TTY or a broken session.
One reason per privileged action. Do not batch unrelated root commands behind a single vague
reason; the user approves what the prompt says, and the prompt should say everything.
House precedent: `bin/panama-migrate` documents the rule — root work goes through
`panama-sudo --reason "..."`, never bare sudo — and every shipped migration follows it.
+99
View File
@@ -0,0 +1,99 @@
# Contracts that take over the desktop you are sitting in front of.
#
# Most of the suite is hermetic: stub commands on PATH, a throwaway HOME, or a
# Quickshell harness booted with `qs -p` under its own semantic entry file. Those
# are safe to run mid-session and are not listed here.
#
# The ones below are not. Each drives the LIVE shell, compositor or machine --
# it opens overlays over whatever you were doing, restarts the running shell,
# moves your windows, changes your monitor, or rewrites a real default. Run
# during a working session they are, at best, a series of surprises; at worst
# they leave state behind when interrupted.
#
# `panama test --safe` skips exactly this list, which is why every entry's
# comment says what it does to the session rather than what it proves.
# tests/setup/desktop-hijacking-contract checks each path exists and sweeps
# tests/ for hijacking contracts that are missing from here.
#
# One repo-relative path per line.
# Pushes a fixture privacy state into the running shell and opens the activity
# panel over the desktop.
tests/quickshell/activity-indicator-contract
# Pushes fixture privacy states and Signal Glass events into the running shell,
# and dismisses the live capsule.
tests/quickshell/activity-state-contract
# Opens the live agenda popover and the notification centre, and starts a real
# focus session on the running shell.
tests/quickshell/calendar-agenda-contract
# Opens and closes the cheatsheet overlay on the running shell.
tests/quickshell/cheatsheet-contract
# Changes the real default browser through the default-apps helper -- xdg-mime
# and xdg-settings defaults on this machine -- and puts it back.
tests/quickshell/default-apps-roles-contract
# Changes the real monitor's mode, position, scale and rotation through the live
# compositor, restoring the display it started from.
tests/quickshell/displays-contract
# Starts, pauses, reveals and ends a real focus session, and opens Mission
# Control on the running shell.
tests/quickshell/focus-session-contract
# Restarts panama-quickshell.service (or `qs kill`s the shell when unsupervised)
# with a focus session in flight; an interrupted run leaves caffeine latched on.
tests/quickshell/focus-session-expiry
# Restarts panama-quickshell.service (or `qs kill`s the shell when unsupervised)
# mid-session to prove a paused focus session survives it.
tests/quickshell/focus-session-restart
# Spawns a real kitty window, moves it between your workspaces and into the
# scratchpad, and opens the overview.
tests/quickshell/overview-live-actions
# Opens the overview on the running shell and types a search into it.
tests/quickshell/overview-search-contract
# Toggles the live caffeine inhibitor and Night Light on the running shell.
tests/quickshell/panama-action-ipc-contract
# Opens and closes the live overview.
tests/quickshell/scratchpad-shelf-contract
# Opens the live screen-intelligence and capture overlays and runs an analysis
# through them.
tests/quickshell/screen-intelligence-contract
# Flips real compositor policy -- gaps, blur, inactive opacity, keyboard layout
# -- on the live compositor and restores it.
tests/quickshell/settings-hyprland-write-contract
# Applies compositor policy through the production write path against the live
# compositor, and writes preferences to the real settings store (no isolated
# config home).
tests/quickshell/settings-system-contract
# Opens the real Settings window, routes it between pages, and closes it through
# the compositor.
tests/quickshell/settings-window-contract
# Flips every compositor-backed setting to a value it does not hold, one at a
# time, on the live compositor.
tests/quickshell/settings-write-sweep-contract
# Publishes a critical fixture event into the live Signal Glass capsule.
tests/quickshell/signal-glass-contract
# Publishes fixture events into the live capsule and toggles Do Not Disturb.
tests/quickshell/status-events-contract
# Drives the live window switcher and commits a switch, which moves your focus.
tests/quickshell/switcher-contract
# Opens and closes the welcome overlay on the running shell.
tests/quickshell/welcome-contract
+184
View File
@@ -0,0 +1,184 @@
#!/usr/bin/env bash
# The desktop-hijacking ledger is complete, and `panama test --safe` obeys it.
#
# `panama test --safe` exists so the suite can be run from inside the session it
# tests. That promise is only as good as tests/desktop-hijacking: a contract
# that takes over the live shell and is not listed there is run by --safe, and
# the desktop goes away in the middle of somebody's work -- with the command
# line having just claimed it would not.
#
# A hand-kept list decays, so it is not trusted on its own. This sweeps tests/
# for the shapes a hijacking contract has and fails on any that are missing from
# the ledger. The heuristics live here, in the thing that runs, so a new
# hijacking contract cannot stay unlisted quietly:
#
# * it calls `qs ipc call` without booting its own `qs -p` harness, so the
# instance answering is the shell you are looking at;
# * it restarts panama-quickshell.service;
# * it calls a bare `qs kill`, which kills that same shell.
#
# The sweep is one direction only. The ledger is deliberately larger than what
# these three shapes find -- a contract that rotates the real monitor or
# rewrites a real xdg-mime default hijacks the session just as thoroughly and
# looks like nothing in particular from the outside -- so an entry the sweep
# does not reach is not a finding.
#
# Nothing here runs a listed contract. The one live check drives `--safe` with a
# pattern that matches only ledger entries, so the run selects them, skips them
# all, and executes nothing.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
ledger="$repo_dir/tests/desktop-hijacking"
panama="$repo_dir/bin/panama"
findings=()
note() { findings+=("$1"); }
[[ -r "$ledger" ]] || { printf 'desktop hijacking contract: %s is missing\n' "$ledger" >&2; exit 1; }
# ── The entries ──────────────────────────────────────────────────────────────
#
# Every line names a contract that exists AND that `panama test` would collect.
# A listed path the runner never picks up (no executable bit, not a *_test.py)
# is skipped by --safe in name only, which reads as protection and is not.
entries=()
commented=()
pending_comment=0
while IFS= read -r line || [[ -n "$line" ]]; do
case "$line" in
'#'*) pending_comment=1; continue ;;
''|[[:space:]]*'') ;;
esac
trimmed="${line%%#*}"
trimmed="${trimmed#"${trimmed%%[![:space:]]*}"}"
trimmed="${trimmed%"${trimmed##*[![:space:]]}"}"
if [[ -z "$trimmed" ]]; then
[[ -z "$line" ]] && pending_comment=0
continue
fi
entries+=("$trimmed")
commented+=("$pending_comment")
pending_comment=0
done < "$ledger"
(( ${#entries[@]} > 0 )) || note 'the ledger lists no contracts at all'
for index in "${!entries[@]}"; do
entry="${entries[$index]}"
path="$repo_dir/$entry"
[[ "$entry" == tests/* ]] \
|| note "\"$entry\" is not a repo-relative path under tests/"
if [[ ! -e "$path" ]]; then
note "the ledger lists $entry, which does not exist"
continue
fi
[[ -x "$path" || "$entry" == *_test.py ]] \
|| note "$entry is listed but 'panama test' would never collect it, so skipping it protects nothing"
# The ledger's whole job is saying what a contract does to the session. An
# entry with no comment is a path somebody has to go and read.
(( commented[index] )) \
|| note "$entry is listed with no comment saying what it does to the live session"
done
duplicates="$(printf '%s\n' "${entries[@]}" | sort | uniq -d)"
[[ -z "$duplicates" ]] || note "the ledger lists these twice: ${duplicates//$'\n'/, }"
# ── The honesty sweep ────────────────────────────────────────────────────────
#
# Whole-line comments are dropped first: contracts discuss `qs ipc call` in
# their headers, and a header is not a call. What survives is matched only at a
# command position -- start of line, or after a pipe, semicolon, &&, (, or ! --
# so the same words quoted inside a grep pattern or a failure message do not
# count as driving anything.
command_position='(^|[|;&({!]|\$\()[[:space:]]*'
is_listed() {
local candidate="$1" listed
for listed in "${entries[@]}"; do
[[ "$listed" == "$candidate" ]] && return 0
done
return 1
}
while IFS= read -r file; do
[[ -x "$file" || "$file" == *_test.py ]] || continue
code="$(grep -v '^[[:space:]]*#' "$file")"
rel="tests/${file#"$repo_dir"/tests/}"
reason=""
if grep -qE "${command_position}qs[[:space:]]+ipc[[:space:]]+call" <<<"$code"; then
# Its own harness means its own Quickshell instance: `qs -p <entry>`
# addresses that root, not the shell running the desktop.
grep -qE "${command_position}[A-Za-z_]*[[:space:]]*=?[[:space:]]*.*qs[[:space:]]+-p" <<<"$code" \
|| reason='calls `qs ipc call` without booting its own `qs -p` harness'
fi
if [[ -z "$reason" ]] && grep -qE "${command_position}systemctl.*restart.*panama-quickshell\.service" <<<"$code"; then
reason='restarts panama-quickshell.service'
fi
if [[ -z "$reason" ]] && grep -qE "${command_position}qs[[:space:]]+kill" <<<"$code"; then
reason='calls a bare `qs kill`, which stops the live shell'
fi
[[ -n "$reason" ]] || continue
is_listed "$rel" \
|| note "$rel $reason, but is not in tests/desktop-hijacking"
done < <(find "$repo_dir/tests" -type f -not -path '*/fixtures/*' -not -path '*__pycache__*' | sort)
# ── --safe actually reads it ─────────────────────────────────────────────────
#
# Static first, because the summary line is the only thing telling a reader that
# anything was left out, and a --safe run that silently skips is worse than one
# that does not skip at all.
if [[ ! -r "$panama" ]]; then
note 'bin/panama is missing'
elif ! test_body="$(sed -n '/^cmd_test()/,/^}/p' "$panama")" || [[ -z "$test_body" ]]; then
note 'cmd_test could not be found in bin/panama'
else
grep -q -- '--safe' <<<"$test_body" \
|| note 'cmd_test does not handle --safe'
grep -qE 'DESKTOP_HIJACKING_LEDGER|desktop-hijacking' <<<"$test_body" \
|| note 'cmd_test never consults the desktop-hijacking ledger, so --safe skips nothing'
grep -qF 'desktop-hijacking contract(s)' <<<"$test_body" \
|| note '--safe no longer reports how many contracts it skipped'
fi
grep -qF 'tests/desktop-hijacking' "$panama" \
|| note 'bin/panama never names tests/desktop-hijacking'
# Then for real. The pattern is the first ledger entry with its 'tests/' prefix
# removed, which cmd_test matches against the full path -- so it selects that
# one contract, --safe removes it, and nothing is left to run. A --safe that
# ignored the ledger would run it instead, which is the failure this catches.
if (( ${#entries[@]} > 0 )) && [[ -x "$panama" ]]; then
probe="${entries[0]#tests/}"
output="$("$panama" test --safe "$probe" 2>&1)"
status=$?
if (( status == 0 )); then
note "'panama test --safe $probe' ran a ledger-listed contract instead of skipping it"
elif ! grep -qF 'desktop-hijacking' <<<"$output"; then
note "'panama test --safe $probe' refused without mentioning the ledger: $output"
fi
fi
if (( ${#findings[@]} > 0 )); then
mapfile -t findings < <(printf '%s\n' "${findings[@]}" | sort -u)
printf 'desktop hijacking contract: %d finding(s)\n' "${#findings[@]}" >&2
printf ' - %s\n' "${findings[@]}" >&2
exit 1
fi
printf 'desktop hijacking contract: PASS (%d contracts listed; the sweep found none unlisted)\n' "${#entries[@]}"
+294
View File
@@ -0,0 +1,294 @@
#!/usr/bin/env bash
# The manual this machine hands an agent.
#
# skills/ and .claude/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
# not check. Prose about design cannot be pinned; the things a skill names can
# be, so this checks every one of them:
#
# 1. The three skills load: SKILL.md with frontmatter whose name is the
# directory's, and a description, which is the only part of a skill the
# loader reads before deciding to open it.
# 2. Every command, path and variable a skill names in backticks resolves.
# 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.
#
# 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.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
installer="$repo_dir/install"
linker="$repo_dir/setup/scripts/link-skills"
user_linker="$repo_dir/setup/scripts/link-user"
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)
# ── 1. Each skill loads ─────────────────────────────────────────────────────
present=()
for relative in "${SKILL_DIRS[@]}"; do
directory="$repo_dir/$relative"
file="$directory/SKILL.md"
if [[ ! -d "$directory" ]]; then
note "$relative does not exist yet, so nothing there can be checked"
continue
fi
if [[ ! -r "$file" ]]; then
note "$relative has no readable SKILL.md, so the loader ignores it"
continue
fi
present+=("$directory")
# Frontmatter is the first --- delimited block, and a skill without one is
# not a skill: Claude Code skips 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"
continue
fi
declared="$(sed -n 's/^name:[[:space:]]*//p' <<<"$frontmatter" | head -1)"
description="$(sed -n 's/^description:[[:space:]]*//p' <<<"$frontmatter" | head -1)"
[[ "$declared" == "$(basename "$directory")" ]] \
|| note "$relative/SKILL.md declares name '$declared', which is not its directory"
[[ -n "$description" ]] \
|| note "$relative/SKILL.md has no description, so nothing ever decides to load it"
done
# ── 2. Every claim resolves ─────────────────────────────────────────────────
#
# A skill points rather than duplicates, so almost everything it says is a
# pointer -- and a pointer is exactly the kind of claim that rots silently. The
# rule: anything in backticks that looks like a command, a path in this
# repository, an environment variable or a panama-action verb must exist.
if (( ${#present[@]} == 0 )); then
printf 'skills contract: no skill is written yet; claim checking skipped\n' >&2
else
while IFS= read -r claim; do
[[ -n "$claim" ]] && note "$claim"
done < <(python3 - "$repo_dir" "${present[@]}" <<'PY'
import os
import re
import subprocess
import sys
repo, directories = sys.argv[1], sys.argv[2:]
findings = []
def read(path):
with open(path, encoding="utf-8") as handle:
return handle.read()
# What bin/panama actually dispatches, read from the dispatcher itself rather
# than from usage(), which is prose and can drift the same way a skill can.
subcommands = set(re.findall(r"^\s+([a-z][a-z-]*)\)\s*shift", read(os.path.join(repo, "bin/panama")), re.M))
# panama-action's verbs, from the one case statement that dispatches them.
action = read(os.path.join(repo, "config/dot/quickshell/scripts/panama-action"))
verbs = set()
for match in re.finditer(r"^\s+([a-z][a-z0-9|-]*)\)", action.split('case "$action" in', 1)[-1], re.M):
verbs.update(match.group(1).split("|"))
tools = set(os.listdir(os.path.join(repo, "bin")))
tools |= set(os.listdir(os.path.join(repo, "config/dot/quickshell/scripts")))
# Every tracked file, so a path written the way the surrounding sentence reads
# -- `services/SettingsRoutes.qml`, not the whole path from the root -- still
# has to resolve to exactly one real file.
tracked = []
for root, names, files in os.walk(repo):
names[:] = [n for n in names if n not in (".git", "__pycache__", "node_modules")]
for name in files:
tracked.append(os.path.relpath(os.path.join(root, name), repo))
seen_variables = {}
def used_outside_the_skills(name):
"""An environment variable a skill names has to be one the tree reads."""
if name not in seen_variables:
found = subprocess.run(
["grep", "-rlF", "--exclude-dir=.git", "--", name, repo],
capture_output=True, text=True,
).stdout.split()
seen_variables[name] = any(
not any(path.startswith(directory) for directory in directories) for path in found
)
return seen_variables[name]
def check(token, where):
token = token.strip()
if not token:
return
words = token.split()
head = words[0]
for variable in re.findall(r"\bPANAMA_[A-Z0-9_]+\b", token):
if not used_outside_the_skills(variable):
findings.append(f"{where} names {variable}, which nothing in the tree reads")
if head == "panama" and len(words) > 1:
subcommand = words[1]
if re.fullmatch(r"[a-z][a-z-]*", subcommand) and subcommand not in subcommands:
findings.append(f"{where} names `panama {subcommand}`, which the dispatcher does not handle")
return
if head == "panama-action" and len(words) > 1:
verb = words[1]
if re.fullmatch(r"[a-z][a-z0-9-]*", verb) and verb not in verbs:
findings.append(f"{where} names the panama-action verb '{verb}', which it does not dispatch")
return
if re.fullmatch(r"panama-[a-z0-9-]+", head) and head not in tools:
# An invocation has to be a command. A bare name may be something else
# Panama calls by that name -- a doctor check group, a systemd unit --
# and then it only has to be real somewhere in the tree.
if len(words) > 1:
findings.append(f"{where} runs `{head}`, which is not in bin/ or the quickshell scripts")
elif not used_outside_the_skills(head):
findings.append(f"{where} names '{head}', which appears nowhere else in the tree")
return
# A repository path. Home paths and URLs are runtime, not tracked here, and
# a glob is a description of several files rather than one claim.
if "/" in head and not head.startswith(("~", "/", "http", "$")):
path = re.sub(r"/\*+$", "", head.rstrip("/"))
if "*" in path or not path:
return
if os.path.exists(os.path.join(repo, path)):
return
matches = [candidate for candidate in tracked if candidate.endswith("/" + path)]
if not matches:
findings.append(f"{where} points at {path}, which is not in the repository")
elif len(matches) > 1:
findings.append(f"{where} points at {path}, which is several files; name it from the root")
backticked = re.compile(r"`([^`\n]+)`")
for directory in directories:
for root, _, names in os.walk(directory):
for name in sorted(names):
if not name.endswith(".md"):
continue
path = os.path.join(root, name)
where = os.path.relpath(path, repo)
for token in backticked.findall(read(path)):
check(token, where)
print("\n".join(sorted(set(findings))))
PY
)
fi
# ── 3. The stage exists and does what it says ───────────────────────────────
if [[ ! -x "$linker" ]]; then
note 'setup/scripts/link-skills is missing or not executable, so no machine gets the skills'
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.
checkout="$work/Panama"
home="$work/home"
mkdir -p "$checkout/setup/scripts" "$checkout/skills/shipped" "$home/.claude"
cp "$linker" "$checkout/setup/scripts/link-skills"
printf 'a shipped skill\n' >"$checkout/skills/shipped/SKILL.md"
# The machine as it is before this stage ever ran: one whole-directory
# symlink, which is what link-user used to leave here.
mkdir -p "$work/personal"
ln -s "$work/personal" "$home/.claude/skills"
run() { HOME="$home" PANAMA_PATH="$checkout" "$checkout/setup/scripts/link-skills" >"$work/log" 2>&1; }
if ! run; then
note "link-skills failed against a throwaway home: $(tail -1 "$work/log")"
fi
[[ -d "$home/.claude/skills" && ! -L "$home/.claude/skills" ]] \
|| 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'
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"
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'
before="$(find "$checkout/config/old" | wc -l)"
run
after="$(find "$checkout/config/old" | wc -l)"
[[ "$before" == "$after" ]] \
|| note 'link-skills backs up its own symlinks, so config/old grows on every upgrade'
fi
# ── The stage runs, in the one order that gives personal skills precedence ──
python3 - "$installer" <<'PY' || note 'link-skills is not in STAGES between link-dotfiles and link-user'
import re, sys
line = next(l for l in open(sys.argv[1], encoding="utf-8") if l.startswith("STAGES="))
stages = re.findall(r"[\w-]+", line)
for name in ("link-dotfiles", "link-skills", "link-user"):
if name not in stages:
raise SystemExit(1)
if not stages.index("link-dotfiles") < stages.index("link-skills") < stages.index("link-user"):
raise SystemExit(1)
PY
# An upgrade drops stages by name. link-skills must not be one of them --
# update-command-contract proves that by running the installer; this says why.
if sed -n '/upgrade_stages=()/,/STAGES=("${upgrade_stages\[@\]}")/p' "$installer" | grep -q 'link-skills'; then
note 'install --upgrade filters link-skills out, so an existing machine never gets the skills'
fi
# ── The personal half hands the directory over ──────────────────────────────
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 -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" \
|| note 'user/README.md does not document the linkdir kind'
if (( ${#findings[@]} > 0 )); then
printf 'skills contract: %d finding(s)\n' "${#findings[@]}" >&2
printf ' - %s\n' "${findings[@]}" >&2
exit 1
fi
printf 'skills contract: PASS (%d skill(s) checked)\n' "${#present[@]}"
+2 -2
View File
@@ -36,7 +36,7 @@ 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-user change-settings STAGE_NAMES=(install-packages link-dotfiles link-skills link-user change-settings
link-vicinae-scripts setup-identity install-hardware) link-vicinae-scripts setup-identity install-hardware)
# 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
@@ -136,7 +136,7 @@ fi
# ── 2. Exactly the answer-free stages ──────────────────────────────────────── # ── 2. Exactly the answer-free stages ────────────────────────────────────────
for stage in install-packages link-dotfiles link-user change-settings link-vicinae-scripts; do for stage in install-packages link-dotfiles link-skills link-user 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
+18 -5
View File
@@ -49,7 +49,7 @@ done < <(find "$repo_dir/user" -type f \( -name 'id_*' -o -name '*.pem' -o -name
while read -r kind source destination; do while read -r kind source destination; do
[[ -e "$repo_dir/user/$source" ]] \ [[ -e "$repo_dir/user/$source" ]] \
|| note "the manifest points at $source, which is not in user/" || note "the manifest points at $source, which is not in user/"
[[ "$kind" == link || "$kind" == copy ]] \ [[ "$kind" == link || "$kind" == linkdir || "$kind" == copy ]] \
|| note "the manifest uses an unknown kind: $kind" || note "the manifest uses an unknown kind: $kind"
[[ "$destination" == '~/'* ]] \ [[ "$destination" == '~/'* ]] \
|| note "the manifest destination $destination is not under the home directory" || note "the manifest destination $destination is not under the home directory"
@@ -72,10 +72,11 @@ printf 'copied once\n' >"$checkout/user/plain.txt"
cat >"$checkout/user/manifest" <<'FIXTURE' 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 link agents/skills ~/.agents/skills
copy plain.txt ~/.config/plain.txt linkdir agents/skills ~/.claude/skills
link missing.txt ~/.config/missing.txt copy plain.txt ~/.config/plain.txt
link missing.txt ~/.config/missing.txt
FIXTURE FIXTURE
home="$work/home" home="$work/home"
@@ -101,6 +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
# 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"
ln -s "$checkout/skills/shipped" "$home/.claude/skills/shipped"
PANAMA_USER_CONTENT=yes run PANAMA_USER_CONTENT=yes run
[[ -L "$home/.claude/CLAUDE.md" ]] \ [[ -L "$home/.claude/CLAUDE.md" ]] \
@@ -109,6 +116,12 @@ PANAMA_USER_CONTENT=yes run
|| 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" ]] \ [[ -L "$home/.agents/skills" && -f "$home/.agents/skills/example/SKILL.md" ]] \
|| note 'the skills directory was not linked as a directory' || note 'the skills directory was not linked as a directory'
[[ -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" ]] \
|| note 'a linkdir entry did not link each child into the destination'
[[ -L "$home/.claude/skills/shipped" ]] \
|| note 'a linkdir entry removed what another stage had linked into the destination'
[[ -f "$home/.config/plain.txt" && ! -L "$home/.config/plain.txt" ]] \ [[ -f "$home/.config/plain.txt" && ! -L "$home/.config/plain.txt" ]] \
|| note 'a copy entry was linked rather than copied' || note 'a copy entry was linked rather than copied'
+25 -1
View File
@@ -13,13 +13,37 @@ 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` | Linked as a directory, so a skill installed on any machine lands in the checkout. | | `agents/skills/` | `~/.agents/skills`, `~/.claude/skills` | A skill installed on any machine lands in the checkout. The Claude path is `linkdir` — see below. |
| `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. |
`manifest` is the authority; this table is a summary of it. `manifest` is the authority; this table is a summary of it.
## The three kinds
`link` and `copy` mean what they say: one symlink, or one copy made only if the
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.
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.
- **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.
## It is off unless you say yes ## It is off unless you say yes
The installer asks, naming the destinations, and the default is no. Nothing here The installer asks, naming the destinations, and the default is no. Nothing here
+13 -5
View File
@@ -7,6 +7,10 @@
# repository. Use this for anything you want to update once and have # repository. Use this for anything you want to update once and have
# change everywhere -- and for anything a tool writes into, because a # change everywhere -- and for anything a tool writes into, because a
# skill installed on this machine should land in the checkout. # skill installed on this machine should land in the checkout.
# linkdir symlink each child of the source into the destination, which is
# created as a real directory. Use this where the destination holds
# more than this one source -- ~/.claude/skills also carries Panama's
# own shipped skills -- so it cannot be a symlink to any single place.
# copy copy once, and never touch it again if the destination exists. Use # copy copy once, and never touch it again if the destination exists. Use
# this for files a machine legitimately diverges on. # this for files a machine legitimately diverges on.
# #
@@ -24,11 +28,15 @@
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 as whole directories rather # Skills, at both paths that look for them. Linked so that a skill installed by
# than file by file, so a skill installed by any tool lands in the checkout and # any tool lands in the checkout and `panama update` offers to commit it.
# `panama update` offers to commit it. #
link agents/skills ~/.agents/skills # ~/.agents/skills is this directory and nothing else, so it stays one link.
link agents/skills ~/.claude/skills # ~/.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
linkdir agents/skills ~/.claude/skills
link agents/rules ~/.claude/rules link agents/rules ~/.claude/rules