Compare commits

...
41 Commits
Author SHA1 Message Date
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
mathias 6cc224f151 chore(release): plugin.json version 0.19.4
Skip-Release: true
2026-09-07 20:34:58 +00: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
mathias 400d2b9b6b chore(release): plugin.json version 0.19.3
Skip-Release: true
2026-09-07 20:23:31 +00: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 2f53346b8c docs(check): say plainly that CI can never verify the MCP env wiring
CI / Check / Test (push) Failing after 2s
CI / Tag release (push) Skipped
infra#368 added jq to the gitea-runner image on the stated grounds that
check-mcp-env would then verify in CI instead of skipping. Verified after
rolling baked-v16: jq IS present now, the `SKIP: jq not installed` branch is
gone -- and the check still reports unverified, because CI has no ~/.op-env.

The env-file branch was always the binding one. It holds live credentials, so
it is not on the runner and must not be, which means this check can only ever
verify on a host that has one. Putting ~/.op-env on the runner to turn the
NOTE green would be trading a real secret for a green tick.

So the message now says so, at the point someone reads it. The risk otherwise
is not the skip -- it is a future reader seeing a passing pipeline and
concluding the MCP wiring is covered. It is covered on developer hosts only.

jq was still worth adding: it is a genuine dependency of this checker, the
runner's tool-contract.txt now asserts it, and a future image built without it
is a red pod at roll time rather than a silently degraded check.

Bump-Type: patch

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 22:21:10 +02:00
mathias dcc0677270 chore(release): plugin.json version 0.19.2
Skip-Release: true
2026-09-07 18:14:53 +00: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 fc6d5628a7 fix(adr): template emitted invalid YAML frontmatter
CI / Check / Test (push) Failing after 1s
CI / Tag release (push) Skipped
The frontmatter exists to be machine-read. Two shapes this template
suggested do not parse, so any ADR that copied them dropped silently out of
every "what is still open?" query while looking correct:

  supersedes:    -                 a bare dash is a YAML sequence entry
  deciders:      pending: Mathias  the unquoted second colon reads as a
                                   nested mapping

Both were found live during an estate-wide ADR survey on 2026-09-05:
openbanking-api ADR-0001 (the one genuinely open decision in the estate --
invisible to the survey until fixed) and infra ADR-0019, each copied
verbatim from here.

Template corrected to `null` and a quoted `"pending: <name>"`, plus a short
section after the fence explaining why and giving a one-line yaml.safe_load
check to run before committing. The explanation is deliberately OUTSIDE the
fenced block so it is not copied into the ADR being written.
2026-09-05 12:25:10 +02:00
mathias 1f7f53e6c0 chore(release): plugin.json version 0.18.1
Skip-Release: true
2026-08-30 09:49:22 +00: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 86529d2ac5 chore(release): plugin.json version 0.18.0
Skip-Release: true
2026-08-29 13:34:27 +00: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 88a4fa407b chore(release): plugin.json version 0.17.1
Skip-Release: true
2026-08-29 13:27:06 +00:00
mathias 808af7b020 chore(release): plugin.json version 0.17.0
Skip-Release: true
2026-08-29 13:27:05 +00: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 2cb6b54a5e feat(skill): adr — the house format, and how to record a decision you cannot make yet
CI / Check / Test (push) Failing after 1s
CI / Tag release (push) Skipped
Codifies cad's ADR template (frontmatter + Status/Context/Decision/Consequences/
Validation) and formalises the productive-tensions pattern that ADR-0001 already
does informally under "Known refinements, deliberately not built yet".

The RED run is why the skill is shaped this way. An unaided agent given a
genuinely-undecidable transport choice produced a strong ADR — it invented a
"Cost of reversal" section, wrote concrete revisit triggers, and reframed the
unanswerable question ("which is better?") into an answerable one ("which
option's correctness depends on the unsettled variables?"). So the reasoning was
not the gap. What it could not guess was the house format: it invented a bullet
header instead of the frontmatter that makes the corpus greppable, and it wrote
no Validation section at all.

The GREEN run then found eleven problems. Four mattered:

- **The Overview contradicted the Escalation section in its first sentence** —
  "An ADR is not documentation of a decision. It is the decision" against
  "decides nothing". Escalation is right; the flourish is gone.
- **The escalation clause trapped every agent-authored ADR.** The deciders are
  never the agent, so `proposed` is mandatory — and the skill separately called
  a lingering `proposed` a failure mode. Now: a `proposed` ADR with a named
  decider is waiting on a person and gets chased; one without is waiting on
  nobody. Naming that person is the deliverable, not a formality.
- **No filename or path convention, in a skill whose thesis is greppability.**
  Called "the biggest hole in the skill", correctly. Now docs/adr/NNNN-slug.md,
  with the cross-repo caveat that a bare number is ambiguous between repos.
- **"Pick the option whose correctness does not depend on the unknown" assumed
  such an option always exists.** It often does not — that is what makes a
  tension productive — and the rule would then push the author to manufacture a
  reason one of them is, which reads as reasoned and never gets re-examined. A
  fallback ladder now says: cheaper to reverse, lower switching cost, already
  built — and say that is why.

Also: `deciders` had no valid value while proposed; the body Status section
duplicated frontmatter with no precedence; the tension template silently dropped
the "address the strongest case against" requirement the plain format demands;
the preservation strategy — the actual technical content — got one angle bracket
while the easy parts got full templates; resolution triggers were singular for
tensions that routinely have several; and lineage/grill-me were listed as
cross-references at the bottom when they are pre-steps that change the content.

Closes #9
Bump-Type: minor
2026-08-29 15:24:11 +02:00
mathias 1448963823 chore(release): plugin.json version 0.16.0
Skip-Release: true
2026-08-29 13:19:26 +00:00
mathias 58687c68a6 chore(release): plugin.json version 0.15.1
Skip-Release: true
2026-08-29 13:19:25 +00:00
mathias 0c7fac3686 fix(debug): close the intake gate properly — and the contradiction it exposed
CI / Check / Test (push) Successful in 1s
CI / Tag release (push) Successful in 1s
The GREEN run confirmed the gate works: given "builds are flaky again... what's
the fix?" with no run id and no log, it produced zero hypotheses and an ordered
list of artifacts, each justified by what it would discriminate. It also caught
that the retry already shipped in that pipeline is destroying the evidence
needed to diagnose it, which is a better finding than any hypothesis would have
been.

Then it found eight problems, and the first two are mine:

**The template contradicted the rule.** I banned high/medium/low labels in
Step 2 and left them in the Step 3 output template and all three worked-example
hypotheses. "Prose loses to a format block every time" — correct, and the
template is what gets imitated. Labels are gone from both; the slot is now WHY
FIRST, which asks for the evidence that sets the order rather than a restatement
of it.

**The description never mentioned the gate.** That is the text a harness shows
before an agent decides to load anything, and it read "Generate 3-5 ranked
hypotheses" — so an agent routing on the description alone does exactly the
thing this skill was rewritten to prevent. Both description and use_when now
lead with the refusal.

The rest:

- Intake vs the failed-fixes section pointed opposite ways for a report that
  fires both, and nothing stated precedence. Intake wins, and says so: you
  cannot aim hypotheses at a premise you have no evidence about either.
- "One run narrows the search to a single component" was overclaimed exactly
  where it gets used — CI flakiness. One run *that reproduces* narrows it; a
  passing run narrows nothing.
- session_log had no phase for a correct refusal, so an intake stop logged as a
  failed read-failure. A metric that penalises the skill's best behaviour trains
  the next model to guess. Added an `intake` phase.
- The Anti-Patterns table — the part people scan in a hurry — was missing the
  failure this whole rewrite is about: answering "what's the fix?" from a report
  with no artifact.
- brain_query told you to search on an error string you may have just refused to
  proceed without. Now: query symptom + component, which may itself name the
  artifact to ask for.
- condition-based-waiting was an unconditional load on any mention of a timeout,
  including during an intake stop, which is the wrong side of the gate.

Closes #36
Bump-Type: minor
2026-08-29 15:17:16 +02:00
mathias cba0fe4337 docs(tdd): fix ten defects the GREEN run found in the language references
CI / Check / Test (push) Successful in 1s
CI / Tag release (push) Successful in 1s
The pressure test that #18 and #20 were waiting on. All six original gaps came
back ANSWERED with the lines quoted — and the run then found ten problems in
what I had written. The worst two are mine, not inherited.

**The Python exemplar was the evaluation task.** references/python.md
demonstrated `split_vat` — the exact function the test scenario asks for,
negative-amount case included. The run said so: "as a teaching example that's
over-fitted; as an evaluation it's a leak... this task can't answer it — the
reference handed over the finished artifact." So the run could not measure
whether the guidance transfers, only whether copying works. The exemplar is now
`parse_retry_after`, deliberately unrelated to anything in this estate, and it
states its own contract rather than requiring the reader to divide 12500 by 1.25
to discover it.

**mypy at RED pointed at the stub the same file forbids.** python.md taught that
a pytest ImportError is a legitimate red, then presented `mypy --strict` as a
matched pair without saying that mypy reports the same missing module — so
following both literally, the way to get mypy green at RED is to create the
module. Now stated: that error is the expected red; what must be fixed
immediately is a --strict violation inside the test file you just wrote.

The rest, each quoted in the run:

- The checklist contradicted itself two lines apart — "strike it deliberately"
  above, "Can't check all boxes? You skipped TDD" below. A struck box is by
  definition unchecked, so the Python path routed straight into "start over".
- `match=` was overclaimed as "free". It is `re.search`, so a message containing
  parens quietly matches almost anything; and it couples every error test to
  wording. The Go comparison was a strawman against `assert.Error` when
  `assert.ErrorIs` exists — the honest Python analogue is a custom exception
  type, with `match=` as the fallback.
- `pytest.approx` was listed as the soft-assert analogue. It solves float
  tolerance and has nothing to do with collecting failures; that is
  pytest-subtests or pytest-check, neither of which ships with pytest.
- The whole-shape assertion silently required value equality — on a plain class
  it compares identity and can never pass.
- "When Stuck" is a language-neutral table that prescribed `t.Helper()`.
- `__tracebackhide__` must be a local inside the helper body; at module level it
  does nothing, silently.

Refs #18, #20
Bump-Type: patch
2026-08-29 15:16:20 +02:00
mathias 06592805b6 chore(release): plugin.json version 0.15.0
Skip-Release: true
2026-08-29 12:56:44 +00: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 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
mathias 1d368d4b5d docs(skills): drop the Mode 2 routing notes, fix the contributing steps
CI / Check / Test (push) Successful in 1s
CI / Tag release (push) Successful in 1s
Seven skills carried a `## Mode 2 Routing Note` citing a routing pod ("Plan 6")
that does not exist. Several admitted in their own text that nothing about the
skill changes between modes — so it was roughly ten lines of context on every
load for zero behavioural effect. The content is not worthless, just not
per-skill: it now lives as one table in README.md, with the caveat that nothing
depends on it.

The related line inside the session_log sections stays, because that one does
motivate behaviour — but it no longer cites a component that has never shipped.
It now says what is true: pass-rate is what a future routing decision would be
based on, and a skill that never logs contributes no evidence either way.

While in README: the contributing steps were stale enough to fail CI for anyone
who followed them. They said to add a row to SKILLS_INDEX.md by hand — the table
is generated now, and `task check` fails on a hand-edited diff — and listed none
of the four frontmatter keys the gates require. Step 4 now names the
pressure-test bar from #19 rather than leaving it to be discovered.

Closes #36
Bump-Type: patch
2026-08-29 14:50:14 +02:00
mathias a565b04e91 chore(release): plugin.json version 0.13.3
Skip-Release: true
2026-08-29 12:49:56 +00: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 b8bacfa7b3 chore(release): plugin.json version 0.13.2
Skip-Release: true
2026-08-29 12:22:35 +00:00
mathias c377c23e21 fix(mcp): correct the parked reason — the tokens work, the delivery does not
CI / Check / Test (push) Successful in 2s
CI / Tag release (push) Successful in 1s
The previous note blamed infra#224 for cad and gitea-mcp being unreachable.
Both claims were wrong. infra#224 is closed, and closed deliberately: rather
than widening koala-homelab's grants, a second scoped token was provisioned at
~/.op-agent-write.env for AgentSecrets. Sourcing it makes `op vault list`
return AgentSecrets, and an MCP initialize against both endpoints returns 200 —
serverInfo gitea-mcp 0.1.0 and cad aed2aaf. Nothing about the services or the
proxying is blocked.

What is actually blocked is delivery into the session environment. By design no
single identity on koala reads both vaults, and the session env comes from one
`op run --env-file ~/.op-env` under the HomeLab-only token. A mixed-vault file
does not degrade gracefully: tested on a two-line copy, `op run` aborts on the
unresolvable reference and BRAIN_MCP_TOKEN goes unresolved with it — so
appending to ~/.op-env would take out the MCP servers that currently work.

Refs #32, #33
Bump-Type: patch
2026-08-29 14:22:17 +02:00
mathias 8f9d5dd8bd chore(release): plugin.json version 0.13.1
Skip-Release: true
2026-08-29 12:19:31 +00:00
mathias 7ba01abdbe chore(mcp): park cad and gitea-mcp entries, blocked on vault visibility
CI / Tag release (push) Successful in 1s
CI / Check / Test (push) Successful in 1s
Both services are now proxied from koala and reachable: tailscale serve 8445 ->
127.0.0.1:31877 (cad) and 8446 -> 127.0.0.1:30340 (gitea-mcp). Both answer 401
unauthenticated, so the path is right and auth is enforced.

They stay out of mcpServers because neither token resolves on this host. The
static tokens live in the 1Password AgentSecrets vault; koala's op identity sees
HomeLab only:

  op vault list                    -> HomeLab, and nothing else
  op://HomeLab/CAD_STATIC_TOKEN    -> could not find item
  op://HomeLab/gitea-mcp-shared-token -> could not find item

That is infra#224, not the OAuth-discovery bug #32 and #33 both flagged as the
likely blocker. A live entry that cannot authenticate would fail on every
session start, so the wiring is recorded rather than enabled.

The item names come from the ExternalSecrets, read by reference only — no
secret value was retrieved, printed, or transformed at any point.

Refs #32, #33
Bump-Type: patch
2026-08-29 14:18:38 +02:00
mathias 9a81a707af chore(release): plugin.json version 0.13.0
Skip-Release: true
2026-08-29 12:10:37 +00:00
mathias 5070a738e8 docs(review): Python adaptation files for clean-code, solid and code-review
CI / Check / Test (push) Successful in 1s
CI / Tag release (push) Successful in 1s
The canonical Python stack has been pinned in ~/dev/.context/AGENT.md since
local-dev v0.3.1, and three skills still had Go examples with no Python
counterpart. Following the structure #18 settled on, and the existing
solid/references/go-adaptation.md precedent, each gets a reference file rather
than inline dilution of the primary doc.

The stack table itself is NOT copied into any of them — each cites AGENT.md as
its home. This repo spent today learning what a second copy of a fact costs.

Content is the part a linter cannot give you:

- clean-code: `raise X from e` as the analogue of `%w` error wrapping, dataclass
  over `dict[str, Any]`, and the two traps where the broken version reads
  tidier — mutable default arguments, and `if not amount:` for a value where
  zero is legitimate.
- solid: `Protocol` over `ABC`, defined at the consumer, which is what makes DIP
  real in Python; plus what does NOT carry over from the Go rewrite (return
  structs/accept interfaces, embedding, package-as-deployment-unit).
- code-review: a Python anti-pattern table parallel to the Go one, opening with
  the rule that a finding ruff or mypy already catches is not a review comment,
  it is a question about why the gate is not running.

Refs #20
Bump-Type: minor
2026-08-29 14:10:10 +02:00
mathias 8da7c9c45f docs(tdd): make the spine language-neutral, add four language references
The discipline in tdd was already language-agnostic; the checklist under it was
not. A Python user inherited a mandatory `go test -race` box they can never tick
and never got told that `mypy --strict` belongs in the red gate.

Per #18's own comment and writing-skills' Multi-Language Dilution warning, the
main SKILL.md keeps ONE canonical inline example (Go, the default stack) and the
other four languages live in reference files: python, typescript, rust, java.

The RED run drove every change. A subagent given a Python task with the Go-only
skill reported, in its own words:

- "Everything from L169 to L244 is Go-only, and the verification checklist at
  L326 silently inherits that Go-ness — which is why a Python user ends up with
  a mandatory box (-race) they can never tick and a required gate (mypy
  --strict) that isn't listed at all."
- "For a brand-new function, the first RED is always a collection-time
  ImportError... The only clean resolutions are a stub that raises
  NotImplementedError (production code before a red test?) or accepting
  ImportError as a legitimate red. The skill picks neither, and this is the
  single biggest gap." It now picks: that red is legitimate, and writing a stub
  to satisfy the runner is the Iron Law with extra steps.
- The Go table-driven exemplar branches on a `wantErr` column inside the loop.
  Translated literally that is a conditional assertion folding two behaviours
  into one test — which this skill's own quality table forbids. The agent caught
  itself mid-translation; both SKILL.md and python.md now say not to port it.

Also: the checklist names the static-analysis gate per language instead of
assuming `go vet`, and says to strike the concurrency box deliberately where no
equivalent exists — a box that cannot be ticked teaches people to skip
checklists.

Refs #18
Bump-Type: minor
2026-08-29 14:10:10 +02:00
mathias 6e5024d498 chore(release): plugin.json version 0.12.1
Skip-Release: true
2026-08-29 09:48:12 +00:00
50 changed files with 3090 additions and 92 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "skills", "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.", "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.12.0", "version": "0.19.4",
"author": { "author": {
"name": "Mathias", "name": "Mathias",
"url": "https://git.d-ma.be/mathias" "url": "https://git.d-ma.be/mathias"
+20 -2
View File
@@ -1,5 +1,5 @@
{ {
"_comment": "Tailscale Serve on koala, not the public d-ma.be hostnames. The public path exits to Cloudflare and NPM to reach a k3s service on the same machine most sessions run on, which imposes rate limits and message-size caps on tool results. Serve terminates TLS with a real Tailscale cert and proxies to the NodePort over loopback, so traffic reaches the service from koala itself and satisfies the default-deny-ingress NetworkPolicy with no cluster change. Clients need 'tailscale set --accept-dns=true' for the MagicDNS name to resolve; see mathias/skills#24.", "_comment": "Tailscale Serve on koala, not the public d-ma.be hostnames. The public path exits to Cloudflare and NPM to reach a k3s service on the same machine most sessions run on, which imposes rate limits and message-size caps on tool results. Serve terminates TLS with a real Tailscale cert and proxies to the NodePort over loopback, so traffic reaches the service from koala itself and satisfies the default-deny-ingress NetworkPolicy with no cluster change. Clients need 'tailscale set --accept-dns=true' for the MagicDNS name to resolve; see mathias/skills#24. Every ${VAR} below must be one ~/.op-env defines -- bin/check-mcp-env enforces that, because an undefined one sends an empty Authorization header and reports as 'requires authentication', which is indistinguishable from a revoked credential.",
"mcpServers": { "mcpServers": {
"brain": { "brain": {
"type": "http", "type": "http",
@@ -11,6 +11,24 @@
"infra": { "infra": {
"type": "http", "type": "http",
"url": "https://koala.tail30472e.ts.net:8444/mcp" "url": "https://koala.tail30472e.ts.net:8444/mcp"
},
"gitea": {
"_comment": "gitea-mcp authenticates by PASS-THROUGH: the bearer is a Gitea PAT, which the server validates against Gitea and then acts as. So no dedicated MCP token is needed, and the AgentSecrets blocker that parked this entry (skills#32, #33) never applied to it -- DMABE_GITEA_API_TOKEN is op://HomeLab and already in the session environment. Verified 2026-09-07: initialize 200 (serverInfo gitea-mcp) and tools/list returns 41 tools. Two DIFFERENT PATs both authenticate while GITEA_MCP_TOKEN 401s, which is what proves this is pass-through validation and not a static-token comparison.",
"type": "http",
"url": "https://koala.tail30472e.ts.net:8446/mcp",
"headers": {
"Authorization": "Bearer ${DMABE_GITEA_API_TOKEN}"
}
}
},
"_parked": {
"_reason": "cad is proxied, reachable and AUTHENTICATING: tailscale serve 8445 -> 127.0.0.1:31877, and an MCP initialize with the correct bearer returns 200 (serverInfo cad aed2aaf). It stays parked only because nothing puts CAD_MCP_TOKEN into the session environment. It lives in the 1Password AgentSecrets vault, and by deliberate design (infra#224, closed) no single identity on koala reads both vaults: koala-homelab is HomeLab-only and resolves ~/.op-env, koala-agentsecrets-write is AgentSecrets-only and lives in ~/.op-agent-write.env. Adding an AgentSecrets reference to ~/.op-env does NOT degrade gracefully -- `op run` fails wholesale and every other token, BRAIN_MCP_TOKEN included, goes unresolved with it. Enabling needs a read-scoped AgentSecrets identity in the session-env path; see mathias/skills#32, #33. gitea was parked here for the same stated reason and has been unparked: it needs no AgentSecrets token at all, because gitea-mcp is pass-through-authenticated.",
"cad": {
"type": "http",
"url": "https://koala.tail30472e.ts.net:8445/mcp",
"headers": {
"Authorization": "Bearer ${CAD_MCP_TOKEN}"
}
} }
} }
} }
+62 -10
View File
@@ -100,9 +100,11 @@ default to point into `~/dev`.
| `AGENTS_SKILLS_DIR` | `$HOME/.agents/skills` | | `AGENTS_SKILLS_DIR` | `$HOME/.agents/skills` |
| `CRUSH_SKILLS_DIR` | `$HOME/.config/crush/skills` | | `CRUSH_SKILLS_DIR` | `$HOME/.config/crush/skills` |
| `OPENCODE_SKILLS_DIR` | `$HOME/.config/opencode/skills` | | `OPENCODE_SKILLS_DIR` | `$HOME/.config/opencode/skills` |
| `ANTIGRAVITY_SKILLS_JSON` | `$HOME/.gemini/config/skills.json` (registers `AGENTS_SKILLS_DIR` as a path entry; needs `jq`) |
| `SKILLS_EXTRA_SOURCES` | unset (colon-separated extra source directories) | | `SKILLS_EXTRA_SOURCES` | unset (colon-separated extra source directories) |
Antigravity and Mistral Vibe targets were removed — unused. The Mistral Vibe target was removed — unused. Antigravity's was removed for
the same reason and re-added once it came back into use.
## Checking a host ## Checking a host
@@ -110,10 +112,25 @@ Antigravity and Mistral Vibe targets were removed — unused.
task doctor # or bin/doctor task doctor # or bin/doctor
``` ```
Reports dangling links, links resolving into a foreign checkout, skill-count Reports dangling links, links into a source nobody configured, skills in the
mismatches, stray entries, and leftover per-skill Claude symlinks. Non-zero checkout with no link, stray entries, leftover per-skill Claude symlinks, and a
exit on drift. It also runs at session start via the plugin, so a drifting Claude Code plugin sitting on an older generation than the host already has.
host says so rather than waiting to be discovered. 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 ## Versioning
@@ -130,13 +147,48 @@ before `task skills:install`; default is `main` for always-current.
## Contributing a skill ## Contributing a skill
1. Add a directory `<short-kebab-name>/`. 1. Add a directory `skills/<short-kebab-name>/`.
2. Create `<short-kebab-name>/SKILL.md` — start with one-paragraph 2. Create `skills/<short-kebab-name>/SKILL.md` with this frontmatter — all four
purpose, then triggers ("use when …"), then mechanics. keys are gated by `task check`, and the index table is generated from the
3. Add a row to `SKILLS_INDEX.md` (description + use-when). last three:
4. Commit directly to `main`. Conventional-commit prefix
```yaml
---
name: <short-kebab-name>
surfaces: [claude-code, library] # any of claude-code | library | chat
summary: "<one line for the index table>"
use_when: "<the trigger, for the index table>"
description: <the paragraph the model selects on>
---
```
Then: one-paragraph purpose, triggers ("use when …"), mechanics.
3. Run `task index` and commit the regenerated `SKILLS_INDEX.md`. Do **not**
hand-edit the table — `task check` regenerates it and fails on a diff.
4. Pressure-test it before shipping: run the scenario against a subagent that
does *not* have the skill, write the skill against what that run actually
did, then re-run with it. See `superpowers-writing-skills` and #19.
5. Commit directly to `main`. Conventional-commit prefix
(`feat:` / `fix:` / `docs:`). Add `Bump-Type: minor` footer when (`feat:` / `fix:` / `docs:`). Add `Bump-Type: minor` footer when
adding a new skill or expanding scope; default patch covers edits. adding a new skill or expanding scope; default patch covers edits.
This repo is markdown-only and public — never include secrets, client This repo is markdown-only and public — never include secrets, client
names, or anything under NDA in a skill. names, or anything under NDA in a skill.
## Mode 2 routing candidates
These notes used to sit in each SKILL.md as a `## Mode 2 Routing Note` section.
They changed nothing about how the skill is invoked — several said so outright —
and cost context on every load, so they live here instead. Nothing in the repo
depends on this table; it is a record of which skills looked mechanical enough
to route to a local model, should a routing layer ever exist.
| Skill | Note |
|---|---|
| `cad-babysit` | Phases 2-4 (reading comments/logs, classifying fault vs outcome) are judgment-heavy and should stay Mode 1. Phase 1 (running `cad stuck` and grouping its output) is mechanical and a Mode 2 routing candidate once Plan 6 ships the routing pod. |
| `code-review` | This skill is invoked identically in Mode 1 (cloud Claude) and Mode 2 (client-local with supervisor routing). The routing layer (Plan 6) does not exist yet; treat this skill as Mode 1 only for now. Findings format and severity classification are unchanged across modes. |
| `debug` | This skill produces high-volume mechanical output (hypothesis enumeration) and is a candidate for Mode 2 routing to a local model in the future. Until Plan 6 ships the routing pod, treat as Mode 1 only. The hypothesis format and discipline are identical regardless of which model generates them. |
| `feature-spec` | Spec writing requires interpretation of intent and tradeoff reasoning — work where Claude generally outperforms a 9-13B local model. This skill is intentionally **not** a Mode 2 routing candidate. In Mode 2, the routing layer should leave `feature-spec` calls on Claude. |
| `session-retrospective` | Reading long session logs and pulling themes is high-volume, mechanical pattern-recognition work — a strong Mode 2 routing candidate. Until Plan 6 ships the routing pod, treat as Mode 1 only. |
| `tdd` | This skill is invoked identically whether the agent is running in Mode 1 (cloud Claude, no routing) or Mode 2 (client-local, supervisor routing layer). The routing pod (Plan 6) does not exist yet; until it does, treat this skill as Mode 1 only. The discipline does not change between modes — only the model behind the call. |
| `trainer` | Trainer is the same shape as `session-retrospective` — long-context reading, mechanical filtering — and is a Mode 2 routing candidate. Until Plan 6, treat as Mode 1 only. |
+4 -1
View File
@@ -9,18 +9,20 @@ the SKILL.md, not the table.
<!-- BEGIN skills-table --> <!-- BEGIN skills-table -->
| Skill | Description | Use when | Surfaces | | Skill | Description | Use when | Surfaces |
|-------|-------------|----------|----------| |-------|-------------|----------|----------|
| `adr` | Architecture Decision Records — house format, and how to record a decision you cannot make yet | Recording an architectural decision; revisiting one; or deciding between options where the right answer genuinely is not knowable yet | claude-code, library |
| `atdd` | Acceptance Test-Driven Development workflow | Feature development with acceptance criteria | claude-code, library | | `atdd` | Acceptance Test-Driven Development workflow | Feature development with acceptance criteria | claude-code, library |
| `cad-babysit` | Focused human-in-the-loop session working CAD's stuck queue (read-only) | Picking up whatever `cad stuck` flags, with a human present | claude-code, library | | `cad-babysit` | Focused human-in-the-loop session working CAD's stuck queue (read-only) | Picking up whatever `cad stuck` flags, with a human present | claude-code, library |
| `clean-code` | Clean code principles with Go adaptation | Code review, refactoring, new code | claude-code, library | | `clean-code` | Clean code principles with Go adaptation | Code review, refactoring, new code | claude-code, library |
| `code-review` | Code smell detection + Go-specific checklist | Pre-merge review | claude-code, library | | `code-review` | Code smell detection + Go-specific checklist | Pre-merge review | claude-code, library |
| `cognitive-load` | Cognitive complexity analysis (CLI score) | Identifying hotspots, before refactoring | claude-code, library | | `cognitive-load` | Cognitive complexity analysis (CLI score) | Identifying hotspots, before refactoring | claude-code, library |
| `debug` | Hypothesis-first debugging discipline | Any test failure or bug whose cause is not immediately obvious | claude-code, library | | `debug` | Hypothesis-first debugging discipline | Any test failure or bug whose cause is not immediately obvious — or a bug report with no failure text, which this skill refuses rather than guesses at | claude-code, library |
| `discovery-framing` | Problem-before-solution discovery/framing method | Framing/validating a problem before any solution; opportunity sizing | claude-code, library | | `discovery-framing` | Problem-before-solution discovery/framing method | Framing/validating a problem before any solution; opportunity sizing | claude-code, library |
| `dream` | Reflective consolidation pass over an agent's memory directory | Pruning/merging memory files, rebuilding a memory index | claude-code, library, chat | | `dream` | Reflective consolidation pass over an agent's memory directory | Pruning/merging memory files, rebuilding a memory index | claude-code, library, chat |
| `experiment-spec` | Rigorous experiment spec — falsifiable hypothesis, quantitative acceptance, null-result protocol | Scoping a research/ML phase instead of a feature | claude-code, library | | `experiment-spec` | Rigorous experiment spec — falsifiable hypothesis, quantitative acceptance, null-result protocol | Scoping a research/ML phase instead of a feature | claude-code, library |
| `feature-spec` | Implementation-level feature spec (tighter than spec-driven-dev) | Scoping one feature inside an already-specced project | claude-code, library | | `feature-spec` | Implementation-level feature spec (tighter than spec-driven-dev) | Scoping one feature inside an already-specced project | claude-code, library |
| `gitea-ci` | Gitea CI/CD pipeline with act_runner, buildah, k3s, GitHub mirror | Setting up or debugging CI on this infra | claude-code, library | | `gitea-ci` | Gitea CI/CD pipeline with act_runner, buildah, k3s, GitHub mirror | Setting up or debugging CI on this infra | claude-code, library |
| `grill-me` | Structured plan interrogation — Quick Poke, Full Grill, Pre-mortem | Stress-testing a plan before committing; end of Diamond 1; before promoting to pre-prod | claude-code, library, chat | | `grill-me` | Structured plan interrogation — Quick Poke, Full Grill, Pre-mortem | Stress-testing a plan before committing; end of Diamond 1; before promoting to pre-prod | claude-code, library, chat |
| `lineage` | Backward decision archaeology — was this tried before, and does the reason it failed still hold | Before proposing something that feels new; when an idea keeps resurfacing; when you inherit code whose reason is gone | claude-code, library |
| `own-the-task` | Delegation ownership — Intake → Brief → Plan → Execute | A task is handed over open-ended; \"take this off my plate\"; before a plan exists | claude-code, library, chat | | `own-the-task` | Delegation ownership — Intake → Brief → Plan → Execute | A task is handed over open-ended; \"take this off my plate\"; before a plan exists | claude-code, library, chat |
| `planning` | Planning and task breakdown | Sprint planning, task decomposition | claude-code, library | | `planning` | Planning and task breakdown | Sprint planning, task decomposition | claude-code, library |
| `playwright` | Playwright E2E testing — setup, HTMX patterns, SSE streams, CI wiring | Adding browser tests to any project with a web UI | claude-code, library | | `playwright` | Playwright E2E testing — setup, HTMX patterns, SSE streams, CI wiring | Adding browser tests to any project with a web UI | claude-code, library |
@@ -47,6 +49,7 @@ the SKILL.md, not the table.
| `tdd` | Test-Driven Development enforcement | Starting any new feature or fixing a bug | claude-code, library | | `tdd` | Test-Driven Development enforcement | Starting any new feature or fixing a bug | claude-code, library |
| `telos-load` | Load TELOS intention substrate at session start | Starting any koala session; before architectural decisions; CAD pipeline entry | claude-code, library | | `telos-load` | Load TELOS intention substrate at session start | Starting any koala session; before architectural decisions; CAD pipeline entry | claude-code, library |
| `test-design` | Dave Farley test design scoring (Farley Index) | Reviewing or writing tests | claude-code, library | | `test-design` | Dave Farley test design scoring (Farley Index) | Reviewing or writing tests | claude-code, library |
| `traceability` | Forward trace — requirement to decision to commit to verification to release, and what cannot be checked | Proving why a change exists; auditing a claim; before an attestation or a compliance ask | claude-code, library |
| `trainer` | Two-phase brain curation (score candidates, write past quality gate) | Periodic brain pruning and curation | claude-code, library | | `trainer` | Two-phase brain curation (score candidates, write past quality gate) | Periodic brain pruning and curation | claude-code, library |
| `user-stories` | Elephant Carpaccio story slicing | Breaking down features | claude-code, library | | `user-stories` | Elephant Carpaccio story slicing | Breaking down features | claude-code, library |
| `web-shot` | Screenshot a running web UI via a koala k3s Playwright job | Visual verification of a running UI when no local browser exists | claude-code, library | | `web-shot` | Screenshot a running web UI via a koala k3s Playwright job | Visual verification of a running UI when no local browser exists | claude-code, library |
+30 -1
View File
@@ -110,7 +110,16 @@ tasks:
# harness instead of erroring. # harness instead of erroring.
sh '{{.TASKFILE_DIR}}/bin/check-surfaces' '{{.TASKFILE_DIR}}' || fail=1 sh '{{.TASKFILE_DIR}}/bin/check-surfaces' '{{.TASKFILE_DIR}}' || fail=1
# 5. Plugin manifest version must match the latest tag, or installers # 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). # never see the update (Claude Code gates updates on this field).
if [ -f '{{.PLUGIN_MANIFEST}}' ]; then if [ -f '{{.PLUGIN_MANIFEST}}' ]; then
manifest_v=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' '{{.PLUGIN_MANIFEST}}' | head -1) manifest_v=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' '{{.PLUGIN_MANIFEST}}' | head -1)
@@ -129,6 +138,26 @@ tasks:
echo "ERROR: missing .claude-plugin/plugin.json" >&2; fail=1 echo "ERROR: missing .claude-plugin/plugin.json" >&2; fail=1
fi 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 if [ $fail -ne 0 ]; then
echo "check FAILED" >&2; exit 1 echo "check FAILED" >&2; exit 1
fi fi
+88
View File
@@ -0,0 +1,88 @@
#!/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
# --- the ratchet must actually ratchet ---------------------------------------
# "This list only shrinks" was asserted in five places and enforced in none. A
# traceability probe falsified it in one run: add an unsectioned skill, append it
# to the list, gate returns 0. So compare against the list as committed at HEAD —
# a convention nothing checks is a convention, and this repo has spent a day on
# what those cost.
if [ -f "$PENDING" ]; then
if ! git -C "$ROOT" rev-parse --git-dir >/dev/null 2>&1; then
echo "WARN: $ROOT is not a git checkout — cannot verify the pending list only shrank" >&2
elif ! git -C "$ROOT" cat-file -e HEAD:bin/escalation-pending.txt 2>/dev/null; then
: # first commit introducing the file — nothing to compare against yet
else
before=$(git -C "$ROOT" show HEAD:bin/escalation-pending.txt 2>/dev/null \
| sed 's/#.*//' | tr -d '[:blank:]' | grep -v '^$' | sort -u || true)
now=$(sed 's/#.*//' "$PENDING" | tr -d '[:blank:]' | grep -v '^$' | sort -u || true)
added=$(printf '%s\n' "$now" | grep -vxF "$before" 2>/dev/null || true)
for entry in $added; do
[ -n "$entry" ] || continue
echo "ERROR: '$entry' was added to bin/escalation-pending.txt — the list only shrinks. Write its '## Escalation' section instead of exempting it." >&2
fail=1
done
fi
fi
exit $fail
+99
View File
@@ -0,0 +1,99 @@
#!/bin/sh
# check-mcp-env — assert every variable a live MCP server interpolates is one
# the session env-file actually defines.
#
# Why this is a gate and not a comment: the failure is silent. A header of
# "Bearer ${FOO}" where FOO is undefined does not error -- it goes out empty,
# the server answers 401, and Claude Code reports "requires authentication",
# which reads exactly like a revoked credential. On 2026-09-07 that cost a
# full investigation on koala before the credential turned out to be fine.
#
# Servers under `_parked` are exempt by design. Parked means "reachable, but
# nothing puts its token in the environment yet" -- flagging those would
# report the intended state as a fault.
#
# Usage: check-mcp-env <mcp.json> <env-file>
#
# Exit 0 every live server's variables are defined
# 1 at least one is not, named on stderr
# 2 could not verify (bad usage, unreadable input, jq absent).
# Deliberately distinct from 0: an unverified config is not a clean
# one, and a gate that cannot run should say so rather than pass.
set -u
CONFIG=${1:-}
ENVFILE=${2:-}
if [ -z "$CONFIG" ] || [ -z "$ENVFILE" ]; then
echo "usage: check-mcp-env <mcp.json> <env-file>" >&2
exit 2
fi
if ! command -v jq >/dev/null 2>&1; then
echo "SKIP: jq not installed -- MCP env wiring unverified" >&2
exit 2
fi
if [ ! -f "$CONFIG" ]; then
echo "ERROR: no such config: $CONFIG" >&2
exit 2
fi
if [ ! -f "$ENVFILE" ]; then
# EXPECTED IN CI, and permanently so. The env-file holds live credentials, so
# it is not on the runner and must not be -- which means this check can only
# ever verify on a host that has one. infra#368 added jq to the runner image
# believing that would let CI verify; it removed the jq branch below and
# changed nothing else, because this branch is the binding one.
#
# Said plainly so nobody concludes CI covers this. It does not, it cannot,
# and putting ~/.op-env on the runner to make it would be trading a real
# secret for a green tick.
echo "ERROR: no such env-file: $ENVFILE -- cannot verify MCP variables" >&2
echo " (expected in CI: this check only verifies on a host with ~/.op-env)" >&2
exit 2
fi
# Every "<server>\t<VAR>" pair a live server interpolates. `..` walks the whole
# value, so a variable is found wherever it is placed, not only under headers.
refs=$(jq -r '
.mcpServers // {}
| to_entries[]
| .key as $server
| (.value | .. | strings)
| select(test("\\$\\{"))
| [ scan("\\$\\{([A-Za-z_][A-Za-z0-9_]*)\\}")[] ]
| .[]
| "\($server)\t\(.)"
' "$CONFIG" 2>/dev/null) || {
echo "ERROR: $CONFIG is not valid JSON -- MCP env wiring unverified" >&2
exit 2
}
if [ -z "$refs" ]; then
echo "check-mcp-env: no interpolated variables in live servers"
exit 0
fi
fail=0
checked=0
for pair in $(printf '%s\n' "$refs" | tr '\t' ':'); do
server=${pair%%:*}
var=${pair#*:}
checked=$((checked + 1))
if grep -qE "^[[:space:]]*(export[[:space:]]+)?$var=" "$ENVFILE"; then
continue
fi
printf 'ERROR: MCP server %s interpolates %s, which %s does not define.\n' \
"$server" "$var" "$ENVFILE" >&2
printf ' It will send an empty Authorization header and report as "requires authentication".\n' >&2
fail=1
done
if [ "$fail" -ne 0 ]; then
exit 1
fi
printf 'check-mcp-env: %d interpolated variable(s) across live MCP servers, all defined in %s\n' \
"$checked" "$ENVFILE"
+63
View File
@@ -0,0 +1,63 @@
#!/bin/sh
# check-session-log — a skill that documents a session_log call must document a
# call the tool would actually accept.
#
# Nine skills documented the payload and none named `session_id`, which is the
# tool's only REQUIRED field. An agent following any of them either failed the
# call or invented an id — and invented ids do not group, so the pass-rate the
# endpoint computes was being assembled from entries that could not be joined
# (#36, found by a pressure test against `debug`).
#
# Also rejected: `duration_ms: <wall-clock>`. An agent does not observe a clock.
# A documented field nobody can honestly populate gets fabricated or silently
# dropped, and fabricated numbers in a metrics stream are worse than no numbers.
#
# Usage: check-session-log [checkout-root]
set -u
ROOT=${1:-$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)}
fail=0
for dir in "$ROOT"/skills/*/; do
[ -f "$dir/SKILL.md" ] || continue
name=$(basename "$dir")
grep -q 'session_log' "$dir/SKILL.md" || continue
# Read the PAYLOAD, not the file. The first version of this gate grepped the
# whole SKILL.md for "session_id", so a payload missing the field passed as
# long as the word appeared anywhere — including in prose explaining that the
# field is required. A traceability probe demonstrated it in one sed.
payloads=$(grep -n 'session_log' "$dir/SKILL.md" | grep '{' || true)
if [ -z "$payloads" ]; then
# Mentions the tool but documents no call. Nothing to check, and nothing to
# get wrong.
continue
fi
printf '%s\n' "$payloads" | while IFS= read -r line; do
[ -n "$line" ] || continue
body=$(printf '%s' "$line" | sed 's/^[0-9]*://')
lineno=$(printf '%s' "$line" | cut -d: -f1)
printf '%s' "$body" | grep -q 'session_id' \
|| echo "ERROR: skills/$name:$lineno documents a session_log payload with no session_id — the tool's only required field" >&2
# Any spelling of the key, quoted or not, followed by anything that is not a
# bare number. A placeholder nobody can fill gets fabricated or dropped; a
# real measurement is fine.
if printf '%s' "$body" | grep -Eq '"?duration_ms"?[[:space:]]*:[[:space:]]*[^0-9,}[:space:]]'; then
echo "ERROR: skills/$name:$lineno documents duration_ms as something other than a real measurement — an agent does not observe a clock; omit it" >&2
fi
done > "$ROOT/.session_log_errors.$$" 2>&1
if [ -s "$ROOT/.session_log_errors.$$" ]; then
cat "$ROOT/.session_log_errors.$$" >&2
fail=1
fi
rm -f "$ROOT/.session_log_errors.$$"
done
exit $fail
+79 -2
View File
@@ -10,6 +10,7 @@
# doctor human report, non-zero exit on drift # doctor human report, non-zero exit on drift
# doctor --quiet print only when something is wrong (SessionStart hook) # doctor --quiet print only when something is wrong (SessionStart hook)
# doctor --json machine-readable findings # 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 # 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. # guaranteed present, and inside a session-start hook where latency shows.
@@ -21,10 +22,12 @@ set -u
QUIET=0 QUIET=0
JSON=0 JSON=0
ONLINE=0
for arg in "$@"; do for arg in "$@"; do
case "$arg" in case "$arg" in
--quiet) QUIET=1 ;; --quiet) QUIET=1 ;;
--json) JSON=1 ;; --json) JSON=1 ;;
--online) ONLINE=1 ;;
-h|--help) -h|--help)
sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//' sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'
exit 0 ;; exit 0 ;;
@@ -288,6 +291,80 @@ 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)" 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 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
# --- toolchain: golangci-lint must be built with Go >= GOROOT ---------------
# infra#375, second recurrence. golangci-lint embeds the go/types export-data
# reader from the Go it was BUILT with. When GOROOT is newer, that reader
# cannot decode the stdlib and the linter dies:
#
# 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 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.
#
# It is invisible to CI -- the runner image has matching versions -- so it
# breaks exactly the local `task check` that AGENT.md requires before a push,
# and nothing else. That is why it belongs in doctor: the signal was missing,
# not the fix.
#
# Fails open on anything unparseable. A host with no Go toolchain is a normal
# host, not a broken one, and doctor runs in a SessionStart hook everywhere.
ver_lt() {
# 0 when $1 < $2 on major.minor. Deliberately not sort -V: this is POSIX sh
# that also runs on macOS, and the export-data format is minor-versioned, so
# patch level does not enter into it.
_a=${1%%.*}; _ar=${1#*.}; _am=${_ar%%.*}
_b=${2%%.*}; _br=${2#*.}; _bm=${_br%%.*}
case "$_a$_am$_b$_bm" in *[!0-9]*) return 1 ;; esac
[ "$_a" -lt "$_b" ] && return 0
[ "$_a" -gt "$_b" ] && return 1
[ "$_am" -lt "$_bm" ]
}
if command -v golangci-lint >/dev/null 2>&1 && command -v go >/dev/null 2>&1; then
lint_go=$(golangci-lint --version 2>/dev/null | sed -n 's/.*built with go\([0-9][0-9.]*\).*/\1/p' | head -1)
root_go=$(go version 2>/dev/null | sed -n 's/.*go version go\([0-9][0-9.]*\).*/\1/p' | head -1)
if [ -n "$lint_go" ] && [ -n "$root_go" ] && ver_lt "$lint_go" "$root_go"; then
add FAIL toolchain "golangci-lint built with go$lint_go but GOROOT is go$root_go — lint panics on every Go repo; reinstall it under the current toolchain (infra#375)"
fi
fi
# --- output ----------------------------------------------------------------- # --- output -----------------------------------------------------------------
if [ "$JSON" -eq 1 ]; then if [ "$JSON" -eq 1 ]; then
printf '{"checkout":"%s","expected":%s,"plugin":{"installed":"%s","available":"%s"},"findings":[%s]}\n' \ 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
+51 -2
View File
@@ -17,8 +17,8 @@
# /plugin install skills@mathias # /plugin install skills@mathias
# #
# That path carries the hooks and MCP server definitions too, which symlinks # That path carries the hooks and MCP server definitions too, which symlinks
# cannot. This script covers the harnesses with no plugin system: Crush, Pi # cannot. This script covers the harnesses with no plugin system: Crush, Pi,
# and OpenCode. # OpenCode, and Antigravity.
# #
# Wiring model — one managed directory, one link per harness: # Wiring model — one managed directory, one link per harness:
# #
@@ -27,6 +27,13 @@
# ~/.config/crush/skills -> ~/.agents/skills # ~/.config/crush/skills -> ~/.agents/skills
# ~/.config/opencode/skills -> ~/.agents/skills # ~/.config/opencode/skills -> ~/.agents/skills
# #
# Antigravity has no symlink-directory discovery — it reads a path-entries
# JSON instead (~/.gemini/config/skills.json:
# {"entries":[{"path":"..."}]}). One entry points at ~/.agents/skills, so it
# sees exactly the same surfaces-filtered, collision-resolved set every other
# harness does. Requires jq; skipped with a warning if jq is unavailable —
# every other target in this script stays dependency-free on purpose.
#
# ~/.agents/skills was itself a link to this checkout until #27. That encoded # ~/.agents/skills was itself a link to this checkout until #27. That encoded
# an assumption nobody had stated — that this checkout is the ONLY source of # an assumption nobody had stated — that this checkout is the ONLY source of
# skills on the host — and on flamingo the only way to install this library # skills on the host — and on flamingo the only way to install this library
@@ -53,7 +60,12 @@ CHECKOUT_DIR="${SKILLS_CHECKOUT_DIR:-$HOME/.local/share/skills}"
AGENTS_DIR="${AGENTS_SKILLS_DIR:-$HOME/.agents/skills}" AGENTS_DIR="${AGENTS_SKILLS_DIR:-$HOME/.agents/skills}"
CRUSH_DIR="${CRUSH_SKILLS_DIR:-$HOME/.config/crush/skills}" CRUSH_DIR="${CRUSH_SKILLS_DIR:-$HOME/.config/crush/skills}"
OPENCODE_DIR="${OPENCODE_SKILLS_DIR:-$HOME/.config/opencode/skills}" OPENCODE_DIR="${OPENCODE_SKILLS_DIR:-$HOME/.config/opencode/skills}"
ANTIGRAVITY_SKILLS_JSON="${ANTIGRAVITY_SKILLS_JSON:-$HOME/.gemini/config/skills.json}"
EXTRA_SOURCES="${SKILLS_EXTRA_SOURCES:-}" EXTRA_SOURCES="${SKILLS_EXTRA_SOURCES:-}"
# Overridable so the jq-absent branch is testable on a host that has jq. That
# branch is a contract, not a fallback: jq is optional here while every other
# target in this script is dependency-free.
JQ="${SKILLS_JQ:-jq}"
# Recorded beside the managed directory rather than inside it: a stray file in # Recorded beside the managed directory rather than inside it: a stray file in
# there would look like a skill to every harness reading the dir. # there would look like a skill to every harness reading the dir.
SOURCES_MANIFEST="$(dirname "$AGENTS_DIR")/$(basename "$AGENTS_DIR")-sources" SOURCES_MANIFEST="$(dirname "$AGENTS_DIR")/$(basename "$AGENTS_DIR")-sources"
@@ -213,6 +225,42 @@ link_dir() {
log "linked $link → $target" log "linked $link → $target"
} }
# wire_antigravity — register $AGENTS_DIR in Antigravity's skills.json.
# Merges rather than overwrites: skills.json is a documented user-editable
# config (json_configs.md), so a hand-added entry (e.g. a personal skills
# dir) must survive a re-run untouched.
wire_antigravity() {
if ! command -v "$JQ" >/dev/null 2>&1; then
log "Antigravity: jq not found — skipping (needed to merge $ANTIGRAVITY_SKILLS_JSON safely)"
return
fi
local existing already updated
mkdir -p "$(dirname "$ANTIGRAVITY_SKILLS_JSON")"
if [ -s "$ANTIGRAVITY_SKILLS_JSON" ]; then
existing="$(cat "$ANTIGRAVITY_SKILLS_JSON")"
else
existing='{}'
fi
already="$(printf '%s' "$existing" | "$JQ" --arg p "$AGENTS_DIR" \
'[.entries // [] | .[] | select(.path == $p)] | length' 2>/dev/null)" || {
log "Antigravity: $ANTIGRAVITY_SKILLS_JSON is not valid JSON — leaving it untouched"
return 1
}
if [ "$already" -gt 0 ]; then
log "Antigravity: $AGENTS_DIR already registered in $ANTIGRAVITY_SKILLS_JSON"
return
fi
updated="$(printf '%s' "$existing" | "$JQ" --arg p "$AGENTS_DIR" \
'.entries = ((.entries // []) + [{path: $p}])')"
printf '%s\n' "$updated" > "$ANTIGRAVITY_SKILLS_JSON"
log "Antigravity: registered $AGENTS_DIR in $ANTIGRAVITY_SKILLS_JSON"
}
main() { main() {
ensure_checkout ensure_checkout
@@ -224,6 +272,7 @@ main() {
link_dir "$CRUSH_DIR" "$AGENTS_DIR" link_dir "$CRUSH_DIR" "$AGENTS_DIR"
link_dir "$OPENCODE_DIR" "$AGENTS_DIR" link_dir "$OPENCODE_DIR" "$AGENTS_DIR"
wire_antigravity
log "done — $LINKED_COUNT skill(s) linked from $(sources | wc -l | tr -d ' ') source(s), ref=$REF" log "done — $LINKED_COUNT skill(s) linked from $(sources | wc -l | tr -d ' ') source(s), ref=$REF"
log "Claude Code: install the plugin instead — /plugin marketplace add $REPO_URL" log "Claude Code: install the plugin instead — /plugin marketplace add $REPO_URL"
+223
View File
@@ -0,0 +1,223 @@
---
name: adr
surfaces: [claude-code, library]
summary: "Architecture Decision Records — house format, and how to record a decision you cannot make yet"
use_when: "Recording an architectural decision; revisiting one; or deciding between options where the right answer genuinely is not knowable yet"
description: Write an Architecture Decision Record in this estate's format — YAML frontmatter, Status/Context/Decision/Consequences/Validation — including the case where the tradeoff is genuinely context-dependent and forcing a resolution would be worse than preserving the tension. Use when recording or superseding an architectural decision, or when a decision keeps getting re-litigated because nobody wrote down why.
---
# ADR
## Overview
An ADR is the *record* of a decision — the artifact someone reads in eight months when
they are about to undo it without knowing why it exists. It does not confer the decision;
a person with authority does that, and this skill never is that person.
Two things make one useful, and neither is the prose quality: a **format consistent
enough to grep across repos**, and an honest account of **what was not decided**.
## Before you write
Both of these change what goes in the ADR, so they are not cross-references, they are
steps:
1. **`lineage`** — was this decided before and reversed? The reason it was dropped is
usually the most valuable sentence in your Context, and if an earlier ADR exists,
yours supersedes it rather than ignoring it.
2. **`grill-me`** (Full Grill) — find the hole now. It is cheaper here than in the
Consequences section of the ADR that supersedes yours.
## When to Use
- Any choice where a reasonable alternative existed and was not taken
- Superseding or amending an earlier ADR (never edit the old one — see Status)
- A decision that keeps getting re-litigated, which means the reasoning was never recorded
- A tradeoff that is genuinely context-dependent — see *Preserving a productive tension*
**Do not use for:** implementation choices with no alternative worth naming, or anything
a code comment answers. An ADR per function is an ADR nobody reads.
## The format
**Where it goes:** `docs/adr/NNNN-kebab-slug.md`, zero-padded to four digits, numbered
sequentially within the repo. Frontmatter you cannot locate is not greppable, and half
the value of the format is that `docs/adr/*` is a known path in every repo here.
**The number is repo-local.** `cad` ADR-0002 and `infra` ADR-0002 are different
documents. When referring across repos — including in `supersedes` — qualify it:
`cad#0002`. Within a repo the bare number is fine.
Frontmatter first. It exists so the corpus is machine-readable — "which ADRs does 0004
supersede", "what is still `proposed`" — and a bespoke header block, however well
written, cannot answer those.
```markdown
---
adr: 0004
title: <the decision, as a sentence — not "database choice">
status: proposed | accepted | superseded | amended
date: 2026-08-29
deciders: <who accepted it, by name — or "pending: <name>" (QUOTED) while proposed>
supersedes: <adr number, or null>
---
# ADR-0004 — <same title>
## Status
**The frontmatter is authoritative for status and date.** This section exists for the one
thing the frontmatter cannot hold: *why* it moved — what superseded it and on what
evidence, or what accepting it was contingent on. If it would only restate the YAML,
delete it.
## Context
The forces. What is true that makes this a decision rather than a default — constraints,
the failure that prompted it, what was tried. Name the unknowns explicitly; a decision
made under uncertainty is not weaker for saying so, it is auditable.
## Decision
One sentence of what was decided, then why *this* option rather than the named
alternatives. Address the strongest case against, not the weakest.
## Consequences
**Positive:** what this buys.
**Negative / watch:** what it costs, and the specific thing to watch that would tell you
it was wrong. "Some added complexity" is not a consequence; "the event schema is now the
coupling surface between cad and every executor — version it deliberately" is.
## Validation
How you will know. A link to the experiment or the metric that proves or kills it, and
the condition that fires a revisit. An ADR with no validation section is a preference
with a template around it.
```
### The frontmatter must actually parse
It exists to be machine-read, so a block that does not load is worse than no block at
all: it drops that ADR silently out of every "what is still open?" query, while looking
correct. Two shapes this template used to suggest are invalid YAML, and both were found
live in the estate on 2026-09-05 — `openbanking-api` ADR-0001 and `infra` ADR-0019, each
copied verbatim from here:
| Wrong | Why | Right |
|---|---|---|
| `supersedes: -` | a bare dash is a YAML *sequence entry*, not a string | `supersedes: null` |
| `deciders: pending: Mathias` | the second, unquoted colon reads as a nested mapping | `deciders: "pending: Mathias"` |
Check before committing:
```sh
python3 -c "import yaml,sys;yaml.safe_load(open(sys.argv[1]).read().split('---')[1]);print('ok')" docs/adr/0004-*.md
```
## Preserving a productive tension
Some tradeoffs are not unresolved because nobody thought hard enough. They are
context-dependent, the context is not settled, and picking now means picking blind.
Forcing resolution there produces a confident record that is wrong half the time.
Record the tension instead, in the Decision section:
```markdown
## Decision
Provisional: **<option A>**, on this reasoning: <what makes it safe to pick now>.
<The strongest case for the option you did NOT take, and why it does not win today. The
plain format demands this and a preserved tension does not get an exemption — the whole
point is that the loser is still live.>
### The tension, preserved
- **Option A** — <what it assumes, what it costs>
- **Option B** — <what it assumes, what it costs>
**What we do not know:** <the variable that decides it>
**Preservation strategy:** <what we build now so BOTH stay reachable. This is the
technical content of the ADR, not a one-liner — enumerate it. It is usually the part both
options need anyway, which is often most of the work: the schema, the interface seam, the
idempotency key. Name each piece and say which option it keeps alive.>
**Resolution triggers:** <plural, and one per unknown. Two independent unknowns do not
collapse into one condition, and a trigger may exist that neither unknown implies.>
```
Two rules keep this from becoming an excuse:
1. **A preserved tension still ships a decision.** "We will decide later" is not one.
Prefer the option whose correctness does not depend on the unknown, say so, and mark
it provisional.
**When no such option exists** — and often none does, because a tension is productive
precisely when both options are hostage to the same unknown — do not manufacture a
reason that one of them is. That reads as reasoned and will not be re-examined, which
makes it worse than an honest arbitrary pick. Fall back, in order: the option that is
cheaper to reverse; the one with the lower switching cost given your preservation
strategy; the one already built. Then **say that is why you picked it.** "Chosen
because it is the cheaper mistake" is a real justification and an auditable one.
2. **The resolution trigger must be observable by someone who is not you.** "When it
becomes a problem" is not a trigger. "When executor count passes five and they are all
long-lived services in one cluster" is. A trigger nobody can evaluate is a decision
that quietly becomes permanent.
`cad`'s ADR-0001 does this informally under *"Known refinements (recorded, deliberately
not built yet)"* — sovereignty deferred to a 3-class model *"until a client actually opts
into an EU provider"*. That clause is the pattern: option, cost, and the event that
forces the choice.
## Status discipline
- **Never edit an accepted ADR to change its decision.** Write a new one and set
`supersedes`. The record of what was believed, and when, is the whole value.
- **Amend** for corrections that do not change the decision (a broken link, a clarified
consequence). Note the amendment inline with its date.
- **`proposed` is not a parking space.** An ADR that sits proposed for a month *with no
named decider* is a decision nobody is making; either accept it provisionally or close
it as declined. With a named decider it is a decision waiting on a person, which is a
different thing and is chased rather than closed.
## Anti-patterns
| Anti-pattern | Why it fails |
|---|---|
| Consequences that are all positive | Then it was not a decision, it was an announcement |
| "We chose X because it is better" | Names no alternative and no force; unfalsifiable |
| Written after the code shipped | Reverse-engineers a justification. Record the reasoning while it is still contested |
| One ADR amended repeatedly | Destroys the history the format exists to keep |
| No Validation section | Nothing distinguishes a good decision from a lucky one |
| A tension "resolved" by picking arbitrarily | The next person re-litigates it, having lost the reasoning |
## Escalation
Writes a file, so it acts — but it decides nothing.
Stop and hand back when: the deciders are not you — which, for an agent, is always. An
ADR records a decision someone with authority made; writing one to *create* that
authority is how a preference acquires a document number.
So the deliverable is a draft marked `proposed` with **the person who must accept it named
in `deciders` as `pending: <name>`**, and that hand-back stated in the response, not left
implied. Naming the decider is the escalation, not a formality — an unnamed one is how a
proposal sits for a month.
That is the intended state for an agent-authored ADR, and it is not the parking-space
failure below: a `proposed` ADR with a named decider is waiting on someone; one without is
waiting on nobody.
Also stop when the Context cannot be written without inventing a constraint. A missing
force means the decision has not been thought through yet, and an ADR built on an
invented one is worse than none: it will be cited.
## Cross-References
- `lineage` and `grill-me` — see *Before you write*; they change the content, so they are
steps rather than references.
- `spec-driven-dev` / `feature-spec` — an ADR is the *why*; a spec is the *what*. If you
are describing behaviour, you are writing a spec.
+12 -8
View File
@@ -234,8 +234,8 @@ wrong" — it means "nothing on the known list is wrong."
Call `session_log` once at the end of every phase to record the outcome. Call `session_log` once at the end of every phase to record the outcome.
**At end of each phase:** **At end of each phase:**
- `session_log` with `{skill: "cad-babysit", phase: "<phase-name>", final_status: "pass" | - `session_log` with `{session_id: "<this session's id>", skill: "cad-babysit", phase: "<phase-name>", final_status: "pass" |
"fail" | "skip", message: "<one-line summary>", duration_ms: <wall-clock>, project_root: "fail" | "skip", message: "<one-line summary>", project_root:
"<absolute path>"}` "<absolute path>"}`
**Phases for this skill:** query, read-comments, read-actions-logs, classify-fault-vs-outcome, **Phases for this skill:** query, read-comments, read-actions-logs, classify-fault-vs-outcome,
@@ -247,12 +247,6 @@ decide
- `fail` — the phase could not be completed (e.g. `cad stuck` errored, Gitea was unreachable). - `fail` — the phase could not be completed (e.g. `cad stuck` errored, Gitea was unreachable).
- `skip` — phase intentionally skipped (e.g. Phase 3 skipped because Phase 2 was conclusive). - `skip` — phase intentionally skipped (e.g. Phase 3 skipped because Phase 2 was conclusive).
## Mode 2 Routing Note
Phases 2-4 (reading comments/logs, classifying fault vs outcome) are judgment-heavy and should
stay Mode 1. Phase 1 (running `cad stuck` and grouping its output) is mechanical and a Mode 2
routing candidate once Plan 6 ships the routing pod.
## Cross-References ## Cross-References
- Depends on `mathias/cad#14`'s `cad stuck` query — this skill is the ritual around it, not a - Depends on `mathias/cad#14`'s `cad stuck` query — this skill is the ritual around it, not a
@@ -264,3 +258,13 @@ routing candidate once Plan 6 ships the routing pod.
- `mathias/cad-atlas#11` renders the same `cad stuck` output as a pipeline overlay — read that - `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 instead of this skill's text output when a visual "where is everything stuck" view is wanted
over working one row in depth. 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.
+9
View File
@@ -281,5 +281,14 @@ During refactoring:
- `references/clean-code.md` — detailed naming rules, object calisthenics - `references/clean-code.md` — detailed naming rules, object calisthenics
- `references/code-smells.md` — comprehensive smell catalog with examples - `references/code-smells.md` — comprehensive smell catalog with examples
- `references/python-adaptation.md` — Python conventions: error chaining, dataclasses, the truthiness trap
- Load `solid` skill for SOLID principles with Go examples - Load `solid` skill for SOLID principles with Go examples
- Load `refactoring` skill for systematic refactoring techniques - 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`.
@@ -0,0 +1,104 @@
# Clean Code: Python Adaptation
The principles in `SKILL.md` are language-agnostic. This file covers the places where
Python's idioms differ from the Go examples, and where following the Go shape produces
bad Python.
The canonical stack (ruff, mypy --strict, pytest, uv, pydantic-settings, src-layout)
lives in `~/dev/.context/AGENT.md` and is not restated here — one source of truth, and
it is not this file.
## Reveal intent with types, not comments
`mypy --strict` is the Python analogue of Go's compiler, and it is non-negotiable for
the same reason: a type is a claim the machine checks, and a comment is a claim nobody
checks. Two consequences for clean code specifically:
- **`Any` is a hole in the argument.** Each one says "the reader is on their own here".
If a value is genuinely polymorphic, say so precisely — `Protocol`, a union, a
`TypeVar` — rather than surrendering.
- **A `# type: ignore` needs the error code and a reason**: `# type: ignore[arg-type] #
boto3 stubs lag the API`. A bare ignore is an unexplained exception to a rule the rest
of the file follows.
## Errors: chain them, never swallow them
Go's `fmt.Errorf("operation: %w", err)` is `raise` with `from`:
```python
# Bad — the original cause is gone, and so is the stack that produced it
try:
row = fetch(invoice_id)
except KeyError:
raise InvoiceNotFound(invoice_id)
# Good — the chain survives; the traceback shows both
try:
row = fetch(invoice_id)
except KeyError as e:
raise InvoiceNotFound(invoice_id) from e
```
Two rules with no exceptions:
- **Never bare `except:`.** It catches `KeyboardInterrupt` and `SystemExit` too, so it
turns Ctrl-C into a swallowed error. `except Exception` if you truly mean everything.
- **Never a silent `pass`.** An exception handler that does nothing is a decision to
ignore a failure; if that is genuinely right, the handler needs a comment saying why,
and usually a log line. Most of the time it is a bug being stored for later.
## Data classes over dicts
A `dict[str, Any]` passed between functions is Go's `map[string]interface{}` — every
reader has to reconstruct the shape from the code that writes it.
```python
# Bad: shape known only by reading every producer
def total(invoice: dict[str, Any]) -> int: ...
# Good: shape declared once, checked everywhere
@dataclass(frozen=True, slots=True)
class Invoice:
id: InvoiceId
lines: tuple[Line, ...]
currency: str # ISO 4217
def total(invoice: Invoice) -> Money: ...
```
`frozen=True` where the value is conceptually immutable — it makes accidental mutation
an error rather than a surprise at a distance. Use `pydantic` models at the *boundary*
(env config, HTTP payloads) where validation is the point, and dataclasses inside,
where validation has already happened.
## Two Python-specific traps that read as clean code
**Mutable default arguments** are evaluated once, at definition:
```python
def add_line(line: Line, lines: list[Line] = []) -> list[Line]: # shared across calls
```
Use `None` and build inside. Ruff catches this (`B006`); the point of naming it here is
that the broken version looks tidier than the correct one.
**Truthiness is not presence.** `if not amount:` is true for `0`, `0.0`, `""`, `[]` and
`None` alike. For anything where zero is a legitimate value — money, counts, offsets —
write `if amount is None:`. This is the single most common way a Python function
silently mishandles a valid input.
## Structure
- `src/` layout, `pyproject.toml` only. A flat layout lets tests import the working
directory instead of the installed package, so the suite passes on a package that
cannot actually be installed.
- Module names are the unit of cohesion, as Go packages are. `utils.py` is the Python
spelling of a god package: it has no reason to change because it has no subject.
- Prefer functions to classes with one method. A class whose only job is to hold
constructor arguments and expose `run()` is a closure with ceremony.
## See also
- `references/clean-code.md` — the language-agnostic practices
- `references/code-smells.md` — the smell catalogue, all of which apply here
- `tdd/references/python.md` — the test-side conventions
+11 -4
View File
@@ -253,7 +253,7 @@ Pass-rate is computed downstream by the `/pass-rate` HTTP endpoint, which
treats `pass` as success, `fail` as failure, `skip` as neither. treats `pass` as success, `fail` as failure, `skip` as neither.
**At end of each phase:** **At end of each phase:**
- `session_log` with `{skill: "code-review", phase: "<phase-name>", final_status: "pass" | "fail" | "skip", message: "<one-line summary>", duration_ms: <wall-clock>, project_root: "<absolute path>"}` - `session_log` with `{session_id: "<this session's id>", skill: "code-review", phase: "<phase-name>", final_status: "pass" | "fail" | "skip", message: "<one-line summary>", project_root: "<absolute path>"}`
**Phases for this skill:** phase-0-iron-laws, phase-1-context, phase-2-architectural-analysis, phase-3-go-checklist, phase-4-code-smell-detection, phase-5-test-quality **Phases for this skill:** phase-0-iron-laws, phase-1-context, phase-2-architectural-analysis, phase-3-go-checklist, phase-4-code-smell-detection, phase-5-test-quality
@@ -262,7 +262,7 @@ treats `pass` as success, `fail` as failure, `skip` as neither.
- `fail` — the phase's intended outcome was NOT reached. - `fail` — the phase's intended outcome was NOT reached.
- `skip` — phase was skipped intentionally. - `skip` — phase was skipped intentionally.
**Why this matters:** the routing pod (Plan 6) reads pass-rate to decide whether to route a future call to a local model. If your skill never logs, the routing pod sees no data. **Why this matters:** pass-rate is what a future routing decision would be based on — whether a given call can be served by a local model instead of a cloud one. A skill that never logs contributes no evidence either way.
## Common Go Anti-Patterns ## Common Go Anti-Patterns
@@ -279,11 +279,18 @@ treats `pass` as success, `fail` as failure, `skip` as neither.
## References ## References
- `references/python-adaptation.md` — the Python anti-pattern table, and what ruff/mypy
should have caught before review
- `clean-code/references/code-smells.md` — comprehensive smell catalog - `clean-code/references/code-smells.md` — comprehensive smell catalog
- Load `refactoring` skill when recommending fixes - Load `refactoring` skill when recommending fixes
- Load `test-design` skill for reviewing test quality - Load `test-design` skill for reviewing test quality
- Load `solid` skill for SOLID principle violations - Load `solid` skill for SOLID principle violations
## Mode 2 Routing Note ## Escalation
This skill is invoked identically in Mode 1 (cloud Claude) and Mode 2 (client-local with supervisor routing). The routing layer (Plan 6) does not exist yet; treat this skill as Mode 1 only for now. Findings format and severity classification are unchanged across modes. 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.
@@ -0,0 +1,60 @@
# Code Review: Python Anti-Patterns
`SKILL.md` carries the Go anti-pattern table. This is the Python one. The review
process, severity classification and output format above it are language-neutral and
apply unchanged.
Canonical stack in `~/dev/.context/AGENT.md`. Two gates decide most of this file:
`ruff` and `mypy --strict`. **If a finding is something ruff or mypy already catches,
the review comment is not "fix this line" — it is "why is the gate not running?"**
## The table
| Anti-pattern | What to look for | Why it matters |
|---|---|---|
| Bare `except:` | `except:` with no exception class | Swallows `KeyboardInterrupt` and `SystemExit`; Ctrl-C stops working |
| Silent `pass` | `except X: pass` with no comment | A decision to ignore a failure, recorded nowhere |
| Lost cause | `raise Y(...)` inside `except X as e` with no `from e` | Traceback loses the original failure — the Python analogue of a naked `fmt.Errorf` |
| Mutable default | `def f(x, acc=[])` or `={}` | Evaluated once at definition; state leaks between calls |
| Truthiness for presence | `if not amount:` where `0` is valid | Silently mishandles zero, empty string, empty list |
| `Any` as an escape | `Any` in a signature that could be `Protocol`/union/`TypeVar` | Removes exactly the checking `--strict` was adopted for |
| Unexplained ignore | bare `# type: ignore` | No error code, no reason; permanent by default |
| `assert` in production code | `assert user.is_admin` outside tests | `python -O` strips asserts — the check vanishes in a tuned deployment |
| Import-time side effects | connections, env reads, or I/O at module scope | Cannot be injected, cannot be tested, ordering-dependent |
| Sync I/O in `async def` | `requests.get`, `time.sleep`, blocking file reads | Blocks the whole event loop; looks concurrent, is not |
| Un-awaited coroutine | a call to an `async def` with no `await` | Silently does nothing; "coroutine was never awaited" is a warning, not an error |
| `float` for money | `float` amounts, `round()` on currency | `0.1 + 0.2 != 0.3`; use `Decimal` or int minor units |
| Broad `Exception` re-raise | `except Exception` around a whole function body | Turns a specific failure into an unclassifiable one |
| Mutating a shared default/global | module-level `list`/`dict` mutated by functions | The Python spelling of shared mutable state |
| `dict[str, Any]` as a domain type | dicts passed between layers | Shape known only by reading every producer; use a dataclass |
| Star imports | `from x import *` | Breaks `--strict`, hides shadowing, defeats grep |
| `os.path` string surgery | manual path joining, `+ "/"` | `pathlib` exists and is correct on every platform |
| `subprocess(..., shell=True)` with interpolation | f-strings into a shell command | Injection; pass a list, no shell |
## What to check that a linter cannot
- **Does the test suite assert the failure mode, or only the happy path?** `pytest.raises`
with no `match=` accepts any message, including one from an unrelated bug.
- **Are fixtures doing real work?** A `conftest.py` fixture with a `session` scope and
mutable state couples every test that touches it; failures then depend on ordering.
- **Is the mock mocking a boundary or a collaborator you own?** The latter tests the mock.
- **Does the type say what the code means?** `-> dict` and `-> tuple[int, int]` both pass
`--strict` and both tell the reader nothing. A named type is a review comment worth
making.
- **Would `--strict` actually run on this file?** Check for a per-module override in
`pyproject.toml` silently exempting it. An exemption nobody revisits is a hole with a
changelog entry.
## Severity in this context
- **Blocking:** anything in the table above under security or correctness — `shell=True`
interpolation, `assert` as an authorization check, `float` money, sync I/O in async.
- **Should fix:** typing escapes (`Any`, unexplained ignores), mutable defaults,
truthiness-for-presence.
- **Consider:** structural comments — dataclass instead of dict, narrower Protocol.
## See also
- `clean-code/references/python-adaptation.md` — the style-level conventions
- `solid/references/python-adaptation.md` — Protocol over ABC, DIP in Python
- `tdd/references/python.md` — the test-side conventions this review checks against
+9
View File
@@ -347,3 +347,12 @@ type UserWriter interface {
- Load `refactoring` skill for systematic techniques to reduce identified hotspots - Load `refactoring` skill for systematic techniques to reduce identified hotspots
- Load `solid` skill for structural fixes (coupling, cohesion) - Load `solid` skill for structural fixes (coupling, cohesion)
- Load `clean-code` skill for naming improvements - 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.
+72 -21
View File
@@ -2,8 +2,8 @@
name: debug name: debug
surfaces: [claude-code, library] surfaces: [claude-code, library]
summary: "Hypothesis-first debugging discipline" summary: "Hypothesis-first debugging discipline"
use_when: "Any test failure or bug whose cause is not immediately obvious" use_when: "Any test failure or bug whose cause is not immediately obvious — or a bug report with no failure text, which this skill refuses rather than guesses at"
description: Systematic hypothesis-first debugging. Generate 3-5 ranked hypotheses with verification steps before suggesting any fix. Use when encountering a bug, test failure, or unexpected behavior. description: Systematic hypothesis-first debugging. Requires the failure itself — error text, stack, run id or log — and names what it needs and stops if the report has none, rather than hypothesising into a vacuum. Given evidence, generates 3-5 ranked, falsifiable hypotheses with verification steps before suggesting any fix. Use when encountering a bug, test failure, or unexpected behavior.
--- ---
# Debug # Debug
@@ -12,7 +12,7 @@ description: Systematic hypothesis-first debugging. Generate 3-5 ranked hypothes
Debugging is hypothesis generation, not fix generation. The first instinct — "try this and see if it works" — wastes time and teaches nothing. A disciplined debug session produces a small set of ranked, falsifiable hypotheses, each with a concrete verification command. The fix comes after one hypothesis is confirmed, not before. Debugging is hypothesis generation, not fix generation. The first instinct — "try this and see if it works" — wastes time and teaches nothing. A disciplined debug session produces a small set of ranked, falsifiable hypotheses, each with a concrete verification command. The fix comes after one hypothesis is confirmed, not before.
**Core principle:** A hypothesis you cannot verify in one command is not a hypothesis — it is a guess. **Core principle:** A hypothesis you cannot verify with a single bounded, decisive observation is not a hypothesis — it is a guess. In a repo that observation is usually one command. Above one it may be an instrumented run, or partitioning the last twenty CI runs by trigger type; what makes it a hypothesis is that the observation *discriminates*, not that it fits on one line.
## When to Use ## When to Use
@@ -25,6 +25,27 @@ Debugging is hypothesis generation, not fix generation. The first instinct — "
- Compile errors with clear messages (just fix the typo) - Compile errors with clear messages (just fix the typo)
- Already-diagnosed bugs where you know the cause (go straight to TDD with a regression test) - Already-diagnosed bugs where you know the cause (go straight to TDD with a regression test)
### Is it actually diagnosed?
That last exclusion is the one people get wrong, because a confident wrong diagnosis feels
exactly like a right one. It is diagnosed if you can state the mechanism *and* name the
observation that would have proved it wrong. If the evidence is "we changed X and it got
better", that is correlation with a deployment, and this skill applies.
The strongest tell is a run of failed fixes — see below. Three plausible fixes that did
not eliminate the symptom is not bad luck, it is the diagnosis being wrong.
### Intake: what you need before hypothesising
Before generating anything, confirm you have the failure itself — the error text, the
stack, the failing run's id, the log. If the report is "it's flaky again" with no
artifact, **name the specific thing you need and stop**. Iron Law 1 forbids "try X and
see"; hypothesising into a vacuum is the same failure with better grammar, and it is
worse because the output looks like analysis.
This is the rule `cad-babysit` already enforces from the other side: read the issue's own
comments and the job log *first*.
## Iron Laws ## Iron Laws
1. **Never suggest "try X and see what happens."** Every hypothesis must have a specific expected outcome if correct. 1. **Never suggest "try X and see what happens."** Every hypothesis must have a specific expected outcome if correct.
@@ -36,8 +57,9 @@ Debugging is hypothesis generation, not fix generation. The first instinct — "
A system with boundaries (CI → build → signing, API → service → database) hides the A system with boundaries (CI → build → signing, API → service → database) hides the
question "which component is wrong?" behind the question "why is the output wrong?". question "which component is wrong?" behind the question "why is the output wrong?".
Specify, as a pre-step, what to log entering and leaving **each** boundary, plus Specify, as a pre-step, what to log entering and leaving **each** boundary, plus
whether config and environment actually propagated. One run of that narrows the search whether config and environment actually propagated. One run *that reproduces the failure* narrows the search to a
to a single component. single component. On an intermittent fault a passing run narrows nothing, so leave the
instrumentation in across several runs rather than concluding from one.
This does not suspend Iron Law 2. Still emit 3-5 hypotheses — the boundary spec comes This does not suspend Iron Law 2. Still emit 3-5 hypotheses — the boundary spec comes
first in the output, and each hypothesis says which boundary observation would kill it. first in the output, and each hypothesis says which boundary observation would kill it.
@@ -53,6 +75,10 @@ than fixes that partially worked.) Each one has been narrowing a window rather t
closing it, and a fourth guess buys another tenth of the rate plus a layer of closing it, and a fourth guess buys another tenth of the rate plus a layer of
interacting guards nobody can reason about. interacting guards nobody can reason about.
Intake still gates this section. A report of three failed fixes with no artifacts fires
both rules at once, and intake wins: you cannot aim hypotheses at a premise you have no
evidence about either. Ask for the log, then aim.
Say that explicitly — the refusal is the finding, not a preamble to it. Then aim the Say that explicitly — the refusal is the finding, not a preamble to it. Then aim the
hypotheses one level up: the premise those fixes share is itself the thing to falsify. hypotheses one level up: the premise those fixes share is itself the thing to falsify.
This is not an exemption from the output format. It is the format, pointed at the This is not an exemption from the output format. It is the format, pointed at the
@@ -76,18 +102,28 @@ For each hypothesis, capture three things:
- **Verification:** the single command or file inspection that confirms or denies it - **Verification:** the single command or file inspection that confirms or denies it
- **Expected outcome if correct:** the specific output you would see - **Expected outcome if correct:** the specific output you would see
Order by likelihood. The most likely cause is hypothesis 1. Order by likelihood, most likely first. **The ordering is the ranking — do not also
attach `high`/`medium`/`low` labels.** With five slots and three labels the labels stop
discriminating (two hypotheses are genuinely both "high" and the label cannot say which
comes first), and a reader then has two orderings that can disagree. Position already
carries it.
Where you can say *why* one outranks another — a prior brain entry, a matching symptom,
a recent change in that area — say that instead of a label. That is information; a label
is a restatement of the order it sits next to.
### Step 3: Output ### Step 3: Output
Use this format: Use this format:
``` ```
HYPOTHESIS 1 (likelihood: high): <mechanism in one sentence> HYPOTHESIS 1: <mechanism in one sentence>
VERIFY: <exact command or file:line to inspect> WHY FIRST: <what makes it likeliest — a matching symptom, a recent change, a brain entry>
VERIFY: <exact command, or the bounded observation that discriminates>
EXPECTED IF CORRECT: <specific output, value, or condition> EXPECTED IF CORRECT: <specific output, value, or condition>
HYPOTHESIS 2 (likelihood: medium): <mechanism> HYPOTHESIS 2: <mechanism>
WHY FIRST: <or why it ranks here rather than first>
VERIFY: <exact command> VERIFY: <exact command>
EXPECTED IF CORRECT: <specific output> EXPECTED IF CORRECT: <specific output>
@@ -109,15 +145,15 @@ End with the recommendation, not a fix.
**Output:** **Output:**
``` ```
HYPOTHESIS 1 (likelihood: high): parser indexes into pages[0] before checking len(pages) > 0; empty PDFs produce a zero-page document HYPOTHESIS 1: parser indexes into pages[0] before checking len(pages) > 0; empty PDFs produce a zero-page document
VERIFY: rg -n 'pages\[0\]' internal/parser/ VERIFY: rg -n 'pages\[0\]' internal/parser/
EXPECTED IF CORRECT: at least one site reads pages[0] without a preceding length check EXPECTED IF CORRECT: at least one site reads pages[0] without a preceding length check
HYPOTHESIS 2 (likelihood: medium): the test fixture is a zero-byte file rather than a valid empty PDF; pdf library returns nil pages slice instead of empty HYPOTHESIS 2: the test fixture is a zero-byte file rather than a valid empty PDF; pdf library returns nil pages slice instead of empty
VERIFY: ls -la testdata/empty.pdf && file testdata/empty.pdf VERIFY: ls -la testdata/empty.pdf && file testdata/empty.pdf
EXPECTED IF CORRECT: file size 0 bytes or "data" rather than "PDF document" EXPECTED IF CORRECT: file size 0 bytes or "data" rather than "PDF document"
HYPOTHESIS 3 (likelihood: low): a recently changed dependency reordered the page-extraction API; pages[0] now refers to metadata, not content HYPOTHESIS 3: a recently changed dependency reordered the page-extraction API; pages[0] now refers to metadata, not content
VERIFY: git log --oneline -10 -- go.sum | grep -i pdf VERIFY: git log --oneline -10 -- go.sum | grep -i pdf
EXPECTED IF CORRECT: a pdf-library bump in the last few commits EXPECTED IF CORRECT: a pdf-library bump in the last few commits
@@ -131,6 +167,7 @@ RECOMMENDED NEXT STEP: Run VERIFY for hypothesis 1 first.
| "Maybe try restarting the service" | Not a mechanism. Not falsifiable. Teaches nothing if it works. | | "Maybe try restarting the service" | Not a mechanism. Not falsifiable. Teaches nothing if it works. |
| "Could be a race condition" | Mechanism without specifics. Which two operations race? On what state? | | "Could be a race condition" | Mechanism without specifics. Which two operations race? On what state? |
| "Let me add some print statements and see" | Skips hypothesis generation. Generates noise, not understanding. | | "Let me add some print statements and see" | Skips hypothesis generation. Generates noise, not understanding. |
| Answering "what's the fix?" from a report with no artifact | Hypothesising into a vacuum. The output looks like analysis, which is what makes it worse than saying "I need the log". |
| Single hypothesis presented as fact | If you are sure, write the regression test, do not run a debug skill. | | Single hypothesis presented as fact | If you are sure, write the regression test, do not run a debug skill. |
| Mixing hypotheses with fix suggestions | The skill is diagnose-only. The fix is a TDD task on its hypothesis. | | Mixing hypotheses with fix suggestions | The skill is diagnose-only. The fix is a TDD task on its hypothesis. |
@@ -139,7 +176,8 @@ RECOMMENDED NEXT STEP: Run VERIFY for hypothesis 1 first.
The brain holds prior debug sessions across the project. Use it to skip rediscovering known failure modes. The brain holds prior debug sessions across the project. Use it to skip rediscovering known failure modes.
**At debug start:** **At debug start:**
- Run `brain_query` with the error message snippet + the package name. Past sessions may have logged identical or similar failures with their resolved hypotheses. - Run `brain_query` with the error message snippet + the package name. On an intake stop you have neither — query the symptom and the component instead ("signed artifact missing CI"), which is worth doing precisely because it may name the artifact to ask for. Past sessions may have logged identical or similar failures with their resolved hypotheses.
- **A hit is evidence, so cite it inline in the hypothesis it informs** — `(brain: <entry-name>)` — rather than mentioning it once at the top. If a prior entry is why a hypothesis ranks first, the reader needs that at the hypothesis, not in a preamble. If a prior entry contradicts your ranking, say so and rank it anyway if the evidence here is better; the brain records what was true then.
**After a hypothesis is confirmed:** **After a hypothesis is confirmed:**
- Run `brain_write` with the failure signature → confirmed mechanism. Future debug sessions on the same area get the answer immediately. - Run `brain_write` with the failure signature → confirmed mechanism. Future debug sessions on the same area get the answer immediately.
@@ -154,20 +192,21 @@ Pass-rate is computed downstream by the `/pass-rate` HTTP endpoint, which
treats `pass` as success, `fail` as failure, `skip` as neither. treats `pass` as success, `fail` as failure, `skip` as neither.
**At end of each phase:** **At end of each phase:**
- `session_log` with `{skill: "debug", phase: "<phase-name>", final_status: "pass" | "fail" | "skip", message: "<one-line summary>", duration_ms: <wall-clock>, project_root: "<absolute path>"}` - `session_log` with `{session_id: "<this session's id>", skill: "debug", phase: "<phase-name>", final_status: "pass" | "fail" | "skip", message: "<one-line summary>", project_root: "<absolute path>"}`
**Phases for this skill:** read-failure, generate-hypotheses, output **Phases for this skill:** intake, read-failure, generate-hypotheses, output
`intake` exists so that refusing correctly is recordable. A run that stops for want of a
run id has done the right thing and logs `intake` / `pass`; without that phase the
refusal logs as a failed `read-failure`, and a metric that penalises the skill's best
behaviour teaches the next model to hypothesise into the vacuum instead.
**Status semantics:** **Status semantics:**
- `pass` — the phase's intended outcome was reached. - `pass` — the phase's intended outcome was reached.
- `fail` — the phase's intended outcome was NOT reached. - `fail` — the phase's intended outcome was NOT reached.
- `skip` — phase was skipped intentionally. - `skip` — phase was skipped intentionally.
**Why this matters:** the routing pod (Plan 6) reads pass-rate to decide whether to route a future call to a local model. If your skill never logs, the routing pod sees no data. **Why this matters:** pass-rate is what a future routing decision would be based on — whether a given call can be served by a local model instead of a cloud one. A skill that never logs contributes no evidence either way.
## Mode 2 Routing Note
This skill produces high-volume mechanical output (hypothesis enumeration) and is a candidate for Mode 2 routing to a local model in the future. Until Plan 6 ships the routing pod, treat as Mode 1 only. The hypothesis format and discipline are identical regardless of which model generates them.
## Cross-References ## Cross-References
@@ -183,6 +222,18 @@ Techniques kept in `debug/references/`, inherited from the deleted
- `defense-in-depth.md` — load after a root cause is confirmed, to decide which layers - `defense-in-depth.md` — load after a root cause is confirmed, to decide which layers
should have caught it. should have caught it.
- `condition-based-waiting.md` — load whenever a timeout appears anywhere in the report, - `condition-based-waiting.md` — load whenever a timeout appears anywhere in the report,
including as one of the fixes already tried. A bumped timeout is a symptom. including as one of the fixes already tried. A bumped timeout is a symptom. Not during an
intake stop: if you are not analysing yet, a symptom-analysis reference is on the wrong
side of the gate.
- `find-polluter.sh` — run when a test passes alone and fails in the suite, or when - `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. 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 > 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. > 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.
+1 -1
View File
@@ -185,7 +185,7 @@ If silhouette ≤ 0.20:
**After experiment concludes:** **After experiment concludes:**
- `brain_write` to `jepa-fx/failures/` with any new failure modes discovered - `brain_write` to `jepa-fx/failures/` with any new failure modes discovered
- `session_log` with outcome - `session_log` with `{session_id: "<this session's id>", skill: "experiment-spec", phase: "conclude", final_status: "pass" | "fail" | "skip", message: "<one-line outcome>"}`
## Cross-References ## Cross-References
+2 -6
View File
@@ -245,7 +245,7 @@ Pass-rate is computed downstream by the `/pass-rate` HTTP endpoint, which
treats `pass` as success, `fail` as failure, `skip` as neither. treats `pass` as success, `fail` as failure, `skip` as neither.
**At end of each phase:** **At end of each phase:**
- `session_log` with `{skill: "feature-spec", phase: "<phase-name>", final_status: "pass" | "fail" | "skip", message: "<one-line summary>", duration_ms: <wall-clock>, project_root: "<absolute path>"}` - `session_log` with `{session_id: "<this session's id>", skill: "feature-spec", phase: "<phase-name>", final_status: "pass" | "fail" | "skip", message: "<one-line summary>", project_root: "<absolute path>"}`
**Phases for this skill:** draft **Phases for this skill:** draft
@@ -254,11 +254,7 @@ treats `pass` as success, `fail` as failure, `skip` as neither.
- `fail` — the phase's intended outcome was NOT reached (spec rejected, blocked on unresolved questions). - `fail` — the phase's intended outcome was NOT reached (spec rejected, blocked on unresolved questions).
- `skip` — phase was skipped intentionally. - `skip` — phase was skipped intentionally.
**Why this matters:** the routing pod (Plan 6) reads pass-rate to decide whether to route a future call to a local model. If your skill never logs, the routing pod sees no data. **Why this matters:** pass-rate is what a future routing decision would be based on — whether a given call can be served by a local model instead of a cloud one. A skill that never logs contributes no evidence either way.
## Mode 2 Routing Note
Spec writing requires interpretation of intent and tradeoff reasoning — work where Claude generally outperforms a 9-13B local model. This skill is intentionally **not** a Mode 2 routing candidate. In Mode 2, the routing layer should leave `feature-spec` calls on Claude.
## Cross-References ## Cross-References
+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. - **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. 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.
+163
View File
@@ -0,0 +1,163 @@
---
name: lineage
surfaces: [claude-code, library]
summary: "Backward decision archaeology — was this tried before, and does the reason it failed still hold"
use_when: "Before proposing something that feels new; when an idea keeps resurfacing; when you inherit code whose reason is gone"
description: Backward decision archaeology. Find out whether an idea was already tried here, why it was dropped, and whether that reason still applies — using git history, decision records and the issue tracker rather than asking whoever is still around. Use before proposing a change that feels novel, when inheriting unexplained code, or when the same idea keeps being re-litigated.
---
# Lineage
## Overview
Most "new" ideas in an established codebase are revivals. The valuable question is never
"is this a good idea" in the abstract — it is **"was this tried, what killed it, and is
the killer still alive?"**
A past failure is not evidence against trying again. It is context for trying again
differently, and the difference between those two readings is usually one commit message.
## When to Use
- Before proposing something that feels new in a codebase older than you
- When an idea keeps resurfacing and being re-argued from scratch
- When you inherit code and cannot tell whether a constraint is load-bearing or vestigial
- Before writing an ADR — the reason a thing was dropped belongs in your Context section
**Do not use for:** code you wrote this week, or a question the current code answers.
Archaeology on a live construction site is just reading.
## The tool
```sh
skills/lineage/lineage.sh <term> [repo-path]
```
Runs the searches people skip because they are fiddly to type. In particular
`git log -S<term>`, which finds commits where the term's **occurrence count changed** —
so it surfaces the *removal* as well as the introduction. A plain `--grep` never finds a
deletion, and the deletion is the whole point.
It also lists files that once carried the term and no longer exist, and names the two
sources it cannot search for you.
**A no-match is not a finding.** The first version of this tool said a null meant "this is
new here"; a pressure test caught it asserting exactly that about the most-discussed
removed feature in this repo — the search term merely had a space in it. One string
against one repo proves nothing. Try at least three variants (the identifier name, a
one-word form, the name it would have had) and the brain and tracker searches before
telling anyone an idea is novel. This is the same rule as the anti-patterns table below,
and it is there because the tool broke it.
Note also that `--grep` takes a regex and `-S` takes a literal string, so a term
containing `.`, `[`, `*` or `+` matches differently in the two halves of the output.
## The sequence
**1. Search on the identifier, not the concept.** If the thing had a name in the code —
a function, a flag, a config key — `git log --all -S<name>` typically returns exactly two
commits, its birth and its death, on one screen. That is the fastest path to the answer in
this whole sequence.
Path-scoped log (`git log --all -- <file>`) is the weaker move and it is the one people
reach for: a function deleted from a file that still exists is the *dominant* removal
shape, and the path search buries it under every other commit that touched the file.
Concept words are the fallback, and they are lossy — a term with a space in it will miss
the record entirely. Try several spellings before you conclude anything.
**2. Read the removal commit's full body, not its subject.**
```sh
git log -1 --format=%B <sha>
git show --stat <sha>
```
This is where the answer almost always is. A subject line says *what*; the body says
*why*, and the why is what you came for. Read the diff too — what was removed alongside
it tells you how load-bearing it was.
**3. Search outside git — that is where this estate keeps its reasoning.** Two sources,
both routinely decisive, neither reachable from a commit log:
- **The brain** (`brain_query`). Postmortems and generalized rules live there. In the run
that shaped this skill, the single best artifact — the rule abstracted from the incident,
with detection one-liners — came from the brain and from nowhere else.
- **The issue tracker, including comments.** The commit records *that* something was
removed; the argument for it is usually in a comment. Search closed issues especially —
a closed issue is where a decision usually is.
A git-only search finds the what and misses the why.
**4. Test whether the failure conditions still hold — empirically, not from the record.**
This is the step that makes the whole exercise worth doing, and it is the one that gets
hand-waved. Write the conditions down as separate claims, then **run a command per claim**:
| Claim from the record | How you check it today |
|---|---|
| "absolute links break across two path roots" | `readlink` one, `test -e` it on this host |
| "it is gitignored, so nobody sees the rot" | `grep` the ignore file |
| "the detector does not cover it" | `grep` the detector for the path it scans |
Twenty seconds each, and it converts *"the record claims X"* into *"X is true right now"* —
which is the only form of the answer worth giving. A constraint that has since been
removed turns a dead idea live; one that is still true kills a revival on the spot.
**5. Separate the mechanism from the goal.** A revival proposal usually bundles both, and
they can have opposite verdicts: the goal is still worth wanting, the mechanism is the one
that failed. Splitting them is often the whole finding — you end up agreeing with the
proposer and rejecting their implementation, which is a far more useful answer than yes
or no.
## What "it was tried and failed" can mean
| Reading | Tell | What to do |
|---|---|---|
| **Structurally impossible** | The removal commit says *why* it cannot work, not that it broke | Believe it. Look for a different mechanism for the same goal |
| **Failed under conditions that changed** | The blocker names a version, a host, a tool that has since moved | Re-open it, citing what changed |
| **Failed on execution** | It broke, was reverted, nobody diagnosed it | Genuinely undecided. The revival is legitimate; budget for the diagnosis |
| **Never actually tried** | Discussed, an issue exists, no commit | There is no evidence either way. Say so rather than inheriting someone's pessimism |
| **Still there, in a different shape** | The idea returned under another name | The most missable one — check before you build it twice |
## Output
Answer the question that was asked, then show your work:
- **Verdict** — tried / not tried / tried and later restored differently
- **The decisive artifact** — commit sha, issue number, or ADR, so the reader can check
- **What killed it**, quoted from the source rather than paraphrased
- **Whether that still applies**, per condition, not as a single yes or no
If the decisive evidence is one commit message, quote it. A conclusion that makes the
reader re-run your search has not saved them anything.
## Anti-patterns
| Anti-pattern | Why it fails |
|---|---|
| `--grep` only | Finds discussion, misses deletions. The deletion is the evidence |
| Reading the commit subject and stopping | Subjects say what; you need why |
| Treating a past failure as a verdict | Conditions change. That is the entire question |
| Git-only search | The argument is usually in the issue tracker |
| "It was tried and it failed" with no sha | Unverifiable, and it will be re-litigated next quarter |
| Assuming absence means it was never considered | It may have been rejected in a conversation. Say what you searched |
## Escalation
Read-only. Reports what the record says; changes nothing and revives nothing.
Stop and hand back when: the record is genuinely silent — no commit, no issue, no ADR.
State what you searched and that nothing exists, rather than inferring intent from
absence. Someone deciding not to build a thing and nobody ever considering it look
identical in git, and only one of them is an argument.
Also stop when the answer turns on why a *person* did something and the record does not
say. Ask them. Reconstructing a colleague's reasoning from a diff is how a guess acquires
a citation.
## Cross-References
- `adr` — what you find here belongs in the Context section of the ADR you are about to
write. If the thing was decided before, your ADR supersedes that one; say so.
- `debug` — the same discipline aimed at a failure rather than a decision: evidence
before hypothesis, and the removal commit is evidence.
+99
View File
@@ -0,0 +1,99 @@
#!/bin/sh
# lineage.sh — decision archaeology for one term.
#
# Answers "why does X exist, was it tried before, and is this 'new' idea a
# revival?"
#
# Its real value is that it makes you run `git log -S` at all — that is a nudge,
# not a capability. -S finds commits where the term's occurrence COUNT changed,
# so it surfaces a removal; --grep only finds people talking about one. On an
# identifier name (`wire_claude_repo`) -S typically returns exactly two commits,
# birth and death, which is the fastest path to the answer in this whole file.
#
# Note the two searches do not match the same way: --grep takes a regex, -S takes
# a literal string. A term containing . [ * + behaves differently in each.
#
# Usage: lineage.sh <term> [repo-path]
#
# Read-only: nothing here writes, and it never touches the index or worktree.
set -u
[ $# -ge 1 ] || { echo "usage: lineage.sh <term> [repo-path]" >&2; exit 2; }
TERM_=$1
REPO=${2:-.}
git -C "$REPO" rev-parse --git-dir >/dev/null 2>&1 || {
echo "lineage: $REPO is not a git repository" >&2; exit 2; }
# Say which repo, always. The default is the cwd, and in a worktree-heavy setup
# that is routinely not the repo the caller means — a confident null against the
# wrong tree is the worst thing this tool can produce.
TOPLEVEL=$(git -C "$REPO" rev-parse --show-toplevel 2>/dev/null || printf '%s' "$REPO")
printf 'searching: %s term: "%s"\n' "$TOPLEVEL" "$TERM_"
FMT='%h %ad %an %s'
DATE='--date=short'
found=0
section() { printf '\n== %s ==\n' "$1"; }
# 1. Commits whose MESSAGE mentions it — the stated reasoning.
msgs=$(git -C "$REPO" log --all -i --grep="$TERM_" --format="$FMT" $DATE 2>/dev/null || true)
if [ -n "$msgs" ]; then
found=1
section "Commits that talk about it (message)"
printf '%s\n' "$msgs"
fi
# 2. Commits where the term was ADDED or REMOVED — the actual history.
# -S counts occurrences, so a deletion shows up here and nowhere else.
picks=$(git -C "$REPO" log --all -S"$TERM_" --format="$FMT" $DATE 2>/dev/null || true)
if [ -n "$picks" ]; then
found=1
section "Commits where it appeared or disappeared (-S)"
printf '%s\n' "$picks"
printf '\n Oldest is the introduction. Anything after it that removed the term is\n'
printf ' the interesting one: read that commit before proposing this again.\n'
fi
# 3. Files that once contained it and no longer exist.
deleted=$(git -C "$REPO" log --all --diff-filter=D --name-only --format='%h' -S"$TERM_" 2>/dev/null \
| grep -v '^[0-9a-f]\{7,\}$' | grep -v '^$' | sort -u || true)
if [ -n "$deleted" ]; then
found=1
section "Files deleted that carried it"
printf '%s\n' "$deleted"
printf '\n Recover one with: git -C %s show <sha>^:<path>\n' "$REPO"
fi
# 4. Decision records naming it.
docs=$(git -C "$REPO" grep -l -i "$TERM_" -- 'docs/adr/*' 'docs/decisions/*' 'docs/*.md' 'ADR*' 2>/dev/null || true)
if [ -n "$docs" ]; then
found=1
section "Decision records naming it"
printf '%s\n' "$docs"
fi
# This estate keeps most of its reasoning OUTSIDE the repo, so the two searches
# this tool cannot run are usually the decisive ones. Say so every time rather
# than letting their absence read as a null result.
section "Not searched here — do these by hand"
printf 'brain: brain_query "%s" — decisions and postmortems live there, not in docs/\n' "$TERM_"
printf 'gitea: closed issues and their COMMENTS. The argument for a removal is\n'
printf ' usually in a comment; the commit only records that it happened.\n'
if [ "$found" -eq 0 ]; then
printf '\nno match for the exact string "%s" in %s.\n' "$TERM_" "$TOPLEVEL"
printf 'This is NOT evidence the idea is new. It is one string against one repo.\n'
printf 'Before concluding anything, try: the identifier or function name; a\n'
printf 'one-word variant (spaces and punctuation break the match); the name it\n'
printf 'would have had; and the brain and issue searches above.\n'
exit 0
fi
printf '\n-- what this cannot tell you --------------------------------------------\n'
printf 'Why it was removed, if it was. The commit subject is a summary; read the\n'
printf 'body, the PR, and the issue it closed. A past failure under conditions that\n'
printf 'no longer hold is not evidence against trying again — it is context for\n'
printf 'trying again differently.\n'
+2 -6
View File
@@ -116,7 +116,7 @@ Pass-rate is computed downstream by the `/pass-rate` HTTP endpoint, which
treats `pass` as success, `fail` as failure, `skip` as neither. treats `pass` as success, `fail` as failure, `skip` as neither.
**At end of each phase:** **At end of each phase:**
- `session_log` with `{skill: "session-retrospective", phase: "<phase-name>", final_status: "pass" | "fail" | "skip", message: "<one-line summary>", duration_ms: <wall-clock>, project_root: "<absolute path>"}` - `session_log` with `{session_id: "<this session's id>", skill: "session-retrospective", phase: "<phase-name>", final_status: "pass" | "fail" | "skip", message: "<one-line summary>", project_root: "<absolute path>"}`
**Phases for this skill:** surfaced **Phases for this skill:** surfaced
@@ -125,11 +125,7 @@ treats `pass` as success, `fail` as failure, `skip` as neither.
- `fail` — the phase's intended outcome was NOT reached (retrospective could not produce coherent candidates). - `fail` — the phase's intended outcome was NOT reached (retrospective could not produce coherent candidates).
- `skip` — phase was skipped intentionally (no novel learnings worth surfacing). - `skip` — phase was skipped intentionally (no novel learnings worth surfacing).
**Why this matters:** the routing pod (Plan 6) reads pass-rate to decide whether to route a future call to a local model. If your skill never logs, the routing pod sees no data. **Why this matters:** pass-rate is what a future routing decision would be based on — whether a given call can be served by a local model instead of a cloud one. A skill that never logs contributes no evidence either way.
## Mode 2 Routing Note
Reading long session logs and pulling themes is high-volume, mechanical pattern-recognition work — a strong Mode 2 routing candidate. Until Plan 6 ships the routing pod, treat as Mode 1 only.
## Cross-References ## Cross-References
+10
View File
@@ -258,5 +258,15 @@ Domain types know nothing about HTTP, SQL, or external APIs.
- `references/solid-principles.md` — canonical SOLID reference with TypeScript examples - `references/solid-principles.md` — canonical SOLID reference with TypeScript examples
- `references/go-adaptation.md` — this workspace's Go-specific rewrite of each principle - `references/go-adaptation.md` — this workspace's Go-specific rewrite of each principle
- `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 `clean-code` skill for naming and structure
- Load `code-review` skill for detecting violations during review - 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.
@@ -0,0 +1,104 @@
# SOLID Principles: Python Adaptation
`references/go-adaptation.md` rewrites each principle for Go. This file does the same
for Python, and marks the places where the Go rewrite's advice does *not* carry over.
The canonical Python stack lives in `~/dev/.context/AGENT.md`; it is not restated here.
## The one idea that changes everything: `Protocol`
Go's interfaces are structural — a type satisfies one by having the methods, with no
declaration. Python has the same thing since 3.8, and it is the right default:
```python
from typing import Protocol
class InvoiceStore(Protocol):
def get(self, invoice_id: InvoiceId) -> Invoice: ...
def save(self, invoice: Invoice) -> None: ...
```
- **Define the Protocol where it is *consumed*, not where it is implemented.** This is
Go's "accept interfaces, return structs" and it is what makes DIP real: the high-level
module owns the abstraction, and the adapter depends on it rather than the reverse.
- **Prefer `Protocol` to `ABC`.** An ABC forces implementers to inherit, which couples
them to your package and makes a third-party class impossible to adapt without a
wrapper. Use an ABC only when you genuinely need shared implementation, not just a
contract.
- Add `@runtime_checkable` only if you actually call `isinstance` — and if you are
calling `isinstance` against a Protocol, check whether the real problem is a missing
polymorphic method (an LSP smell).
## S — Single Responsibility
Same as Go: the unit is the module, not just the class. `utils.py` is the Python god
package — it cannot have one reason to change because it has no subject.
Python-specific tell: a class whose `__init__` stores arguments and whose only other
method is `run()` or `execute()`. That is a function with ceremony. Write the function.
## O — Open/Closed
Extension in Python usually means **passing behaviour in**, not subclassing:
```python
# Closed for modification: adding a rule does not touch this function
def apply_rules(invoice: Invoice, rules: Sequence[Rule]) -> Invoice: ...
```
`functools.singledispatch` is the idiomatic open extension point when dispatch is on
type and the set of types grows outside your module. Beware the alternative that looks
Pythonic and is not: monkey-patching is modification pretending to be extension, and it
is invisible at the call site.
## L — Liskov
The Python-specific violation is **signature widening/narrowing that the type checker
would catch if you let it**. Under `mypy --strict`, an override that narrows a parameter
type or widens a return type is an error — which means most LSP violations in Python are
type errors you chose not to see. This is the strongest argument for `--strict` in a
design document rather than a style one.
Raising a *new* exception type in an override is the violation `mypy` will not catch:
callers wrote `except StoreError`, and your subclass raises `TimeoutError`. Exceptions
are part of the contract; Python just will not check them for you.
## I — Interface Segregation
Protocols make this cheap: define the narrow one you need at the call site.
```python
class InvoiceReader(Protocol):
def get(self, invoice_id: InvoiceId) -> Invoice: ...
```
A function that only reads should accept `InvoiceReader`, not the full `InvoiceStore`.
The test double then implements one method instead of eight, which is the practical
payoff and the reason this principle survives contact with real code.
## D — Dependency Inversion
Constructor injection, exactly as in Go. Two Python-specific hazards:
- **Import-time side effects are hidden dependencies.** A module that opens a database
connection or reads an env var at import has a dependency nothing can inject and no
test can replace. Configuration belongs in `pydantic-settings` objects passed in.
- **Default arguments are compile-time bindings.** `def send(client: Client = Client())`
constructs the default once, at definition, and welds it to every caller. Default to
`None` and construct inside, or require it.
## What does not carry over from the Go rewrite
- **"Return structs, accept interfaces"** — the returning half is about Go's zero values
and nil interfaces. In Python, returning a Protocol type is fine and often better.
- **Small interfaces because of embedding** — Python has no embedding; composition is
explicit delegation, which is more verbose but reads honestly.
- **Package-level responsibility as the unit of deployment** — Python packaging is much
looser. The module boundary is still the right unit for *reasoning*, but do not expect
it to be enforced.
## See also
- `references/solid-principles.md` — the canonical, language-neutral reference
- `references/go-adaptation.md` — the Go rewrite; contrast is instructive
- `clean-code/references/python-adaptation.md` — style-level Python conventions
+2 -2
View File
@@ -332,7 +332,7 @@ Pass-rate is computed downstream by the `/pass-rate` HTTP endpoint, which
treats `pass` as success, `fail` as failure, `skip` as neither. treats `pass` as success, `fail` as failure, `skip` as neither.
**At end of each phase:** **At end of each phase:**
- `session_log` with `{skill: "spec-driven-dev", phase: "<phase-name>", final_status: "pass" | "fail" | "skip", message: "<one-line summary>", duration_ms: <wall-clock>, project_root: "<absolute path>"}` - `session_log` with `{session_id: "<this session's id>", skill: "spec-driven-dev", phase: "<phase-name>", final_status: "pass" | "fail" | "skip", message: "<one-line summary>", project_root: "<absolute path>"}`
**Phases for this skill:** specify, plan, tasks, implement **Phases for this skill:** specify, plan, tasks, implement
@@ -341,7 +341,7 @@ treats `pass` as success, `fail` as failure, `skip` as neither.
- `fail` — the phase's intended outcome was NOT reached (gate blocked, rework required). - `fail` — the phase's intended outcome was NOT reached (gate blocked, rework required).
- `skip` — phase was skipped intentionally. - `skip` — phase was skipped intentionally.
**Why this matters:** the routing pod (Plan 6) reads pass-rate to decide whether to route a future call to a local model. If your skill never logs, the routing pod sees no data. **Why this matters:** pass-rate is what a future routing decision would be based on — whether a given call can be served by a local model instead of a cloud one. A skill that never logs contributes no evidence either way.
## Cross-References ## Cross-References
+9
View File
@@ -151,3 +151,12 @@ stays in the client boundary**:
- Council/forum names, internal stage labels, internal tooling, named contacts, strategy - 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. 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. - 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.
+43 -16
View File
@@ -100,13 +100,21 @@ go test -run TestRetryOperation_RetriesThreeTimes ./...
``` ```
Confirm: Confirm:
- Test fails (not compilation errors) - Test fails, and fails because the feature is missing
- Failure message is expected - The failure message is the one you expected
- Fails because feature is missing (not typos) - It is not failing on a typo in the test itself
**Test passes?** You're testing existing behavior. Fix test. **Test passes?** You're testing existing behavior. Fix test.
**Compilation errors?** Fix errors, re-run until it fails correctly. **Failing because the thing does not exist yet?** That is a legitimate red — a Go
compile error for an undefined function, a Python `ImportError` for a module you have
not written, a TypeScript "cannot find module". Read the message, confirm it names the
unit you are about to build, and proceed to GREEN. **Do not write a stub to make the
runner happier.** A stub is production code written before a failing test, which is the
Iron Law with extra steps.
**Failing on a typo or a broken import elsewhere?** Different thing. Fix it and re-run
until the failure is the one above.
### GREEN - Minimal Code ### GREEN - Minimal Code
@@ -166,6 +174,23 @@ Keep tests green. Don't add behavior.
Next failing test for next behavior. Next failing test for next behavior.
## Other languages
The discipline above is language-neutral; the examples below it are Go, because Go is
this fleet's default stack and one good example beats five mediocre ones.
For anything else, the per-language conventions live in reference files — load the one
you need rather than translating Go idioms by eye. Translation is where the mistakes
happen: the Go table-driven exemplar below branches on a `wantErr` column inside the
loop, which is idiomatic Go and an anti-pattern in pytest, where it produces a
conditional assertion and folds two behaviours into one test.
- `references/python.md` — pytest, `@pytest.mark.parametrize`, `mypy --strict` in the
RED gate, `pytest.raises(..., match=)`, fixtures vs helpers
- `references/typescript.md` — vitest/jest, `it.each`, `expect(...).rejects`
- `references/rust.md` — `#[cfg(test)]` modules, table-driven via arrays, `#[should_panic]`
- `references/java.md` — JUnit 5, `@ParameterizedTest`, `assertThrows`
## Go-Specific TDD Notes ## Go-Specific TDD Notes
### Table-Driven Tests (Preferred Pattern) ### Table-Driven Tests (Preferred Pattern)
@@ -329,12 +354,18 @@ Before marking work complete:
- [ ] Watched each test fail before implementing - [ ] Watched each test fail before implementing
- [ ] Each test failed for expected reason (feature missing, not typo) - [ ] Each test failed for expected reason (feature missing, not typo)
- [ ] Wrote minimal code to pass each test - [ ] Wrote minimal code to pass each test
- [ ] All tests pass: `go test ./...` - [ ] All tests pass
- [ ] Race detector clean: `go test -race ./...` - [ ] The language's static gate is clean — `go vet`, `mypy --strict`, `tsc --noEmit`,
`cargo clippy`. A red type-check is a red build; it does not wait for GREEN.
- [ ] The language's concurrency gate is clean **where one exists and applies** —
`go test -race`, `cargo test` under Miri for unsafe code. There is no equivalent
for a pure Python function, and a checklist box that can never be ticked teaches
people to skip checklists. Tick it or strike it deliberately; do not leave it
hanging.
- [ ] Tests use real code (mocks only if crossing a system boundary) - [ ] Tests use real code (mocks only if crossing a system boundary)
- [ ] Edge cases and errors covered - [ ] Edge cases and errors covered
Can't check all boxes? You skipped TDD. Start over. Can't check **or deliberately strike** all boxes? You skipped TDD. Start over. A box struck with a stated reason is answered; a box left hanging is not.
## When Stuck ## When Stuck
@@ -343,7 +374,7 @@ Can't check all boxes? You skipped TDD. Start over.
| Don't know how to test | Write wished-for API. Write assertion first. Ask Mathias. | | Don't know how to test | Write wished-for API. Write assertion first. Ask Mathias. |
| Test too complicated | Design too complicated. Simplify interface. | | Test too complicated | Design too complicated. Simplify interface. |
| Must mock everything | Code too coupled. Use dependency injection. | | Must mock everything | Code too coupled. Use dependency injection. |
| Test setup huge | Extract helpers with `t.Helper()`. Still complex? Simplify design. | | Test setup huge | Extract helpers (`t.Helper()` in Go, a local `__tracebackhide__ = True` in Python). Still complex? Simplify design. |
## Debugging Integration ## Debugging Integration
@@ -375,20 +406,20 @@ Pass-rate is computed downstream by the `/pass-rate` HTTP endpoint, which
treats `pass` as success, `fail` as failure, `skip` as neither. treats `pass` as success, `fail` as failure, `skip` as neither.
**At end of `red` phase:** **At end of `red` phase:**
- `session_log` with `{skill: "tdd", phase: "red", final_status: "pass" | "fail" | "skip", message: "<one-line summary>", duration_ms: <wall-clock>, project_root: "<absolute path>"}` - `session_log` with `{session_id: "<this session's id>", skill: "tdd", phase: "red", final_status: "pass" | "fail" | "skip", message: "<one-line summary>", project_root: "<absolute path>"}`
**At end of `green` phase:** **At end of `green` phase:**
- `session_log` with `{skill: "tdd", phase: "green", final_status: "pass" | "fail" | "skip", message: "<one-line summary>", duration_ms: <wall-clock>, project_root: "<absolute path>"}` - `session_log` with `{session_id: "<this session's id>", skill: "tdd", phase: "green", final_status: "pass" | "fail" | "skip", message: "<one-line summary>", project_root: "<absolute path>"}`
**At end of `refactor` phase:** **At end of `refactor` phase:**
- `session_log` with `{skill: "tdd", phase: "refactor", final_status: "pass" | "fail" | "skip", message: "<one-line summary>", duration_ms: <wall-clock>, project_root: "<absolute path>"}` - `session_log` with `{session_id: "<this session's id>", skill: "tdd", phase: "refactor", final_status: "pass" | "fail" | "skip", message: "<one-line summary>", project_root: "<absolute path>"}`
**Status semantics:** **Status semantics:**
- `pass` — the phase's intended outcome was reached (red: test fails as expected; green: test passes; refactor: tests still pass after refactor). - `pass` — the phase's intended outcome was reached (red: test fails as expected; green: test passes; refactor: tests still pass after refactor).
- `fail` — the phase's intended outcome was NOT reached (test compiled when it shouldn't, test still fails after green attempt, refactor broke tests). - `fail` — the phase's intended outcome was NOT reached (test compiled when it shouldn't, test still fails after green attempt, refactor broke tests).
- `skip` — phase was skipped intentionally (e.g. refactor not warranted). - `skip` — phase was skipped intentionally (e.g. refactor not warranted).
**Why this matters:** the routing pod (Plan 6) reads pass-rate to decide whether to route a future `tdd` call to a local model. If your skill never logs, the routing pod sees no data and may default-route or default-not-route in a way that doesn't reflect real performance. **Why this matters:** pass-rate is what a future routing decision would be based on — whether a `tdd` call can be served by a local model instead of a cloud one. A skill that never logs contributes no evidence either way, and a decision made on no evidence is a coin flip wearing a metric.
## Final Rule ## Final Rule
@@ -398,7 +429,3 @@ Otherwise → not TDD
``` ```
No exceptions without Mathias's permission. No exceptions without Mathias's permission.
## Mode 2 Routing Note
This skill is invoked identically whether the agent is running in Mode 1 (cloud Claude, no routing) or Mode 2 (client-local, supervisor routing layer). The routing pod (Plan 6) does not exist yet; until it does, treat this skill as Mode 1 only. The discipline does not change between modes — only the model behind the call.
+78
View File
@@ -0,0 +1,78 @@
# TDD in Java
The Iron Law and Red-Green-Refactor in `SKILL.md` apply unchanged. This file replaces
the Go-Specific TDD Notes for Java. JUnit 5 (Jupiter) throughout.
## The first red is a compile error, and that is correct
`cannot find symbol: method splitVat` is a legitimate red. Do not stub the method with
`throw new UnsupportedOperationException()` to make the module compile — that is
production code before a failing test.
## Table-driven: `@ParameterizedTest`
```java
@ParameterizedTest(name = "{0}")
@MethodSource("vatCases")
void roundsHalfToEven(String name, long amountMinor, BigDecimal rate, long net, long vat) {
assertEquals(new Split(net, vat), VatSplitter.split(amountMinor, rate));
}
static Stream<Arguments> vatCases() {
return Stream.of(
Arguments.of("tie rounds down to even", 14L, new BigDecimal("0.12"), 12L, 2L),
Arguments.of("tie rounds up to even", 42L, new BigDecimal("0.12"), 38L, 4L),
Arguments.of("exact division", 12500L, new BigDecimal("0.25"), 10000L, 2500L)
);
}
```
`name = "{0}"` puts the case label in the report — the `t.Run` analogue. `@CsvSource` is
terser for primitives; `@MethodSource` is the one to use as soon as a case carries a
`BigDecimal` or a domain object.
## Error cases get their own test
```java
@Test
void rejectsNegativeAmount() {
var e = assertThrows(IllegalArgumentException.class,
() -> VatSplitter.split(-1, new BigDecimal("0.25")));
assertTrue(e.getMessage().contains("non-negative"));
}
```
Assert on the message, not only the type — `IllegalArgumentException` is thrown by half
the JDK, and a test that accepts any of them will keep passing for the wrong reason.
## Money is `BigDecimal` or `long` minor units, never `double`
`0.1 + 0.2 != 0.3` is not a rounding curiosity in a payments system, it is a defect.
Where a rounding mode matters, state it: `setScale(0, RoundingMode.HALF_EVEN)`. Banker's
rounding is the default expectation in Swedish accounting and is *not* Java's default.
## Assertions
Prefer AssertJ (`assertThat(x).isEqualTo(y)`) where the project already has it: the
failure messages name the actual value. Where it does not, plain JUnit assertions are
fine — do not add a dependency for aesthetics.
`assertAll` is the closest analogue to Go's soft `assert`: it reports every failure in
the group instead of stopping at the first.
## Mocks
Mockito is standard, and easy to overuse. Per `SKILL.md`, mock only across a system
boundary. A mocked class you own tests the mock; a `@SpringBootTest` that mocks the
repository tests neither the wiring nor the query.
## Gates
```bash
./gradlew test # the failing test
./gradlew check # static gate: compiler warnings, spotless, errorprone
```
Java has no `-race`. For concurrent code the honest substitutes are a stress test with
`CountDownLatch` to force the interleaving, and jcstress for lock-free work. A pure
function should strike that checklist box deliberately.
+109
View File
@@ -0,0 +1,109 @@
# TDD in Python
The Iron Law, Red-Green-Refactor and the rationalization table in `SKILL.md` apply
unchanged. This file replaces the Go-Specific TDD Notes for Python work.
Canonical stack (`~/dev/.context/AGENT.md`): pytest + pytest-cov, `mypy --strict`, ruff,
uv, src-layout. Not restated here — that table has one home.
## The first red is an ImportError, and that is correct
For a function that does not exist yet, the first run fails at collection:
```
E ModuleNotFoundError: No module named 'invoicing.vat'
```
That is a legitimate red. Read it, confirm it names the module you are about to write,
and go to GREEN. **Do not create a stub raising `NotImplementedError` to make pytest
collect.** A stub is production code written before a failing test.
## Table-driven: `parametrize`, one behaviour per function
```python
@pytest.mark.parametrize(
("amount_minor", "rate", "expected"),
[
pytest.param(14, Decimal("0.12"), (12, 2), id="tie-rounds-down-to-even"),
pytest.param(42, Decimal("0.12"), (38, 4), id="tie-rounds-up-to-even"),
pytest.param(12500, Decimal("0.25"), (10000, 2500), id="exact-division"),
pytest.param(0, Decimal("0.25"), (0, 0), id="zero-is-not-an-error"),
],
)
def test_split_vat_rounds_half_to_even(
amount_minor: int, rate: Decimal, expected: tuple[int, int]
) -> None:
assert split_vat(amount_minor, rate) == expected
```
`id=` is the analogue of Go's `t.Run` name: it is what appears in the failure output and
what you pass to `pytest -k`.
**Do not port Go's `wantErr` column.** The Go exemplar branches inside the loop —
`if tt.wantErr { assert.Error } else { assert.NoError }`. Translated literally that
gives a conditional assertion and folds two behaviours into one test, which `SKILL.md`'s
own quality table forbids ("One thing. 'and' in name? Split it."). Error cases get
their own function:
```python
def test_split_vat_rejects_negative_amount() -> None:
with pytest.raises(ValueError, match="amount_minor must be non-negative"):
split_vat(-1, Decimal("0.25"))
```
`match=` pins the message, not just the type. Go's `assert.Error` does not, and the
stricter version is available here for free — use it.
## `mypy --strict` is part of RED, not a chore after GREEN
A red type-check is a red build.
```bash
uv run pytest tests/test_vat.py -x -q # the failing test
uv run mypy --strict src tests # same gate, same phase
```
Under `--strict` every test function needs `-> None`, or it is an untyped def and the
type-checker rejects the file — including the file that holds your failing test.
## Layout and imports
- `src/` layout, tests in a sibling `tests/`. This is the *opposite* of Go's
same-directory convention, and it buys black-box testing structurally: `from
invoicing.vat import split_vat` can only resolve against the installed package, so
the preference Go has to state explicitly is enforced here by the import system.
- No `__init__.py` in `tests/` unless you need shared test packages; pytest's rootdir
handling copes, and an accidental package shadows names.
- `conftest.py` holds fixtures shared across a directory. A fixture in a single test
file that only one test uses should be a plain function call instead — fixtures are
indirection, and indirection is only worth it at reuse.
## No `require` vs `assert`
Every pytest `assert` is fatal to its test; there is no continue-on-failure form. Where
Go would use `assert` to collect several soft failures, either split the test or use
`pytest.approx`/a single structural comparison:
```python
assert result == Invoice(id=InvoiceId("42"), net=10000, vat=2500) # one assertion, whole shape
```
Comparing whole values beats three field assertions: the failure output shows the entire
diff rather than the first field that differed.
## Fixtures vs helpers
- **Helper function** — when the setup is cheap and local. It reads top-to-bottom.
- **Fixture** — when the setup is shared, or needs teardown, or needs a scope
(`session`, `module`) because it is expensive.
- Set `__tracebackhide__ = True` in an assertion helper so failures point at the caller,
the way `t.Helper()` does in Go.
## Coverage is not the concurrency gate
`SKILL.md`'s checklist asks for the language's concurrency gate. Python has no `-race`
equivalent, and pretending `pytest --cov` fills that slot is dishonest — coverage says
which lines ran, not whether two threads can interleave badly. For a pure function,
strike the box deliberately. For genuinely concurrent code (`asyncio`, threads), the
substitute is a test that runs the interleaving you fear, plus `pytest-timeout` so a
deadlock fails rather than hangs.
+86
View File
@@ -0,0 +1,86 @@
# TDD in Rust
The Iron Law and Red-Green-Refactor in `SKILL.md` apply unchanged. This file replaces
the Go-Specific TDD Notes for Rust.
## The first red is a compile error, and that is correct
```
error[E0425]: cannot find function `split_vat` in this scope
```
Legitimate — the compiler is the test runner's first phase. Read it, confirm it names
the item you are about to write, and go to GREEN. Do not write a `todo!()` stub first;
that is production code before a failing test. (`todo!()` is fine *during* GREEN as a
branch you have not reached yet.)
## Where tests live
- **Unit tests** in the same file, in `mod tests` behind `#[cfg(test)]`. They can reach
private items — the white-box option Go spells `package foo`.
- **Integration tests** in `tests/`, which link the crate as an external consumer and see
only the public API. This is the black-box default `SKILL.md` prefers; use it unless
the behaviour under test is genuinely private.
```rust
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tie_rounds_down_to_even() {
assert_eq!(split_vat(14, dec!(0.12)).unwrap(), (12, 2));
}
}
```
## Table-driven: an array of cases
```rust
#[test]
fn rounds_half_to_even() {
let cases = [
("tie rounds down", 14u64, dec!(0.12), (12, 2)),
("tie rounds up", 42, dec!(0.12), (38, 4)),
("exact division", 12500, dec!(0.25), (10000, 2500)),
];
for (name, amount, rate, want) in cases {
assert_eq!(split_vat(amount, rate).unwrap(), want, "case: {name}");
}
}
```
The `"case: {name}"` message is the `t.Run` analogue — without it a failure names a line
number and leaves you counting rows. If you want each case to be its own reported test,
use `rstest`'s `#[case]`; that is a dependency, so justify it before adding it.
## Error cases
`Result` makes the Go `wantErr` column unnecessary — assert on the variant:
```rust
#[test]
fn rejects_negative_amount() {
let err = split_vat_signed(-1, dec!(0.25)).unwrap_err();
assert!(matches!(err, VatError::NegativeAmount));
}
```
`#[should_panic(expected = "...")]` is for genuine panics only — invariant violations,
not expected error paths. A function whose contract includes "rejects negative input"
should return `Err`, not panic, and a test that accepts a panic hides that design choice.
## Gates
```bash
cargo test # the failing test
cargo clippy -- -D warnings # static gate, same phase as RED
cargo fmt --check
```
`cargo test` compiles in debug, so integer overflow panics rather than wrapping — a
release build will not. Where overflow matters, test the checked path explicitly
(`checked_mul`), or you are testing debug-only behaviour.
For unsafe code, `cargo +nightly miri test` is the nearest thing to Go's `-race`.
Safe-only crates have no equivalent and should strike that checklist box deliberately.
+85
View File
@@ -0,0 +1,85 @@
# TDD in TypeScript
The Iron Law and Red-Green-Refactor in `SKILL.md` apply unchanged. This file replaces
the Go-Specific TDD Notes for TypeScript.
Vitest is the default; Jest differs only in imports and a few matcher names, both noted
where it matters.
## The first red is a resolution error, and that is correct
```
Error: Failed to resolve import "./vat" from "src/vat.test.ts"
```
Legitimate. Do not create an empty module or a stub throwing `new Error("not
implemented")` to get past collection — that is production code before a failing test.
## Table-driven: `it.each`
```ts
describe('splitVat', () => {
it.each([
{ amountMinor: 14, rate: 0.12, expected: [12, 2], name: 'tie rounds down to even' },
{ amountMinor: 42, rate: 0.12, expected: [38, 4], name: 'tie rounds up to even' },
{ amountMinor: 12500, rate: 0.25, expected: [10000, 2500], name: 'exact division' },
])('$name', ({ amountMinor, rate, expected }) => {
expect(splitVat(amountMinor, rate)).toEqual(expected)
})
})
```
`$name` interpolates into the test title, which is the `t.Run` analogue. Prefer the
object form over the array form — a positional tuple with five entries is unreadable at
the point of failure.
**Do not port Go's `wantErr` column.** Error cases get their own test:
```ts
it('rejects a negative amount', () => {
expect(() => splitVat(-1, 0.25)).toThrow(/must be non-negative/)
})
it('rejects a negative amount (async)', async () => {
await expect(splitVat(-1, 0.25)).rejects.toThrow(/must be non-negative/)
})
```
The `await` on the async form is load-bearing. Without it the assertion returns a
promise nobody checks, the test passes, and it will keep passing after you break the
code. This is the single most common false-green in a TypeScript suite.
## `tsc --noEmit` is part of RED
A red type-check is a red build:
```bash
npx vitest run src/vat.test.ts # the failing test
npx tsc --noEmit # same gate, same phase
```
Vitest transpiles without type-checking, so a suite can be green while the code does not
compile. `tsc --noEmit` is not optional here — it is the half of the gate the runner
does not perform.
## Equality matchers are not interchangeable
- `toBe` — `Object.is`. For primitives and identity.
- `toEqual` — structural, and **ignores `undefined` properties**: `{a: 1, b: undefined}`
equals `{a: 1}`. That is a real hole when the bug you are hunting is a missing field.
- `toStrictEqual` — structural, respects `undefined`, and checks class identity. Default
to this one; reach for `toEqual` deliberately.
## Mocks
`vi.mock` (Jest: `jest.mock`) is hoisted above imports, so a factory referencing an
outer variable throws at runtime. Use `vi.hoisted` when the mock needs shared state.
Per `SKILL.md`, mock only across a system boundary — a mocked module you own is a test
of your mock.
## Async and fake timers
- `vi.useFakeTimers()` needs a matching `vi.useRealTimers()` in teardown, or the next
file inherits frozen time and fails somewhere unrelated.
- Prefer condition polling (`await vi.waitFor(...)`) over a fixed `setTimeout` — an
arbitrary wait is a flake with a schedule.
+8
View File
@@ -82,3 +82,11 @@ created in a CAD session should trace to a TELOS GOAL. Every GOAL
traces to a PROBLEM. traces to a PROBLEM.
Traceability chain: PROBLEM → GOAL → SPEC → TICKET → IMPL → TEST 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 `tdd` skill for the full TDD workflow
- Load `code-review` skill for test quality review during pre-merge review - Load `code-review` skill for test quality review during pre-merge review
- See `clean-code/references/code-smells.md` for testing-specific smells - 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.
+170
View File
@@ -0,0 +1,170 @@
---
name: traceability
surfaces: [claude-code, library]
summary: "Forward trace — requirement to decision to commit to verification to release, and what cannot be checked"
use_when: "Proving why a change exists; auditing a claim; before an attestation or a compliance ask"
description: Establish the forward trace for a change — requirement, decision, commit, verification, CI, release, closure — as a chain of links that are each checked rather than asserted. Names the links that cannot be verified from the repository instead of letting them pass as verified. Use when auditing why a change exists, preparing an attestation, or when a claim needs to be provable rather than plausible.
---
# Traceability
## Overview
A trace is not a story about how a change came to be. It is a chain of links, each of
which is either **checked** or **named as unchecked**. The value is entirely in that
distinction: a trace where every link is asserted is a narrative, and narratives are what
audits exist to defeat.
The chain in this estate:
```
issue (requirement) → comment (decision) → commit → test/gate → CI run on that sha
→ release tag → closing comment (evidence)
```
Note where the decisions live: **in issue comments**, not issue bodies. A tool that reads
issue titles will trace the wrong thing.
## When to Use
- Auditing why a change exists, especially one nobody remembers making
- Preparing an attestation, a compliance answer, or a client-facing "how do you know"
- Before claiming a requirement is satisfied
- When a claim is repeated in several places, which is the condition under which nobody
checks it
**Do not use for:** a change you are about to make. That is `adr` (the decision) and
`spec-driven-dev` (the requirement). This skill reconstructs; it does not author.
## The tool
```sh
skills/traceability/trace.sh <sha> [repo-path]
```
Prints what git can prove — the commit, its files, the test files it touched, the release
that contains it, every issue it references as a URL — and then prints, every time, the
three things git **cannot** prove. That last section is the point of the tool.
## The sequence
**1. Run the tool.** It does the mechanical half in one command.
**2. Open the issues it printed, and read the comments.** The requirement is in the body;
the decision is almost always in a comment, and so is the closure record. A trace built
from commit messages alone will confidently cite an issue that says something else.
**3. Match CI on the head sha, not on the branch.** "The pipeline is green" is not
evidence about a commit. Find the run whose `head_sha` is this commit and read its
conclusion — and check which *job* and which *step* passed, because a run can be red for a
reason that has nothing to do with the gate you care about, and green overall while the
gate you care about was skipped.
The estate's `workflow_run_status` returns only the run-level conclusion — no jobs, no
steps. For that breakdown you have to drop to the API:
```sh
op run --env-file ~/.op-env -- sh -c 'curl -sS -H "Authorization: token $DMABE_GITEA_API_TOKEN" \
"https://git.d-ma.be/api/v1/repos/<owner>/<repo>/actions/runs/<id>/jobs"'
```
Said explicitly because a step whose tooling dead-ends is exactly where a trace becomes
"the run was green".
**4. Check that the tests actually cover the behaviour.** A commit that touched a test
file is not a commit that is verified. Read the assertions and ask what would have to
break for them to fail. If nothing would, the link is decorative.
**5. Probe the load-bearing claim — try to falsify it.** This is the step that separates a
trace from a summary, and it is the one that finds things.
Pick the claim the chain rests on, and attack it in a scratch copy:
```sh
git archive <sha> | (mkdir -p /tmp/probe && tar -x -C /tmp/probe)
# then break exactly the thing the claim protects, and run the gate
```
If the gate stays green, the claim is false — regardless of how many places assert it.
This is not hypothetical: the probe that shaped this skill falsified a "the list only
shrinks" claim that was stated in five places in the repository and enforced in none.
**6. Look for what cites the commit, not only what it cites.** A commit cannot reference
an issue filed after it, and that is precisely where an outcome gets falsified. Search the
tracker for the sha, across issues *and comments*, and `git log --all --grep=<sha>` for
later commits referring back. In the run that shaped this skill, the issue proving the
traced commit had *relocated* rather than fixed its defect was filed 90 minutes later and
was reachable only this way.
**7. Report unverifiable links as unverifiable.** Not as weak links, not as caveats at the
end. Each gets its own line saying what would be needed to close it.
## What a broken link looks like
| Symptom | What it means |
|---|---|
| The commit cites `#37`, and `#37` describes something else | The reference is decoration; someone renumbered or misremembered |
| A claim appears in five places and a check in none | Convention wearing a mechanism's clothes. Probe it |
| The gate greps for a heading | Presence is not compliance. A heading with nothing under it passes |
| `git describe` says unreleased | Usually stale local tags, not an unreleased commit. `git fetch --tags` first |
| The requirement is a bare `#5` with no URL | Recoverable only while the tracker lives. Not traceable from the repo alone |
| CI green, but on a different sha | The most common false link, and the easiest to accept by accident |
## Output
State the chain, then the gaps, in that order:
```
CHAIN
requirement #36 item 3 (defect report) → verified, read: body + comments
requirement #19 (standing execution bar) → verified, read
decision comment 8509 → verified; names the commit
commit b616c51 → verified
claim "nine skills, none named session_id" → verified against b616c51^: 9/9
claim "session_id is the only required field" → verified against the live tool schema
gate bin/check-session-log → RED on parent (17 errors), GREEN on it
wiring Taskfile check step 5 → verified; CI runs task check
verification tests/check_session_log_test.sh → verified, 4 cases, both directions
CI run 4880, job "Check / Test" → verified, head_sha matches, success
release v0.13.3 → verified against the REMOTE
GAPS
- The gate greps the file, not the payload. Probe: stripped session_id from
every payload, left the word in prose — gate returned 0. FALSIFIED.
- Defect relocated, not removed: #39, filed 90 minutes later by the same
author, says so. The commit's outcome claim does not hold.
```
Four slots that are not optional, each learned by leaving them out:
- **More than one requirement, and they differ in kind.** A defect report and a standing
policy bar are both requirements and collapsing them hides which one was not met.
- **The gate, separately from the test.** A commit that ships a check has three artifacts,
not two, and the chain usually rests on the one in the middle.
- **The RED/GREEN baseline.** Run the gate against the parent commit. Without it you have
shown a gate exists, not that it caught anything.
- **The commit's factual claims, checked.** "Nine skills, none named it" is falsifiable in
one command. Without a slot for it, "commit → verified" means only "this sha exists".
Gaps ranked by how much they matter, not by where you found them. A trace whose gap
section is empty should be suspicious of itself.
## Escalation
Read-only. Reports the chain; changes nothing, and closes no issue on the strength of
what it finds.
Stop and hand back when: a link's evidence is outside anything you can reach — a tracker
you cannot read, a CI system you have no access to, a decision made in conversation. Say
which link and what would close it. An inferred link presented as a traced one is worse
than a gap, because the gap is honest and the inference is citable.
Also stop before *acting* on a falsified claim. Finding that a gate does not enforce what
it says is a finding; deciding what to do about it is the owner's call.
## Cross-References
- `lineage` — the same discipline backwards: this asks "does the chain hold", that asks
"was it tried before and why did it stop".
- `adr` — the decision layer of the chain. A trace that reaches an ADR has found the
reasoning; a trace that reaches only a commit message has found a summary of it.
+107
View File
@@ -0,0 +1,107 @@
#!/bin/sh
# trace.sh — the forward trace for one commit, and an honest list of what it
# could not check.
#
# The chain this estate actually uses:
#
# issue (requirement) -> comment (decision) -> commit -> test/gate -> CI run
# -> release tag -> closing comment
#
# git can prove the middle of that and nothing else. The issue bodies, the
# comments where decisions actually live, and the CI run are all tracker state.
# So this prints what git knows and then names, with URLs, the links a human has
# to follow — because a link nobody checked must not read like a verified one.
#
# Usage: trace.sh <sha> [repo-path]
#
# Read-only.
set -u
[ $# -ge 1 ] || { echo "usage: trace.sh <sha> [repo-path]" >&2; exit 2; }
SHA=$1
REPO=${2:-.}
git -C "$REPO" rev-parse --git-dir >/dev/null 2>&1 || {
echo "trace: $REPO is not a git repository" >&2; exit 2; }
git -C "$REPO" rev-parse --verify --quiet "$SHA^{commit}" >/dev/null || {
echo "trace: $SHA is not a commit in $REPO" >&2; exit 2; }
TOP=$(git -C "$REPO" rev-parse --show-toplevel)
ORIGIN=$(git -C "$REPO" config --get remote.origin.url 2>/dev/null || true)
# Never print the URL as configured: url.insteadOf can expand it into a live
# token. Reduce it to owner/repo and use that.
SLUG=$(printf '%s' "$ORIGIN" | sed -E 's#\.git$##; s#^.*[/:]([^/]+/[^/]+)$#\1#' 2>/dev/null || true)
section() { printf '\n== %s ==\n' "$1"; }
section "Commit"
git -C "$REPO" log -1 --format='%h %ad %an%n%n%B' --date=short "$SHA"
section "Files"
git -C "$REPO" show --stat --format='' "$SHA"
section "Verification it touched"
tests=$(git -C "$REPO" show --name-only --format='' "$SHA" \
| grep -Ei '(^|/)(tests?|spec)/|_test\.|\.test\.|_spec\.' || true)
if [ -n "$tests" ]; then
printf '%s\n' "$tests"
else
printf 'none — this commit changed no test or spec file.\n'
printf 'That is not automatically wrong (docs, config), but if it changed behaviour\n'
printf 'it means the change has no verification of its own.\n'
fi
section "Released in"
tag=$(git -C "$REPO" describe --tags --contains "$SHA" 2>/dev/null | sed 's/[~^].*//' || true)
if [ -n "$tag" ]; then
printf '%s (LOCAL tags only — confirm with: git ls-remote --tags origin)\n' "$tag"
else
printf 'no release contains this commit in THIS checkout.\n'
printf 'Local tags go stale — run `git fetch --tags` and re-check before\n'
printf 'concluding it was never released.\n'
fi
section "Referenced issues"
refs=$(git -C "$REPO" log -1 --format='%B' "$SHA" | grep -oE '#[0-9]+' | sort -u || true)
if [ -n "$refs" ]; then
for r in $refs; do
n=${r#\#}
if [ -n "$SLUG" ]; then
printf '%s -> https://git.d-ma.be/%s/issues/%s\n' "$r" "$SLUG" "$n"
else
printf '%s\n' "$r"
fi
done
else
printf 'none — the commit cites no issue, so its requirement is unrecorded.\n'
fi
section "Issues that cite THIS commit"
printf 'Not searchable from the commit — an issue filed after it cannot appear in\n'
printf 'its message, and that is exactly where an outcome gets falsified. Check:\n\n'
printf ' git -C %s log --all --grep=%s # later commits referring back\n' "$REPO" "$SHA"
if [ -n "$SLUG" ]; then
printf ' tracker search for "%s" across issues AND comments\n' "$SHA"
fi
printf '\nIn the run that shaped this tool, the issue that falsified the traced\n'
printf 'commit was filed 90 minutes after it and was reachable only this way.\n'
section "Cannot verify from here"
cat <<'TXT'
git proves the commit, its files, and which tag contains it. It cannot prove:
1. That the issues above say what the commit claims they say. The DECISION is
usually in a comment, not the issue body — open them and read the comments.
2. That CI actually passed on this exact sha. A green pipeline elsewhere is not
evidence about this commit; match on head_sha.
3. That the tests listed above assert the behaviour this commit added. A test
file touched is not a behaviour covered.
4. That any gate this commit ships enforces what its message says it does.
Probe it: break exactly the thing the claim protects, in a scratch copy,
and see whether the gate goes red.
None of the three is a formality. Each has been the broken link in this estate:
a claim asserted in five places and enforced in none, a red run whose gate had
passed, and a gate that greps for a heading and calls it a contract.
TXT
+3 -7
View File
@@ -231,10 +231,10 @@ Pass-rate is computed downstream by the `/pass-rate` HTTP endpoint, which
treats `pass` as success, `fail` as failure, `skip` as neither. treats `pass` as success, `fail` as failure, `skip` as neither.
**At end of `read` phase:** **At end of `read` phase:**
- `session_log` with `{skill: "trainer", phase: "read", final_status: "pass" | "fail" | "skip", message: "<one-line summary>", duration_ms: <wall-clock>, project_root: "<absolute path>"}` - `session_log` with `{session_id: "<this session's id>", skill: "trainer", phase: "read", final_status: "pass" | "fail" | "skip", message: "<one-line summary>", project_root: "<absolute path>"}`
**At end of `write` phase:** **At end of `write` phase:**
- `session_log` with `{skill: "trainer", phase: "write", final_status: "pass" | "fail" | "skip", message: "<one-line summary>", duration_ms: <wall-clock>, project_root: "<absolute path>"}` - `session_log` with `{session_id: "<this session's id>", skill: "trainer", phase: "write", final_status: "pass" | "fail" | "skip", message: "<one-line summary>", project_root: "<absolute path>"}`
**Phases for this skill:** read, write **Phases for this skill:** read, write
@@ -243,11 +243,7 @@ treats `pass` as success, `fail` as failure, `skip` as neither.
- `fail` — the phase's intended outcome was NOT reached. - `fail` — the phase's intended outcome was NOT reached.
- `skip` — phase was skipped intentionally (e.g. no candidates passed the gate, write skipped). - `skip` — phase was skipped intentionally (e.g. no candidates passed the gate, write skipped).
**Why this matters:** the routing pod (Plan 6) reads pass-rate to decide whether to route a future call to a local model. If your skill never logs, the routing pod sees no data. **Why this matters:** pass-rate is what a future routing decision would be based on — whether a given call can be served by a local model instead of a cloud one. A skill that never logs contributes no evidence either way.
## Mode 2 Routing Note
Trainer is the same shape as `session-retrospective` — long-context reading, mechanical filtering — and is a Mode 2 routing candidate. Until Plan 6, treat as Mode 1 only.
## Cross-References ## Cross-References
+127
View File
@@ -0,0 +1,127 @@
#!/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"
# --- the ratchet must actually ratchet ---------------------------------------
# Five places in this repo claimed the pending list "only shrinks" and nothing
# enforced it. A traceability probe falsified that in one run: add an unsectioned
# skill, append it to the list, gate returns 0. A convention stated five times is
# still a convention.
#
# Enforcement compares against the list as committed at HEAD, so the fixtures
# below need a real git repo.
git_fixture() {
_f="$WORK/$1"
mkdir -p "$_f/skills" "$_f/bin"
( cd "$_f" && git init -q . && git config user.email t@example.com && git config user.name test )
skill "$_f" alpha no
printf 'alpha\n' > "$_f/bin/escalation-pending.txt"
( cd "$_f" && git add -A && git commit -qm baseline )
printf '%s' "$_f"
}
f=$(git_fixture ratchet_same)
expect 0 'an unchanged list passes' "$f"
f=$(git_fixture ratchet_shrink)
skill "$f" alpha yes
: > "$f/bin/escalation-pending.txt"
expect 0 'removing an entry (and writing its section) passes' "$f"
f=$(git_fixture ratchet_grow)
skill "$f" beta no
printf 'alpha\nbeta\n' > "$f/bin/escalation-pending.txt"
expect 1 'adding a NEW entry fails — the list only shrinks' "$f" 'beta'
# Outside a git repo the comparison is impossible; that must not be silent.
f=$(fixture ratchet_nogit); skill "$f" alpha no
printf 'alpha\n' > "$f/bin/escalation-pending.txt"
_out=$(sh "$SUT" "$f" 2>&1)
if printf '%s' "$_out" | grep -qi 'cannot verify'; then
pass=$((pass + 1)); printf ' ok an unverifiable ratchet says so rather than passing silently\n'
else
fail=$((fail + 1)); printf ' FAIL an unverifiable ratchet says so rather than passing silently\n%s\n' "$_out"
fi
printf '\ncheck_escalation: %d passed, %d failed\n' "$pass" "$fail"
[ "$fail" -eq 0 ]
+100
View File
@@ -0,0 +1,100 @@
#!/bin/sh
# Tests for bin/check-session-log.
#
# The regression: nine skills documented a session_log payload and not one of
# them named `session_id` — the tool's ONLY required field. Every documented
# call was incomplete, so an agent following any of them either failed the call
# or invented an id, and invented ids do not group. Found by a pressure test
# against `debug`, then found to be fleet-wide (#36).
#
# Also gated: `duration_ms: <wall-clock>`, which an agent cannot measure. A
# documented field nobody can honestly populate gets fabricated or silently
# dropped; both are worse than omitting it.
set -u
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
SUT="$ROOT/bin/check-session-log"
pass=0
fail=0
WORK=${TMPDIR:-/tmp}/check_session_log_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> <body> — a checkout with one skill whose SKILL.md is <body>.
fixture() {
_f="$WORK/$1"
mkdir -p "$_f/skills/alpha"
printf -- '---\nname: alpha\ndescription: a\n---\n\n%s\n' "$2" > "$_f/skills/alpha/SKILL.md"
printf '%s' "$_f"
}
# 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"
}
expect 0 'a skill that never mentions session_log is fine' \
"$(fixture quiet 'Just a skill. No logging here.')"
expect 0 'a documented call naming session_id passes' \
"$(fixture good 'Call `session_log` with `{session_id: "<id>", skill: "alpha", phase: "red", final_status: "pass"}`')"
expect 1 'a documented call omitting session_id fails' \
"$(fixture bad 'Call `session_log` with `{skill: "alpha", phase: "red", final_status: "pass"}`')" \
'session_id'
expect 1 'documenting duration_ms as wall-clock fails' \
"$(fixture clock 'Call `session_log` with `{session_id: "<id>", skill: "alpha", duration_ms: <wall-clock>}`')" \
'duration_ms'
# --- the gate must read the PAYLOAD, not the file ---------------------------
# A traceability probe falsified the original: it grepped the whole file for the
# string "session_id", so stripping the field from every documented payload and
# leaving the word once in prose passed cleanly. The exact regression the gate
# exists to prevent was back in the file and the gate said nothing.
expect 1 'a payload without session_id fails even when prose mentions the word' \
"$(fixture prose 'The tool has a required session_id field.
Call `session_log` with `{skill: "alpha", phase: "red", final_status: "pass"}`')" \
'session_id'
expect 1 'one bad payload among several fails' \
"$(fixture mixed 'Call `session_log` with `{session_id: "<id>", skill: "alpha", phase: "red"}`
Then `session_log` with `{skill: "alpha", phase: "green", final_status: "pass"}`')" \
'session_id'
# duration_ms was banned by matching exactly one spelling of it — the historical
# one. Both of these passed the original gate.
expect 1 'duration_ms with a JSON-quoted key fails' \
"$(fixture jsonkey 'Call `session_log` with `{session_id: "<id>", "duration_ms": <wall-clock>}`')" \
'duration_ms'
expect 1 'duration_ms described in prose rather than angle brackets fails' \
"$(fixture prosems 'Call `session_log` with `{session_id: "<id>", duration_ms: elapsed wall-clock ms}`')" \
'duration_ms'
# A real measurement is allowed: the objection is to a placeholder no agent can
# fill, not to the field.
expect 0 'a duration_ms carrying a real value is allowed' \
"$(fixture realms 'Call `session_log` with `{session_id: "<id>", duration_ms: 1430}`')"
printf '\ncheck_session_log: %d passed, %d failed\n' "$pass" "$fail"
[ "$fail" -eq 0 ]
@@ -0,0 +1,70 @@
#!/bin/sh
# `task check` must survive a check that reports "could not verify".
#
# The regression these exist for: bin/check-mcp-env exits 2 to mean "I could
# not run — no jq, or no env-file", which the gate deliberately treats as a
# reported NOTE rather than a failure. The Taskfile said exactly that:
#
# "$DIR/check-mcp-env" "$DIR/.mcp.json" "$HOME/.op-env"
# case $? in 0) ;; 2) echo "NOTE: ..." ;; *) fail=1 ;; esac
#
# and it did not work. Task runs each cmds block under `sh -e`, so a bare
# command exiting non-zero aborts the shell *before* `case $?` is ever
# reached. The whole gate died with `exit status 2`. Locally the check found
# jq and exited 0, so it went green; CI has neither jq nor ~/.op-env, so main
# went red on the very commit that added it (skills run #57, 2026-09-07).
#
# A tolerated exit code that is only tolerated on the host where it never
# fires is not tolerated at all. This pins the tolerance itself.
#
# No mocking: pointing HOME at an empty directory removes ~/.op-env, which is
# exactly the condition CI runs under, so check-mcp-env genuinely exits 2.
set -u
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
pass=0
fail=0
ok() { pass=$((pass + 1)); printf ' ok %s\n' "$1"; }
no() { fail=$((fail + 1)); printf ' FAIL %s\n' "$1"; }
if ! command -v task >/dev/null 2>&1; then
printf ' SKIP: task not installed -- cannot exercise the gate\n'
exit 0
fi
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
# Sanity: the precondition must actually hold, or the test proves nothing.
# An empty HOME means no ~/.op-env, so check-mcp-env cannot verify anything.
"$ROOT/bin/check-mcp-env" "$ROOT/.mcp.json" "$TMP/.op-env" >/dev/null 2>&1
rc=$?
if [ "$rc" -eq 2 ]; then
ok 'precondition: check-mcp-env exits 2 when the env-file is absent'
else
no "precondition: expected check-mcp-env to exit 2, got $rc"
fi
# The assertion. `task check` must still pass, and must say why it could not
# verify -- silence would mean the check was skipped rather than tolerated.
out=$(HOME="$TMP" task --dir "$ROOT" check 2>&1)
rc=$?
if [ "$rc" -eq 0 ]; then
ok 'task check passes when MCP env wiring cannot be verified'
else
no "task check exited $rc when check-mcp-env reported unverifiable"
printf '%s\n' "$out" | sed 's/^/ | /' | tail -8
fi
if printf '%s' "$out" | grep -q 'unverified'; then
ok 'the unverifiable check is reported, not silently skipped'
else
no 'no NOTE reported -- an unverifiable check must never look like a pass'
fi
printf ' %d passed, %d failed\n' "$pass" "$fail"
[ "$fail" -eq 0 ]
+149
View File
@@ -24,6 +24,18 @@ if [ ! -f "$DOCTOR" ]; then
exit 1 exit 1
fi fi
# The toolchain check (golangci-lint build-Go vs GOROOT) reads the real host
# unless we say otherwise, which made every unrelated fixture fail on a machine
# whose golangci-lint is genuinely stale. Give the whole suite a matching fake
# pair so only the cases that OPT IN exercise that check.
STUB_BIN="$WORK/_stub_bin"
mkdir -p "$STUB_BIN"
printf '#!/bin/sh\nprintf "golangci-lint has version 2.0.0 built with go1.27.0 from abc\\n"\n' > "$STUB_BIN/golangci-lint"
printf '#!/bin/sh\ncase "$1" in version) printf "go version go1.27.0 linux/amd64\\n";; *) printf "\\n";; esac\n' > "$STUB_BIN/go"
chmod +x "$STUB_BIN/golangci-lint" "$STUB_BIN/go"
PATH="$STUB_BIN:$PATH"
export PATH
cleanup() { rm -rf "$WORK"; } cleanup() { rm -rf "$WORK"; }
trap cleanup EXIT INT TERM trap cleanup EXIT INT TERM
@@ -224,5 +236,142 @@ manifest "$f/ck" 0.7.0
manifest "$f/home/.claude/plugins/marketplaces/mathias" 0.9.0 manifest "$f/home/.claude/plugins/marketplaces/mathias" 0.9.0
assert_fails "$f" '0.9.0' 'a newer marketplace clone is reported' 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
# --- toolchain: golangci-lint must be built with Go >= GOROOT ---------------
# Measured on koala 2026-09-08 (infra#375, second recurrence): golangci-lint
# 2.11.4 built with go1.26.1 against a go1.27.0 GOROOT PANICS on any Go repo --
#
# panic: file requires newer Go version go1.27 (application built with go1.26)
#
# and the earlier form of the same fault printed a typecheck error naming YOUR
# file, so the natural response is to debug code that is fine. It cost time in
# two repos. CI never sees it (the runner image matches), so it breaks only the
# local `task check` that AGENT.md requires before every push.
#
# The fixtures inject fake `go` and `golangci-lint` on PATH; nothing here reads
# the real host toolchain, so the suite still passes on a machine without Go.
mk_toolchain() { # $1=fixture $2=lint-build-go $3=goroot-go ("" = absent)
_b="$1/bin"; mkdir -p "$_b"
if [ -n "$2" ]; then
printf '#!/bin/sh\nprintf "golangci-lint has version 2.11.4 built with %s from abc on 2026-01-01\\n"\n' "$2" > "$_b/golangci-lint"
chmod +x "$_b/golangci-lint"
fi
if [ -n "$3" ]; then
printf '#!/bin/sh\ncase "$1" in version) printf "go version %s linux/amd64\\n";; *) printf "\\n";; esac\n' "$3" > "$_b/go"
chmod +x "$_b/go"
fi
printf '%s' "$_b"
}
f=$(new_fixture golangci_old); wire_clean "$f"
b=$(mk_toolchain "$f" go1.26.1 go1.27.0)
_out=$(PATH="$b:$PATH" HOME="$f/home" DOCTOR_CHECKOUT="$f/ck" "$DOCTOR" 2>&1); _rc=$?
if [ "$_rc" -ne 0 ] && printf '%s' "$_out" | grep -qi 'golangci'; then
pass=$((pass + 1)); printf ' ok golangci-lint older than GOROOT is reported\n'
else
fail=$((fail + 1)); printf ' FAIL golangci-lint older than GOROOT is reported\n rc=%s got: %s\n' "$_rc" "$_out"
fi
# Matching versions are the normal case and must stay silent.
f=$(new_fixture golangci_ok); wire_clean "$f"
b=$(mk_toolchain "$f" go1.27.0 go1.27.0)
_out=$(PATH="$b:$PATH" HOME="$f/home" DOCTOR_CHECKOUT="$f/ck" "$DOCTOR" 2>&1); _rc=$?
if [ "$_rc" -eq 0 ]; then
pass=$((pass + 1)); printf ' ok golangci-lint matching GOROOT is silent\n'
else
fail=$((fail + 1)); printf ' FAIL golangci-lint matching GOROOT is silent\n rc=%s got: %s\n' "$_rc" "$_out"
fi
# A NEWER lint than GOROOT is fine -- the constraint is one-directional.
f=$(new_fixture golangci_newer); wire_clean "$f"
b=$(mk_toolchain "$f" go1.28.0 go1.27.0)
_out=$(PATH="$b:$PATH" HOME="$f/home" DOCTOR_CHECKOUT="$f/ck" "$DOCTOR" 2>&1); _rc=$?
if [ "$_rc" -eq 0 ]; then
pass=$((pass + 1)); printf ' ok golangci-lint newer than GOROOT is not a fault\n'
else
fail=$((fail + 1)); printf ' FAIL golangci-lint newer than GOROOT is not a fault\n rc=%s got: %s\n' "$_rc" "$_out"
fi
# Fail OPEN on anything unparseable. doctor runs in a SessionStart hook on every
# machine; a toolchain it cannot read is not a fault it may assert. (A host with
# no Go at all takes the same path -- command -v simply misses.)
f=$(new_fixture golangci_garbled); wire_clean "$f"
_b="$f/bin"; mkdir -p "$_b"
printf '#!/bin/sh\nprintf "golangci-lint dev build\\n"\n' > "$_b/golangci-lint"
printf '#!/bin/sh\nprintf "go version devel\\n"\n' > "$_b/go"
chmod +x "$_b/golangci-lint" "$_b/go"
_out=$(PATH="$_b:$PATH" HOME="$f/home" DOCTOR_CHECKOUT="$f/ck" "$DOCTOR" 2>&1); _rc=$?
if [ "$_rc" -eq 0 ]; then
pass=$((pass + 1)); printf ' ok an unreadable toolchain version is not asserted as a fault\n'
else
fail=$((fail + 1)); printf ' FAIL an unreadable toolchain version is not asserted as a fault\n rc=%s got: %s\n' "$_rc" "$_out"
fi
printf '\ndoctor: %d passed, %d failed\n' "$pass" "$fail" printf '\ndoctor: %d passed, %d failed\n' "$pass" "$fail"
[ "$fail" -eq 0 ] [ "$fail" -eq 0 ]
+91 -1
View File
@@ -17,6 +17,7 @@ SUT="$ROOT/install.sh"
pass=0 pass=0
fail=0 fail=0
skipped=0
WORK=${TMPDIR:-/tmp}/install_test.$$ WORK=${TMPDIR:-/tmp}/install_test.$$
if [ ! -f "$SUT" ]; then if [ ! -f "$SUT" ]; then
@@ -55,6 +56,7 @@ run_install() {
AGENTS_SKILLS_DIR="$_f/home/.agents/skills" \ AGENTS_SKILLS_DIR="$_f/home/.agents/skills" \
CRUSH_SKILLS_DIR="$_f/home/.config/crush/skills" \ CRUSH_SKILLS_DIR="$_f/home/.config/crush/skills" \
OPENCODE_SKILLS_DIR="$_f/home/.config/opencode/skills" \ OPENCODE_SKILLS_DIR="$_f/home/.config/opencode/skills" \
ANTIGRAVITY_SKILLS_JSON="$_f/home/.gemini/config/skills.json" \
SKILLS_EXTRA_SOURCES="$_extra" \ SKILLS_EXTRA_SOURCES="$_extra" \
bash "$SUT" 2>&1) bash "$SUT" 2>&1)
rc=$? rc=$?
@@ -156,6 +158,40 @@ else
no 'configured sources are recorded for doctor' "$out" no 'configured sources are recorded for doctor' "$out"
fi fi
# --- Antigravity: jq is optional, so BOTH branches must be tested -----------
# These three cases inherited whatever jq the host happened to have. koala has
# it, the CI runner does not, so they passed locally and failed in CI — the
# classic works-on-my-machine gate. install.sh treats jq as optional by design,
# which means the skip is a contract and needs a test of its own.
# run_install_nojq <fixture> — same as run_install, with the jq lookup pointed
# at a binary that does not exist. Deterministic regardless of the host.
run_install_nojq() {
_f=$1
_out=$(HOME="$_f/home" \
SKILLS_SKIP_FETCH=1 \
SKILLS_CHECKOUT_DIR="$_f/ck" \
AGENTS_SKILLS_DIR="$_f/home/.agents/skills" \
CRUSH_SKILLS_DIR="$_f/home/.config/crush/skills" \
OPENCODE_SKILLS_DIR="$_f/home/.config/opencode/skills" \
SKILLS_JQ="definitely-not-jq-$$" \
bash "$SUT" 2>&1)
rc=$?
printf '%s' "$_out"
}
f=$(new_fixture antigravity_nojq)
out=$(run_install_nojq "$f")
if [ -e "$f/home/.gemini/config/skills.json" ]; then
no 'without jq, no skills.json is written' "$out"
elif ! printf '%s' "$out" | grep -qi 'jq not found'; then
no 'without jq, the skip is reported' "$out"
elif [ ! -L "$f/home/.agents/skills/alpha" ]; then
no 'without jq, the other harnesses are still wired' "$out"
else
ok 'without jq, Antigravity is skipped and the rest still wires'
fi
# --- 8. surfaces: the library link set is not "every skill" ----------------- # --- 8. surfaces: the library link set is not "every skill" -----------------
# claude.ai has no shell, no cluster and no checkout, so a chat-only skill # claude.ai has no shell, no cluster and no checkout, so a chat-only skill
# linked into Crush is a skill that can never fire, competing for selection # linked into Crush is a skill that can never fire, competing for selection
@@ -183,5 +219,59 @@ else
no 'a source with no surfaces convention is linked as-is' "$out" no 'a source with no surfaces convention is linked as-is' "$out"
fi fi
printf '\ninstall: %d passed, %d failed\n' "$pass" "$fail" # --- 9-11. Antigravity's merge behaviour, only where jq exists ---------------
# These three assert what jq DOES. install.sh treats jq as optional, so on a
# host without it there is nothing to assert — and failing there is what turned
# main red: they passed on koala (jq present) and failed on the runner (absent).
# A skipped case is honest; 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.
if ! command -v jq >/dev/null 2>&1; then
skipped=$((skipped + 3))
printf ' SKIP jq absent — Antigravity merge behaviour not covered on this host (3 cases)\n'
else
# skills.json entries must point at $AGENTS_DIR — that carries the
# surfaces-filtering, collision handling and multi-source merge. Pointing at
# the checkout directly would give Antigravity a different skill set than
# every other harness.
f=$(new_fixture antigravity)
out=$(run_install "$f")
AG="$f/home/.gemini/config/skills.json"
if [ ! -f "$AG" ]; then
no 'Antigravity skills.json is created' "$out"
else
registered=$(jq -r --arg p "$f/home/.agents/skills" '[.entries[]? | select(.path == $p)] | length' "$AG")
if [ "$registered" = "1" ]; then
ok 'Antigravity registers the managed agents dir, not the raw checkout'
else
no 'Antigravity registers the managed agents dir, not the raw checkout' "$(cat "$AG")"
fi
fi
f=$(new_fixture antigravity_idempotent)
run_install "$f" >/dev/null
out=$(run_install "$f")
AG="$f/home/.gemini/config/skills.json"
count=$(jq '.entries | length' "$AG" 2>/dev/null || echo "?")
if [ "$count" = "1" ]; then
ok 'Antigravity wiring is idempotent (no duplicate entries)'
else
no 'Antigravity wiring is idempotent (no duplicate entries)' "entries=$count"$'\n'"$out"
fi
# A user may hand-edit skills.json to point at their own personal skills dir
# (json_configs.md documents this). Our merge must not clobber it.
f=$(new_fixture antigravity_preserve)
mkdir -p "$f/home/.gemini/config"
printf '{"entries":[{"path":"/Users/mathias/personal-skills"}]}' > "$f/home/.gemini/config/skills.json"
out=$(run_install "$f")
AG="$f/home/.gemini/config/skills.json"
personal=$(jq -r '[.entries[]? | select(.path == "/Users/mathias/personal-skills")] | length' "$AG" 2>/dev/null || echo 0)
if [ "$personal" = "1" ]; then
ok 'Antigravity wiring preserves pre-existing hand-authored entries'
else
no 'Antigravity wiring preserves pre-existing hand-authored entries' "$(cat "$AG")"$'\n'"$out"
fi
fi
printf '\ninstall: %d passed, %d failed, %d skipped\n' "$pass" "$fail" "$skipped"
[ "$fail" -eq 0 ] [ "$fail" -eq 0 ]
+90
View File
@@ -0,0 +1,90 @@
#!/bin/sh
# Tests for skills/lineage/lineage.sh — the decision-archaeology helper.
#
# The techniques this automates are the ones people skip because they are
# fiddly to type: `git log -S` to find when a thing was introduced AND removed,
# --diff-filter=D to find files that once existed, and grepping decision records
# for the term. A skill that only *describes* those is a skill that gets read and
# not run (#11, #19's "small robust tools over prose").
set -u
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
SUT="$ROOT/skills/lineage/lineage.sh"
pass=0
fail=0
WORK=${TMPDIR:-/tmp}/lineage_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
# A repo where a thing was built, written about, and then removed.
mkdir -p "$WORK/repo/docs/adr"
cd "$WORK/repo" || exit 1
git init -q .
git config user.email t@example.com
git config user.name test
printf 'func WidgetCache() {}\n' > cache.go
git add -A && git commit -qm "feat: add WidgetCache for lookup speed"
printf -- '---\nadr: 0001\ntitle: use a WidgetCache\n---\nWe chose WidgetCache over a database index.\n' > docs/adr/0001-widgetcache.md
git add -A && git commit -qm "docs: record the WidgetCache decision"
git rm -q cache.go
git commit -qm "revert: drop WidgetCache, it went stale under concurrent writes"
cd - >/dev/null || exit 1
out=$(sh "$SUT" WidgetCache "$WORK/repo" 2>&1)
check() {
if printf '%s' "$out" | grep -q "$1"; then
pass=$((pass + 1)); printf ' ok %s\n' "$2"
else
fail=$((fail + 1)); printf ' FAIL %s\n output had no %s\n%s\n' "$2" "$1" "$out"
fi
}
check 'add WidgetCache' 'finds the commit that introduced it'
check 'drop WidgetCache' 'finds the commit that removed it — the revival question'
check '0001-widgetcache.md' 'finds the decision record naming it'
check 'cache.go' 'names the file that no longer exists'
# A no-match must NOT be reported as evidence the idea is new. The first version
# of this tool said "it means this is new here" and a pressure test caught it
# asserting exactly that about the most-discussed removed feature in this repo —
# the search term simply had a space in it. That is the inference the skill's own
# anti-pattern table forbids, printed in the tool's voice.
out=$(sh "$SUT" NeverExisted "$WORK/repo" 2>&1)
if printf '%s' "$out" | grep -q 'NOT evidence the idea is new' \
&& printf '%s' "$out" | grep -qi 'identifier'; then
pass=$((pass + 1)); printf ' ok a no-match refuses to imply novelty and suggests other terms\n'
else
fail=$((fail + 1)); printf ' FAIL a no-match refuses to imply novelty and suggests other terms\n%s\n' "$out"
fi
# The repo actually searched is always stated — a confident null against the
# wrong tree is this tool's worst failure mode, and the default is the cwd.
out=$(sh "$SUT" WidgetCache "$WORK/repo" 2>&1)
if printf '%s' "$out" | grep -q "searching: "; then
pass=$((pass + 1)); printf ' ok names the repo it searched\n'
else
fail=$((fail + 1)); printf ' FAIL names the repo it searched\n%s\n' "$out"
fi
# The sources it cannot reach are named every run, so their absence never reads
# as a null result.
if printf '%s' "$out" | grep -q 'brain_query' && printf '%s' "$out" | grep -qi 'closed issues'; then
pass=$((pass + 1)); printf ' ok names the sources it cannot search\n'
else
fail=$((fail + 1)); printf ' FAIL names the sources it cannot search\n%s\n' "$out"
fi
printf '\nlineage: %d passed, %d failed\n' "$pass" "$fail"
[ "$fail" -eq 0 ]
+118
View File
@@ -0,0 +1,118 @@
#!/bin/sh
# Tests for bin/check-mcp-env.
#
# The fault this gate exists for is silent, which is why it needs a gate at
# all. An `.mcp.json` server whose Authorization header interpolates a
# variable nothing in the session environment defines does not error: the
# header goes out empty, the server answers 401, and Claude Code reports
# "requires authentication". That is indistinguishable from a revoked or
# expired credential, and on 2026-09-07 it cost a full investigation on koala
# to establish that the credential was fine and the variable was simply never
# set.
#
# So the property is: every ${VAR} a LIVE server interpolates must be a
# variable the session env-file actually defines.
#
# The `_parked` block is deliberately exempt. Parked servers reference
# variables nothing provides on purpose -- that is what parked means -- and a
# gate that flagged them would be reporting the intended state as a fault.
#
# Fixtures drive the checker through argv, so no test touches the real host.
set -u
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
CHECK="$ROOT/bin/check-mcp-env"
pass=0
fail=0
WORK=${TMPDIR:-/tmp}/mcp_config_test.$$
if [ ! -f "$CHECK" ]; then
printf 'FAIL: %s does not exist\n' "$CHECK"
exit 1
fi
cleanup() { rm -rf "$WORK"; }
trap cleanup EXIT INT TERM
mkdir -p "$WORK"
if ! command -v jq >/dev/null 2>&1; then
printf 'SKIP: jq not installed -- bin/check-mcp-env cannot be exercised\n'
exit 0
fi
# ok <name> / bad <name> <detail>
ok() { pass=$((pass+1)); printf ' ok %s\n' "$1"; }
bad() { fail=$((fail+1)); printf ' FAIL %s %s\n' "$1" "${2:-}"; }
# run <mcp-json> <env-file> -> sets $rc and $out
run() {
out=$("$CHECK" "$1" "$2" 2>&1); rc=$?
}
ENVFILE="$WORK/op-env"
cat > "$ENVFILE" <<'ENVEOF'
BRAIN_MCP_TOKEN=op://HomeLab/brain/token
DMABE_GITEA_API_TOKEN=op://HomeLab/gitea/token
ENVEOF
# --- a live server whose variable IS defined ---------------------------------
cat > "$WORK/good.json" <<'JSONEOF'
{"mcpServers":{"brain":{"type":"http","url":"https://x/mcp",
"headers":{"Authorization":"Bearer ${BRAIN_MCP_TOKEN}"}}}}
JSONEOF
run "$WORK/good.json" "$ENVFILE"
[ "$rc" -eq 0 ] && ok "defined variable passes" || bad "defined variable passes" "rc=$rc $out"
# --- a live server whose variable is NOT defined ------------------------------
# The exact shape that broke: the server looks correct and fails at runtime.
cat > "$WORK/missing.json" <<'JSONEOF'
{"mcpServers":{"gitea":{"type":"http","url":"https://x/mcp",
"headers":{"Authorization":"Bearer ${GITEA_MCP_STATIC_TOKEN}"}}}}
JSONEOF
run "$WORK/missing.json" "$ENVFILE"
if [ "$rc" -ne 0 ]; then
case "$out" in
*gitea*GITEA_MCP_STATIC_TOKEN*) ok "undefined variable is flagged, naming server and variable" ;;
*) bad "undefined variable is flagged, naming server and variable" "report did not name both: $out" ;;
esac
else
bad "undefined variable is flagged, naming server and variable" "rc=0"
fi
# --- a server with no headers at all (the `infra` shape) ----------------------
# Unauthenticated by design. Nothing to resolve, so nothing to flag.
cat > "$WORK/noauth.json" <<'JSONEOF'
{"mcpServers":{"infra":{"type":"http","url":"https://x/mcp"}}}
JSONEOF
run "$WORK/noauth.json" "$ENVFILE"
[ "$rc" -eq 0 ] && ok "header-less server is not flagged" || bad "header-less server is not flagged" "rc=$rc $out"
# --- parked servers are exempt ------------------------------------------------
cat > "$WORK/parked.json" <<'JSONEOF'
{"mcpServers":{"brain":{"type":"http","url":"https://x/mcp",
"headers":{"Authorization":"Bearer ${BRAIN_MCP_TOKEN}"}}},
"_parked":{"_reason":"no identity provides these yet",
"cad":{"type":"http","url":"https://x/mcp",
"headers":{"Authorization":"Bearer ${CAD_MCP_TOKEN}"}}}}
JSONEOF
run "$WORK/parked.json" "$ENVFILE"
if [ "$rc" -eq 0 ]; then
ok "parked server is exempt"
else
bad "parked server is exempt" "rc=$rc $out"
fi
# --- malformed JSON must not pass silently ------------------------------------
# A config the checker cannot read is an unverified config, not a clean one.
printf '{"mcpServers":{' > "$WORK/broken.json"
run "$WORK/broken.json" "$ENVFILE"
[ "$rc" -ne 0 ] && ok "unparseable config does not report success" || bad "unparseable config does not report success" "rc=0"
# --- a missing env-file is unverifiable, not clean ----------------------------
run "$WORK/good.json" "$WORK/no-such-env"
[ "$rc" -ne 0 ] && ok "missing env-file does not report success" || bad "missing env-file does not report success" "rc=0"
printf '\n%s: %d passed, %d failed\n' "$(basename "$0")" "$pass" "$fail"
[ "$fail" -eq 0 ]
+103
View File
@@ -0,0 +1,103 @@
#!/bin/sh
# Tests for hooks/pre-task-ritual.sh — the UserPromptSubmit hook that surfaces
# rule 0 on every prompt (#44).
#
# WHY THIS EXISTS, AND WHAT IT DELIBERATELY DOES NOT CLAIM
# This hook is three lines and echoes a string. It has no bash-isms, so it
# cannot fail the way secret-guard appeared to; that risk was overstated when
# this test was requested. What it CAN do is stop working silently, and its
# whole job is to be visible -- if the text stops appearing, rule 0 stops being
# surfaced on every prompt and nothing announces that.
#
# THE ASSERTION THAT MATTERS IS REGISTRATION.
# A hook file that exists but is not in hooks.json does absolutely nothing, and
# looks identical on disk to one that works. So this derives the command from
# hooks.json rather than assuming one: the registration is the source of truth
# for how the hook is invoked, and a test that invokes it its own way is
# testing something the harness never runs.
#
# That distinction is not hypothetical. 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`, and the suite failed 20/20 in CI against a hook that
# was fine in production. The test was wrong, not the hook. Deriving the
# invocation removes the opportunity for that class of mistake entirely.
set -u
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
HOOK="$ROOT/hooks/pre-task-ritual.sh"
REG="$ROOT/hooks/hooks.json"
pass=0
fail=0
ok() { pass=$((pass + 1)); printf ' ok %s\n' "$1"; }
no() { fail=$((fail + 1)); printf ' FAIL %s\n' "$1"; }
if ! command -v jq >/dev/null 2>&1; then
printf ' SKIP: jq not installed -- cannot read the hook registration\n'
exit 0
fi
[ -f "$HOOK" ] || { printf ' FAIL %s does not exist\n' "$HOOK"; exit 1; }
ok 'hook file exists'
[ -f "$REG" ] || { printf ' FAIL %s does not exist\n' "$REG"; exit 1; }
# 1. Registered, and on the right event. UserPromptSubmit is what makes it fire
# on every prompt; registered under any other event it would still "exist"
# and still do nothing useful.
CMD=$(jq -r '
.hooks.UserPromptSubmit[]?.hooks[]?
| select(.command | test("pre-task-ritual"))
| .command
' "$REG" 2>/dev/null | head -1)
if [ -n "$CMD" ]; then
ok 'registered on UserPromptSubmit in hooks.json'
else
no 'not registered on UserPromptSubmit -- the file would exist and never run'
printf ' %d passed, %d failed\n' "$pass" "$fail"
exit 1
fi
# 2. Run it EXACTLY as registered, with the harness variable substituted the way
# the harness substitutes it.
OUT=$(CLAUDE_PLUGIN_ROOT="$ROOT" sh -c "$CMD" 2>&1)
rc=$?
[ "$rc" -eq 0 ] && ok 'exits 0 as registered' \
|| no "exited $rc -- a non-zero UserPromptSubmit hook can block the prompt"
# 3. The content is the point. A hook that runs and says nothing is the same as
# no hook, and that is the failure this pins.
if [ -n "$OUT" ]; then
ok 'produces output'
else
no 'produced no output -- rule 0 would silently stop being surfaced'
fi
case "$OUT" in
*"Rule 0"*) ok 'names rule 0' ;;
*) no "does not name rule 0: '$OUT'" ;;
esac
# Each of the four steps, individually. Naming rule 0 while having quietly lost
# a step is the plausible drift, not losing the whole line.
for step in brain_query skill 'failing test' 'success criterion'; do
case "$OUT" in
*"$step"*) ok "states the '$step' step" ;;
*) no "lost the '$step' step from the ritual" ;;
esac
done
# 4. The interpreter named in the registration must actually exist, or the hook
# fails on that host with a message nobody reads.
INTERP=${CMD%% *}
if command -v "$INTERP" >/dev/null 2>&1; then
ok "registered interpreter '$INTERP' is available"
else
no "registered interpreter '$INTERP' not found on this host"
fi
printf ' %d passed, %d failed\n' "$pass" "$fail"
[ "$fail" -eq 0 ]
+15 -1
View File
@@ -24,7 +24,7 @@ decision_for() {
_tool=${2:-Bash} _tool=${2:-Bash}
jq -n --arg t "$_tool" --arg c "$_cmd" \ jq -n --arg t "$_tool" --arg c "$_cmd" \
'{tool_name: $t, tool_input: {command: $c}}' \ '{tool_name: $t, tool_input: {command: $c}}' \
| sh "$GUARD" \ | "$GUARD" \
| jq -r '.hookSpecificOutput.permissionDecision // "MISSING"' | jq -r '.hookSpecificOutput.permissionDecision // "MISSING"'
} }
@@ -42,6 +42,20 @@ expect() {
fi fi
} }
# Invoked as a PROGRAM above, never as `sh "$GUARD"`. Claude Code executes the
# hook directly, so its `#!/usr/bin/env bash` shebang decides the interpreter;
# piping it into `sh` overrides that and tests something production never runs.
#
# It is not academic. 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 and
# every deny case returned empty -- 20 of 20 assertions failed in CI while
# passing on a host where /bin/sh happens to be bash. A security gate that
# tests green and cannot start is the worst of both.
if [ ! -x "$GUARD" ]; then
printf 'FAIL: %s is not executable -- it must run via its own shebang\n' "$GUARD"
exit 1
fi
if [ ! -f "$GUARD" ]; then if [ ! -f "$GUARD" ]; then
printf 'FAIL: %s does not exist\n' "$GUARD" printf 'FAIL: %s does not exist\n' "$GUARD"
exit 1 exit 1
+81
View File
@@ -0,0 +1,81 @@
#!/bin/sh
# Tests for skills/traceability/trace.sh.
#
# The RED run for #10 established what a forward trace costs by hand: sixteen
# commands, of which the decisive ones were reading issue comments and running a
# probe against a claim. This tool does the mechanical half — what git can prove
# — and, more importantly, states what it CANNOT prove, so an unverifiable link
# never reads as a verified one.
set -u
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
SUT="$ROOT/skills/traceability/trace.sh"
pass=0
fail=0
WORK=${TMPDIR:-/tmp}/traceability_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
mkdir -p "$WORK/repo/tests"
cd "$WORK/repo" || exit 1
git init -q .
git config user.email t@example.com
git config user.name test
# A remote is needed for the tool to turn "#37" into a checkable URL, which is
# the point of that section.
git remote add origin https://git.d-ma.be/mathias/demo.git
printf 'baseline\n' > README.md
git add -A && git commit -qm "chore: baseline"
printf 'gate\n' > gate.sh
printf 'case\n' > tests/gate_test.sh
git add -A && git commit -qm "feat(gate): add the thing
Implements the decision recorded in #5, filed as #37.
Closes #37"
SHA=$(git rev-parse --short HEAD)
git tag -a v1.2.0 -m "release v1.2.0"
printf 'more\n' >> gate.sh
git add -A && git commit -qm "chore: later work"
cd - >/dev/null || exit 1
out=$(sh "$SUT" "$SHA" "$WORK/repo" 2>&1)
check() {
if printf '%s' "$out" | grep -q "$1"; then
pass=$((pass + 1)); printf ' ok %s\n' "$2"
else
fail=$((fail + 1)); printf ' FAIL %s\n output had no %s\n%s\n' "$2" "$1" "$out"
fi
}
check '#5' 'lists every issue the commit references, not just the closing one'
check '#37' 'lists the closing reference'
check 'tests/gate_test.sh' 'names the test files the commit touched'
check 'v1.2.0' 'names the release that contains it'
check 'Cannot verify' 'states what it could not verify'
check 'issues/37' 'emits a checkable URL for the tracker link it cannot follow'
# A commit in no release must say so — "no tag" and "not checked" are different
# answers and only one of them is about the commit.
last=$(git -C "$WORK/repo" rev-parse --short HEAD)
out=$(sh "$SUT" "$last" "$WORK/repo" 2>&1)
if printf '%s' "$out" | grep -qi 'no release contains'; then
pass=$((pass + 1)); printf ' ok an unreleased commit is reported as unreleased\n'
else
fail=$((fail + 1)); printf ' FAIL an unreleased commit is reported as unreleased\n%s\n' "$out"
fi
printf '\ntraceability: %d passed, %d failed\n' "$pass" "$fail"
[ "$fail" -eq 0 ]