Commit Graph
27 Commits
Author SHA1 Message Date
mathias 6838baa071 fix(ci): gate the release-bump commit — nothing has ever tested it
CI / Check / Test (push) Successful in 3s
CI / Tag release (push) Successful in 1s
Found from the other end: the koala cluster's CIHeadUncovered{repo=skills}
alert had been firing for 85 hours, and it is a true positive.

    mathias/skills main HEAD   f031c2b
    workflow runs for that sha  0

Not a one-off. The previous release-bump commit has the same property:

    6cc224f  chore(release): plugin.json version 0.19.4  ->  0 runs
    f031c2b  chore(release): plugin.json version 0.19.5  ->  0 runs

The `tag` job pushes the manifest bump itself with secrets.GITEA_TOKEN, and a
push authenticated with the Actions token does not trigger workflows -- that is
deliberate platform behaviour to stop CI recursing. The `Skip-Release: true`
trailer stops the TAGGER recursing and works; nothing stopped the whole run,
`gate` included, from never firing on that commit.

So after every release, main's HEAD is a commit no CI has seen, until an
unrelated human push moves it. CIHeadUncovered therefore cannot clear on its
own -- and an alert that can only be cleared by doing something unrelated is
worse than a noisy one, because it teaches the reader that red is the normal
state of that rule.

The commit is not cosmetic either. plugin.json's version is what Claude Code
gates plugin updates on, `task check` asserts it is not behind the latest tag,
and the sed-and-retry logic that writes it already shipped a mismatched
manifest once -- v0.10.0 tagged with the manifest still reading 0.9.0, per the
comment above that very loop.

Fix: run `task check` on the bump commit before pushing it. The commit is
produced in that step, so it can be validated in that step -- one check instead
of a second full CI run, and no recursion risk to reintroduce.

Considered and rejected: re-triggering CI with a different credential after the
push (costs a full run per release and brings back what the token behaviour
exists to prevent), and teaching CIHeadUncovered to ignore manifest-only
commits (narrowing the measure to fit the system, and it would hide a real gap
too).

The assertion in tests/ci_gate_test.sh had to be anchored with ^[^#]*: the tag
job's own prose says "`task check` asserts the manifest is not behind the
latest tag", so a bare grep matched the comment explaining the problem and
reported the problem fixed.

Closes #46.
2026-09-11 11:11:16 +02:00
mathiasandClaude Opus 5 1328dcd8f3 feat(doctor): report golangci-lint built with a Go older than GOROOT
CI / Check / Test (push) Successful in 3s
CI / Tag release (push) Successful in 1s
Second recurrence of the same host fault (infra#375; first hit
openbanking-api 2026-09-03, then cobalt-dingo 2026-09-08). golangci-lint
embeds the go/types export-data reader from the Go it was BUILT with, so a
newer GOROOT makes it unable to decode the stdlib:

  panic: file requires newer Go version go1.27 (application built with go1.26)

An earlier form printed a typecheck error naming the user's own source file,
which sends people to debug code that is fine. It cost time in two repos before
anyone recognised it as a host fault rather than a repo fault.

CI never sees it — the runner image has matching versions — so it breaks
exactly the local `task check` that AGENT.md requires before every push, and
nothing else. The fix was always one reinstall; the missing thing was the
signal, which is what doctor is for.

Fails open by design: an unparseable version, or no Go at all, is a normal host
and not a fault doctor may assert. Version comparison is major.minor in POSIX
sh rather than sort -V, since this runs on macOS too and the export-data format
is minor-versioned.

Also makes the suite hermetic. The check reads the real host, so on a machine
whose golangci-lint is genuinely stale every unrelated fixture started failing.
The harness now prepends a matching fake pair for the whole file, and only the
four cases that opt in exercise the check.

Verified against koala, which is currently in exactly this state:

  FAIL toolchain: golangci-lint built with go1.26.1 but GOROOT is go1.27.0
       — lint panics on every Go repo; reinstall it under the current
       toolchain (infra#375)

doctor: 25 passed, 0 failed. Full suite green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 21:34:44 +02:00
mathiasandClaude Opus 5 8359d61e33 test(hooks): pin the pre-task-ritual hook to its registration
CI / Tag release (push) Successful in 1s
CI / Check / Test (push) Successful in 2s
Closes #44.

That issue overstated the risk and this corrects it. The hook is three lines
and echoes a string -- no bash-isms, so it cannot fail the way secret-guard
appeared to. The real exposure is narrower and duller: it can stop working
silently, and its entire job is to be visible.

THE ASSERTION THAT MATTERS IS REGISTRATION. A hook file that exists but is
absent from hooks.json does nothing at all and looks identical on disk to one
that works. So the test derives the command from hooks.json and runs it exactly
as registered, substituting CLAUDE_PLUGIN_ROOT the way the harness does, rather
than inventing its own invocation.

That choice is the lesson from #44's sibling. secret_guard_test.sh piped its
hook into `sh` while hooks.json runs it with `bash`; on Debian /bin/sh is dash,
which has no `set -o pipefail`, so the suite failed 20/20 in CI against a hook
that was FINE in production. The test was wrong, not the hook -- and the
earlier commit message for that fix said the shebang decides the interpreter,
which hooks.json shows is not true. Deriving the invocation removes the whole
class rather than fixing one instance of it.

No red phase to report: the hook was already correct, so this is regression
pinning, not TDD. Teeth proven by mutation against a throwaway copy instead:

  - hook runs but prints nothing      -> 6 failures
  - hook removed from hooks.json      -> caught, suite stops early
  - ONE ritual step dropped, rule 0
    still named                       -> caught (9 passed, 1 failed)

The third is the one worth having. Losing the whole line is obvious; quietly
losing "failing test" from the ritual while the banner still reads correctly is
the drift that would actually happen.

Bump-Type: patch

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 22:33:52 +02:00
mathiasandClaude Opus 5 4edf42ce9f fix(test): run the secret guard via its own shebang, not through sh
CI / Check / Test (push) Successful in 3s
CI / Tag release (push) Successful in 0s
The suite piped hook input into `sh "$GUARD"`, which overrides the hook's
`#!/usr/bin/env bash` and tests an interpreter production never uses. Claude
Code executes the hook as a program.

On Debian /bin/sh is dash, which has no `set -o pipefail`, so the guard died
on line 23 before reading a byte of input:

    secret-guard.sh: 23: set: Illegal option -o pipefail
    secret_guard: 0 passed, 20 failed

while passing 20/20 on a host where /bin/sh happens to be bash. A security
gate that tests green and cannot start is the worst of both, and the test was
the only thing standing between those two states.

Found because baked-v16 added jq to the runner: this suite needs jq to build
its hook JSON, so it had been silently doing nothing in CI and started running
for the first time. Same shape as installing zip and immediately learning the
gitea backup check had never run -- a missing tool does not announce that it
is suppressing a whole suite.

Also asserts the hook is executable, since direct invocation depends on the
exec bit, and that assertion is what stops this regressing to `sh "$GUARD"`.

Checked the rest of the suite: every other `sh "$SUT"` targets a POSIX script,
which is correct. hooks/pre-task-ritual.sh is the other bash-shebang hook and
has no test at all -- filed separately rather than fixed here.

Bump-Type: patch

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 22:23:06 +02:00
mathiasandClaude Opus 5 9fd5a054b3 fix(check): tolerate an unverifiable check instead of dying on it
CI / Check / Test (push) Failing after 1s
CI / Tag release (push) Skipped
bin/check-mcp-env exits 2 for "could not run" -- no jq, or no env-file --
and the gate was written to report that as a NOTE rather than fail:

    "$DIR/check-mcp-env" "$DIR/.mcp.json" "$HOME/.op-env"
    case $? in 0) ;; 2) echo "NOTE: ..." ;; *) fail=1 ;; esac

That tolerance never executed. Task runs each cmds block under `sh -e`, so
a bare command exiting non-zero aborts the shell before `case $?` is
reached. main went red on the very commit that added the check (run #57),
with `task: Failed to run task "check": exit status 2`.

It passed locally because this host has both jq and ~/.op-env, so the
check exited 0 and the untested branch never ran. CI has neither. A
tolerated exit code that is only tolerated where it never fires is not
tolerated at all.

`|| rc=$?` puts the invocation in a compound command, which `set -e`
exempts, so `case` is reached. Both exit-2 paths verified: no env-file
(the new test's precondition) and no jq (PATH sandbox, same SKIP message
CI printed).

The test pins the tolerance itself rather than the checker: it points HOME
at an empty directory, which is exactly CI's condition, and asserts `task
check` exits 0 AND still says it could not verify -- silence would mean
skipped rather than tolerated.

Bump-Type: patch

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 20:14:19 +02:00
mathiasandClaude Opus 5 da76a6b3d2 feat(mcp): unpark the gitea server — it needs no AgentSecrets token
CI / Check / Test (push) Failing after 1s
CI / Tag release (push) Skipped
gitea-mcp was parked alongside cad on the stated reason that nothing puts its
token into the session environment: GITEA_MCP_STATIC_TOKEN lives in the
AgentSecrets vault, which the session identity deliberately cannot read
(infra#224), so enabling it was blocked behind #32/#33.

That reason never applied to gitea. gitea-mcp authenticates by PASS-THROUGH:
the bearer is a Gitea PAT, which the server validates against Gitea and then
acts as. No dedicated MCP token exists to be missing. DMABE_GITEA_API_TOKEN is
op://HomeLab and already in the session environment.

Verified on koala 2026-09-07 against tailscale serve 8446 -> NodePort 30340:
initialize returns 200 with serverInfo gitea-mcp, tools/list returns 41 tools.
Two DIFFERENT PATs both authenticate while GITEA_MCP_TOKEN 401s -- that is what
proves pass-through validation rather than a static-token comparison, and it
also shows GITEA_MCP_TOKEN is stale (it 401s against git-mcp.d-ma.be too).

cad stays parked; its blocker is real and unchanged.

Also adds bin/check-mcp-env and tests/mcp_config_test.sh, wired into task check.
The fault it gates is silent: a header interpolating an undefined variable does
not error, it sends an empty Authorization, and Claude Code reports "requires
authentication" -- indistinguishable from a revoked credential. That cost a full
investigation on koala today before the credential turned out to be fine. The
check exits 2 rather than 0 when it cannot run (no jq, no env-file, as in CI),
so an unverified config is never reported as a clean one. Servers under _parked
are exempt: referencing an unprovided variable is what parked means.

Bump-Type: minor

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 07:24:23 +02:00
mathias bedb6d278d fix(test): stop the Antigravity cases failing on a host without jq
CI / Check / Test (push) Successful in 1s
CI / Tag release (push) Successful in 1s
main has been red since e9ad692. The gate passed; the shell suite failed on
three Antigravity cases.

Not a bug in the wiring. install.sh treats jq as optional by design — every
other target is dependency-free, so wire_antigravity warns and skips when jq is
missing. It did exactly that on the runner:

  [skills] Antigravity: jq not found — skipping

The tests were the problem: they inherited whatever jq the host happened to
have. koala has it and reported 12/12; the runner does not and reported 3
failures on the same commit. Works-on-my-machine, caught by CI doing its job.

Two changes:

- The three merge-behaviour cases now run only where jq exists, and print a SKIP
  naming what is not covered. A case that fails because the host lacks an
  optional dependency is a false red, and false reds are how a suite stops being
  read.
- The skip path gets a real test of its own, because it is a contract rather than
  a fallback. install.sh gains SKILLS_JQ so the jq-absent branch is exercisable
  on a host that has jq — the same seam pattern as DOCTOR_LATEST_TAG_CMD. It
  asserts no skills.json is written, the skip is reported, and the other
  harnesses still wire.

Verified both ways on koala by building a jq-free PATH:
  with jq:    install: 13 passed, 0 failed, 0 skipped
  without jq: install: 10 passed, 0 failed, 3 skipped

Consequence worth naming: CI now SKIPS the merge assertions rather than running
them, so idempotency and entry-preservation are untested there until the runner
gets jq. Filed as #42.

Refs #41
Bump-Type: patch
2026-08-30 11:48:56 +02:00
mathiasandClaude Sonnet 5 e9ad692418 feat(install): re-add Antigravity as a wiring target
CI / Check / Test (push) Failing after 1s
CI / Tag release (push) Skipped
Antigravity discovers skills via a path-entries JSON
(~/.gemini/config/skills.json), not a symlink directory. One entry points
at $AGENTS_DIR — the same managed, surfaces-filtered, collision-resolved
directory Crush/OpenCode/Pi already read — so Antigravity sees exactly the
same skill set as every other harness, for free.

Requires jq; every other target in this script stays dependency-free on
purpose, so wire_antigravity skips with a warning (not a hard failure) if
jq is missing. Merges into skills.json rather than overwriting it: the
file is documented as user-editable (json_configs.md — e.g. a personal
skills dir), so a hand-added entry must survive a re-run.

Was previously removed as "unused" (mathias/skills#41 — Antigravity usage
picked back up). Mistral Vibe stays removed.

Closes #41

Bump-Type: minor

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 08:58:34 +02:00
mathias d6e892ac4c feat(skill): traceability — a chain of checked links, and a tool that says what it cannot check
CI / Check / Test (push) Successful in 2s
CI / Tag release (push) Successful in 1s
Forward trace: requirement → decision → commit → gate → verification → CI on
that sha → release → closure. The distinction the skill exists for is that each
link is either checked or named as unchecked; a trace where every link is
asserted is a narrative, and narratives are what audits exist to defeat.

`trace.sh` does the mechanical part — commit, files, test files touched, release
containing it, referenced issues as URLs — and then always prints the four things
git cannot prove. The GREEN run's own accounting: "the tool did the mechanical
15%. Its real contribution is the 'Cannot verify from here' block, which is a
checklist, not an output." Kept, because it is true.

The RED run established the shape by tracing a commit by hand in sixteen steps.
Its decisive moves were reading issue COMMENTS — where the decisions actually
live — and running a probe that falsified a claim. Both are now steps.

The GREEN run then used step 5 against a gate I shipped this morning, and broke
it. It also found four things wrong with what I had written:

- The slug sed used `[^/]+?` expecting a lazy quantifier. POSIX ERE has none, so
  `.git` was swallowed and every emitted evidence URL carried the suffix. It
  happened to work against Gitea and would 404 on GitHub — a bad failure for the
  one tool whose pitch is that an unchecked link must not look checked.
- "Prints every issue it references" was overclaimed: it greps the commit body,
  so it structurally cannot find the issue that cites the COMMIT. That is the
  commoner break — the issue filed afterwards that falsifies the outcome — and
  it is now step 6 with the two searches that find it.
- Step 3 told you to check which job passed, and the estate's own
  `workflow_run_status` returns no jobs. A step whose tooling dead-ends is where
  a trace becomes "the run was green"; the REST fallback is now written out.
- The output format did not survive contact. It grew four slots, each forced by
  a real trace: two requirements differing in kind (a defect report and a
  standing bar), the gate as an artifact separate from the test, the RED/GREEN
  baseline against the parent, and the commit's factual claims checked. Without
  the last one, "commit → verified" means only "this sha exists".

Also: `git describe --contains` reads local tags, so the tool now says so on
every hit rather than only when it finds nothing.

Closes #10
Bump-Type: minor
2026-08-29 15:34:11 +02:00
mathias 4d6ebb626f fix(check): read the session_log payload, not the file
A traceability probe falsified this gate four hours after I shipped it. The
check was `grep -q 'session_id' "$dir/SKILL.md"` — file-scoped. So the probe
stripped session_id from all three documented payloads in tdd/SKILL.md, added
one prose line saying "the tool has a required session_id field", and the gate
returned 0. The exact regression it exists to prevent was back in the file, and
the gate said nothing.

The duration_ms rule was no better: it matched one spelling, the historical one.
Both of these passed — `"duration_ms": <wall-clock>` with a JSON-quoted key, and
`duration_ms: elapsed wall-clock ms` with no angle brackets. It was a regression
test against the nine strings that existed that morning, described in the commit
message as a rule.

Now each documented payload is checked on its own line and reported with a line
number, and duration_ms is rejected in any spelling unless it carries an actual
number — the objection was always to a placeholder no agent can fill, not to the
field.

Five new cases, each one a probe from that run: prose mentioning the word while
the payload omits it, one bad payload among several, both duration_ms spellings,
and a real measurement being allowed.

Also fixed while writing them: my first regex had `[[:space:]]*` before a
negated class that did not exclude whitespace, so the star matched empty and the
space itself tripped the check — a real measurement failed. The tests caught it
immediately, which is the argument for writing them from the probes rather than
from the intent.

Refs #36, #39
Bump-Type: patch
2026-08-29 15:34:11 +02:00
mathias 40405cd95a fix(check): make the escalation ratchet actually ratchet
CI / Check / Test (push) Successful in 1s
CI / Tag release (push) Successful in 1s
"That list only shrinks" was asserted in the commit message, the script header,
the pending file's own header, the test header, and the issue comment. It was
enforced nowhere.

A traceability probe falsified it in one run: extract the commit to a temp tree,
add a skill with no `## Escalation` section, append its name to
bin/escalation-pending.txt — gate returns 0. The list could grow silently and
forever. "Self-cleaning" was true; "only shrinks" was a social convention wearing
a mechanism's clothes, and this repo has spent a day on what those cost.

The gate now diffs the pending list against the version committed at HEAD and
fails on any added entry, naming it. Where the comparison is impossible — not a
git checkout — it says so rather than passing silently, because a ratchet that
cannot be verified must not look like one that was.

Four new cases: unchanged list, shrinking list, growing list, and the
unverifiable case.

Refs #37
Bump-Type: patch
2026-08-29 15:26:24 +02:00
mathias 39ac61c411 feat(skill): lineage — backward decision archaeology, with a tool
CI / Check / Test (push) Successful in 2s
CI / Tag release (push) Successful in 1s
Ports the technique #11 asked for and ships the script #19 asks for: was this
tried, what killed it, and is the killer still alive.

The RED run did excellent archaeology unaided — fourteen steps, correct verdict,
and it split the proposal into a goal worth keeping and a mechanism that had
failed, which is the finding. What it never ran was `git log -S`, and what it
leaned on hardest was the issue tracker. Both facts shaped the skill.

The GREEN run then broke the tool's central claim, live. Asked about a feature
this repo removed and argued about at length, `lineage.sh "per repo"` printed:

  no trace of "per repo" — that is a real finding: it means this is new here

Flatly wrong; the term simply had a space in it. Worse, the skill's own
anti-pattern table forbids that exact inference — "assuming absence means it was
never considered" — so the tool was printing, in its own voice, the mistake the
skill warns against. A no-match now says it is one string against one repo, and
names the variants to try before anyone concludes novelty. The test asserts the
refusal rather than the old message.

Three more from the same run:

- The tool searched `docs/adr/*` for decision records. This repo has no `docs/`,
  and this estate keeps decisions in the brain and in gitea comments — so that
  section silently searched nothing four times, and its null was indistinguishable
  from a real one. It now names both unsearchable sources on every run, and the
  skill makes `brain_query` a step: the single best artifact in the GREEN run came
  from the brain and nowhere else.
- Step 1 steered readers toward path-scoped log and away from `-S`. Backwards. A
  function deleted from a file that still exists is the dominant removal shape,
  and `-S<identifier>` returns birth and death on one screen while the path search
  buries it. Rewritten to lead with the identifier.
- "Which conditions still hold" had no method, and the run answered it by running
  commands against the live host in twenty seconds. Now a table: one command per
  claim, because "the record says X" and "X is true right now" are different
  answers and only the second is worth giving.

Also fixed: the tool defaulted to the cwd and would return a confident null
against the wrong repo in a worktree-heavy setup. It now prints which repo it
searched, every run.

Closes #11
Bump-Type: minor
2026-08-29 15:26:14 +02:00
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 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 b616c51a2c fix(skills): document a session_log call the tool would actually accept
CI / Check / Test (push) Successful in 1s
CI / Tag release (push) Successful in 1s
Nine skills documented the payload. None named `session_id`, which is the
tool's only required field. So every documented call was one the tool rejects —
an agent following any of them either failed the call or invented an id, and
invented ids do not group, which means the pass-rate the /pass-rate endpoint
computes was being assembled from entries that cannot be joined.

Found by the #19 pressure test against `debug`, which invented
`debug-signed-artifact-missing-2026-08-29` and said so: "my three entries are
only groupable by luck of naming". The grep afterwards showed it was fleet-wide,
not a `debug` bug.

Also removed: `duration_ms: <wall-clock>` in eight of them. An agent does not
observe a clock. The same run omitted it "rather than fabricate", which is the
good outcome; the bad outcome is the run that fabricates, and a made-up number
in a metrics stream is worse than a missing one. It is an optional field and is
now simply absent.

bin/check-session-log gates both, with tests. This is exactly the case #19 has
in mind for a small tool over prose: the rule is a grep, so it should be a gate,
not a note asking people to remember.

Closes part of #36
Bump-Type: patch
2026-08-29 14:49:20 +02:00
mathias c6787e04d7 feat(index): generate the skills table from frontmatter
CI / Check / Test (push) Failing after 1s
CI / Tag release (push) Skipped
Second half of #29. The index was hand-maintained and had already drifted
once: dream and experiment-spec shipped with no row, their rows lost to a
squash-merge after a directory reorg. bin/check-index caught that after the
fact; generating removes the class.

The curated summary and trigger move into each SKILL.md as `summary:` and
`use_when:`, carried over verbatim from the existing table rather than derived
from `description:` — description is a paragraph written for model selection,
and truncating it would have produced a worse table than the one it replaced.
Knowledge now lives once, next to the skill it describes, which is the argument
that made a hand-maintained index the wrong shape to begin with.

The table is written between markers, so the wiring docs, the relationships
tree and the trigger table below it are untouched — those are prose, not data.
Rows are alphabetical now; the conceptual grouping the old order carried lives
in the relationships tree, which is the better place for it. A fourth column
shows each skill's surfaces, which is what makes #29's field visible rather
than merely enforced.

bin/check-index and its test are deleted: a generated table cannot drift in
either direction, so `generate-index --check` subsumes both of its assertions.
Verified by mutation — deleting the tdd row turns `task check` red with
"SKILLS_INDEX.md is out of date".

Closes #29
Bump-Type: minor
2026-08-29 11:23:14 +02:00
mathias 90a58f0b9e feat(skills): declare which surfaces each skill ships to
CI / Check / Test (push) Failing after 1s
CI / Tag release (push) Skipped
The repo distributes three ways — the Claude Code plugin, the symlinked
library for Crush/Pi/OpenCode, and claude.ai, which has no fetch path at all —
and treated every skill as belonging to all of them. claude.ai is not a fourth
host to mirror: gitea-ci, playwright and web-shot have no shell and no cluster
there, so a flat mirror installs skills that can never fire and degrades
selection for the ones that can.

Every SKILL.md now carries `surfaces:`, defaulting to [claude-code, library];
dream, own-the-task and grill-me add `chat`, matching what is actually
installed on claude.ai today. Claude Code accepts the extra key —
`claude plugin validate` passes and the skills still load.

bin/check-surfaces validates values, not just presence, because the failure
mode here is silent: a wrong surface removes a skill from a harness rather than
erroring. install.sh links only skills whose surfaces include `library`, and a
source that declares nothing at all is treated as foreign and linked as-is —
Anthropic's official set has no reason to know this convention. doctor's
missing-link check skips non-library skills for the same reason.

Refs #29
Bump-Type: minor
2026-08-29 10:09:52 +02:00
mathias 15737321ac fix(check): fail when the manifest is behind the tag, not merely different
CI / Check / Test (push) Failing after 0s
CI / Tag release (push) Skipped
`task check` asserted plugin.json's version EQUALS the latest tag. `git
describe` is local-only, so any checkout that had not fetched the tag CI just
pushed went red on a commit that was correct — three times in one backlog
session, on the loop push → CI tags and pushes the bump → pull → red until you
also fetch tags.

The fault the assertion exists for is the manifest failing to move: a release
whose manifest still names the old version, so no installed host ever sees the
update. That is manifest < tag. Ahead is normal and is now quiet.

Ordering needed a real comparison, and bin/doctor had already grown one for
#30, so it moves to bin/newer-version with its own tests rather than existing
twice. Seven cases including the lexical trap (0.8.10 beats 0.8.9) and a
missing component counting as zero.

Verified by mutation: manifest 0.1.0 against tag v0.9.0 exits 201 naming both
versions; manifest 9.9.9 exits 0; the real tree exits 0.

Closes #34
Bump-Type: patch
2026-08-29 10:07:27 +02:00
mathias 025f01aa80 feat(install): serve several sources instead of displacing them
CI / Check / Test (push) Successful in 0s
CI / Tag release (push) Failing after 1s
~/.agents/skills was a single link straight at this checkout. That encoded an
assumption nobody had stated — that this checkout is the only source of skills
on the host. On flamingo it was not: Crush's directory held Anthropic's
official set, and the only way to install this library was to move that set
aside. A capability loss dressed up as a tidy-up.

The canonical location becomes a real directory of per-skill symlinks, fed by
the checkout plus anything in SKILLS_EXTRA_SOURCES. Harnesses keep their one
directory link, so the count is N + one per harness rather than N x harnesses,
which is what #23 was actually about.

The three requirements #27 named, each with a test:
- collisions are logged with both paths and the checkout wins, never resolved
  in silence;
- removing a skill from a source prunes its link;
- anything in the managed directory that is not our symlink is reported and
  left in place — the installer does not delete content it did not create.

doctor learns the same model: sources are recorded at ~/.agents/skills-sources,
a link into a configured source is healthy, a link into an unknown one is not,
and the old count check becomes a per-skill check because a per-skill layout
can lose one skill without changing any total. The pre-#27 wiring is named as
legacy rather than reported as damage. count_skills needed find -L; without it
symlinked skill directories count as zero.

Verified live on koala: migrated in place to 42 per-skill links, then a second
source added a 43rd alongside them, then a re-run without that source pruned it
back to 42. Only doctor finding throughout was the genuine stale plugin.

Closes #27
Bump-Type: minor
2026-08-29 10:05:46 +02:00
mathias a2afb35d1d feat(doctor): report the installed plugin generation, not just its presence
CI / Tag release (push) Successful in 0s
CI / Check / Test (push) Successful in 1s
Every other check in doctor is about symlink paths. That is the wrong shape
for Claude Code, which has no symlinks — it has the plugin, and it gates
updates on plugin.json's version. A host that never picked up a release loads
every skill successfully and is simply a generation behind, silently.

Found live while writing this: koala had skills@mathias 0.6.0 installed, a
marketplace clone at 0.7.0 and a checkout at 0.8.4, and doctor said "clean".

doctor now compares the version recorded in installed_plugins.json against the
newest manifest already on the host — the active checkout and any marketplace
clone, filtered to plugin.json files that actually name this plugin — and fails
naming both versions. Offline, like the rest of doctor: "available" never means
a fetch.

A host with no install record is a valid symlink-only configuration and stays
clean. --json grows a "plugin":{"installed","available"} object so per-host
results can be collected without parsing prose.

Four new fixture cases: match, stale, absent record, newer marketplace clone.
Live output on koala:

  FAIL plugin-version: Claude Code has skills@mathias 0.6.0 installed but
  0.8.4 is available on this host

Closes #30
Bump-Type: minor
2026-08-29 10:00:11 +02:00
mathias 83b069afba docs(index): add dream + experiment-spec rows, and gate the index
CI / Check / Test (push) Successful in 0s
CI / Tag release (push) Successful in 1s
Both skills shipped without an index row. The rows existed — on the feature
branches deleted in #22 — and were dropped by the squash-merge that followed
a directory reorg: the SKILL.md landed under skills/, the row did not. Nothing
errored, the index just under-reported the library to every harness reading it.

Adding two rows by hand fixes today and not tomorrow, so the assertion goes in
`task check` via bin/check-index, following the same pattern as
bin/should-skip-release: a real script with a real test, not untested logic
buried in a Taskfile. It checks both directions — a skill with no row, and a
row naming a skill that no longer exists.

Verified by mutation: deleting the `dream` row turns `task check` red and the
report names it.

Closes #15
Bump-Type: patch
2026-08-29 09:52:03 +02:00
mathias a42ec18f40 fix(secret-guard): treat env as a dump only when no command follows
CI / Tag release (push) Successful in 1s
CI / Check / Test (push) Successful in 1s
`env -u VAR cmd` and `env FOO=bar cmd` exec a command with a modified
environment and print nothing themselves. The old rule matched any `env`
token followed anywhere later by a pipe into a filter, so it blocked the
documented 1Password write path and forced a `(unset VAR; cmd)` workaround.
False positives only — never a leak — but a guard people route around is a
guard on its way out.

The rule now denies only when everything after `env` is exhausted by options
and VAR=value assignments. That is strictly the dumping shape, and it also
closes a hole the two previous rules left open: `env FOO=bar` with no command
word prints the whole environment and was allowed.

Three new deny/allow pairs pin it, including the 1Password write shape that
motivated the report. 20 cases now, up from 14, all passing.

Closes #25
Bump-Type: patch
2026-08-29 09:50:08 +02:00
mathias 3112b4ce92 chore(ci): gate the release tagger on check + test
CI / Check / Test (push) Successful in 0s
CI / Tag release (push) Successful in 0s
The repo had a `task check` structure gate and a 28-case shell test suite
that nothing ever ran. The only workflow auto-tagged every push to main, so
CI reported green having tested nothing — a gate that fails open, and the
reason `mathias/skills` shows up as repository-blocked in the estate backlog
classification.

release.yml becomes ci.yml with two jobs: `gate` runs check + test on every
push and PR; `tag` keeps the existing semver-from-trailer logic but declares
`needs: gate`, so a red check cannot ship a tag. The pull_request trigger
also gives `tbd_ship` a CI run to read on a branch head — without one it
fails closed and never merges (#22).

The tagger now pushes the manifest bump commit and the tag in a single
`git push`. Two pushes left a window where the gate ran `task check` on the
bump commit before the tag existed, and the manifest/tag parity assertion
would fail on a commit that was in fact correct.

tests/ci_gate_test.sh pins the wiring: PR trigger present, both tasks run,
tag needs gate, no second ungated workflow file.

Refs: #22
Bump-Type: minor
2026-08-29 09:46:10 +02:00
mathiasandClaude Opus 5 abd81f939a fix(doctor): report a real harness directory as unmanaged, not as "outside"
release / tag (push) Successful in 1s
Found on flamingo during the first Mac install. ~/.config/crush/skills held
real directories from a pre-plugin install, and doctor said:

  FAIL outside: ~/.config/crush/skills resolves to ~/.config/crush/skills,
  outside the active checkout ~/.local/share/skills/skills

Naming a directory as its own resolution target is true and useless. Worse, it
pointed at the wrong fix: "outside" means a link into a foreign checkout, which
you repair by re-pointing the link. A real directory is repaired by moving it
aside and re-running install.sh — install.sh already refuses to delete it,
correctly, since it did not create it and cannot know what is in there.

Now reported as its own class, with the skill count so it is obvious whether
the directory holds a stale copy of the library or something unique that
belongs in the repo:

  FAIL unmanaged: <dir> is a real directory, not a link into the checkout —
  it holds N unmanaged skill(s); move it aside and re-run install.sh

The distinction matters because content in that directory reaches exactly one
harness and cannot receive new skills — the failure mode is silent divergence,
which is the same family as everything else this tool exists to catch.

Bump-Type: patch

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BeAp5LTscnz2W7Ubt4eJ6m
2026-08-26 07:53:14 +02:00
mathiasandClaude Opus 5 e2a312d05e fix(ci): anchor the release-skip marker to a trailer, and test it
release / tag (push) Successful in 1s
The tagger searched the entire commit message for a bracketed marker as a
substring. The commit that introduced the manifest-bump step described that
marker in its own body — so the tagger read its own documentation as an
instruction and skipped the release. Run 15 reported success with no tag, no
manifest bump and no error. Same failure family as everything else in this
sweep: the wrong thing happened silently.

The rule is now a git trailer matched anchored to its own line, consistent
with Bump-Type, and it lives in bin/should-skip-release rather than untested
inside YAML. tests/release_skip_test.sh pins six cases, including the exact
regression: a message that spells the marker out in prose must still tag.

A marker you cannot mention without triggering is a footgun, not a mechanism.

Bump-Type: patch

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BeAp5LTscnz2W7Ubt4eJ6m
2026-08-23 14:39:31 +02:00
mathiasandClaude Opus 5 88c54399fe feat(doctor): fail loud on skills drift, wired to SessionStart
Every failure this fleet actually hit was silent. 243 dangling symlinks were
invisible to git because **/.claude/skills/ is in the global gitignore. A
checkout 20 skills behind still resolved, so nothing complained. A loose
secret-hygiene.md loaded in no harness and errored in none. Two machines had
the library missing entirely. Exit status is the signal that was absent.

bin/doctor reports, with a non-zero exit:
  - dangling links, anywhere under the harness dirs
  - links that RESOLVE but land outside the active checkout — the dangerous
    case, because it works while serving stale content
  - skill-count mismatch between a harness and the checkout
  - stray entries where <name>/SKILL.md was expected
  - leftover per-skill Claude symlinks (plugin skills do not override
    same-named user skills, so leftovers mean everything loads twice)
  - divergence between the plugin copy and the copy the other harnesses link to

Wired as a SessionStart hook with --quiet, so a healthy host prints nothing and
a drifting one says so at the top of the next session. That beats a timer:
no launchd/systemd wiring, and it reaches every host that installs the plugin.
27ms on koala, no network, no git fetch.

Two design points found by the tests, both worth keeping:
  - The canonical checkout is whatever ~/.agents/skills resolves to, NOT the
    copy doctor runs from. As a plugin hook $0 lives in the plugin cache, which
    is legitimately a second copy; judging harness links against it flagged
    every one of them as "outside".
  - "cd X/.." resolves ".." lexically against the link path and lands back in
    ~/.agents. Resolve the link first, then take the parent.

tests/doctor_test.sh drives seven fixtures through $HOME and $DOCTOR_CHECKOUT,
so no test touches the real host. Each asserts both the non-zero exit and that
the report names the fault — an exit-code-only assertion would pass while the
message said nothing useful.

Bump-Type: minor

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BeAp5LTscnz2W7Ubt4eJ6m
2026-08-23 14:34:32 +02:00
mathiasandClaude Opus 5 747c6c7f4c feat(plugin): ship the secret-guard and pre-task-ritual hooks + MCP servers
The plugin now carries the two hooks Mathias owns and the brain/gitea/infra
MCP server definitions, not just skills.

Why this matters more than tidiness: both hooks existed ONLY in koala's
~/.claude/, which is host-local and unsynced. flamingo and iguana have been
running with no secret-guard at all — that is the control which blocks the
decrypt-and-print shape behind the 2026-08-05 ntfy-token leak, the op-item-get
shape behind 2026-07-30, and the base64-bypass shape behind 2026-06-11. Its
absence on two of three machines is a security gap, not a config gap. Shipping
it in the plugin means installing the plugin installs the guard.

.mcp.json references tokens as ${BRAIN_MCP_TOKEN} / ${GITEA_MCP_TOKEN} only —
env indirection, never literal values, so the manifest is safe to commit.

tests/secret_guard_test.sh pins 14 cases. It asserts on
hookSpecificOutput.permissionDecision, NOT exit status: the guard always exits
0 by contract, so an exit-status assertion would pass vacuously while the guard
silently allowed everything. Each deny case names the incident that produced it.

gsd-* hooks are deliberately NOT migrated — GSD owns and updates those in place.

Bump-Type: minor

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BeAp5LTscnz2W7Ubt4eJ6m
2026-08-23 14:31:04 +02:00