Compare commits

...
4 Commits
Author SHA1 Message Date
mathias 1d140ec525 feat(check): every skill states when it stops and hands back
CI / Check / Test (push) Successful in 1s
CI / Tag release (push) Successful in 1s
Implements #5's decision. The hard part was #37's, not #5's: which skills need
an escalation rule? A hardcoded list rots, sniffing the prose for "does this
write anything" is unfalsifiable, and a frontmatter flag is the option #5
rejected wearing a hat.

So there is no acting set. Every skill answers the question, and a read-only one
answers it in a line. That turns an absence into a claim someone can disagree
with, which is the whole point — "this skill never hands back" is a design
decision worth stating, and today it was indistinguishable from nobody having
thought about it.

Eleven written here, all advisory or read-only, where the honest answer is short
and the value is in being specific rather than generic. `cognitive-load` stops
when the metric disagrees with the people reading the code, because that is
evidence about the metric. `test-design` stops when the tests are fine and the
design under test is what makes them awkward. `code-review` stops when the diff
is big enough that a review would be a skim, and says so rather than producing
confident coverage of a fraction.

The remaining thirty are the acting set — writes, dispatches, credentials,
releases — and each needs a rule someone thought about rather than a rote
paragraph, so they sit in bin/escalation-pending.txt. That list only shrinks,
and it is self-cleaning: a listed skill that HAS a section fails the gate, so an
exemption cannot outlive its reason. Mutation-verified both ways.

Six test cases including the stale-entry case, because an exemption for a skill
that no longer exists is how a gate quietly stops covering something.

Refs #37, closes the mechanism half
Bump-Type: minor
2026-08-29 14:56:04 +02:00
mathias 2fcc15e9e4 chore(release): plugin.json version 0.14.0
Skip-Release: true
2026-08-29 12:53:49 +00:00
mathias b2af4e1129 feat(doctor): --online, for the release a host has never fetched
CI / Check / Test (push) Successful in 1s
CI / Tag release (push) Successful in 1s
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
2026-08-29 14:52:52 +02:00
mathias 3c8d2f5613 chore(release): plugin.json version 0.13.4
Skip-Release: true
2026-08-29 12:50:31 +00:00
19 changed files with 423 additions and 8 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "skills",
"description": "Mathias's engineering skills library, plus the hooks and MCP servers that make it enforceable: TDD, clean code, debugging, specs, discovery, CI, and secret hygiene.",
"version": "0.13.3",
"version": "0.14.0",
"author": {
"name": "Mathias",
"url": "https://git.d-ma.be/mathias"
+19 -4
View File
@@ -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
+6 -1
View File
@@ -114,7 +114,12 @@ tasks:
# session_id is its only required field, and nine skills omitted it.
sh '{{.TASKFILE_DIR}}/bin/check-session-log' '{{.TASKFILE_DIR}}' || fail=1
# 6. Plugin manifest version must match the latest tag, or installers
# 6. Every skill says when it stops and hands back (#5). Skills whose
# rule is not written yet are listed in bin/escalation-pending.txt,
# which only shrinks.
sh '{{.TASKFILE_DIR}}/bin/check-escalation' '{{.TASKFILE_DIR}}' || fail=1
# 7. Plugin manifest version must match the latest tag, or installers
# never see the update (Claude Code gates updates on this field).
if [ -f '{{.PLUGIN_MANIFEST}}' ]; then
manifest_v=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' '{{.PLUGIN_MANIFEST}}' | head -1)
+64
View File
@@ -0,0 +1,64 @@
#!/bin/sh
# check-escalation — every skill states when it stops and hands back.
#
# #5 decided this belongs in the body, not the frontmatter: no harness reads
# frontmatter beyond name/description, so a `failure_criteria:` key would be a
# guarantee nothing honours. The model reads the body.
#
# #37 then had to define which skills need one, and every obvious answer was
# bad: a hardcoded list rots, sniffing the text for "does it write anything" is
# unfalsifiable, and a frontmatter flag is the rejected option wearing a hat.
#
# So there is no acting set. EVERY skill answers the question. A read-only skill
# answers it in one line — "read-only; findings are the output, nothing is
# handed back" — which turns an absence into a claim someone can disagree with.
#
# bin/escalation-pending.txt is a ratchet for skills whose real rule has not
# been written yet (a stop condition nobody thought about is not worth
# enforcing). It is self-cleaning: a listed skill that HAS a section fails, so
# the list cannot outlive its reason.
#
# Usage: check-escalation [checkout-root]
set -u
ROOT=${1:-$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)}
PENDING="$ROOT/bin/escalation-pending.txt"
fail=0
listed() {
[ -f "$PENDING" ] || return 1
sed 's/#.*//' "$PENDING" | grep -qx "[[:space:]]*$1[[:space:]]*"
}
for dir in "$ROOT"/skills/*/; do
[ -f "$dir/SKILL.md" ] || continue
name=$(basename "$dir")
if grep -q '^## Escalation' "$dir/SKILL.md"; then
if listed "$name"; then
echo "ERROR: skills/$name has an Escalation section but is still in bin/escalation-pending.txt — remove it from the list" >&2
fail=1
fi
continue
fi
listed "$name" && continue
echo "ERROR: skills/$name has no '## Escalation' section — say when it stops and hands back, or say it never does" >&2
fail=1
done
# A pending entry for a skill that no longer exists is a stale exemption, and a
# stale exemption is how a gate quietly stops covering something. Looped without
# a pipe on purpose: a `while read` on the right of a pipe runs in a subshell,
# so `fail=1` would be set in a process that exits before it is read.
if [ -f "$PENDING" ]; then
entries=$(sed 's/#.*//' "$PENDING" | tr -d '[:blank:]' | grep -v '^$' || true)
for entry in $entries; do
[ -f "$ROOT/skills/$entry/SKILL.md" ] && continue
echo "ERROR: bin/escalation-pending.txt lists '$entry', which has no skills/$entry/SKILL.md" >&2
fail=1
done
fi
exit $fail
+41 -2
View File
@@ -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' \
+42
View File
@@ -0,0 +1,42 @@
# Skills whose escalation rule has not been written yet (#37).
#
# This list only shrinks. A skill here is exempt from the '## Escalation'
# requirement until someone writes its real stop conditions; a skill that HAS a
# section and is still listed fails the gate, so the list cannot outlive its
# reason.
#
# The ones already done were the advisory and read-only skills, where the honest
# answer is a line. What is left is the acting set — skills that write files,
# dispatch agents, touch credentials or move releases — and each of those needs
# a rule someone thought about, not a rote paragraph. Per #19, one at a time.
atdd
dream
experiment-spec
feature-spec
gitea-ci
own-the-task
planning
playwright
problem-analysis
refactoring
regulatory-risk-assessment
secret-hygiene
session-retrospective
spec-driven-dev
superpowers-brainstorming
superpowers-dispatching-parallel-agents
superpowers-executing-plans
superpowers-finishing-a-development-branch
superpowers-receiving-code-review
superpowers-requesting-code-review
superpowers-subagent-driven-development
superpowers-using-git-worktrees
superpowers-using-superpowers
superpowers-verification-before-completion
superpowers-writing-plans
superpowers-writing-skills
tdd
trainer
user-stories
web-shot
+10
View File
@@ -258,3 +258,13 @@ decide
- `mathias/cad-atlas#11` renders the same `cad stuck` output as a pipeline overlay — read that
instead of this skill's text output when a visual "where is everything stuck" view is wanted
over working one row in depth.
## Escalation
Read-only by construction, and the Iron Laws above are the escalation rule: never close
an issue, never dispatch, never write a label. Everything that is the operator's call
leaves as a `DECISION NEEDED` block (question / options / recommendation / blocks).
Stop and hand back when: the stuck queue is empty; a diagnosis needs a write you are not
allowed to make; or two runs in a row produce the same finding, which means the finding
is not being acted on and repeating it is noise.
+8
View File
@@ -284,3 +284,11 @@ During refactoring:
- `references/python-adaptation.md` — Python conventions: error chaining, dataclasses, the truthiness trap
- Load `solid` skill for SOLID principles with Go examples
- Load `refactoring` skill for systematic refactoring techniques
## Escalation
Advisory during the REFACTOR phase. Suggests; does not restructure behaviour.
Stop and hand back when: the cleanup you are proposing changes behaviour. That is not
refactoring, it is a change, and it needs its own failing test first — hand back to
`tdd`.
+9
View File
@@ -285,3 +285,12 @@ treats `pass` as success, `fail` as failure, `skip` as neither.
- Load `refactoring` skill when recommending fixes
- Load `test-design` skill for reviewing test quality
- Load `solid` skill for SOLID principle violations
## Escalation
Read-only analysis. Findings are the output; nothing is handed back mid-review.
Stop and hand back when: the diff is large enough that a review would be a skim — say so
and ask for it in parts, rather than producing confident coverage of a fraction of it; or
the problem is structural rather than pointwise, in which case name it and hand off to
`solid` or `refactoring` instead of filing twenty line-level comments about one cause.
+9
View File
@@ -347,3 +347,12 @@ type UserWriter interface {
- Load `refactoring` skill for systematic techniques to reduce identified hotspots
- Load `solid` skill for structural fixes (coupling, cohesion)
- Load `clean-code` skill for naming improvements
## Escalation
Read-only measurement. The score is the output.
Stop and hand back when: the measurement disagrees with the reader's lived experience of
the code. A metric that says "fine" about code everyone finds hard is evidence about the
metric, not about the code, and continuing to defend the number is how a measure becomes
a target.
+10
View File
@@ -182,3 +182,13 @@ Techniques kept in `debug/references/`, inherited from the deleted
including as one of the fixes already tried. A bumped timeout is a symptom.
- `find-polluter.sh` — run when a test passes alone and fails in the suite, or when
something in the tree appears that no test admits to creating.
## Escalation
Diagnose only — Iron Law 3 is already an escalation rule: the fix is a separate TDD cycle,
not this skill's output.
Stop and hand back when: the report contains no failure text and no locator, in which case
name the specific artifact you need (run id, job log, stack trace) and stop rather than
hypothesising into a vacuum; or the evidence needed to discriminate between your top two
hypotheses requires access you do not have.
+9
View File
@@ -152,3 +152,12 @@ hand-wavy. Crisp solution + fuzzy problem = reverse-engineered justification. St
> Named instances of both war stories are client-tagged and held out of this public skill
> pending client-store decision. The transferable lesson lives here; the wrapper stays out.
## Escalation
Advisory. Produces a problem brief; commits to nothing.
Stop and hand back when: the evidence needed to size a problem requires talking to
customers you cannot reach. Sizing a problem from assumptions and presenting it as
discovery is worse than saying the number is unknown — it launders a guess into a brief
that later decisions will cite.
+9
View File
@@ -148,3 +148,12 @@ This skill maps to the **convergence moments** in the Double Diamond workflow:
- **Mid-Diamond 2 (Develop):** Use Quick Poke when an agent's experiment result is ambiguous and you're deciding whether to continue or pivot.
The skill does NOT replace human judgment at convergence points — it sharpens the plan so that judgment is better informed.
## Escalation
Advisory. Produces sharper questions and a better plan; decides nothing.
Stop and hand back when: the plan's weak point is a decision that is the operator's to
make rather than a gap in reasoning — surface it as a question with options and a
recommendation, and do not grill past it. Also stop when a round produces no new
objection: interrogation past that point manufactures doubt rather than finding it.
+9
View File
@@ -261,3 +261,12 @@ Domain types know nothing about HTTP, SQL, or external APIs.
- `references/python-adaptation.md` — the Python rewrite: Protocol over ABC, and what does not carry over from Go
- Load `clean-code` skill for naming and structure
- Load `code-review` skill for detecting violations during review
## Escalation
Advisory. Names principle violations and suggests structure; writes no code.
Stop and hand back when: applying a principle would require a change larger than the one
under discussion. "This wants dependency inversion" on a two-line fix is a design
conversation, not a review comment, and forcing it through as one is how a small change
acquires an architecture.
+9
View File
@@ -151,3 +151,12 @@ stays in the client boundary**:
- Council/forum names, internal stage labels, internal tooling, named contacts, strategy
specifics, deal terms → client dir/org only, local models only, never the public brain.
- This skill carries the **pattern**; the client carries the **wrapper**. Never merge them.
## Escalation
Advisory. Prepares or chairs a gate; the council decides, not this skill.
Stop and hand back when: a discipline's evidence is missing rather than weak. A gap on a
gating discipline means do not bring the ask — bring a feedback-only ask instead. Marking
it "cleared with a note" is precisely the gate theatre the anti-patterns section is
about.
+8
View File
@@ -82,3 +82,11 @@ created in a CAD session should trace to a TELOS GOAL. Every GOAL
traces to a PROBLEM.
Traceability chain: PROBLEM → GOAL → SPEC → TICKET → IMPL → TEST
## Escalation
Read-only. Loads intention context; changes nothing.
Stop and hand back when: the brain is unreachable or returns nothing for the wing. Working
from a remembered TELOS is the failure this skill exists to prevent — say the load failed
rather than reconstructing it from memory.
+8
View File
@@ -309,3 +309,11 @@ Apply this checklist to every new test:
- Load `tdd` skill for the full TDD workflow
- Load `code-review` skill for test quality review during pre-merge review
- See `clean-code/references/code-smells.md` for testing-specific smells
## Escalation
Read-only scoring of existing tests.
Stop and hand back when: the tests are fine and the *design under test* is what makes them
awkward. Hard-to-test is a design finding, not a test finding, and rewriting the tests
around it hides the thing worth knowing.
+85
View File
@@ -0,0 +1,85 @@
#!/bin/sh
# Tests for bin/check-escalation.
#
# #5 asked whether SKILL.md needs first-class failure/escalation fields and
# decided: not frontmatter (nothing reads it), a body section (the model reads
# that). #37 then had to define WHICH skills need one, and every obvious answer
# was bad — a hardcoded list rots, content sniffing is unfalsifiable, a
# frontmatter flag is the rejected option through the back door.
#
# So there is no acting set. EVERY skill answers the question; a read-only one
# answers it in a line. The classification problem disappears, and "this skill
# never hands back" becomes a stated claim rather than an absence.
#
# bin/escalation-pending.txt is a ratchet for skills whose real rule has not
# been written yet. It is self-cleaning: a skill that has a section AND is still
# listed fails, so the list cannot silently outlive its reason.
set -u
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
SUT="$ROOT/bin/check-escalation"
pass=0
fail=0
WORK=${TMPDIR:-/tmp}/check_escalation_test.$$
if [ ! -f "$SUT" ]; then
printf 'FAIL: %s does not exist\n' "$SUT"
exit 1
fi
cleanup() { rm -rf "$WORK"; }
trap cleanup EXIT INT TERM
# fixture <name> — an empty checkout with a bin/ for the pending list.
fixture() {
_f="$WORK/$1"
mkdir -p "$_f/skills" "$_f/bin"
: > "$_f/bin/escalation-pending.txt"
printf '%s' "$_f"
}
# skill <root> <name> <has-section>
skill() {
mkdir -p "$1/skills/$2"
{
printf -- '---\nname: %s\ndescription: d\n---\n\n# %s\n' "$2" "$2"
[ "$3" = yes ] && printf '\n## Escalation\n\nStop and hand back when X. Hand back by: saying so.\n'
} > "$1/skills/$2/SKILL.md"
}
# expect <rc> <label> <fixture> [needle]
expect() {
_want=$1; _label=$2; _fx=$3; _needle=${4:-}
_out=$(sh "$SUT" "$_fx" 2>&1); _rc=$?
if [ "$_rc" -ne "$_want" ]; then
fail=$((fail + 1)); printf ' FAIL %s (want rc=%s got %s)\n%s\n' "$_label" "$_want" "$_rc" "$_out"; return
fi
if [ -n "$_needle" ] && ! printf '%s' "$_out" | grep -q "$_needle"; then
fail=$((fail + 1)); printf ' FAIL %s (rc ok, report never says %s)\n%s\n' "$_label" "$_needle" "$_out"; return
fi
pass=$((pass + 1)); printf ' ok %s\n' "$_label"
}
f=$(fixture ok); skill "$f" alpha yes
expect 0 'a skill with an Escalation section passes' "$f"
f=$(fixture missing); skill "$f" alpha no
expect 1 'a skill with no section fails, naming it' "$f" 'alpha'
f=$(fixture pending); skill "$f" alpha no; printf 'alpha\n' > "$f/bin/escalation-pending.txt"
expect 0 'a listed skill is allowed through the ratchet' "$f"
f=$(fixture done); skill "$f" alpha yes; printf 'alpha\n' > "$f/bin/escalation-pending.txt"
expect 1 'a listed skill that now HAS a section fails — remove it from the list' "$f" 'escalation-pending'
f=$(fixture stale); skill "$f" alpha yes; printf 'ghost\n' > "$f/bin/escalation-pending.txt"
expect 1 'a listed skill that does not exist fails' "$f" 'ghost'
f=$(fixture comments); skill "$f" alpha no
printf '# a comment, and a blank line follow\n\nalpha\n' > "$f/bin/escalation-pending.txt"
expect 0 'comments and blank lines in the list are ignored' "$f"
printf '\ncheck_escalation: %d passed, %d failed\n' "$pass" "$fail"
[ "$fail" -eq 0 ]
+67
View File
@@ -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 <fixture> <stub-tag> <want-rc> <needle> <label>
assert_online() {
_out=$(HOME="$1/home" DOCTOR_CHECKOUT="$1/ck" \
DOCTOR_LATEST_TAG_CMD="printf %s\\n $2" sh "$DOCTOR" --online 2>&1); _rc=$?
if [ "$3" -eq 0 ] && [ "$_rc" -ne 0 ]; then
fail=$((fail + 1)); printf ' FAIL %s\n exited %s on a current host\n got: %s\n' "$5" "$_rc" "$_out"; return
fi
if [ "$3" -ne 0 ] && [ "$_rc" -eq 0 ]; then
fail=$((fail + 1)); printf ' FAIL %s\n exited 0; staleness went unreported\n' "$5"; return
fi
if [ -n "$4" ] && ! printf '%s' "$_out" | grep -q -- "$4"; then
fail=$((fail + 1)); printf ' FAIL %s\n report never mentions "%s"\n got: %s\n' "$5" "$4" "$_out"; return
fi
pass=$((pass + 1)); printf ' ok %s\n' "$5"
}
f=$(online_fixture behind 0.8.0)
assert_online "$f" v0.9.0 1 '0.9.0' '--online reports a release the host has not fetched'
f=$(online_fixture current 0.9.0)
assert_online "$f" v0.9.0 0 '' '--online stays quiet when the host is current'
# A host AHEAD of the newest tag is the normal state between a release commit
# and its tag; that is not drift (same reasoning as check's ordering rule).
f=$(online_fixture ahead 0.9.1)
assert_online "$f" v0.9.0 0 '' '--online does not complain about being ahead'
# Without the flag, no lookup happens at all — the stub would report a newer
# tag and must be ignored.
f=$(online_fixture offline 0.8.0)
_out=$(HOME="$f/home" DOCTOR_CHECKOUT="$f/ck" \
DOCTOR_LATEST_TAG_CMD="printf v9.9.9\\n" sh "$DOCTOR" 2>&1); _rc=$?
if [ "$_rc" -eq 0 ]; then
pass=$((pass + 1)); printf ' ok the default run performs no remote lookup\n'
else
fail=$((fail + 1)); printf ' FAIL the default run performs no remote lookup\n got: %s\n' "$_out"
fi
# A remote that cannot be reached is not drift and must not be reported as it.
# An unreachable remote produces no output at all, so it gets its own run
# rather than an empty stub argument — "prints nothing" and "prints an empty
# string" are different things and only the first is the case under test.
f=$(online_fixture unreachable 0.9.0)
_out=$(HOME="$f/home" DOCTOR_CHECKOUT="$f/ck" \
DOCTOR_LATEST_TAG_CMD="true" sh "$DOCTOR" --online 2>&1); _rc=$?
if [ "$_rc" -ne 0 ] && printf '%s' "$_out" | grep -q 'could not'; then
pass=$((pass + 1)); printf ' ok --online reports an unreachable remote as its own fault\n'
else
fail=$((fail + 1)); printf ' FAIL --online reports an unreachable remote as its own fault\n rc=%s got: %s\n' "$_rc" "$_out"
fi
printf '\ndoctor: %d passed, %d failed\n' "$pass" "$fail"
[ "$fail" -eq 0 ]