Compare commits

..
13 Commits
Author SHA1 Message Date
mathias 3112b4ce92 chore(ci): gate the release tagger on check + test
CI / Check / Test (push) Successful in 0s
CI / Tag release (push) Successful in 0s
The repo had a `task check` structure gate and a 28-case shell test suite
that nothing ever ran. The only workflow auto-tagged every push to main, so
CI reported green having tested nothing — a gate that fails open, and the
reason `mathias/skills` shows up as repository-blocked in the estate backlog
classification.

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

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

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

Refs: #22
Bump-Type: minor
2026-08-29 09:46:10 +02:00
mathias 51a8c25b4d chore(release): plugin.json version 0.7.0
Skip-Release: true
2026-08-28 11:09:50 +00:00
mathiasandClaude Sonnet 5 d40169f2b7 feat(skill): cad-babysit — human-in-the-loop session over cad-stuck (skills#31)
release / tag (push) Successful in 1s
Wraps mathias/cad#14's `cad stuck` query surface: five-phase diagnostic
order (re-derive the worklist, read issue comments FIRST, then Actions job
logs, separate infrastructure fault from task outcome, decide-or-say
DECISION-NEEDED), read-only by Iron Law — no dispatch, no label writes, no
issue close. Every diagnosis cites a locator; every operator-only call
surfaces as a structured DECISION NEEDED block instead of being decided for
them.

Built after cad#14 passed its live-estate acceptance test (7 real findings,
locators verified) — the skill is the ritual around a query already proven
to produce actionable rows, not a checklist with no input.

Bump-Type: minor

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 13:09:31 +02:00
mathias d4f0416dc5 chore(release): plugin.json version 0.6.3
Skip-Release: true
2026-08-26 05:54:12 +00:00
mathiasandClaude Opus 5 abd81f939a fix(doctor): report a real harness directory as unmanaged, not as "outside"
release / tag (push) Successful in 1s
Found on flamingo during the first Mac install. ~/.config/crush/skills held
real directories from a pre-plugin install, and doctor said:

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

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

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

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

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

Bump-Type: patch

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BeAp5LTscnz2W7Ubt4eJ6m
2026-08-26 07:53:14 +02:00
mathias e42d2ba9cb chore(release): plugin.json version 0.6.2
Skip-Release: true
2026-08-25 08:54:05 +00:00
mathiasandClaude Opus 5 68cebc0bbb fix(gitea-ci): hand out the call, not a copy of the build implementation (infra#309)
release / tag (push) Successful in 1s
This skill is the third factory. Two repo templates were fixed when the
buildah->buildctl migration went through, and this one kept handing out
the broken step for weeks afterwards -- because it is what gets consulted
when adding CI to an EXISTING repo, which the templates never touch.

Pasting an implementation is the actual defect. The estate now has one
(mathias/infra ci-templates/build-image.sh) with byte-identical copies
held in place by a drift gate, so the skill teaches the four adoption
steps and the input list instead of a block to copy.

Step 1 of those four -- registering in adopters.txt -- is called out
explicitly, because a copy nobody registered works today and falls
silently behind on the next canonical change. That is the failure this
whole issue exists to end.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011PL5XcG68SvQqz3vGVbWbf
2026-08-25 10:54:02 +02:00
mathias f50dd6be6a chore(release): plugin.json version 0.6.1
Skip-Release: true
2026-08-25 05:04:23 +00:00
mathiasandClaude Opus 5 1906a774b3 fix(gitea-ci): teach buildctl, not buildah (infra#241)
release / tag (push) Successful in 1s
This skill was the source of the buildah build step that infra#241 has
spent a sprint removing from nine repos: rootless buildah cannot run in
the containerized gitea-runner (drop ALL capabilities,
allowPrivilegeEscalation false, no OCI runtime), and the failure is
latent -- warm base-image layers hide it until the first cold pull.

Two templates were migrated for exactly this reason (stop new repos
inheriting the trap); this skill is the third factory and was still
handing out the broken pattern.

Also drops the `sudo k3s ctr` smoke test it recommended: written for the
Arch host runner, impossible in a pod (`sudo: command not found`). The
deploy job's rollout verification covers the same ground.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011PL5XcG68SvQqz3vGVbWbf
2026-08-25 07:00:50 +02:00
mathias 38f905e705 chore(release): plugin.json version 0.6.0
Skip-Release: true
2026-08-24 19:16:00 +00:00
mathiasandClaude Opus 5 7db7e0779b feat(mcp): serve brain and infra over Tailscale Serve with real TLS
release / tag (push) Successful in 1s
Replaces the raw NodePort URLs with Tailscale Serve endpoints on koala:

  brain  https://koala.tail30472e.ts.net:8443/mcp  -> 127.0.0.1:30330
  infra  https://koala.tail30472e.ts.net:8444/mcp  -> 127.0.0.1:30342

Why this beats both alternatives that were tried:

The public d-ma.be path exits to Cloudflare and NPM to reach a k3s service on
the machine the session is running on, and Cloudflare rate-limits and
size-caps tool results — the original complaint. The raw NodePort path fixed
that but only worked from koala: every namespace carries default-deny-ingress
and an allow policy naming the traefik namespace, so kube-proxy's
KUBE-EXTERNAL-SERVICES chain RSTs external callers. Admitting tailnet clients
there would need externalTrafficPolicy: Local on each service plus an ipBlock
per policy, because the default Cluster policy SNATs the source and an ipBlock
can never match.

Serve sidesteps all of it. tailscaled terminates TLS with a genuine cert and
proxies over loopback, so the request reaches the NodePort from koala itself —
the case the NetworkPolicy already permits. No policy change, no
externalTrafficPolicy change, no firewall rule, no cert-manager, no DNS record.

Port-per-service rather than path-per-service: the mapping is 1:1 with no
prefix rewriting, so what the client requests and what the server sees cannot
desync.

Verified: 200 authenticated in 7.6ms from koala, and `claude mcp list` reports
Connected against the Serve URL. An earlier apparent OAuth resource-metadata
mismatch turned out to be an artifact of testing without an Authorization
header — with a valid bearer the client never enters OAuth discovery.

Client requirement: `tailscale set --accept-dns=true`. Confirmed on iguana that
100.100.100.100 resolves the MagicDNS name correctly while the system resolver
does not, so the name fails to resolve until Tailscale is accepted as a DNS
provider. Tracked in #24 as the Mac prerequisite. Note Serve matches on SNI, so
substituting the raw tailnet IP is not a workaround.

Bump-Type: minor

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BeAp5LTscnz2W7Ubt4eJ6m
2026-08-24 21:11:49 +02:00
mathias 0be3506799 chore(release): plugin.json version 0.5.0
Skip-Release: true
2026-08-24 16:01:14 +00:00
mathiasandClaude Opus 5 6151b973bc perf(mcp): reach brain and infra over tailnet NodePorts, not the public edge
release / tag (push) Successful in 1s
Every MCP call from Claude Code was going out through claude.ai's connector
infrastructure, across the public internet, through Cloudflare and NPM on
piguard, back into koala's k3s ingress — to reach a service on the same
machine the session is running on. For brain that means a two-cloud round trip
to read /var/lib/supervisor/brain, a file on koala's local disk.

Cloudflare rate-limits and size-caps tool results on that path, which is the
actual user-facing symptom. Measured from koala:

  brain  koala:30330    200 in  6.8ms
  infra  koala:30342    200 in  3.6ms
  brain  public edge    200 in 27.7ms

infra starts working again as a side effect: its only fault was an SNI/cert
gap at the public edge (TLS "unrecognized name"), which the NodePort path does
not traverse. No NPM change needed for local use.

Note this is not a relocation of existing config. The mcpServers block that
lived in ~/.claude/settings.json was inert — Claude Code reads server
definitions from ~/.claude.json, project .mcp.json, plugins and connectors,
not from settings.json. Verified: a uniquely-named server placed there is
invisible to both `claude mcp list` and `claude mcp get`. So before the plugin
existed, koala had no direct path at all and every call went via Cloudflare by
construction. That also explains how a stale GITEA_MCP_TOKEN went unnoticed
for five days: nothing consumed it.

Safe to land now because koala is the only host with the plugin installed
(#24). The koala firewall must allow 30330/30342 on the Tailscale interface —
NOT on the LAN broadly, so tailnet membership stays the perimeter — before the
Macs get the plugin. Tracked as a prerequisite in #24. Measured today: those
ports are refused from iguana over both LAN and tailnet while :22 answers, so
the rule does not exist yet.

Bump-Type: minor

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BeAp5LTscnz2W7Ubt4eJ6m
2026-08-24 18:00:17 +02:00
9 changed files with 438 additions and 51 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.4.2", "version": "0.7.0",
"author": { "author": {
"name": "Mathias", "name": "Mathias",
"url": "https://git.d-ma.be/mathias" "url": "https://git.d-ma.be/mathias"
@@ -1,23 +1,52 @@
name: release name: CI
# Auto-tag every push to main with a semver bump derived from the latest # Two jobs, in order:
# commit footer:
# Bump-Type: major → vX.0.0
# Bump-Type: minor → v0.Y.0
# (default) → v0.0.Z (patch)
# #
# Skips when the latest commit is itself the auto-tagger (commit message # gate — `task check` (structure, frontmatter, manifest/tag parity) plus
# contains "[skip-release]") to prevent recursion. Tag is pushed back # `task test` (hooks, doctor, release-skip). Runs on every push and
# to the repo using the GITEA_TOKEN provided by act_runner. # every PR. Before this existed the suite ran nowhere: CI reported
# green having tested nothing (#22).
#
# tag — auto-tags a push to main with a semver bump derived from the latest
# commit footer:
# Bump-Type: major → vX.0.0
# Bump-Type: minor → v0.Y.0
# (default) → v0.0.Z (patch)
# Skips when the commit carries the `Skip-Release: true` trailer (the
# tagger's own manifest bump) to prevent recursion. Gated on `gate`,
# so a red check cannot ship a tag.
on: "on":
push: push:
branches: branches: ["**"]
- main pull_request:
branches: [main]
jobs: jobs:
tag: gate:
name: Check / Test
runs-on: self-hosted runs-on: self-hosted
steps:
- uses: actions/checkout@v4
with:
# `task check` compares plugin.json's version to `git describe`;
# a shallow checkout has no tags and would skip that assertion.
fetch-depth: 0
- name: Verify toolchain
run: task --version
- name: Structure gate
run: task check
- name: Shell test suite
run: task test
tag:
name: Tag release
needs: gate
runs-on: self-hosted
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps: steps:
- name: checkout - name: checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -72,16 +101,19 @@ jobs:
manifest=.claude-plugin/plugin.json manifest=.claude-plugin/plugin.json
version="${TAG#v}" version="${TAG#v}"
current=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$manifest" | head -1) current=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$manifest" | head -1)
git tag -a "$TAG" -m "release $TAG"
if [ "$current" != "$version" ]; then if [ "$current" != "$version" ]; then
sed -i "s/\"version\"[[:space:]]*:[[:space:]]*\"$current\"/\"version\": \"$version\"/" "$manifest" sed -i "s/\"version\"[[:space:]]*:[[:space:]]*\"$current\"/\"version\": \"$version\"/" "$manifest"
git add "$manifest" git add "$manifest"
# The trailer below stops this push from re-triggering the tagger. # The trailer below stops this push from re-triggering the tagger.
git commit -m "chore(release): plugin.json version $version" -m "Skip-Release: true" git commit -m "chore(release): plugin.json version $version" -m "Skip-Release: true"
git push origin HEAD:main # Commit and tag go up in ONE push: the gate job runs `task check`
# on the bump commit, and that asserts manifest == latest tag. Two
# separate pushes leave a window where it legitimately fails.
git push origin HEAD:main "$TAG"
echo "bumped $manifest to $version" echo "bumped $manifest to $version"
else
git push origin "$TAG"
fi fi
git tag -a "$TAG" -m "release $TAG"
# gitea-actions checkout uses an x-access-token URL; reuse it.
git push origin "$TAG"
echo "pushed $TAG" echo "pushed $TAG"
+4 -3
View File
@@ -1,15 +1,16 @@
{ {
"_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.",
"mcpServers": { "mcpServers": {
"brain": { "brain": {
"type": "http", "type": "http",
"url": "https://brain-mcp.d-ma.be/mcp", "url": "https://koala.tail30472e.ts.net:8443/mcp",
"headers": { "headers": {
"Authorization": "Bearer ${BRAIN_MCP_TOKEN}" "Authorization": "Bearer ${BRAIN_MCP_TOKEN}"
} }
}, },
"infra": { "infra": {
"type": "http", "type": "http",
"url": "https://infra-mcp.d-ma.be/mcp" "url": "https://koala.tail30472e.ts.net:8444/mcp"
} }
} }
} }
+1
View File
@@ -30,6 +30,7 @@ This index lists all available engineering skills. Load the full SKILL.md on dem
| `discovery-framing` | Problem-before-solution discovery/framing method | Framing/validating a problem before any solution; opportunity sizing | | `discovery-framing` | Problem-before-solution discovery/framing method | Framing/validating a problem before any solution; opportunity sizing |
| `stage-gate-review` | Stage-gate product governance (go/no-go council) | Preparing a Product Council ask; building a go/no-go checklist; gate review | | `stage-gate-review` | Stage-gate product governance (go/no-go council) | Preparing a Product Council ask; building a go/no-go checklist; gate review |
| `web-shot` | Screenshot a running web UI via a koala k3s Playwright job | Visual verification of a running UI when no local browser exists | | `web-shot` | Screenshot a running web UI via a koala k3s Playwright job | Visual verification of a running UI when no local browser exists |
| `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 |
| `superpowers-brainstorming` | Structured divergent brainstorming | Generating or expanding a set of ideas | | `superpowers-brainstorming` | Structured divergent brainstorming | Generating or expanding a set of ideas |
| `superpowers-writing-plans` | Writing implementation plans | Planning substantial work before coding | | `superpowers-writing-plans` | Writing implementation plans | Planning substantial work before coding |
| `superpowers-executing-plans` | Executing a written plan step by step | Running an approved plan to completion | | `superpowers-executing-plans` | Executing a written plan step by step | Running an approved plan to completion |
+9
View File
@@ -109,6 +109,15 @@ do
continue continue
fi fi
# A real directory where a link belongs holds content nothing manages: it
# cannot receive new skills and silently shadows the checkout. Distinguished
# from the foreign-checkout case below because the fix differs — move it
# aside and re-run install.sh, rather than re-point a link.
if [ ! -L "$dir" ] && [ -d "$dir" ]; then
add FAIL unmanaged "$dir is a real directory, not a link into the checkout — it holds $(count_skills "$dir") unmanaged skill(s); move it aside and re-run install.sh"
continue
fi
# Resolve to a physical path and confirm it lands inside THIS checkout. # Resolve to a physical path and confirm it lands inside THIS checkout.
# A link that resolves to a different checkout is the dangerous case: it # A link that resolves to a different checkout is the dangerous case: it
# works, so nothing complains, while serving stale content. # works, so nothing complains, while serving stale content.
+263
View File
@@ -0,0 +1,263 @@
---
name: cad-babysit
description: Focused human-in-the-loop session working CAD's stuck queue (cad-stuck output). Read-only — diagnoses, never dispatches, arms a label, or closes an issue. Use when picking up whatever CAD has run into trouble on, with a human present.
---
# CAD Babysit
## Overview
A deliberately narrow session: pick up whatever CAD has run into trouble on, work it with a
human present, and stop. This is not an autonomous supervisor — it is a babysitter with a
person in the room, and that is the point, not a limitation to route around later.
**Core principle:** an unattended supervisor reproduces the exact failure it exists to catch.
`dispatch` ran dead for eight weeks because it exited 0 and nobody noticed (`dispatch#33`); a
long-running watcher process that dies quietly has the same property one level up, and
manufactures false confidence while doing it. With a human present, **the human is the
liveness check** — that removes the objection entirely and needs no new machinery.
This skill is the ritual around `cad stuck` (`mathias/cad#14`), not a replacement for it.
Without that query it would be a checklist with no input; the query without this skill is
data nobody reads carefully. Neither closes the loop alone.
## When to Use
- Picking up CAD's stuck queue with a human present and time set aside to work it
- After `dispatch` (or any executor) reports a failure and it is not immediately obvious
whether it is an infrastructure fault or a real task outcome
- Reviewing the "awaiting human" queue (issues CI has verified but nobody has closed)
**Do not use for:**
- Unattended/scheduled runs. This skill assumes a human is present for its entire duration —
if nobody is watching, its central safety property (human-as-liveness-check) does not hold.
- Dispatching, arming a redispatch label, or closing an issue. This skill only ever diagnoses;
see Iron Laws.
## Iron Laws
1. **Read-only, always.** No dispatching, no arming labels (including `status:redispatch`), no
closing or reopening an issue. A reviewer that can act on its own findings has no
independent check — control stays where it already is.
2. **Every finding cites a locator.** A run id, commit sha, comment permalink, or a specific
log line. A finding without one is an unfalsifiable opinion about a machine — do not emit
it, and do not soften this into "probably" language instead of dropping it.
3. **Re-derive, never recall.** State lives in Gitea and Actions, not in this session. Start
cold every time: run `cad stuck` fresh, do not carry forward a prior session's worklist.
Sessions get compacted and lose exactly the cross-run history that makes a stale worklist
dangerous to trust.
4. **Distinguish "blocked" from "awaiting human".** `cad stuck` already makes this split
(`stuck` vs `awaiting_human`); preserve it in every summary. The `awaiting_human` class is
the human gate working, not a bottleneck — do not report it as one.
5. **Anything that is the operator's call surfaces as `DECISION NEEDED`, never gets decided
for them.** See Output Format.
## Process
### Phase 1 — Query (re-derive the worklist)
Run `cad stuck` against the estate (the `dispatch-repos.txt` scope, or whatever repo set the
operator names). This is the only input. Do not substitute a memory of a prior run, a brain
note, or this issue's own worked examples — all three can be stale within the same day (a
taxonomy row's cited live example can stop reproducing between when it was written and when
you run the query, because the underlying state genuinely changed; that is the design working,
not a bug to paper over).
Group the output by class exactly as `cad stuck` returns it: `stuck` first, `awaiting_human`
second. Pick ONE row to work per pass — this skill is deliberately narrow, not a full-estate
sweep.
### Phase 2 — Read the issue's own comments FIRST
Before anything else, before touching Actions or pod logs: read every comment on the issue,
oldest first. This step exists because it was skipped once and it cost a session — a confident
wrong root cause was produced from pod logs while the correct diagnosis sat in the issue's own
comment thread from nine days earlier (`cad-atlas#10`). The rule "read issue comments before
working" was already written down elsewhere and was not followed; it is written down again
here because that is exactly the kind of already-known thing a rushed session skips.
What to look for: the bot's own report comments (routing decision, ground-truth verdict,
attempt count), and any human comment that already contains the diagnosis.
### Phase 3 — THEN the Actions job logs
Only after Phase 2 turns up nothing conclusive, read the CI job logs:
```
GET /repos/{owner}/{repo}/actions/runs # find the run, match on .run_number
GET /repos/{owner}/{repo}/actions/jobs/{job_id}/logs # returns 200, full text, secrets masked
```
This endpoint works. A prior note claiming it 404s was wrong and cost a diagnosis the day this
skill was written — do not rediscover that. Get the run id from `/actions/runs`, not from the
`run_number` shown in a comment (they are different fields; match on `.run_number` to find the
internal `id` the jobs endpoint needs).
This is where the two most valuable findings of the day this skill is drawn from lived: an
agent issuing the identical refused command five times and stopping (never trying the tools it
was actually permitted to use), and a different model, same denial, writing a Go program to
route around it. Neither is visible from the issue comments alone — no metric produces either
of them. They need a reader of the raw transcript.
### Phase 4 — Separate infrastructure fault from task outcome
A run that died before turn 1 says nothing about the issue itself. Before concluding anything
about the WORK, check whether the run reached the model at all:
- Zero turns, a 2-3 record transcript, a "could not start" comment, or a claim label with no
attempt comment behind it → infrastructure fault. This is not evidence the issue is hard, ill
specified, or that the assigned model is unsuitable — it is evidence something upstream
(credit, a key, MCP connectivity, the executor binary) is broken.
- A run that reached the model, drove tools, and stopped on a ceiling, a denial loop, or a
genuine task failure → a real task outcome, worth diagnosing on its own terms.
Four claim labels were burned this exact way in one estate sweep and the tracker read them as
four handled issues (`dispatch#33`). Get this distinction wrong and everything downstream is
about the wrong thing.
### Phase 5 — Decide, or say `DECISION NEEDED`
For each row worked, produce one of:
- **A closed diagnosis** — cause identified, cited to a locator, no operator judgment call
required (e.g. "infrastructure fault, credit exhausted, see comment #N — retry is automatic
on the next cycle, no action needed").
- **A `DECISION NEEDED`** — anything that is genuinely the operator's call: whether to close a
verified-but-open issue, whether to raise a ceiling, whether to abandon an issue that keeps
hitting the same denial. Never make this call on the operator's behalf.
This skill's OWN last act, every time, is to stop — not to dispatch, not to redispatch, not to
close. Ending a pass is itself the enforcement of Iron Law 1.
## Output Format
For a closed diagnosis:
```
DIAGNOSIS: <repo>#<issue> — <category from cad stuck, e.g. ceiling_hit>
CAUSE: <one sentence, cites the mechanism>
LOCATOR: <url or run id / log line>
ACTION: <what happens automatically, or "none — informational">
```
For anything requiring a human call:
```
DECISION NEEDED: <repo>#<issue>
QUESTION: <the exact thing only the operator can decide>
OPTIONS:
1. <option> — <consequence>
2. <option> — <consequence>
RECOMMENDATION: <your pick, with reasoning — but the operator decides>
BLOCKS: <what stays stuck until this is answered, if anything>
```
End every pass with a one-line summary: `N diagnosed, M decisions needed, stopping.`
## Worked Example
`cad stuck` returns `mathias/cad-atlas#10 [near_attempt_cap] 4 dispatch attempts recorded, at
or above the near-cap threshold (4 of MaxAttempts=5).`
Phase 2 (issue comments): the issue's own thread already explains the pattern — repeated
`status:redispatch` re-arming without the label being cleared after a completed attempt.
Phase 3: not needed — Phase 2 was conclusive.
Phase 4: each of the 4 attempts reached the model and ran (not an infrastructure fault) — a
real re-fire pattern, not a claim-label artifact.
Phase 5 output:
```
DECISION NEEDED: mathias/cad-atlas#10
QUESTION: one more automatic attempt remains before MaxAttempts=5 gives up permanently. Let
it fire, or intervene now (fix the underlying cause, or manually close/redirect the issue)?
OPTIONS:
1. Let it fire — costs one more headless run; if it fails the same way, dispatch gives up
automatically and posts a terminal comment. No action needed until then.
2. Intervene now — diagnose why redispatch keeps re-arming (likely: the watcher isn't
clearing RedispatchLabel after a serviced run) and fix that before the cap is spent on
a symptom rather than the cause.
RECOMMENDATION: option 2. The pattern (redispatch not self-clearing after a completed run) is
already documented as a known operational hazard (dispatch#54) — spending the last attempt
on it again teaches nothing new.
BLOCKS: nothing else; this issue's own progress.
```
`1 diagnosed, 1 decision needed, stopping.`
## Anti-Patterns
| Anti-Pattern | Why It Fails |
|---|---|
| Diagnosing from pod/Actions logs before reading issue comments | The exact mistake that cost a session — the answer was already written down nine days earlier. |
| Reporting `awaiting_human` rows as a backlog or bottleneck | It is the human gate working. Misreporting it manufactures urgency that isn't there and trains the operator to distrust the queue. |
| Treating a zero-turn, could-not-start run as evidence about the issue | It is an infrastructure fault. Four claim labels were burned exactly this way and read as four handled issues. |
| Deciding a `DECISION NEEDED` case on the operator's behalf "to save time" | Removes the one property that makes this skill safe to run unattended-adjacent: the human decides, this skill only prepares the decision. |
| Applying `status:redispatch`, closing an issue, or adding any label from this skill | Control stays where it is. This skill has no write path and should never be given one. |
| Carrying forward a worklist from an earlier session instead of re-running `cad stuck` | State changes between sessions (see the worked note on `brain-gardener#190` in `cad#14`'s own acceptance run — its trigger label was gone hours later). A stale worklist gives false confidence, which is the specific failure mode this whole skill exists to avoid. |
## Honest Limitation
This skill encodes the *repeatable* diagnostic sequence: read comments, then logs, separate
fault from outcome, verify by exit code, re-derive rather than recall. It reliably catches the
taxonomy `cad stuck` already knows about. It will not, by itself, produce a genuinely novel
finding the way an unhurried human-plus-agent reading session did the day this skill was
written — that came from noticing things (an agent editing the file that authorizes it, an
issue body recommending a command the policy forbids) that no checklist anticipates. Expect
this skill to be reliable on known failure modes and to miss the next new one. That is an
acceptable trade as long as nobody mistakes a clean pass through this skill for "nothing is
wrong" — it means "nothing on the known list is wrong."
## Brain MCP Integration
**At session start:**
- Run `brain_query` for the domain (`cad`, `dispatch`, the specific repo) to check whether a
finding this session is about to produce has already been diagnosed and written up. Do not
re-diagnose a known pattern from scratch every session.
**After a genuinely novel finding (not already in the taxonomy or the brain):**
- `brain_write` the pattern, generalized beyond the specific issue — the failure mode, not the
issue number. Do not write routine, already-known-taxonomy findings; that is what `cad
stuck`'s own output already records, durably, in Gitea.
**Never:**
- Write a `DECISION NEEDED` outcome to the brain before the operator has actually decided —
that would record a guess as a resolution.
### Logging
Call `session_log` once at the end of every phase to record the outcome.
**At end of each phase:**
- `session_log` with `{skill: "cad-babysit", phase: "<phase-name>", final_status: "pass" |
"fail" | "skip", message: "<one-line summary>", duration_ms: <wall-clock>, project_root:
"<absolute path>"}`
**Phases for this skill:** query, read-comments, read-actions-logs, classify-fault-vs-outcome,
decide
**Status semantics:**
- `pass` — the phase's intended outcome was reached (worklist re-derived; comments read;
fault/outcome separated; a diagnosis or `DECISION NEEDED` was produced).
- `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).
## 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
- Depends on `mathias/cad#14`'s `cad stuck` query — this skill is the ritual around it, not a
replacement.
- Load `debug` for the hypothesis-generation discipline if Phase 4's fault/outcome split is
itself ambiguous.
- Load `secret-hygiene` before reading raw Actions job logs if the repo is client-tagged —
logs are secret-masked by Gitea but a pasted excerpt into a summary is not.
- `mathias/cad-atlas#11` renders the same `cad stuck` output as a pipeline overlay — read that
instead of this skill's text output when a visual "where is everything stuck" view is wanted
over working one row in depth.
+40 -29
View File
@@ -1,6 +1,6 @@
--- ---
name: gitea-ci name: gitea-ci
description: End-to-end Gitea CI/CD setup with self-hosted act_runner, quality gate pipeline, buildah image build, k3s deploy, and GitHub mirror. Covers act_runner-specific gotchas that are not documented anywhere. description: End-to-end Gitea CI/CD setup with self-hosted act_runner, quality gate pipeline, shared buildctl image-build script, k3s deploy, and GitHub mirror. Covers act_runner-specific gotchas that are not documented anywhere.
--- ---
# Gitea CI/CD Pipeline Setup # Gitea CI/CD Pipeline Setup
@@ -31,7 +31,7 @@ Before writing any YAML, collect the following. Use AskUserQuestion for anything
| Container registry | Ask or check existing config | `localhost:5000` on koala, or external | | Container registry | Ask or check existing config | `localhost:5000` on koala, or external |
| k3s namespace | k8s/ manifests or ask | Deploy target | | k3s namespace | k8s/ manifests or ask | Deploy target |
| GitHub mirror repo | Ask | Target for the mirror job | | GitHub mirror repo | Ask | Target for the mirror job |
| Image name | Ask or derive from repo name | Used in buildah + k3s steps | | Image name | Ask or derive from repo name | Used in buildctl + k3s steps |
Check the project's `CLAUDE.md` for infra notes first — often this answers most questions without needing to ask. Check the project's `CLAUDE.md` for infra notes first — often this answers most questions without needing to ask.
@@ -133,35 +133,46 @@ jobs:
echo "version-tag=${REF#refs/tags/}" >> "$GITHUB_OUTPUT" echo "version-tag=${REF#refs/tags/}" >> "$GITHUB_OUTPUT"
fi fi
# infra#309: do NOT paste a build implementation here. The estate has
# exactly one, mathias/infra's ci-templates/build-image.sh, and each
# repo carries a byte-identical copy at .gitea/build-image.sh that a
# drift gate keeps current.
#
# Copying the implementation is what made the buildah->buildctl
# migration (infra#241) an eleven-repo sprint, and what left this very
# skill handing out the broken version for weeks after the app repos
# were fixed. Per-repo differences are INPUTS, not edits to the copy.
#
# To adopt in a new repo:
# 1. add the repo to mathias/infra ci-templates/adopters.txt
# 2. run `task ci:sync` in mathias/infra -- it writes the copy
# 3. commit .gitea/build-image.sh in your repo
# 4. call it as below
#
# Skipping step 1 leaves the copy unmanaged: it works today and falls
# silently behind the next time the canonical file changes.
#
# Inputs: IMAGE (required, usually the workflow-level env), SHA_TAG,
# VERSION_TAG, GIT_SHA, IMAGE_SOURCE, BUILD_ARGS, EXTRA_LABELS,
# CONTEXT, DOCKERFILE_DIR, DOCKERFILE_NAME, PUSH_LATEST. Read the
# canonical file's header for the full list.
#
# `bash <script>`, not a direct exec: a file written through the Gitea
# API lands 0644, and CI that depends on a file mode is a trap.
- name: Build and push to local registry - name: Build and push to local registry
run: | env:
REGISTRY="localhost:5000" SHA_TAG: ${{ steps.meta.outputs.sha-tag }}
REF="${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.sha-tag }}" VERSION_TAG: ${{ steps.meta.outputs.version-tag }}
buildah build \ GIT_SHA: ${{ github.sha }}
--label "org.opencontainers.image.revision=${{ github.sha }}" \ IMAGE_SOURCE: ${{ github.repositoryUrl }}
--label "org.opencontainers.image.source=${{ github.repositoryUrl }}" \ run: bash .gitea/build-image.sh
-t ${REF} \
-t ${REGISTRY}/${{ env.IMAGE }}:latest \
.
buildah push --tls-verify=false ${REF}
buildah push --tls-verify=false ${REGISTRY}/${{ env.IMAGE }}:latest
[[ -n "${{ steps.meta.outputs.version-tag }}" ]] && \
buildah push --tls-verify=false \
${REF} \
${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.version-tag }} || true
echo "✓ Image pushed to ${REF}"
- name: Smoke test # No smoke test step. The one this skill used to recommend ran
run: | # `sudo k3s ctr run` against the host's containerd -- that worked when
REGISTRY="localhost:5000" # the runner was the Arch host, and is impossible now that it is a pod:
REF="${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.sha-tag }}" # no sudo, no ctr, no containerd socket (`sudo: command not found`).
CNAME="smoke-${{ steps.meta.outputs.sha-tag }}" # The deploy job's `Verify rollout` + `Confirm pod running new image`
sudo k3s ctr images pull --plain-http ${REF} # below assert the same property against the real workload.
OUTPUT=$(timeout 5 sudo k3s ctr run --rm ${REF} ${CNAME} /<binary-name> 2>&1 || true)
sudo k3s ctr containers delete ${CNAME} 2>/dev/null || true
echo "$OUTPUT" | grep -q "<project-name>" \
&& echo "✓ Smoke test passed" \
|| echo "⚠ Smoke test inconclusive: $OUTPUT"
# ── 3. Deploy to k3s ──────────────────────────────────────────────────────── # ── 3. Deploy to k3s ────────────────────────────────────────────────────────
deploy: deploy:
+57
View File
@@ -0,0 +1,57 @@
#!/bin/sh
# Tests for the CI gate workflow.
#
# The regression these exist for: this repo had a release workflow that
# auto-tagged every push to main, and a `task check` / `task test` suite that
# nothing ever ran. CI reported green having tested nothing — a gate that
# fails *open*. These assertions pin the wiring so it cannot silently regress.
set -u
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
SUT="$ROOT/.gitea/workflows/ci.yml"
pass=0
fail=0
ok() { pass=$((pass + 1)); printf ' ok %s\n' "$1"; }
no() { fail=$((fail + 1)); printf ' FAIL %s\n' "$1"; }
if [ ! -f "$SUT" ]; then
printf 'FAIL: %s does not exist\n' "$SUT"
exit 1
fi
ok 'ci.yml exists'
# A pull_request trigger is what gives tbd_ship a CI run to read on the head
# commit of a branch; without one it fails closed and never merges (#22).
grep -qE '^ pull_request:' "$SUT" \
&& ok 'runs on pull_request' \
|| no 'runs on pull_request'
grep -qE '^ +push:' "$SUT" \
&& ok 'runs on push' \
|| no 'runs on push'
grep -qE '^ +- *(run: *)?task check$|task check' "$SUT" \
&& ok 'gate runs task check' \
|| no 'gate runs task check'
grep -qE 'task test' "$SUT" \
&& ok 'gate runs task test' \
|| no 'gate runs task test'
# The point of the gate: tagging must not happen on a red check.
tag_job=$(awk '/^ tag:/{f=1} f&&/^ [a-z][a-z_-]*:/&&!/^ tag:/{f=0} f' "$SUT")
printf '%s' "$tag_job" | grep -qE '^ +needs: *gate' \
&& ok 'tag job needs the gate' \
|| no 'tag job needs the gate'
# The old workflow file must be gone, or two workflows race on the same push
# and the ungated one still tags.
[ ! -f "$ROOT/.gitea/workflows/release.yml" ] \
&& ok 'ungated release.yml removed' \
|| no 'ungated release.yml removed'
printf '\nci_gate: %d passed, %d failed\n' "$pass" "$fail"
[ "$fail" -eq 0 ]
+13
View File
@@ -112,6 +112,19 @@ mkdir -p "$f/home/.claude/skills"
ln -sfn "$f/ck/skills/alpha" "$f/home/.claude/skills/alpha" ln -sfn "$f/ck/skills/alpha" "$f/home/.claude/skills/alpha"
assert_fails "$f" 'legacy' 'leftover per-skill Claude symlinks are reported' assert_fails "$f" 'legacy' 'leftover per-skill Claude symlinks are reported'
# --- 7b. harness dir is a REAL directory, not a link into the checkout -------
# Found on flamingo 2026-08-26: ~/.config/crush/skills held real directories
# from a pre-plugin install. The old message read "resolves to
# <path>, outside the active checkout" — naming the directory as its own
# resolution target, which is true but useless. It must say the dir is not a
# link at all, because the fix (move it aside, re-run install.sh) is different
# from the fix for a link into a foreign checkout.
f=$(new_fixture realdir)
mkdir -p "$f/home/.agents" "$f/home/.config/crush/skills/alpha"
ln -sfn "$f/ck/skills" "$f/home/.agents/skills"
printf -- '---\nname: alpha\ndescription: a\n---\n' > "$f/home/.config/crush/skills/alpha/SKILL.md"
assert_fails "$f" 'not a link' 'real directory is reported as unmanaged, not as "outside"'
# --- 7. dangling link inside a harness dir (not the canonical link itself) -- # --- 7. dangling link inside a harness dir (not the canonical link itself) --
# Exercises the per-entry scan rather than the canonical-link check: this is # Exercises the per-entry scan rather than the canonical-link check: this is
# the shape the 243 dead links actually had. # the shape the 243 dead links actually had.