diff --git a/.claude/skills/panama/SKILL.md b/.claude/skills/panama/SKILL.md new file mode 100644 index 0000000..2f11633 --- /dev/null +++ b/.claude/skills/panama/SKILL.md @@ -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 ` 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. diff --git a/README.md b/README.md index 72bf5d9..8c8e89b 100644 --- a/README.md +++ b/README.md @@ -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 | | `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/` → `~/.config/`, 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) | | `change-settings` | Copies `config/copy/` over `/`, applies gsettings, enables user services | | `link-vicinae-scripts` | Publishes the Vicinae script commands | @@ -156,6 +157,8 @@ config/ extensions, linked into ~/.local/share old/ Backups of whatever was replaced (gitignored) wallpapers/ Copied into ~/Pictures/Wallpapers when absent +skills/ Agent skills for operating this desktop, linked into + ~/.claude/skills setup/ apps/ Applications built from source, one file each 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 -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 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 doctor # what is actually running, not what was installed panama test # every contract, or a subset by pattern +panama test --safe # the same, minus the ones that take over the desktop +panama contracts # which contracts mention a file, and can they be run panama migrate # apply repairs this machine has not had yet panama upgrade # re-run ./install from anywhere, interview and all panama apps # choose applications to install, by category diff --git a/bin/panama b/bin/panama index 0dd8833..19f42f9 100755 --- a/bin/panama +++ b/bin/panama @@ -5,16 +5,17 @@ # Author: Gabriel Brown # # Commands: -# update Bring this machine up to date: pull, then install --upgrade -# sync Review, commit & push local changes to this repo -# edit Open the Panama repo in Neovim -# doctor Report what is actually running on this machine -# test Run every contract under tests/ -# upgrade Re-run the installer from anywhere, interview included -# migrate Apply repairs this machine has not had yet -# apps Choose applications to install, by category -# app Build and install an application that no repository packages -# help Show this help +# update Bring this machine up to date: pull, then install --upgrade +# sync Review, commit & push local changes to this repo +# edit Open the Panama repo in Neovim +# doctor Report what is actually running on this machine +# test Run every contract under tests/ (--safe skips the hijacking ones) +# contracts Name the contracts that mention a given file +# upgrade Re-run the installer from anywhere, interview included +# migrate Apply repairs this machine has not had yet +# apps Choose applications to install, by category +# 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 # 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. ${GREEN}test${RESET} Run every contract under tests/. Give it a pattern to run 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 machine, or to change an answer you gave. Routine updates are '$PROGRAM update', which asks nothing. @@ -106,6 +112,8 @@ ${BOLD}Examples:${RESET} $PROGRAM edit $PROGRAM doctor --summary $PROGRAM test dock + $PROGRAM test --safe + $PROGRAM contracts config/dot/quickshell/services/Displays.qml $PROGRAM upgrade $PROGRAM apps $PROGRAM app @@ -340,6 +348,32 @@ cmd_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 # ---------------------------------------------------------------------------- @@ -351,9 +385,34 @@ cmd_doctor() { # 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 # 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() { - 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 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, 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. while IFS= read -r path; do [[ -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) 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 fi @@ -391,14 +464,111 @@ cmd_test() { header "Result" if (( ${#failed[@]} == 0 )); then ok "${#suite[@]} contract(s) passed" + if (( safe )); then + printf 'Skipped %d desktop-hijacking contract(s) (%s).\n' "$skipped" "$DESKTOP_HIJACKING_LEDGER" + fi return 0 fi err "${#failed[@]} of ${#suite[@]} failed:" printf ' %s\n' "${failed[@]}" >&2 warn "Run one on its own to see why: ${BOLD}${PANAMA_DIR}/tests/${RESET}" + if (( safe )); then + printf 'Skipped %d desktop-hijacking contract(s) (%s).\n' "$skipped" "$DESKTOP_HIJACKING_LEDGER" + fi 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 ${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 # ---------------------------------------------------------------------------- @@ -636,6 +806,7 @@ main() { edit) shift; cmd_edit "$@" ;; doctor) shift; cmd_doctor "$@" ;; test) shift; cmd_test "$@" ;; + contracts) shift; cmd_contracts "$@" ;; upgrade) shift; cmd_upgrade "$@" ;; migrate) shift; cmd_migrate "$@" ;; app) shift; cmd_app "$@" ;; diff --git a/docs/superpowers/specs/2026-08-25-agent-integration.md b/docs/superpowers/specs/2026-08-25-agent-integration.md new file mode 100644 index 0000000..1debaa0 --- /dev/null +++ b/docs/superpowers/specs/2026-08-25-agent-integration.md @@ -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 `. 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 ` + `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 ` 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 `, + `--repair `; the 30 check ids by group. +2. `panama-action ` as the stable boundary for desktop actions (the verb list), and + `qs ipc call ` 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 `; 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/` to `config/old/` (house `displace` semantics; symlinks just + removed) and link `~/.claude/skills/` → `$PANAMA_PATH/skills/`. +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 ` + +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/ [safe|desktop]`, +tier read from the ledger. No hits → `No contract mentions — 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 ` `` subcommand a skill names exists in + `bin/panama`'s dispatcher; every `` `panama-` `` 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. diff --git a/install b/install index a667eb4..30409e5 100755 --- a/install +++ b/install @@ -17,7 +17,7 @@ # What --upgrade changes, and nothing else: # # * 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. # * setup-identity and install-hardware are dropped. They exist only to # 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 # theme files it selects among (symlinked into place by link-dotfiles); # 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 # 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 @@ -222,7 +223,7 @@ if [[ -n "${PANAMA_HOSTNAME:-}" ]]; then echo "Hostname set to: $(hostname)" 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 # both are first-run decisions: who you are and what hardware this is. Filtered diff --git a/setup/scripts/link-skills b/setup/scripts/link-skills new file mode 100755 index 0000000..f65f23c --- /dev/null +++ b/setup/scripts/link-skills @@ -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." diff --git a/setup/scripts/link-user b/setup/scripts/link-user index 6ec8bec..3a45989 100755 --- a/setup/scripts/link-user +++ b/setup/scripts/link-user @@ -95,6 +95,31 @@ while read -r kind source destination; do log "Linked $source → $dst" 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) if [[ -e "$dst" ]]; then log "Keeping existing $dst" diff --git a/skills/panama-desktop/SKILL.md b/skills/panama-desktop/SKILL.md new file mode 100644 index 0000000..af7d696 --- /dev/null +++ b/skills/panama-desktop/SKILL.md @@ -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 ` — a single check, same envelope +- `panama doctor --repair ` — 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 ` (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 `, `dock-pin`, `health`, `dnd`, `caffeine`, +`night-light`, `focus-start`, `focus-end`, `capture`, `intelligence`, `screenshot`, `microphone`, +`gallery`, `restart-shell`. + +Beneath it sits `qs ipc call ` — 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 '' -- 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 ` (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. diff --git a/skills/panama-sudo/SKILL.md b/skills/panama-sudo/SKILL.md new file mode 100644 index 0000000..6a8b625 --- /dev/null +++ b/skills/panama-sudo/SKILL.md @@ -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. diff --git a/tests/desktop-hijacking b/tests/desktop-hijacking new file mode 100644 index 0000000..19820f2 --- /dev/null +++ b/tests/desktop-hijacking @@ -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 diff --git a/tests/setup/desktop-hijacking-contract b/tests/setup/desktop-hijacking-contract new file mode 100755 index 0000000..7c8f8c9 --- /dev/null +++ b/tests/setup/desktop-hijacking-contract @@ -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 ` + # 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[@]}" diff --git a/tests/setup/skills-contract b/tests/setup/skills-contract new file mode 100755 index 0000000..cb88aa8 --- /dev/null +++ b/tests/setup/skills-contract @@ -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[@]}" diff --git a/tests/setup/update-command-contract b/tests/setup/update-command-contract index 4c0018d..8b6272e 100755 --- a/tests/setup/update-command-contract +++ b/tests/setup/update-command-contract @@ -36,7 +36,7 @@ note() { findings+=("$1"); } tmp="$(mktemp -d -t panama-update-contract.XXXXXX)" 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) # 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 ──────────────────────────────────────── -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" done for stage in setup-identity install-hardware; do diff --git a/tests/setup/user-content-contract b/tests/setup/user-content-contract index c9bd39a..3edc0a1 100755 --- a/tests/setup/user-content-contract +++ b/tests/setup/user-content-contract @@ -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 [[ -e "$repo_dir/user/$source" ]] \ || 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" [[ "$destination" == '~/'* ]] \ || 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' # a comment, and a blank line follow -link agents/AGENTS.md ~/.claude/CLAUDE.md -link agents/skills ~/.agents/skills -copy plain.txt ~/.config/plain.txt -link missing.txt ~/.config/missing.txt +link agents/AGENTS.md ~/.claude/CLAUDE.md +link agents/skills ~/.agents/skills +linkdir agents/skills ~/.claude/skills +copy plain.txt ~/.config/plain.txt +link missing.txt ~/.config/missing.txt FIXTURE home="$work/home" @@ -101,6 +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" +ln -s "$checkout/skills/shipped" "$home/.claude/skills/shipped" + PANAMA_USER_CONTENT=yes run [[ -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' [[ -L "$home/.agents/skills" && -f "$home/.agents/skills/example/SKILL.md" ]] \ || 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" ]] \ || note 'a copy entry was linked rather than copied' diff --git a/user/README.md b/user/README.md index b72a457..06804a8 100644 --- a/user/README.md +++ b/user/README.md @@ -13,13 +13,37 @@ 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` | 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` | | | `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. | `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 The installer asks, naming the destinations, and the default is no. Nothing here diff --git a/user/manifest b/user/manifest index 0009190..278f42c 100644 --- a/user/manifest +++ b/user/manifest @@ -7,6 +7,10 @@ # repository. Use this for anything you want to update once and have # change everywhere -- and for anything a tool writes into, because a # 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 # this for files a machine legitimately diverges on. # @@ -24,11 +28,15 @@ link agents/AGENTS.md ~/.claude/CLAUDE.md link agents/AGENTS.md ~/.codex/AGENTS.md -# Skills, at both paths that look for them. Linked as whole directories rather -# than file by file, so a skill installed by any tool lands in the checkout and -# `panama update` offers to commit it. -link agents/skills ~/.agents/skills -link agents/skills ~/.claude/skills +# 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. +# +# ~/.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 +linkdir agents/skills ~/.claude/skills link agents/rules ~/.claude/rules