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