From 2610545c83bb470d4a5e83ade671ec213112702c Mon Sep 17 00:00:00 2001 From: "pi@mbp-m1-2020" Date: Tue, 8 Sep 2026 23:01:00 +0200 Subject: [PATCH] hooks: warn at push time when skill/ changes are not yet mirrored Editing skill/ here is only half the job: skillset mirrors it at skills/pi-extensions/, and that copy is what most consumers actually read -- each Mac's host-side ~/.agents/skills/pi-extensions symlinks into it, and a workstation with only skillset cloned has no other copy. The refresh is a manual cp in another repo and it has now been forgotten on two consecutive edits, so the mirror drifted 4890 B behind, then 9579 B. hooks/pre-push compares the PUSHED content (git show :skill/...) against the mirror on disk and prints the exact cp/sed/commit sequence when they differ. Pushed content rather than the worktree: uncommitted local edits are not what this push publishes. It warns and exits 0 rather than blocking, for two reasons that are not squeamishness: the direction rule is "edit upstream, THEN refresh", so the refresh legitimately comes after this push, and it is a commit in a different repo that cannot be made from here. Enforcement belongs downstream and already exists -- skillset's pre-commit gate refuses a commit that leaves the mirror stale. This hook only shortens time-to-detection from "next skillset commit" to "seconds, to the person who caused it". Silent when the mirror already matches, when the push does not touch skill/, on branch deletions, and when no skillset clone is on disk -- a reminder that cannot be acted on is noise that trains people to skim hook output. install.sh activates it (core.hooksPath=hooks, per-clone config that cannot be tracked), preserves a foreign hooksPath rather than clobbering it, and does NOT undo the activation on --uninstall: removing a safety gate as a side effect of uninstalling extensions would be a surprise in the wrong direction. Verified: all three activate_hooks branches in a throwaway repo, and five pre-push scenarios driven through the real stdin protocol (stale -> warns, in-sync -> silent, non-skill push -> silent, branch deletion -> silent, no skillset clone -> silent). --- README.md | 13 ++++++ hooks/pre-push | 105 +++++++++++++++++++++++++++++++++++++++++++++++++ install.sh | 28 +++++++++++++ 3 files changed, 146 insertions(+) create mode 100755 hooks/pre-push diff --git a/README.md b/README.md index 873e4e0..58f936c 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,19 @@ mounted). `skill/evaluate-extension-usage.py` is referenced by the skill and must stay alongside it. `install.sh` does not deploy the skill — skill deployment remains the `skillset` repo's job on a normal workstation. +**Editing `skill/` obliges you to refresh the mirror.** `skillset` keeps a copy at +`skills/pi-extensions/`, and that copy — not this one — is what most consumers +read: every Mac's host-side `~/.agents/skills/pi-extensions` symlinks into it, and +a workstation with only `skillset` cloned has no other copy. The refresh has been +forgotten on two consecutive edits (the mirror drifted 4,890 B behind, then +9,579 B), so `hooks/pre-push` now warns at push time when a pushed `skill/` change +is not yet mirrored, printing the exact `cp`. It **warns rather than blocks** — +the refresh is a commit in another repo and chronologically comes after this push. +Enforcement lives downstream: `skillset`'s own pre-commit gate refuses a commit +that leaves the mirror stale. `./install.sh` activates the hook +(`core.hooksPath=hooks`, per-clone config that cannot be tracked); it is silent +when no `skillset` clone is on disk, since a reminder you cannot act on is noise. + **Install a subset:** ```bash diff --git a/hooks/pre-push b/hooks/pre-push new file mode 100755 index 0000000..e90bf61 --- /dev/null +++ b/hooks/pre-push @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# Push-time reminder: skill/ changed, so the skillset MIRROR needs refreshing. +# +# WHY THIS EXISTS +# skill/ is canonical for the pi-extensions agent skill, but it is not the copy +# most consumers read. skillset mirrors it at skills/pi-extensions/, and every +# Mac's host-side ~/.agents/skills/pi-extensions symlinks into that mirror. So a +# skill/ edit that is pushed without refreshing the mirror is invisible to the +# people it was written for. +# +# That has now happened on two consecutive edits: the mirror sat 4890 B behind, +# then 9579 B behind. Both times the edit was correct and the omission was the +# follow-up step in another repo. +# +# WHAT IT DOES +# On push, if the pushed commits touch skill/, compare the PUSHED content +# against the mirror on disk. If they differ, print the refresh commands. +# +# It WARNS, it does not block. The direction rule is "edit upstream, THEN +# refresh" -- the refresh is chronologically after this push, and it is a commit +# in a different repo, so refusing this push would be both wrong-ordered and +# unfixable from here. Enforcement lives downstream, in skillset's own +# pre-commit gate, which refuses a commit that leaves the mirror stale. +# +# Silent when the mirror already matches, and silent when no skillset clone is +# on disk -- a machine without one cannot act on the reminder, and a warning it +# cannot act on is noise that trains people to skim hook output. +# +# ACTIVATION (per clone, cannot be tracked in git) +# ./install.sh -- or -- git config core.hooksPath hooks +# +# ESCAPE HATCH +# git push --no-verify +set -euo pipefail + +tag="[mirror]" +MIRROR_SUBPATH="skills/pi-extensions" +ZERO="0000000000000000000000000000000000000000" + +# Locate the skillset clone. Explicit override, then the layouts this fleet has. +skillset="" +repo_root="$(git rev-parse --show-toplevel)" +for cand in "${PI_EXTENSIONS_SKILLSET:-}" "$repo_root/../skillset" "/workspace/skillset"; do + [ -n "$cand" ] || continue + if [ -d "$cand/$MIRROR_SUBPATH" ]; then skillset="$(cd "$cand" && pwd)"; break; fi +done +[ -n "$skillset" ] || exit 0 # nothing actionable on this machine + +# pre-push feeds ref updates on stdin: +touched="" +pushed_sha="" +while read -r _local_ref local_sha _remote_ref remote_sha; do + [ -n "${local_sha:-}" ] || continue + [ "$local_sha" = "$ZERO" ] && continue # branch deletion + + if [ "$remote_sha" = "$ZERO" ]; then + # New ref on the remote: consider commits not already on any remote, so a + # first push of a branch does not diff against the whole of history. + range_args=("$local_sha" "--not" "--remotes=origin") + else + range_args=("$remote_sha..$local_sha") + fi + + if git log --format= --name-only "${range_args[@]}" -- skill/ 2>/dev/null | grep -q .; then + touched="yes" + pushed_sha="$local_sha" + fi +done + +[ -n "$touched" ] || exit 0 + +# Compare what is being PUSHED (committed content at that sha) against the mirror +# on disk -- the file consumers actually read. Not the worktree: uncommitted local +# edits are not what this push publishes. +differ="" +while read -r path; do + base="$(basename "$path")" + mirror_file="$skillset/$MIRROR_SUBPATH/$base" + if [ ! -f "$mirror_file" ]; then + differ="${differ}${base} (missing from mirror)"$'\n' + continue + fi + if ! git show "$pushed_sha:$path" 2>/dev/null | diff -q - "$mirror_file" >/dev/null 2>&1; then + differ="${differ}${base}"$'\n' + fi +done < <(git ls-tree -r --name-only "$pushed_sha" skill/) + +[ -n "$differ" ] || exit 0 # mirror already refreshed; nothing to say + +short="$(git rev-parse --short "$pushed_sha")" +echo "$tag this push changes skill/, and skillset's mirror does not match it yet." >&2 +echo "$tag" >&2 +printf '%s' "$differ" | sed 's/^/'"$tag"' differs: /' >&2 +echo "$tag" >&2 +echo "$tag Consumers read the MIRROR, not this repo: each Mac's host-side" >&2 +echo "$tag ~/.agents/skills/pi-extensions symlinks into it, and a workstation with" >&2 +echo "$tag only skillset cloned has no other copy. Refresh it:" >&2 +echo "$tag" >&2 +echo "$tag cp $repo_root/skill/SKILL.md $repo_root/skill/evaluate-extension-usage.py $skillset/$MIRROR_SUBPATH/" >&2 +echo "$tag sed -i 's/^# Last refreshed from: .*/# Last refreshed from: pi-extensions $short/' $skillset/$MIRROR_SUBPATH/.skill-source" >&2 +echo "$tag git -C $skillset commit -m 'skills(pi-extensions): refresh mirror from upstream $short' $MIRROR_SUBPATH" >&2 +echo "$tag" >&2 +echo "$tag Not blocking this push -- the refresh comes after it. skillset's own" >&2 +echo "$tag pre-commit gate will refuse a commit that leaves the mirror stale." >&2 +exit 0 diff --git a/install.sh b/install.sh index e9358d8..06a98e4 100755 --- a/install.sh +++ b/install.sh @@ -176,10 +176,38 @@ do_install() { ok "Linked ${name} → ${src}" done + echo + activate_hooks + echo ok "Done. Reload pi with /reload or restart to pick up new extensions." } +# ── commit/push hooks ──────────────────────────────── +# core.hooksPath is per-clone git CONFIG and cannot be tracked, so a fresh clone +# has no hooks until something sets it. This is that something. +# +# Deliberately NOT undone by --uninstall: uninstall means "stop managing my pi +# extensions", and silently removing a safety gate as a side effect of that would +# be a surprise in the wrong direction. Unset it by hand if you want it gone. +activate_hooks() { + [[ -d "${SCRIPT_DIR}/hooks" ]] || return 0 + + local cur + cur="$(git -C "$SCRIPT_DIR" config --local core.hooksPath 2>/dev/null || true)" + + if [[ -z "$cur" ]]; then + git -C "$SCRIPT_DIR" config core.hooksPath hooks + ok "Activated repo hooks (core.hooksPath=hooks)" + ok " pre-push warns when a skill/ change needs mirroring into skillset" + elif [[ "$cur" == "hooks" ]]; then + ok "Repo hooks already active (core.hooksPath=hooks)" + else + warn "core.hooksPath is '$cur', leaving it alone -- the skill-mirror" + warn "pre-push reminder will not run. Copy hooks/pre-push into '$cur' to keep it." + fi +} + # ── uninstall ──────────────────────────────────────── do_uninstall() { echo