Files
skills/Taskfile.yml
T
mathias c25175e4b3
CI / Check / Test (push) Successful in 4s
CI / Tag release (push) Successful in 1s
feat(agents): ship pr-gatekeeper from the repo, not one host's ~/.claude (#51)
pr-gatekeeper blocked dispatch#75 five times. Every block was a real,
executable escape — twelve pathspec bypasses, arbitrary command
execution via `git -c alias`, `--upload-pack='rm …'`, a write primitive
through `--output=`, `${IFS}` word-splitting past the policy, and a path
denylist that had been a no-op in the live flow since it was written.
task check was green on all five.

The agent definition existed in no repository. A code search across the
estate found only references to it — comments in dispatch source, a
local-dev sprint doc — while the file itself lived at
~/.claude/agents/pr-gatekeeper.md on koala alone: unversioned, on one
host, one rm from gone. local-dev AGENT.md rule 8 now requires routing
control code through it, so doctrine depended on an artifact with no
history.

PORTED, PLUS WHAT THE FIVE ROUNDS ACTUALLY TAUGHT

The file already had flip-testing. Three habits that did the finding were
implied at best, and are now explicit (§4b):

  - Verify by EXECUTION, not by reading the implementation. Every finding
    came from running the command in a scratch repo and looking at the
    filesystem. The matcher said one thing and the filesystem another,
    five times.
  - Read the SURVIVORS. After an allow/deny sweep, read what is still
    permitted, one at a time. This caught `git commit -F /etc/passwd` —
    exfiltration wearing a message flag — which neither the review nor
    the differential found, because both implementations agreed and both
    were wrong. Agreement is not correctness.
  - Grade a tool on its EXIT CODE, never by grepping output. A crashed
    linter greps as clean.

Plus rule 9 in §4: a test whose name claims more than it proves is worse
than no test, because the name wins in the reader's head.

AND THE INCIDENT THIS AGENT CAUSED (§4c)

On 2026-09-19 a probe of this agent's own making printed /proc/$PPID/environ
and `ps e` raw, and a `git credential fill` consulted the host's real
store. Seven live credentials reached the transcript and need rotation
(infra#445). The finding was correct; the method was not. Versioning the
file without that lesson would have shipped the incident along with the
agent. So: a probe that reads an environment prints a count, never the
buffer; invented sentinels only; isolated HOME for anything touching
git's credential subsystem; prove a primitive and stop.

DOCTOR

New `shadow` check: a real file in ~/.claude/agents with the same name as
a shipped agent wins over the shipped one and nothing says so. Same
silent-drift shape as the existing `outside` and `unmanaged` checks — it
works, so nothing complains, while serving content nobody updates. A
symlink is managed wiring and is not flagged.

The host-local copy on koala has been moved aside, so the shipped file is
what runs once the plugin updates.

TESTS

14 assertions, each named for what it observes rather than what it
guarantees — a grep proves a phrase is present, not that a procedure is
followed, and naming it otherwise would be the defect rule 9 exists for.
Flip-tested both directions: removing the doctor check, and altering the
probe-safety phrase, each turn the suite red.

`agents` added to task check's root-entry allowlist. Leaving it out made
check_tolerates_unverifiable_test.sh fail, which is #23's stray-entry
guard working exactly as designed — an undeclared root entry loads in no
harness and errors in none either.

Closes #51

Bump-Type: minor
2026-09-28 23:30:37 +02:00

184 lines
8.0 KiB
YAML

version: '3'
# Skills library Taskfile.
#
# This repo is two things at once:
# 1. A Claude Code PLUGIN (skills + hooks + MCP servers), distributed through
# the marketplace this same repo hosts at .claude-plugin/marketplace.json.
# 2. A plain skills library for harnesses with no plugin system — Crush, Pi,
# OpenCode — wired by install.sh as ONE directory symlink per harness.
#
# The wiring loops that used to live here were five near-identical copies that
# drifted; install.sh owns wiring now and this file delegates to it.
vars:
REPO_URL: 'https://git.d-ma.be/mathias/skills.git'
SKILLS_DIR: '{{.TASKFILE_DIR}}/skills'
PLUGIN_MANIFEST: '{{.TASKFILE_DIR}}/.claude-plugin/plugin.json'
tasks:
default:
cmds:
- task: list
list:
desc: 'Print every skill in this repo'
cmds:
- 'ls -1 {{.SKILLS_DIR}}'
silent: true
install:
desc: 'Wire THIS checkout into Crush / Pi / OpenCode. Claude Code uses the plugin instead.'
cmds:
- SKILLS_SKIP_FETCH=1 SKILLS_CHECKOUT_DIR='{{.TASKFILE_DIR}}' bash '{{.TASKFILE_DIR}}/install.sh'
update:
desc: 'Fast-forward this checkout and re-wire.'
cmds:
- git -C '{{.TASKFILE_DIR}}' pull --ff-only
- task: install
doctor:
desc: 'Report drift across every harness on this host. Non-zero exit means something is wrong.'
cmds:
- '{{.TASKFILE_DIR}}/bin/doctor'
validate:
desc: 'Validate the plugin + marketplace manifests with the Claude Code CLI.'
cmds:
- claude plugin validate '{{.TASKFILE_DIR}}'
test:
desc: 'Run the shell test suite (hooks + doctor).'
cmds:
- |
fail=0
for t in '{{.TASKFILE_DIR}}'/tests/*_test.sh; do
[ -f "$t" ] || continue
echo "--- $(basename "$t")"
sh "$t" || fail=1
done
exit $fail
index:
desc: 'Regenerate SKILLS_INDEX.md''s table from skill frontmatter.'
cmds:
- "'{{.TASKFILE_DIR}}/bin/generate-index' '{{.TASKFILE_DIR}}'"
check:
desc: 'Structure gate — layout, frontmatter, surfaces, generated index, manifest/tag parity. Runs in CI.'
cmds:
- |
fail=0
# 1. No stray entries at the repo root. A loose foo.md where
# skills/foo/SKILL.md was meant loads in NO harness and errors in
# none either — it just silently does nothing. (See #23.)
for entry in "{{.TASKFILE_DIR}}"/*; do
base=$(basename "$entry")
case "$base" in
install.sh|Taskfile.yml|README.md|SKILLS_INDEX.md|skills|hooks|bin|tests|agents) continue ;;
esac
echo "ERROR: unexpected entry at repo root: $base" >&2
fail=1
done
# 2. Every skill is a directory with a SKILL.md carrying name + description.
count=0
for dir in "{{.SKILLS_DIR}}"/*; do
name=$(basename "$dir")
if [ ! -d "$dir" ]; then
echo "ERROR: skills/$name is not a directory" >&2; fail=1; continue
fi
if [ ! -f "$dir/SKILL.md" ]; then
echo "ERROR: skills/$name missing SKILL.md" >&2; fail=1; continue
fi
head -n 20 "$dir/SKILL.md" | grep -q '^name:' || { echo "ERROR: skills/$name SKILL.md has no 'name:' frontmatter" >&2; fail=1; }
head -n 20 "$dir/SKILL.md" | grep -q '^description:' || { echo "ERROR: skills/$name SKILL.md has no 'description:' frontmatter" >&2; fail=1; }
count=$((count + 1))
done
# 3. SKILLS_INDEX.md's table is generated from frontmatter, so the
# assertion is that regenerating it changes nothing. This replaces
# bin/check-index: a generated table cannot drift in either
# direction, so there is no separate rule left to check.
sh '{{.TASKFILE_DIR}}/bin/generate-index' --check '{{.TASKFILE_DIR}}' || fail=1
# 4. Every skill says which surfaces it ships to, and names only
# surfaces that exist — a wrong value silently drops a skill from a
# harness instead of erroring.
sh '{{.TASKFILE_DIR}}/bin/check-surfaces' '{{.TASKFILE_DIR}}' || fail=1
# 5. A documented session_log call must be one the tool would accept:
# session_id is its only required field, and nine skills omitted it.
sh '{{.TASKFILE_DIR}}/bin/check-session-log' '{{.TASKFILE_DIR}}' || fail=1
# 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)
tag_v=$(git -C '{{.TASKFILE_DIR}}' describe --tags --abbrev=0 2>/dev/null | sed 's/^v//' || echo '')
# Ordering, not equality. The fault this guards is the manifest
# failing to MOVE — a release tag whose manifest still names the old
# version, so no installed host ever sees the update. A manifest
# ahead of the newest tag this checkout knows about is the normal
# state between the tagger's bump commit and a tag fetch (#34).
if [ -n "$tag_v" ] && [ "$manifest_v" != "$tag_v" ] \
&& [ "$(sh '{{.TASKFILE_DIR}}/bin/newer-version' "$manifest_v" "$tag_v")" = "$tag_v" ]; then
echo "ERROR: plugin.json version ($manifest_v) is behind the latest tag ($tag_v) — installed hosts never see the update" >&2
fail=1
fi
else
echo "ERROR: missing .claude-plugin/plugin.json" >&2; fail=1
fi
# Every ${VAR} a live .mcp.json server interpolates must be one the
# session env-file defines. An undefined one is silent: the header
# goes out empty and the server reports "requires authentication",
# which reads as a revoked credential. Exit 2 means the check could
# not run (no jq, or no env-file — CI has neither); that is reported
# and not fatal, but it is never treated as a pass.
if [ -x "{{.TASKFILE_DIR}}/bin/check-mcp-env" ]; then
# `|| rc=$?` is load-bearing, not style. Task runs this block under
# `sh -e`, so a bare invocation exiting non-zero kills the shell
# before `case` is reached -- the tolerance below never ran, and the
# gate died with `exit status 2` on the commit that added it.
rc=0
"{{.TASKFILE_DIR}}/bin/check-mcp-env" "{{.TASKFILE_DIR}}/.mcp.json" "$HOME/.op-env" || rc=$?
case $rc in
0) ;;
2) echo "NOTE: MCP env wiring unverified on this host" >&2 ;;
*) fail=1 ;;
esac
fi
if [ $fail -ne 0 ]; then
echo "check FAILED" >&2; exit 1
fi
echo "check OK — $count skills, manifest matches tag"
release:
desc: 'Tag HEAD per Bump-Type in the latest commit footer. Normally CI handles this.'
cmds:
- |
latest=$(git -C "{{.TASKFILE_DIR}}" describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
major=$(echo "$latest" | sed -E 's/^v([0-9]+)\.([0-9]+)\.([0-9]+)$/\1/')
minor=$(echo "$latest" | sed -E 's/^v([0-9]+)\.([0-9]+)\.([0-9]+)$/\2/')
patch=$(echo "$latest" | sed -E 's/^v([0-9]+)\.([0-9]+)\.([0-9]+)$/\3/')
bump=$(git -C "{{.TASKFILE_DIR}}" log -1 --pretty=%B | grep -iE "^Bump-Type:" | head -1 | awk '{print tolower($2)}')
case "$bump" in
major) major=$((major + 1)); minor=0; patch=0 ;;
minor) minor=$((minor + 1)); patch=0 ;;
*) patch=$((patch + 1)) ;;
esac
next="v${major}.${minor}.${patch}"
echo "tagging $next (previous $latest, bump=${bump:-patch})"
git -C "{{.TASKFILE_DIR}}" tag -a "$next" -m "release $next"
echo "now: git push origin $next"