From b2af4e11296d4d02b594956e4260fa18fc4e3cf3 Mon Sep 17 00:00:00 2001 From: Mathias Date: Sat, 29 Aug 2026 14:52:52 +0200 Subject: [PATCH] feat(doctor): --online, for the release a host has never fetched MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everything doctor checks compares copies already on the machine, deliberately — it runs in a SessionStart hook where latency shows, and its header promises no network I/O. That leaves exactly one case invisible, and #30 walked straight into it: a host whose checkout, marketplace clone and installed plugin all agree reads as clean while all three sit a release behind origin. `--online` closes it with one `git ls-remote`, opt-in, for a scheduled per-host job rather than the session hook. Comparison is by order, not equality, so a host ahead of the newest tag — normal between a release commit and its tag — stays quiet. An unreachable remote is a WARN naming itself, not a staleness FAIL. "I could not check" and "you are behind" are different answers, and collapsing them would make a network blip look like a stale library. The remote URL is never printed: a remote rewritten by url.insteadOf expands into a live token, which is a documented way this fleet has leaked one before. Five fixture cases through a $DOCTOR_LATEST_TAG_CMD seam, including one that asserts the DEFAULT run performs no lookup at all — the promise in the header is now a test, not a comment. Verified live on koala: $ bin/doctor --online FAIL behind: this host has 0.12.1; origin has released 0.13.4 $ bin/doctor skills doctor: clean — 41 skills Closes #38 Bump-Type: minor --- README.md | 23 ++++++++++++--- bin/doctor | 43 ++++++++++++++++++++++++++-- tests/doctor_test.sh | 67 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 127 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index ce74d00..172d595 100644 --- a/README.md +++ b/README.md @@ -110,10 +110,25 @@ Antigravity and Mistral Vibe targets were removed — unused. task doctor # or bin/doctor ``` -Reports dangling links, links resolving into a foreign checkout, skill-count -mismatches, stray entries, and leftover per-skill Claude symlinks. Non-zero -exit on drift. It also runs at session start via the plugin, so a drifting -host says so rather than waiting to be discovered. +Reports dangling links, links into a source nobody configured, skills in the +checkout with no link, stray entries, leftover per-skill Claude symlinks, and a +Claude Code plugin sitting on an older generation than the host already has. +Non-zero exit on drift. It also runs at session start via the plugin, so a +drifting host says so rather than waiting to be discovered. + +All of that compares copies already on the machine — deliberately, since it +runs in a session hook where latency shows. That leaves one case invisible: a +host where the checkout, the marketplace clone and the installed plugin all +agree, and all three are a release behind origin. + +```bash +task doctor -- --online # or bin/doctor --online +``` + +One `git ls-remote` against origin, opt-in. Belongs in a scheduled per-host job, +not the session hook. An unreachable remote is reported as its own fault rather +than as staleness — "I could not check" and "you are behind" are different +answers and only one of them is about the host. ## Versioning diff --git a/bin/doctor b/bin/doctor index 7adbe2e..3043ad7 100755 --- a/bin/doctor +++ b/bin/doctor @@ -10,6 +10,7 @@ # doctor human report, non-zero exit on drift # doctor --quiet print only when something is wrong (SessionStart hook) # doctor --json machine-readable findings +# doctor --online ALSO ask origin for the latest tag (one network call) # # POSIX sh on purpose: it runs on macOS and Linux, before any toolchain is # guaranteed present, and inside a session-start hook where latency shows. @@ -21,10 +22,12 @@ set -u QUIET=0 JSON=0 +ONLINE=0 for arg in "$@"; do case "$arg" in - --quiet) QUIET=1 ;; - --json) JSON=1 ;; + --quiet) QUIET=1 ;; + --json) JSON=1 ;; + --online) ONLINE=1 ;; -h|--help) sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//' exit 0 ;; @@ -288,6 +291,42 @@ if [ -n "$installed_v" ] && [ -n "$available_v" ] && [ "$installed_v" != "$avail add FAIL plugin-version "Claude Code has skills@mathias $installed_v installed but $available_v is available on this host — /plugin update skills@mathias (a host stale enough to matter is running the old doctor, so the first detection may need this run by hand)" fi +# --- optional: the release this host has never fetched ---------------------- +# Every check above compares copies already present, so a host whose checkout, +# marketplace clone and installed plugin all agree reads as clean while sitting +# a release behind origin (#38). Closing that needs a network call, which the +# default run must not make — hence opt-in, for a scheduled per-host job rather +# than the SessionStart hook. +# +# $DOCTOR_LATEST_TAG_CMD is the test seam; it prints the newest remote tag. +if [ "$ONLINE" -eq 1 ]; then + if [ -n "${DOCTOR_LATEST_TAG_CMD:-}" ]; then + remote_tag=$(eval "$DOCTOR_LATEST_TAG_CMD" 2>/dev/null | tail -1 | tr -d '[:space:]') + else + # The URL is never printed: a remote rewritten by url.insteadOf can expand + # into a live token. + origin=$(git -C "$CHECKOUT" config --get remote.origin.url 2>/dev/null || true) + if [ -n "$origin" ]; then + remote_tag=$(git ls-remote --tags --refs "$origin" 2>/dev/null \ + | sed 's#.*refs/tags/##' | sort -t. -k1,1n -k2,2n -k3,3n | tail -1 | tr -d '[:space:]') + else + remote_tag='' + fi + fi + + if [ -z "$remote_tag" ]; then + # Not drift. A host that cannot reach origin has an unanswered question, + # not a stale library, and saying otherwise would make the two look alike. + add WARN offline "could not reach origin for the latest tag — this run proves nothing about staleness" + else + remote_v=${remote_tag#v} + if [ -n "$available_v" ] && [ "$remote_v" != "$available_v" ] \ + && [ "$(newer_version "$available_v" "$remote_v")" = "$remote_v" ]; then + add FAIL behind "this host has $available_v; origin has released $remote_v — run 'task update' and '/plugin update skills@mathias'" + fi + fi +fi + # --- output ----------------------------------------------------------------- if [ "$JSON" -eq 1 ]; then printf '{"checkout":"%s","expected":%s,"plugin":{"installed":"%s","available":"%s"},"findings":[%s]}\n' \ diff --git a/tests/doctor_test.sh b/tests/doctor_test.sh index 88dba5b..4cd16ed 100644 --- a/tests/doctor_test.sh +++ b/tests/doctor_test.sh @@ -224,5 +224,72 @@ manifest "$f/ck" 0.7.0 manifest "$f/home/.claude/plugins/marketplaces/mathias" 0.9.0 assert_fails "$f" '0.9.0' 'a newer marketplace clone is reported' +# --- 9. --online: the release this host has never fetched ------------------ +# Everything above compares copies already on the host, so a host where the +# checkout, the marketplace clone and the installed plugin all agree reads as +# clean while being a release behind origin. Offline by design (#30); this is +# the opt-in that closes it (#38). +# +# The remote lookup is stubbed through $DOCTOR_LATEST_TAG_CMD so no test needs +# a network or a real remote. + +online_fixture() { + _f=$(new_fixture "$1"); wire_clean "$_f" + installed_state "$_f" "$2" + manifest "$_f/ck" "$2" + printf '%s' "$_f" +} + +# assert_online