#!/usr/bin/env bash # Deploy althing on this box. FOUR surfaces, not three — the fourth is the one # that had no step and drifted for five days without anyone noticing. # # scripts/deploy-althing.sh deploy # scripts/deploy-althing.sh --check report drift, change nothing # # ───────────────────────────────────────────────────────────────────────────── # WHY FOUR — AND WHY THE COUNT IS NOW SIX # # 1. uv tool install --force . the 7 binaries (postbox, althing-listen, # althing-route, althing-po-herald, ...) # 2. herald restart guards live in the herald; nothing new takes # effect until it restarts # 3. scripts/sync_skill.sh the machine-global participant skill # 4. PLUGIN repo plugin/ -> marketplace dir -> CC cache # # Surface 4 is two hops and NEITHER was automated. The marketplace directory # was a frozen copy from 2026-08-28 carrying plugin 0.0.1 with ONLY the # UserPromptSubmit hook — no SessionStart, no SessionEnd, no pane-route.sh. # Consequence: "CC seats re-declare their pane route automatically at next # SessionStart" was never true on this box, which is why every seat had to be # hand-declared with a pid someone measured by hand. # # ⚠ `uv tool install .` WITHOUT --force IS A SILENT NO-OP. uv matches the # source SPEC, not its contents: on a box that already installed from this # path it prints "already installed" and exits 0 having done nothing. # # ⚠ The plugin cache update needs a Claude Code RESTART to take effect. The # hooks are read at session start; a running session keeps the old ones. # ───────────────────────────────────────────────────────────────────────────── set -euo pipefail REPO="${ALTHING_REPO:-$HOME/development/althing}" MARKET="${ALTHING_MARKETPLACE_DIR:-$HOME/.local/share/althing-plugin}" CHECK=0 [[ "${1:-}" == "--check" ]] && CHECK=1 say() { printf '%s\n' "$*"; } step() { printf '\n── %s\n' "$*"; } [[ -d "$REPO/.git" ]] || { say "✗ no althing checkout at $REPO"; exit 2; } repo_plugin_version() { python3 -c "import json;print(json.load(open('$REPO/plugin/.claude-plugin/plugin.json'))['version'])" } market_plugin_version() { python3 -c "import json;print(json.load(open('$MARKET/.claude-plugin/plugin.json'))['version'])" 2>/dev/null || echo "absent" } cache_versions() { ls -1 "$HOME/.claude/plugins/cache/althing/althing/" 2>/dev/null | tr '\n' ' ' || echo "absent" } # ⚠ COMPARE CONTENT, NOT THE VERSION STRING. # # `claude plugin update` matches on the version in plugin.json and declines # when it is unchanged — so a release that edits hook or script CONTENT without # bumping the plugin version leaves the Claude Code cache stale while every # version check in this script reports success. Measured on the 3.3.0 deploy # (2026-09-02): marketplace and cache both read 0.1.1, update said "already at # the latest version", and pane-route.sh + README differed. That delta was # documentation-only and harmless — the next one need not be. # # The 2026-09-01 lesson was "compare the hook list, not the version string". # This is the same lesson one turn deeper: the hook list was identical too. plugin_content_drift() { # Only the cache dir matching the marketplace version is live. Older dirs # carry a .orphaned_at marker and Claude Code no longer loads them; they # differ by construction and saying so every run is noise. local live="$HOME/.claude/plugins/cache/althing/althing/$(market_plugin_version)" local c for c in "$HOME"/.claude/plugins/cache/althing/althing/*/; do [[ -d "$c" && -f "$c/.orphaned_at" ]] && say " · cache $(basename "$c") orphaned, ignored" done if [[ ! -d "$live" ]]; then say " ⚠ no cache dir for the marketplace version $(market_plugin_version)" return 1 fi if diff -rq --exclude marketplace.json "$MARKET" "$live" >/dev/null 2>&1; then say " ✓ live cache $(basename "$live") matches the marketplace tree" return 0 fi say " ⚠ live cache $(basename "$live") DIFFERS from the marketplace tree:" diff -rq --exclude marketplace.json "$MARKET" "$live" 2>&1 | sed 's/^/ /' return 1 } # ── SURFACE 6: ~/.claude/settings.json crossSessionInbound ──────────────────── # # The only surface that is NOT in the althing repo, so a version check cannot # reach it. Claude Code holds an inbound cross-session message unless the # sender's permission-mode class matches yours, and a sender that asserts no # class is held. The herald is a daemon and asserts none — deliberately — so on # a default-configured seat a cc poke is HELD and the notice goes to whoever is # looking at the pane instead of to the session. # # The failure shape is the expensive one: the seat is declared, reachable and # green, and nothing arrives. Same family as the SessionStart hook that was # never deployed. # # ⚠ REPORT ONLY, NEVER SET. This file is the operator's permission # configuration; a deploy script that edits it is a deploy script that grants # itself trust. Print what is there and let a human decide. settings_inbound() { python3 - <<'EOF' 2>/dev/null || echo "unreadable" import json, pathlib p = pathlib.Path.home() / ".claude/settings.json" try: print(json.loads(p.read_text()).get("crossSessionInbound", "UNSET")) except Exception: print("unreadable") EOF } step "state" say " repo $(git -C "$REPO" describe --tags --always) $(git -C "$REPO" diff --quiet && echo clean || echo DIRTY)" say " installed tool $(uv tool list 2>/dev/null | awk '/^althing-core/{print $2}')" say " repo plugin $(repo_plugin_version)" say " marketplace $(market_plugin_version)" say " CC cache $(cache_versions)" plugin_content_drift || true inbound="$(settings_inbound)" say " cc inbound crossSessionInbound=$inbound (~/.claude/settings.json)" if [[ "$inbound" != "accept" ]]; then say " ⚠ cc pokes to a seat on this box are HELD at first contact, not delivered." say " The seat still looks declared, reachable and healthy. Set" say " crossSessionInbound: \"accept\" BY HAND — this script will not" say " edit the operator's permission configuration." fi if (( CHECK )); then say "" say "--check: nothing changed." exit 0 fi step "1/4 uv tool install --force (NEVER without --force: silent no-op)" ( cd "$REPO" && uv tool install --force . 2>&1 | tail -2 ) step "2/4 restart the herald" systemctl --user restart althing-po-herald sleep 2 say " herald: $(systemctl --user is-active althing-po-herald)" step "3/4 sync the machine-global skill" "$REPO/scripts/sync_skill.sh" >/dev/null "$REPO/scripts/sync_skill.sh" --check | sed 's/^/ /' step "4/4 plugin — repo -> marketplace -> Claude Code cache" if [[ -d "$MARKET" ]]; then cp -a "$MARKET" "$MARKET.bak-$(date -u +%Y%m%dT%H%M%SZ)" say " backed up the marketplace dir" fi mkdir -p "$MARKET/.claude-plugin" # marketplace.json lives ONLY in the deployed tree (the repo keeps its own at # .claude-plugin/marketplace.json pointing at ./plugin). Preserve and re-stamp. tmp=$(mktemp) [[ -f "$MARKET/.claude-plugin/marketplace.json" ]] && cp -a "$MARKET/.claude-plugin/marketplace.json" "$tmp" rsync -a --delete --exclude marketplace.json "$REPO/plugin/" "$MARKET/" [[ -s "$tmp" ]] && cp -a "$tmp" "$MARKET/.claude-plugin/marketplace.json" rm -f "$tmp" python3 - "$REPO" "$MARKET" <<'PY' import json, pathlib, sys repo, market = sys.argv[1], sys.argv[2] src = json.loads(pathlib.Path(f"{repo}/plugin/.claude-plugin/plugin.json").read_text()) p = pathlib.Path(f"{market}/.claude-plugin/marketplace.json") d = json.loads(p.read_text()) d["plugins"][0]["version"] = src["version"] d["plugins"][0]["description"] = src["description"] p.write_text(json.dumps(d, indent=2) + "\n") print(f" marketplace.json stamped {src['version']}") PY say " updating the Claude Code plugin cache ..." # `claude plugin update` is the SUPPORTED path. Do NOT hand-edit # ~/.claude/plugins/installed_plugins.json or fabricate a cache directory — # that is Claude Code's own bookkeeping and a subtle mistake there breaks the # plugin in a way that looks like an upstream bug. claude plugin update althing 2>&1 | sed 's/^/ /' || say " (update reported nothing to do)" step "verify" say " installed tool $(uv tool list 2>/dev/null | awk '/^althing-core/{print $2}')" say " marketplace $(market_plugin_version)" say " CC cache $(cache_versions)" if ! plugin_content_drift; then say "" say " ⚠ The cache did NOT take the new plugin content. This happens when the" say " release changed hook/script content without bumping the plugin version" say " — `claude plugin update` matches on version and declines. Ask the" say " althing maintainer for a version bump; do NOT hand-edit the cache or" say " ~/.claude/plugins/installed_plugins.json, which is Claude Code's own" say " bookkeeping. Check the diff above for whether it is load-bearing." fi say " herald $(systemctl --user is-active althing-po-herald)" say " cc inbound crossSessionInbound=$(settings_inbound)" say "" say "⚠ RESTART Claude Code to load the new plugin hooks. A running session keeps the old ones."