Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
025f01aa80 | ||
|
|
eee62e4fc0 | ||
|
|
a2afb35d1d | ||
|
|
ad17a7c6c0 | ||
|
|
21dbb35526 | ||
|
|
8ad2079f86 | ||
|
|
b188965161 | ||
|
|
ae66840a03 | ||
|
|
e7e514390b | ||
|
|
087685f2e3 | ||
|
|
83b069afba | ||
|
|
a699ad8cbd | ||
|
|
a42ec18f40 | ||
|
|
46ff1acdfe | ||
|
|
3112b4ce92 | ||
|
|
51a8c25b4d | ||
|
|
d40169f2b7 | ||
|
|
d4f0416dc5 | ||
|
|
abd81f939a | ||
|
|
e42d2ba9cb | ||
|
|
68cebc0bbb | ||
|
|
f50dd6be6a | ||
|
|
1906a774b3 | ||
|
|
38f905e705 | ||
|
|
7db7e0779b | ||
|
|
0be3506799 | ||
|
|
6151b973bc | ||
|
|
6c1b6ce9db | ||
|
|
5c8341c797 | ||
|
|
5ca44c9c6b | ||
|
|
e2a312d05e | ||
|
|
78b9b59eca | ||
|
|
88c54399fe | ||
|
|
747c6c7f4c | ||
|
|
56a947b2a0 | ||
|
|
21be3ea245 | ||
|
|
85e125703e |
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "mathias",
|
||||||
|
"description": "Mathias's private plugin marketplace, hosted on gitea.d-ma.be. Self-hosted by design — nothing here transits GitHub.",
|
||||||
|
"owner": {
|
||||||
|
"name": "Mathias",
|
||||||
|
"url": "https://git.d-ma.be/mathias"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "skills",
|
||||||
|
"source": ".",
|
||||||
|
"description": "Engineering skills, the pre-task-ritual and secret-guard hooks, and the brain/gitea/infra MCP servers as one versioned unit."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "skills",
|
||||||
|
"description": "Mathias's engineering skills library, plus the hooks and MCP servers that make it enforceable: TDD, clean code, debugging, specs, discovery, CI, and secret hygiene.",
|
||||||
|
"version": "0.9.0",
|
||||||
|
"author": {
|
||||||
|
"name": "Mathias",
|
||||||
|
"url": "https://git.d-ma.be/mathias"
|
||||||
|
},
|
||||||
|
"homepage": "https://git.d-ma.be/mathias/skills",
|
||||||
|
"repository": "https://git.d-ma.be/mathias/skills.git"
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
# Two jobs, in order:
|
||||||
|
#
|
||||||
|
# gate — `task check` (structure, frontmatter, manifest/tag parity) plus
|
||||||
|
# `task test` (hooks, doctor, release-skip). Runs on every push and
|
||||||
|
# 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":
|
||||||
|
push:
|
||||||
|
branches: ["**"]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
gate:
|
||||||
|
name: Check / Test
|
||||||
|
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:
|
||||||
|
- name: checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
token: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
|
||||||
|
- name: derive next version
|
||||||
|
id: bump
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
last_commit=$(git log -1 --pretty=%B)
|
||||||
|
# Delegated to bin/should-skip-release so the rule is covered by
|
||||||
|
# tests/release_skip_test.sh rather than living untested in YAML.
|
||||||
|
if printf '%s\n' "$last_commit" | sh bin/should-skip-release; then
|
||||||
|
echo "skipping — commit carries the Skip-Release trailer"
|
||||||
|
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
latest=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
|
||||||
|
major=$(echo "$latest" | sed -E 's/^v([0-9]+)\.([0-9]+)\.([0-9]+)$/\1/')
|
||||||
|
minor=$(echo "$latest" | sed -E 's/^v([0-9]+)\.([0-9]+)\.([0-9]+)$/\2/')
|
||||||
|
patch=$(echo "$latest" | sed -E 's/^v([0-9]+)\.([0-9]+)\.([0-9]+)$/\3/')
|
||||||
|
bump=$(printf '%s' "$last_commit" | grep -iE '^Bump-Type:' | head -1 | awk '{print tolower($2)}' || true)
|
||||||
|
case "$bump" in
|
||||||
|
major) major=$((major + 1)); minor=0; patch=0 ;;
|
||||||
|
minor) minor=$((minor + 1)); patch=0 ;;
|
||||||
|
*) patch=$((patch + 1)) ;;
|
||||||
|
esac
|
||||||
|
next="v${major}.${minor}.${patch}"
|
||||||
|
echo "previous=$latest"
|
||||||
|
echo "bump=${bump:-patch}"
|
||||||
|
echo "next=$next"
|
||||||
|
echo "tag=$next" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: tag + push
|
||||||
|
if: steps.bump.outputs.skip != 'true'
|
||||||
|
env:
|
||||||
|
TAG: ${{ steps.bump.outputs.tag }}
|
||||||
|
GITEA_ACTOR: ${{ gitea.actor }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
git config user.name "${GITEA_ACTOR}"
|
||||||
|
git config user.email "${GITEA_ACTOR}@noreply.gitea.d-ma.be"
|
||||||
|
|
||||||
|
# Claude Code gates plugin updates on plugin.json's version field:
|
||||||
|
# if it does not move, installed hosts never see the new tag. Bump
|
||||||
|
# it in the same commit train so the tag and the manifest agree —
|
||||||
|
# `task check` enforces that equality on every run.
|
||||||
|
manifest=.claude-plugin/plugin.json
|
||||||
|
version="${TAG#v}"
|
||||||
|
current=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$manifest" | head -1)
|
||||||
|
git tag -a "$TAG" -m "release $TAG"
|
||||||
|
|
||||||
|
if [ "$current" != "$version" ]; then
|
||||||
|
sed -i "s/\"version\"[[:space:]]*:[[:space:]]*\"$current\"/\"version\": \"$version\"/" "$manifest"
|
||||||
|
git add "$manifest"
|
||||||
|
# The trailer below stops this push from re-triggering the tagger.
|
||||||
|
git commit -m "chore(release): plugin.json version $version" -m "Skip-Release: true"
|
||||||
|
# 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"
|
||||||
|
else
|
||||||
|
git push origin "$TAG"
|
||||||
|
fi
|
||||||
|
echo "pushed $TAG"
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
name: release
|
|
||||||
|
|
||||||
# Auto-tag every 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 latest commit is itself the auto-tagger (commit message
|
|
||||||
# contains "[skip-release]") to prevent recursion. Tag is pushed back
|
|
||||||
# to the repo using the GITEA_TOKEN provided by act_runner.
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
tag:
|
|
||||||
runs-on: self-hosted
|
|
||||||
steps:
|
|
||||||
- name: checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
token: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
|
|
||||||
- name: derive next version
|
|
||||||
id: bump
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
last_commit=$(git log -1 --pretty=%B)
|
|
||||||
if printf '%s' "$last_commit" | grep -qi '\[skip-release\]'; then
|
|
||||||
echo "skipping — commit marked [skip-release]"
|
|
||||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
latest=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
|
|
||||||
major=$(echo "$latest" | sed -E 's/^v([0-9]+)\.([0-9]+)\.([0-9]+)$/\1/')
|
|
||||||
minor=$(echo "$latest" | sed -E 's/^v([0-9]+)\.([0-9]+)\.([0-9]+)$/\2/')
|
|
||||||
patch=$(echo "$latest" | sed -E 's/^v([0-9]+)\.([0-9]+)\.([0-9]+)$/\3/')
|
|
||||||
bump=$(printf '%s' "$last_commit" | grep -iE '^Bump-Type:' | head -1 | awk '{print tolower($2)}' || true)
|
|
||||||
case "$bump" in
|
|
||||||
major) major=$((major + 1)); minor=0; patch=0 ;;
|
|
||||||
minor) minor=$((minor + 1)); patch=0 ;;
|
|
||||||
*) patch=$((patch + 1)) ;;
|
|
||||||
esac
|
|
||||||
next="v${major}.${minor}.${patch}"
|
|
||||||
echo "previous=$latest"
|
|
||||||
echo "bump=${bump:-patch}"
|
|
||||||
echo "next=$next"
|
|
||||||
echo "tag=$next" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: tag + push
|
|
||||||
if: steps.bump.outputs.skip != 'true'
|
|
||||||
env:
|
|
||||||
TAG: ${{ steps.bump.outputs.tag }}
|
|
||||||
GITEA_ACTOR: ${{ gitea.actor }}
|
|
||||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
git config user.name "${GITEA_ACTOR}"
|
|
||||||
git config user.email "${GITEA_ACTOR}@noreply.gitea.d-ma.be"
|
|
||||||
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"
|
|
||||||
@@ -0,0 +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": {
|
||||||
|
"brain": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://koala.tail30472e.ts.net:8443/mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer ${BRAIN_MCP_TOKEN}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"infra": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://koala.tail30472e.ts.net:8444/mcp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
# skills
|
# skills
|
||||||
|
|
||||||
Engineering skills library — markdown `SKILL.md` files that plug into any
|
Engineering skills library — markdown `SKILL.md` files that plug into any
|
||||||
project or AI-coding harness. Installed via `task skills:install` in
|
AI-coding harness.
|
||||||
consumer projects, or directly by `install.sh` on hosts without Task.
|
|
||||||
|
For **Claude Code** this repo is a plugin, installed from the marketplace it
|
||||||
|
also hosts; that path carries the hooks and MCP servers too. For **Crush, Pi
|
||||||
|
and OpenCode** it is a plain library wired by `install.sh` as one directory
|
||||||
|
symlink per harness. See [`SKILLS_INDEX.md`](./SKILLS_INDEX.md#wiring-into-tools).
|
||||||
|
|
||||||
## What is a skill?
|
## What is a skill?
|
||||||
|
|
||||||
@@ -16,65 +20,100 @@ references.
|
|||||||
See [`SKILLS_INDEX.md`](./SKILLS_INDEX.md) for the full catalogue with
|
See [`SKILLS_INDEX.md`](./SKILLS_INDEX.md) for the full catalogue with
|
||||||
descriptions and "use when" triggers.
|
descriptions and "use when" triggers.
|
||||||
|
|
||||||
## Install (consumer project)
|
## Install
|
||||||
|
|
||||||
The expected pattern: each consumer project's `Taskfile.yml` has a
|
### Claude Code — the plugin
|
||||||
`skills:install` target that delegates here. From a project root:
|
|
||||||
|
|
||||||
```bash
|
```
|
||||||
task skills:install
|
/plugin marketplace add https://git.d-ma.be/mathias/skills.git
|
||||||
|
/plugin install skills@mathias
|
||||||
```
|
```
|
||||||
|
|
||||||
Bare-shell fallback for hosts without Task installed:
|
The plugin ships skills, the `secret-guard` + `pre-task-ritual` hooks, and the
|
||||||
|
brain/gitea/infra MCP servers as one versioned unit. Skills are namespaced
|
||||||
|
(`skills:tdd`). Updates land when `.claude-plugin/plugin.json`'s `version`
|
||||||
|
moves — CI bumps it on every release.
|
||||||
|
|
||||||
|
### Crush, Pi, OpenCode — `install.sh`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://gitea.d-ma.be/mathias/skills/raw/branch/main/install.sh | bash
|
curl -fsSL https://git.d-ma.be/mathias/skills/raw/branch/main/install.sh | bash
|
||||||
```
|
```
|
||||||
|
|
||||||
Both forms are idempotent and re-runnable. On every run they:
|
Idempotent. It clones or fast-forwards the checkout, then populates one
|
||||||
|
**managed directory** and gives each harness a single link to it:
|
||||||
|
|
||||||
1. `git clone` (or `git pull`) this repo into `~/.local/share/skills/` —
|
| Path | Target |
|
||||||
the **canonical local checkout** on every host.
|
|---|---|
|
||||||
2. Wire per-skill symlinks into the harnesses found on the host (see
|
| `~/.agents/skills/` | a real directory, one symlink per skill (Pi reads this path directly) |
|
||||||
[Wired harnesses](#wired-harnesses)).
|
| `~/.agents/skills/<skill>` | `$SKILLS_CHECKOUT_DIR/skills/<skill>`, or another configured source |
|
||||||
|
| `~/.config/crush/skills` | `~/.agents/skills` |
|
||||||
|
| `~/.config/opencode/skills` | `~/.agents/skills` |
|
||||||
|
|
||||||
Re-run after `git pull` upstream to pick up new skills or version
|
Total links are N + one per harness — not N x harnesses, which is what #23 was
|
||||||
changes. CI tags every change as a `vX.Y.Z` release (see
|
actually about.
|
||||||
[Versioning](#versioning)).
|
|
||||||
|
|
||||||
## Wired harnesses
|
### More than one source of skills
|
||||||
|
|
||||||
| Harness | Target path | Notes |
|
`~/.agents/skills` used to be a single link straight at this checkout. That
|
||||||
|---|---|---|
|
encoded an assumption nobody had stated — that this checkout is the only source
|
||||||
| **Claude Code (global)** | `~/.claude/skills/<name>` | Visible in every Claude Code session on the host. |
|
of skills on the host — and on flamingo the only way to install this library
|
||||||
| **Claude Code (per-repo)** | `<repo>/.claude/skills/<name>` | Created when `install` is invoked inside a git repo (not the skills repo itself). Gitignored globally via `**/.claude/skills/`. |
|
was to displace Anthropic's official set, which lived in Crush's directory.
|
||||||
| **Crush** | `~/.config/crush/skills/<name>` | Charmbracelet Crush — successor to `opencode-ai/opencode` (archived). |
|
Real capability loss dressed up as a tidy-up (#27).
|
||||||
| **Antigravity** | `~/.gemini/antigravity/skills/<name>` | Google Antigravity (VS Code extension). Our `SKILL.md` frontmatter (`name` + `description`) is already in the format Antigravity expects, so symlinks suffice — no manifest translation. |
|
|
||||||
|
|
||||||
### Not wired (and why)
|
Point `SKILLS_EXTRA_SOURCES` at any number of extra directories, colon
|
||||||
|
separated, each holding `<skill>/SKILL.md`:
|
||||||
|
|
||||||
- **opencode** (`opencode-ai/opencode`): archive notice; succeeded by Crush.
|
```
|
||||||
Its only "skill"-like surface — Custom Commands at `~/.config/opencode/commands/` —
|
SKILLS_EXTRA_SOURCES=~/.config/crush/skills.anthropic bash install.sh
|
||||||
is for user-facing prompt templates, not system-level instructions.
|
```
|
||||||
Misaligned with how we use skills. Skipped.
|
|
||||||
- **gitea-resident agents** (cobalt-dingo, agentsquad): they consume
|
- The checkout is always first and always wins a name collision — but the
|
||||||
skills via their containing project's `.claude/skills` directory
|
collision is **logged**, never resolved in silence. Quietly picking one is
|
||||||
(populated by the per-repo wirer when run from the host), or via the
|
how stale content wins.
|
||||||
brain MCP. No special target needed.
|
- Removing a skill from a source removes its link on the next run.
|
||||||
|
- Anything in the managed directory that is not a symlink is reported and left
|
||||||
|
alone. install.sh does not delete content it did not create.
|
||||||
|
- The source list is recorded at `~/.agents/skills-sources` so `bin/doctor`
|
||||||
|
can tell a link into a known source from a link into a forgotten one.
|
||||||
|
|
||||||
|
### Where the checkout must live
|
||||||
|
|
||||||
|
`SKILLS_CHECKOUT_DIR` defaults to `~/.local/share/skills`, and that default is
|
||||||
|
load-bearing: it is **host-local, outside any synced or shared directory**.
|
||||||
|
|
||||||
|
On this fleet `~/dev` is an iCloud folder shared by flamingo and iguana, and it
|
||||||
|
spans two path roots — `/Users/mathias` on the Macs, `/home/mathias` on koala.
|
||||||
|
A checkout there is a git working tree with two concurrent writers, and any
|
||||||
|
absolute symlink stored there is broken on one side by construction. That is
|
||||||
|
what produced 243 dangling links across 12 repos (#23). Do not "fix" the
|
||||||
|
default to point into `~/dev`.
|
||||||
|
|
||||||
### Per-host env-var overrides
|
### Per-host env-var overrides
|
||||||
|
|
||||||
Each target path can be overridden by setting the matching env var before
|
|
||||||
running `install.sh`:
|
|
||||||
|
|
||||||
| Env var | Default |
|
| Env var | Default |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `SKILLS_REPO_URL` | `https://gitea.d-ma.be/mathias/skills.git` |
|
| `SKILLS_REPO_URL` | `https://git.d-ma.be/mathias/skills.git` |
|
||||||
| `SKILLS_REF` | `main` (set to e.g. `v0.1.0` to pin a release) |
|
| `SKILLS_REF` | `main` (set to e.g. `v0.4.0` to pin a release) |
|
||||||
| `SKILLS_CHECKOUT_DIR` | `$HOME/.local/share/skills` |
|
| `SKILLS_CHECKOUT_DIR` | `$HOME/.local/share/skills` |
|
||||||
| `CLAUDE_SKILLS_DIR` | `$HOME/.claude/skills` |
|
| `SKILLS_SKIP_FETCH` | unset (`1` wires an existing checkout without touching HEAD) |
|
||||||
|
| `AGENTS_SKILLS_DIR` | `$HOME/.agents/skills` |
|
||||||
| `CRUSH_SKILLS_DIR` | `$HOME/.config/crush/skills` |
|
| `CRUSH_SKILLS_DIR` | `$HOME/.config/crush/skills` |
|
||||||
| `ANTIGRAVITY_SKILLS_DIR` | `$HOME/.gemini/antigravity/skills` |
|
| `OPENCODE_SKILLS_DIR` | `$HOME/.config/opencode/skills` |
|
||||||
|
| `SKILLS_EXTRA_SOURCES` | unset (colon-separated extra source directories) |
|
||||||
|
|
||||||
|
Antigravity and Mistral Vibe targets were removed — unused.
|
||||||
|
|
||||||
|
## Checking a host
|
||||||
|
|
||||||
|
```bash
|
||||||
|
task doctor # or bin/doctor
|
||||||
|
```
|
||||||
|
|
||||||
|
Reports dangling links, links resolving into a foreign checkout, skill-count
|
||||||
|
mismatches, stray entries, and leftover per-skill Claude symlinks. Non-zero
|
||||||
|
exit on drift. It also runs at session start via the plugin, so a drifting
|
||||||
|
host says so rather than waiting to be discovered.
|
||||||
|
|
||||||
## Versioning
|
## Versioning
|
||||||
|
|
||||||
|
|||||||
+69
-24
@@ -19,22 +19,26 @@ This index lists all available engineering skills. Load the full SKILL.md on dem
|
|||||||
| `gitea-ci` | Gitea CI/CD pipeline with act_runner, buildah, k3s, GitHub mirror | Setting up or debugging CI on this infra |
|
| `gitea-ci` | Gitea CI/CD pipeline with act_runner, buildah, k3s, GitHub mirror | Setting up or debugging CI on this infra |
|
||||||
| `debug` | Hypothesis-first debugging discipline | Any test failure or bug whose cause is not immediately obvious |
|
| `debug` | Hypothesis-first debugging discipline | Any test failure or bug whose cause is not immediately obvious |
|
||||||
| `feature-spec` | Implementation-level feature spec (tighter than spec-driven-dev) | Scoping one feature inside an already-specced project |
|
| `feature-spec` | Implementation-level feature spec (tighter than spec-driven-dev) | Scoping one feature inside an already-specced project |
|
||||||
|
| `experiment-spec` | Rigorous experiment spec — falsifiable hypothesis, quantitative acceptance, null-result protocol | Scoping a research/ML phase instead of a feature |
|
||||||
| `playwright` | Playwright E2E testing — setup, HTMX patterns, SSE streams, CI wiring | Adding browser tests to any project with a web UI |
|
| `playwright` | Playwright E2E testing — setup, HTMX patterns, SSE streams, CI wiring | Adding browser tests to any project with a web UI |
|
||||||
| `session-retrospective` | Surface non-obvious learnings from a session log | After a coding session ends, before context is lost |
|
| `session-retrospective` | Surface non-obvious learnings from a session log | After a coding session ends, before context is lost |
|
||||||
| `trainer` | Two-phase brain curation (score candidates, write past quality gate) | Periodic brain pruning and curation |
|
| `trainer` | Two-phase brain curation (score candidates, write past quality gate) | Periodic brain pruning and curation |
|
||||||
|
| `dream` | Reflective consolidation pass over an agent's memory directory | Pruning/merging memory files, rebuilding a memory index |
|
||||||
| `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 |
|
| `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 |
|
||||||
|
| `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 |
|
||||||
| `telos-load` | Load TELOS intention substrate at session start | Starting any koala session; before architectural decisions; CAD pipeline entry |
|
| `telos-load` | Load TELOS intention substrate at session start | Starting any koala session; before architectural decisions; CAD pipeline entry |
|
||||||
|
| `secret-hygiene` | Stop credentials rendering into the session transcript; safe rotation chain when one leaks | Before any kubectl/SOPS/token command; after a confirmed leak |
|
||||||
| `regulatory-risk-assessment` | Structured risk register for regulated-industry features | Filing a CAD issue (needs Risk: level); features touching payments, auth, external APIs, user data |
|
| `regulatory-risk-assessment` | Structured risk register for regulated-industry features | Filing a CAD issue (needs Risk: level); features touching payments, auth, external APIs, user data |
|
||||||
| `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 |
|
||||||
| `superpowers-dispatching-parallel-agents` | Fan work out across parallel subagents | Decomposing work for concurrent agents |
|
| `superpowers-dispatching-parallel-agents` | Fan work out across parallel subagents | Decomposing work for concurrent agents |
|
||||||
| `superpowers-subagent-driven-development` | Subagent-driven development workflow | Building via orchestrated subagents |
|
| `superpowers-subagent-driven-development` | Subagent-driven development workflow | Building via orchestrated subagents |
|
||||||
| `superpowers-systematic-debugging` | Hypothesis-first systematic debugging | Diagnosing a failure methodically |
|
| `superpowers-systematic-debugging` | Hypothesis-first systematic debugging | Diagnosing a failure methodically |
|
||||||
| `superpowers-test-driven-development` | TDD discipline (superpowers variant) | Red → green → refactor on a change |
|
|
||||||
| `superpowers-verification-before-completion` | Verify behavior before claiming done | Before marking any task complete |
|
| `superpowers-verification-before-completion` | Verify behavior before claiming done | Before marking any task complete |
|
||||||
| `superpowers-requesting-code-review` | Requesting a code review well | Asking another agent/human to review |
|
| `superpowers-requesting-code-review` | Requesting a code review well | Asking another agent/human to review |
|
||||||
| `superpowers-receiving-code-review` | Acting on received review feedback | Responding to review comments |
|
| `superpowers-receiving-code-review` | Acting on received review feedback | Responding to review comments |
|
||||||
@@ -45,23 +49,60 @@ This index lists all available engineering skills. Load the full SKILL.md on dem
|
|||||||
|
|
||||||
## Wiring into tools
|
## Wiring into tools
|
||||||
|
|
||||||
This repo **is** the canonical home. Wire every skill into each harness with the
|
This repo is the canonical home, and it is delivered two different ways.
|
||||||
**`install.sh`** script (idempotent, per-skill symlinks) — either the one-shot
|
|
||||||
`curl -fsSL https://git.d-ma.be/mathias/skills/raw/branch/main/install.sh | bash`,
|
|
||||||
or, from a clone, `SKILLS_CHECKOUT_DIR="$PWD" bash install.sh`. (The `task
|
|
||||||
install` targets are currently broken — see issue — use `install.sh`.)
|
|
||||||
|
|
||||||
- **Claude Code (global)** — `~/.claude/skills/<name>` → `<repo>/<name>`.
|
### Claude Code — install the plugin
|
||||||
- **Claude Code (per-repo)** — `<repo>/.claude/skills/<name>` when `task install`
|
|
||||||
is run inside another git repo (excluded via `~/.config/git/ignore`).
|
|
||||||
- **Crush** — `~/.config/crush/skills/<name>`.
|
|
||||||
- **Antigravity** — `~/.gemini/antigravity/skills/<name>`.
|
|
||||||
- **Mistral Vibe** — `~/.vibe/skills/<name>.md` → `<repo>/<name>/SKILL.md`
|
|
||||||
(flat `.md` layout).
|
|
||||||
|
|
||||||
Individual targets are also runnable: `task install:claude:global`,
|
This repo hosts a marketplace containing exactly one plugin: itself. The plugin
|
||||||
`install:crush`, `install:antigravity`, `install:vibe`. `task update` pulls the
|
carries the skills **plus** the `secret-guard` and `pre-task-ritual` hooks and
|
||||||
canonical checkout then re-wires.
|
the brain/gitea/infra MCP servers — none of which a symlink can deliver.
|
||||||
|
|
||||||
|
```
|
||||||
|
/plugin marketplace add https://git.d-ma.be/mathias/skills.git
|
||||||
|
/plugin install skills@mathias
|
||||||
|
```
|
||||||
|
|
||||||
|
Skills are namespaced: `tdd` becomes `skills:tdd`. Updates arrive when
|
||||||
|
`.claude-plugin/plugin.json`'s `version` moves, which CI does on every release.
|
||||||
|
Remove any leftover `~/.claude/skills/<name>` symlinks from the pre-plugin era —
|
||||||
|
plugin skills do **not** override same-named user skills, so leftovers make
|
||||||
|
everything load twice.
|
||||||
|
|
||||||
|
### Crush, Pi, OpenCode — run `install.sh`
|
||||||
|
|
||||||
|
These have no plugin system, so they get symlinks — but through one managed
|
||||||
|
directory, never one link per skill per harness:
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.agents/skills/ (real directory; Pi reads it directly)
|
||||||
|
~/.agents/skills/tdd -> $SKILLS_CHECKOUT_DIR/skills/tdd
|
||||||
|
~/.agents/skills/xlsx -> $SKILLS_EXTRA_SOURCES entry
|
||||||
|
~/.config/crush/skills -> ~/.agents/skills
|
||||||
|
~/.config/opencode/skills -> ~/.agents/skills
|
||||||
|
```
|
||||||
|
|
||||||
|
Total links are N + one per harness. `SKILLS_EXTRA_SOURCES` (colon-separated
|
||||||
|
directories) lets a second library — Anthropic's official set, for instance —
|
||||||
|
live alongside this one instead of being displaced by it; name collisions are
|
||||||
|
logged rather than silently resolved, and the checkout wins (#27).
|
||||||
|
|
||||||
|
```
|
||||||
|
curl -fsSL https://git.d-ma.be/mathias/skills/raw/branch/main/install.sh | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
`SKILLS_CHECKOUT_DIR` defaults to `~/.local/share/skills` and **must stay
|
||||||
|
outside `~/dev`**. On this fleet `~/dev` is an iCloud folder shared by both
|
||||||
|
Macs and spans two path roots (`/Users/mathias` vs `/home/mathias` on koala);
|
||||||
|
a checkout there means a git working tree with two concurrent writers, and any
|
||||||
|
absolute link stored there is broken on one side by construction. See #23.
|
||||||
|
|
||||||
|
### Check a host
|
||||||
|
|
||||||
|
`task doctor` (or `bin/doctor`) reports dangling links, links into a source
|
||||||
|
nobody configured, skills in the checkout with no link, stray entries, leftover
|
||||||
|
Claude symlinks, and a Claude Code plugin sitting on an older generation than
|
||||||
|
the host already has. It also runs automatically at session start via the
|
||||||
|
plugin, so drift announces itself instead of waiting to be found.
|
||||||
|
|
||||||
## Skill Relationships
|
## Skill Relationships
|
||||||
|
|
||||||
@@ -72,14 +113,16 @@ problem-analysis
|
|||||||
│
|
│
|
||||||
├── spec-driven-dev (PM-kickoff lifecycle)
|
├── spec-driven-dev (PM-kickoff lifecycle)
|
||||||
│ │
|
│ │
|
||||||
│ └── feature-spec (per-feature, mid-implementation)
|
│ ├── feature-spec (per-feature, mid-implementation)
|
||||||
│ │
|
│ │ │
|
||||||
│ └── planning
|
│ │ └── planning
|
||||||
│ │
|
│ │ │
|
||||||
│ └── atdd ──→ tdd (foundation)
|
│ │ └── atdd ──→ tdd (foundation)
|
||||||
│ ├──→ debug (when tests fail)
|
│ │ ├──→ debug (when tests fail)
|
||||||
│ ├──→ clean-code (REFACTOR phase)
|
│ │ ├──→ clean-code (REFACTOR phase)
|
||||||
│ └──→ test-design (review)
|
│ │ └──→ test-design (review)
|
||||||
|
│ │
|
||||||
|
│ └── experiment-spec (research/ML phase, instead of feature-spec)
|
||||||
│
|
│
|
||||||
└── planning
|
└── planning
|
||||||
|
|
||||||
@@ -90,6 +133,7 @@ code-review ──→ refactoring ──→ clean-code
|
|||||||
session-retrospective ──→ trainer ──→ brain (via brain_write)
|
session-retrospective ──→ trainer ──→ brain (via brain_write)
|
||||||
|
|
||||||
(planning & convergence)
|
(planning & convergence)
|
||||||
|
own-the-task ──→ grill-me (intent first, then stress-test the plan)
|
||||||
grill-me ──→ planning (after plan is sharpened)
|
grill-me ──→ planning (after plan is sharpened)
|
||||||
──→ spec-driven-dev (after hypothesis is validated)
|
──→ spec-driven-dev (after hypothesis is validated)
|
||||||
```
|
```
|
||||||
@@ -113,5 +157,6 @@ grill-me ──→ planning (after plan is sharpened)
|
|||||||
| "Grill me / stress-test this / is this ready?" | `grill-me` |
|
| "Grill me / stress-test this / is this ready?" | `grill-me` |
|
||||||
| "End of Diamond 1 — should we build this?" | `grill-me` (Full Grill) |
|
| "End of Diamond 1 — should we build this?" | `grill-me` (Full Grill) |
|
||||||
| "About to promote to pre-prod" | `grill-me` (Pre-mortem) |
|
| "About to promote to pre-prod" | `grill-me` (Pre-mortem) |
|
||||||
|
| "Take this off my plate / own this / act as my AI employee" | `own-the-task` |
|
||||||
| "What are the risks?" / "compliance gate" / "risk register" | `regulatory-risk-assessment` |
|
| "What are the risks?" / "compliance gate" / "risk register" | `regulatory-risk-assessment` |
|
||||||
| "Start of session" / "load TELOS" / "what are we optimizing toward?" | `telos-load` |
|
| "Start of session" / "load TELOS" / "what are we optimizing toward?" | `telos-load` |
|
||||||
|
|||||||
+94
-154
@@ -2,22 +2,19 @@ version: '3'
|
|||||||
|
|
||||||
# Skills library Taskfile.
|
# Skills library Taskfile.
|
||||||
#
|
#
|
||||||
# Primary entry point for project + host installation. Consumer projects
|
# This repo is two things at once:
|
||||||
# do NOT depend on this file directly — they ship their own
|
# 1. A Claude Code PLUGIN (skills + hooks + MCP servers), distributed through
|
||||||
# `skills:install` target that delegates here via `install.sh`. This
|
# the marketplace this same repo hosts at .claude-plugin/marketplace.json.
|
||||||
# Taskfile is invoked by maintainers of the skills repo itself, plus by
|
# 2. A plain skills library for harnesses with no plugin system — Crush, Pi,
|
||||||
# install.sh during the post-clone wire step.
|
# OpenCode — wired by install.sh as ONE directory symlink per harness.
|
||||||
|
#
|
||||||
|
# The wiring loops that used to live here were five near-identical copies that
|
||||||
|
# drifted; install.sh owns wiring now and this file delegates to it.
|
||||||
|
|
||||||
vars:
|
vars:
|
||||||
REPO_URL: 'https://git.d-ma.be/mathias/skills.git'
|
REPO_URL: 'https://git.d-ma.be/mathias/skills.git'
|
||||||
CHECKOUT_DIR: '{{.HOME}}/.local/share/skills'
|
SKILLS_DIR: '{{.TASKFILE_DIR}}/skills'
|
||||||
CLAUDE_GLOBAL_DIR: '{{.HOME}}/.claude/skills'
|
PLUGIN_MANIFEST: '{{.TASKFILE_DIR}}/.claude-plugin/plugin.json'
|
||||||
CRUSH_DIR: '{{.HOME}}/.config/crush/skills'
|
|
||||||
ANTIGRAVITY_DIR: '{{.HOME}}/.gemini/antigravity/skills'
|
|
||||||
VIBE_DIR: '{{.HOME}}/.vibe/skills'
|
|
||||||
# Anything at the repo root that is NOT a skill directory. Both
|
|
||||||
# Taskfile + install.sh share this exclusion regex.
|
|
||||||
NON_SKILL_ENTRIES: '^(Taskfile.yml|install.sh|README.md|SKILLS_INDEX.md|.gitea|.git$)$'
|
|
||||||
|
|
||||||
tasks:
|
tasks:
|
||||||
|
|
||||||
@@ -28,134 +25,100 @@ tasks:
|
|||||||
list:
|
list:
|
||||||
desc: 'Print every skill in this repo'
|
desc: 'Print every skill in this repo'
|
||||||
cmds:
|
cmds:
|
||||||
- 'ls -1 {{.TASKFILE_DIR}} | grep -Ev "^(Taskfile.yml|install.sh|README.md|SKILLS_INDEX.md|.gitea|.git$)$" || true'
|
- 'ls -1 {{.SKILLS_DIR}}'
|
||||||
silent: true
|
silent: true
|
||||||
|
|
||||||
install:
|
install:
|
||||||
desc: 'Wire skills into every supported harness (Claude Code, Crush, Antigravity, Mistral Vibe).'
|
desc: 'Wire THIS checkout into Crush / Pi / OpenCode. Claude Code uses the plugin instead.'
|
||||||
cmds:
|
cmds:
|
||||||
- task: install:claude:global
|
- SKILLS_SKIP_FETCH=1 SKILLS_CHECKOUT_DIR='{{.TASKFILE_DIR}}' bash '{{.TASKFILE_DIR}}/install.sh'
|
||||||
- task: install:claude:repo
|
|
||||||
- task: install:crush
|
|
||||||
- task: install:antigravity
|
|
||||||
- task: install:vibe
|
|
||||||
|
|
||||||
install:claude:global:
|
|
||||||
desc: 'Per-skill symlinks under ~/.claude/skills/<name> — visible in every Claude Code project on this host.'
|
|
||||||
cmds:
|
|
||||||
- mkdir -p {{.CLAUDE_GLOBAL_DIR}}
|
|
||||||
- |
|
|
||||||
for skill in $(ls -1 "{{.TASKFILE_DIR}}" | grep -Ev "{{.NON_SKILL_ENTRIES}}"); do
|
|
||||||
target="{{.TASKFILE_DIR}}/${skill}"
|
|
||||||
link="{{.CLAUDE_GLOBAL_DIR}}/${skill}"
|
|
||||||
if [ -L "$link" ] || [ -e "$link" ]; then
|
|
||||||
current=$(readlink "$link" 2>/dev/null || true)
|
|
||||||
if [ "$current" = "$target" ]; then
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
rm -rf "$link"
|
|
||||||
fi
|
|
||||||
ln -s "$target" "$link"
|
|
||||||
echo "linked $link → $target"
|
|
||||||
done
|
|
||||||
|
|
||||||
install:claude:repo:
|
|
||||||
desc: 'Per-skill symlinks under $(pwd)/.claude/skills/<name> when invoked inside a git repo.'
|
|
||||||
cmds:
|
|
||||||
- |
|
|
||||||
if ! git rev-parse --show-toplevel >/dev/null 2>&1; then
|
|
||||||
echo "not in a git repo — skipping per-repo wire"; exit 0
|
|
||||||
fi
|
|
||||||
repo=$(git rev-parse --show-toplevel)
|
|
||||||
if [ "$repo" = "{{.TASKFILE_DIR}}" ]; then
|
|
||||||
echo "running from skills repo itself — skipping per-repo wire"; exit 0
|
|
||||||
fi
|
|
||||||
target_dir="$repo/.claude/skills"
|
|
||||||
mkdir -p "$target_dir"
|
|
||||||
for skill in $(ls -1 "{{.TASKFILE_DIR}}" | grep -Ev "{{.NON_SKILL_ENTRIES}}"); do
|
|
||||||
target="{{.TASKFILE_DIR}}/${skill}"
|
|
||||||
link="${target_dir}/${skill}"
|
|
||||||
if [ -L "$link" ] || [ -e "$link" ]; then
|
|
||||||
current=$(readlink "$link" 2>/dev/null || true)
|
|
||||||
if [ "$current" = "$target" ]; then
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
rm -rf "$link"
|
|
||||||
fi
|
|
||||||
ln -s "$target" "$link"
|
|
||||||
echo "linked $link → $target"
|
|
||||||
done
|
|
||||||
|
|
||||||
install:crush:
|
|
||||||
desc: 'Per-skill symlinks under ~/.config/crush/skills/<name> — visible in every Crush session on this host.'
|
|
||||||
cmds:
|
|
||||||
- mkdir -p {{.CRUSH_DIR}}
|
|
||||||
- |
|
|
||||||
for skill in $(ls -1 "{{.TASKFILE_DIR}}" | grep -Ev "{{.NON_SKILL_ENTRIES}}"); do
|
|
||||||
target="{{.TASKFILE_DIR}}/${skill}"
|
|
||||||
link="{{.CRUSH_DIR}}/${skill}"
|
|
||||||
if [ -L "$link" ] || [ -e "$link" ]; then
|
|
||||||
current=$(readlink "$link" 2>/dev/null || true)
|
|
||||||
if [ "$current" = "$target" ]; then
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
rm -rf "$link"
|
|
||||||
fi
|
|
||||||
ln -s "$target" "$link"
|
|
||||||
echo "linked $link → $target"
|
|
||||||
done
|
|
||||||
|
|
||||||
install:antigravity:
|
|
||||||
desc: 'Per-skill symlinks under ~/.gemini/antigravity/skills/<name>. SKILL.md frontmatter (name + description) is already antigravity-compatible.'
|
|
||||||
cmds:
|
|
||||||
- mkdir -p {{.ANTIGRAVITY_DIR}}
|
|
||||||
- |
|
|
||||||
for skill in $(ls -1 "{{.TASKFILE_DIR}}" | grep -Ev "{{.NON_SKILL_ENTRIES}}"); do
|
|
||||||
target="{{.TASKFILE_DIR}}/${skill}"
|
|
||||||
link="{{.ANTIGRAVITY_DIR}}/${skill}"
|
|
||||||
if [ -L "$link" ] || [ -e "$link" ]; then
|
|
||||||
current=$(readlink "$link" 2>/dev/null || true)
|
|
||||||
if [ "$current" = "$target" ]; then
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
rm -rf "$link"
|
|
||||||
fi
|
|
||||||
ln -s "$target" "$link"
|
|
||||||
echo "linked $link → $target"
|
|
||||||
done
|
|
||||||
|
|
||||||
install:vibe:
|
|
||||||
desc: 'Per-skill file symlinks under ~/.vibe/skills/<name>.md → <skill>/SKILL.md for Mistral Vibe (flat .md layout, not a directory).'
|
|
||||||
cmds:
|
|
||||||
- mkdir -p {{.VIBE_DIR}}
|
|
||||||
- |
|
|
||||||
for skill in $(ls -1 "{{.TASKFILE_DIR}}" | grep -Ev "{{.NON_SKILL_ENTRIES}}"); do
|
|
||||||
target="{{.TASKFILE_DIR}}/${skill}/SKILL.md"
|
|
||||||
[ -f "$target" ] || continue
|
|
||||||
link="{{.VIBE_DIR}}/${skill}.md"
|
|
||||||
if [ -L "$link" ] || [ -e "$link" ]; then
|
|
||||||
current=$(readlink "$link" 2>/dev/null || true)
|
|
||||||
if [ "$current" = "$target" ]; then
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
rm -rf "$link"
|
|
||||||
fi
|
|
||||||
ln -s "$target" "$link"
|
|
||||||
echo "linked $link → $target"
|
|
||||||
done
|
|
||||||
|
|
||||||
update:
|
update:
|
||||||
desc: 'git pull the canonical checkout then re-run install.'
|
desc: 'Fast-forward this checkout and re-wire.'
|
||||||
cmds:
|
cmds:
|
||||||
- |
|
- git -C '{{.TASKFILE_DIR}}' pull --ff-only
|
||||||
if [ ! -d "{{.CHECKOUT_DIR}}/.git" ]; then
|
|
||||||
echo "no canonical checkout at {{.CHECKOUT_DIR}} — run install.sh first"; exit 1
|
|
||||||
fi
|
|
||||||
git -C "{{.CHECKOUT_DIR}}" pull --ff-only
|
|
||||||
- task: install
|
- task: install
|
||||||
|
|
||||||
|
doctor:
|
||||||
|
desc: 'Report drift across every harness on this host. Non-zero exit means something is wrong.'
|
||||||
|
cmds:
|
||||||
|
- '{{.TASKFILE_DIR}}/bin/doctor'
|
||||||
|
|
||||||
|
validate:
|
||||||
|
desc: 'Validate the plugin + marketplace manifests with the Claude Code CLI.'
|
||||||
|
cmds:
|
||||||
|
- claude plugin validate '{{.TASKFILE_DIR}}'
|
||||||
|
|
||||||
|
test:
|
||||||
|
desc: 'Run the shell test suite (hooks + doctor).'
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
fail=0
|
||||||
|
for t in '{{.TASKFILE_DIR}}'/tests/*_test.sh; do
|
||||||
|
[ -f "$t" ] || continue
|
||||||
|
echo "--- $(basename "$t")"
|
||||||
|
sh "$t" || fail=1
|
||||||
|
done
|
||||||
|
exit $fail
|
||||||
|
|
||||||
|
check:
|
||||||
|
desc: 'Structure gate — layout, frontmatter, manifest/tag parity. Runs in CI.'
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
fail=0
|
||||||
|
|
||||||
|
# 1. No stray entries at the repo root. A loose foo.md where
|
||||||
|
# skills/foo/SKILL.md was meant loads in NO harness and errors in
|
||||||
|
# none either — it just silently does nothing. (See #23.)
|
||||||
|
for entry in "{{.TASKFILE_DIR}}"/*; do
|
||||||
|
base=$(basename "$entry")
|
||||||
|
case "$base" in
|
||||||
|
install.sh|Taskfile.yml|README.md|SKILLS_INDEX.md|skills|hooks|bin|tests) continue ;;
|
||||||
|
esac
|
||||||
|
echo "ERROR: unexpected entry at repo root: $base" >&2
|
||||||
|
fail=1
|
||||||
|
done
|
||||||
|
|
||||||
|
# 2. Every skill is a directory with a SKILL.md carrying name + description.
|
||||||
|
count=0
|
||||||
|
for dir in "{{.SKILLS_DIR}}"/*; do
|
||||||
|
name=$(basename "$dir")
|
||||||
|
if [ ! -d "$dir" ]; then
|
||||||
|
echo "ERROR: skills/$name is not a directory" >&2; fail=1; continue
|
||||||
|
fi
|
||||||
|
if [ ! -f "$dir/SKILL.md" ]; then
|
||||||
|
echo "ERROR: skills/$name missing SKILL.md" >&2; fail=1; continue
|
||||||
|
fi
|
||||||
|
head -n 20 "$dir/SKILL.md" | grep -q '^name:' || { echo "ERROR: skills/$name SKILL.md has no 'name:' frontmatter" >&2; fail=1; }
|
||||||
|
head -n 20 "$dir/SKILL.md" | grep -q '^description:' || { echo "ERROR: skills/$name SKILL.md has no 'description:' frontmatter" >&2; fail=1; }
|
||||||
|
count=$((count + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
# 3. SKILLS_INDEX.md lists exactly the skills that exist. Delegated
|
||||||
|
# to bin/check-index so the rule is covered by
|
||||||
|
# tests/check_index_test.sh rather than living untested here.
|
||||||
|
sh '{{.TASKFILE_DIR}}/bin/check-index' '{{.TASKFILE_DIR}}' || fail=1
|
||||||
|
|
||||||
|
# 4. Plugin manifest version must match the latest tag, or installers
|
||||||
|
# never see the update (Claude Code gates updates on this field).
|
||||||
|
if [ -f '{{.PLUGIN_MANIFEST}}' ]; then
|
||||||
|
manifest_v=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' '{{.PLUGIN_MANIFEST}}' | head -1)
|
||||||
|
tag_v=$(git -C '{{.TASKFILE_DIR}}' describe --tags --abbrev=0 2>/dev/null | sed 's/^v//' || echo '')
|
||||||
|
if [ -n "$tag_v" ] && [ "$manifest_v" != "$tag_v" ]; then
|
||||||
|
echo "ERROR: plugin.json version ($manifest_v) != latest tag ($tag_v)" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "ERROR: missing .claude-plugin/plugin.json" >&2; fail=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ $fail -ne 0 ]; then
|
||||||
|
echo "check FAILED" >&2; exit 1
|
||||||
|
fi
|
||||||
|
echo "check OK — $count skills, manifest matches tag"
|
||||||
|
|
||||||
release:
|
release:
|
||||||
desc: 'Tag HEAD per Bump-Type in the latest commit footer. Normally CI handles this — run locally only when CI is unavailable.'
|
desc: 'Tag HEAD per Bump-Type in the latest commit footer. Normally CI handles this.'
|
||||||
cmds:
|
cmds:
|
||||||
- |
|
- |
|
||||||
latest=$(git -C "{{.TASKFILE_DIR}}" describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
|
latest=$(git -C "{{.TASKFILE_DIR}}" describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
|
||||||
@@ -172,26 +135,3 @@ tasks:
|
|||||||
echo "tagging $next (previous $latest, bump=${bump:-patch})"
|
echo "tagging $next (previous $latest, bump=${bump:-patch})"
|
||||||
git -C "{{.TASKFILE_DIR}}" tag -a "$next" -m "release $next"
|
git -C "{{.TASKFILE_DIR}}" tag -a "$next" -m "release $next"
|
||||||
echo "now: git push origin $next"
|
echo "now: git push origin $next"
|
||||||
|
|
||||||
check:
|
|
||||||
desc: 'Sanity check — every skill dir has a SKILL.md, no stray files at root.'
|
|
||||||
cmds:
|
|
||||||
- |
|
|
||||||
missing=0
|
|
||||||
for skill in $(ls -1 "{{.TASKFILE_DIR}}" | grep -Ev "^(Taskfile.yml|install.sh|README.md|SKILLS_INDEX.md|.gitea|.git$)$"); do
|
|
||||||
dir="{{.TASKFILE_DIR}}/${skill}"
|
|
||||||
if [ ! -d "$dir" ]; then
|
|
||||||
echo "ERROR: $skill is not a directory" >&2
|
|
||||||
missing=$((missing + 1))
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
if [ ! -f "$dir/SKILL.md" ]; then
|
|
||||||
echo "ERROR: $dir missing SKILL.md" >&2
|
|
||||||
missing=$((missing + 1))
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
if [ $missing -gt 0 ]; then
|
|
||||||
echo "$missing skill(s) failed structure check" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "all skills have SKILL.md"
|
|
||||||
|
|||||||
Executable
+42
@@ -0,0 +1,42 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# check-index — asserts SKILLS_INDEX.md lists exactly the skills that exist.
|
||||||
|
#
|
||||||
|
# The index is hand-maintained and drifts silently in both directions: a skill
|
||||||
|
# ships with no row (`dream` and `experiment-spec` did — their rows lived on
|
||||||
|
# branches squash-merged after a directory reorg, so the SKILL.md landed and
|
||||||
|
# the row did not, #15), or a row outlives the skill it names. Neither errors
|
||||||
|
# anywhere; the library just misreports itself to every harness that reads it.
|
||||||
|
#
|
||||||
|
# Usage: check-index [checkout-root] (defaults to this script's repo root)
|
||||||
|
|
||||||
|
set -u
|
||||||
|
|
||||||
|
ROOT=${1:-$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)}
|
||||||
|
INDEX="$ROOT/SKILLS_INDEX.md"
|
||||||
|
fail=0
|
||||||
|
|
||||||
|
if [ ! -f "$INDEX" ]; then
|
||||||
|
echo "ERROR: missing SKILLS_INDEX.md" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# A row is a table line whose first cell is the skill name in backticks.
|
||||||
|
listed=$(sed -n 's/^|[[:space:]]*`\([^`]*\)`.*/\1/p' "$INDEX" | sort -u)
|
||||||
|
|
||||||
|
for dir in "$ROOT"/skills/*/; do
|
||||||
|
name=$(basename "$dir")
|
||||||
|
[ -f "$dir/SKILL.md" ] || continue
|
||||||
|
printf '%s\n' "$listed" | grep -qx "$name" || {
|
||||||
|
echo "ERROR: skills/$name has no row in SKILLS_INDEX.md" >&2
|
||||||
|
fail=1
|
||||||
|
}
|
||||||
|
done
|
||||||
|
|
||||||
|
for name in $listed; do
|
||||||
|
[ -f "$ROOT/skills/$name/SKILL.md" ] || {
|
||||||
|
echo "ERROR: SKILLS_INDEX.md lists '$name', which has no skills/$name/SKILL.md" >&2
|
||||||
|
fail=1
|
||||||
|
}
|
||||||
|
done
|
||||||
|
|
||||||
|
exit $fail
|
||||||
Executable
+301
@@ -0,0 +1,301 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# doctor — report skills-library drift on this host.
|
||||||
|
#
|
||||||
|
# Written because every failure this fleet actually hit was SILENT: 243
|
||||||
|
# dangling symlinks hidden from git by the **/.claude/skills/ ignore rule, a
|
||||||
|
# checkout that still resolved but was 20 skills behind, a loose .md that
|
||||||
|
# loaded in no harness, and two machines where the library was simply absent.
|
||||||
|
# None of it errored. Exit status here is the signal that was missing.
|
||||||
|
#
|
||||||
|
# doctor human report, non-zero exit on drift
|
||||||
|
# doctor --quiet print only when something is wrong (SessionStart hook)
|
||||||
|
# doctor --json machine-readable findings
|
||||||
|
#
|
||||||
|
# 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.
|
||||||
|
# It does no network I/O and never shells out to git for a fetch.
|
||||||
|
#
|
||||||
|
# Test seams: $HOME drives harness paths, $DOCTOR_CHECKOUT names the checkout.
|
||||||
|
|
||||||
|
set -u
|
||||||
|
|
||||||
|
QUIET=0
|
||||||
|
JSON=0
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--quiet) QUIET=1 ;;
|
||||||
|
--json) JSON=1 ;;
|
||||||
|
-h|--help)
|
||||||
|
sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'
|
||||||
|
exit 0 ;;
|
||||||
|
*) printf 'doctor: unknown option %s\n' "$arg" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||||
|
SELF_CHECKOUT=$(CDPATH= cd -- "$SELF_DIR/.." && pwd)
|
||||||
|
|
||||||
|
# The canonical checkout is whatever ~/.agents/skills resolves to — NOT the
|
||||||
|
# copy doctor happens to be running from. When invoked as a plugin hook, $0
|
||||||
|
# lives in the plugin cache, which is legitimately a second copy: judging the
|
||||||
|
# harness links against it would flag every one of them as "outside".
|
||||||
|
AGENTS_DIR="$HOME/.agents/skills"
|
||||||
|
SOURCES_FILE="$(dirname -- "$AGENTS_DIR")/$(basename -- "$AGENTS_DIR")-sources"
|
||||||
|
|
||||||
|
if [ -n "${DOCTOR_CHECKOUT:-}" ]; then
|
||||||
|
CHECKOUT="$DOCTOR_CHECKOUT"
|
||||||
|
elif [ -f "$SOURCES_FILE" ]; then
|
||||||
|
# Since #27 the canonical location is a real directory fed by several
|
||||||
|
# sources, so it can no longer be resolved to a checkout. install.sh records
|
||||||
|
# the list; the first line is always this repo's skills/ directory.
|
||||||
|
CHECKOUT=$(dirname -- "$(head -n 1 "$SOURCES_FILE")")
|
||||||
|
elif [ -L "$AGENTS_DIR" ]; then
|
||||||
|
# Pre-#27 wiring: one link straight at the checkout. Resolve the link FIRST,
|
||||||
|
# then take the parent. "cd X/.." resolves ".." lexically against the link
|
||||||
|
# path, which lands back in ~/.agents.
|
||||||
|
_resolved=$(CDPATH= cd -- "$AGENTS_DIR" 2>/dev/null && pwd -P)
|
||||||
|
CHECKOUT=$(dirname -- "$_resolved")
|
||||||
|
else
|
||||||
|
CHECKOUT="$SELF_CHECKOUT"
|
||||||
|
fi
|
||||||
|
SKILLS_DIR="$CHECKOUT/skills"
|
||||||
|
|
||||||
|
findings=0
|
||||||
|
report=""
|
||||||
|
|
||||||
|
# add <severity> <check> <detail>
|
||||||
|
add() {
|
||||||
|
findings=$((findings + 1))
|
||||||
|
if [ "$JSON" -eq 1 ]; then
|
||||||
|
report="${report}{\"severity\":\"$1\",\"check\":\"$2\",\"detail\":\"$(printf '%s' "$3" | sed 's/"/\\"/g')\"},"
|
||||||
|
else
|
||||||
|
report="${report}$1 $2: $3
|
||||||
|
"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# count_skills <dir> — directories containing a SKILL.md, one level down.
|
||||||
|
# -L on purpose: since #27 the entries are per-skill symlinks, and find does
|
||||||
|
# not descend into a symlinked directory without it.
|
||||||
|
count_skills() {
|
||||||
|
[ -d "$1" ] || { printf '0'; return; }
|
||||||
|
find -L "$1"/ -mindepth 2 -maxdepth 2 -name SKILL.md 2>/dev/null | wc -l | tr -d ' '
|
||||||
|
}
|
||||||
|
|
||||||
|
# under_known_source <physical-path> — 0 when the path lies inside a source
|
||||||
|
# install.sh recorded, or inside the active checkout.
|
||||||
|
under_known_source() {
|
||||||
|
_path=$1
|
||||||
|
_list="$SKILLS_DIR"
|
||||||
|
[ -f "$SOURCES_FILE" ] && _list="$_list
|
||||||
|
$(cat "$SOURCES_FILE")"
|
||||||
|
printf '%s\n' "$_list" | while IFS= read -r _src; do
|
||||||
|
[ -n "$_src" ] || continue
|
||||||
|
_real=$(CDPATH= cd -- "$_src" 2>/dev/null && pwd -P) || continue
|
||||||
|
[ -n "$_real" ] || continue
|
||||||
|
case "$_path" in "$_real"/*|"$_real") printf 'yes'; break ;; esac
|
||||||
|
done | grep -q yes
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- the checkout itself ----------------------------------------------------
|
||||||
|
if [ ! -d "$SKILLS_DIR" ]; then
|
||||||
|
add FAIL checkout "no skills/ directory at $CHECKOUT"
|
||||||
|
# Nothing downstream can be judged without it.
|
||||||
|
printf 'FAIL checkout: no skills/ directory at %s\n' "$CHECKOUT" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
EXPECTED=$(count_skills "$SKILLS_DIR")
|
||||||
|
[ "$EXPECTED" -gt 0 ] || add FAIL checkout "skills/ holds no SKILL.md at all"
|
||||||
|
|
||||||
|
# --- stray entries in the checkout -----------------------------------------
|
||||||
|
# A loose foo.md where foo/SKILL.md was meant loads in NO harness and errors
|
||||||
|
# in none either. That is exactly how secret-hygiene.md sat dead for months.
|
||||||
|
for entry in "$SKILLS_DIR"/*; do
|
||||||
|
[ -e "$entry" ] || continue
|
||||||
|
name=$(basename "$entry")
|
||||||
|
if [ ! -d "$entry" ]; then
|
||||||
|
add FAIL stray "$SKILLS_DIR/$name is not a directory — it loads nowhere"
|
||||||
|
elif [ ! -f "$entry/SKILL.md" ]; then
|
||||||
|
add FAIL stray "$SKILLS_DIR/$name has no SKILL.md"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# --- the canonical directory --------------------------------------------------
|
||||||
|
# Pre-#27 this was a symlink at the checkout. It is a real directory now, one
|
||||||
|
# per-skill link per skill, fed by every configured source — that is what lets
|
||||||
|
# a second library (Anthropic's official set on flamingo) coexist instead of
|
||||||
|
# being displaced.
|
||||||
|
if [ -L "$AGENTS_DIR" ]; then
|
||||||
|
add WARN legacy "$AGENTS_DIR is the pre-#27 single-link wiring — re-run install.sh to adopt the multi-source layout"
|
||||||
|
elif [ -d "$AGENTS_DIR" ]; then
|
||||||
|
for entry in "$AGENTS_DIR"/*; do
|
||||||
|
[ -e "$entry" ] || [ -L "$entry" ] || continue
|
||||||
|
name=$(basename "$entry")
|
||||||
|
|
||||||
|
if [ ! -L "$entry" ]; then
|
||||||
|
add FAIL unmanaged "$AGENTS_DIR/$name is not a link into any source — nothing manages it and nothing updates it"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if [ ! -e "$entry" ]; then
|
||||||
|
add FAIL dangling "$entry -> $(readlink "$entry") (target does not exist)"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
resolved=$(CDPATH= cd -- "$entry" 2>/dev/null && pwd -P)
|
||||||
|
if [ -n "$resolved" ] && ! under_known_source "$resolved"; then
|
||||||
|
add FAIL outside "$entry resolves to $resolved, an unknown source — it works, so nothing complains, while serving content nobody updates"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# A per-skill layout can lose one skill without changing any total, so the
|
||||||
|
# invariant is per skill, not a count.
|
||||||
|
for dir in "$SKILLS_DIR"/*/; do
|
||||||
|
[ -f "$dir/SKILL.md" ] || continue
|
||||||
|
name=$(basename "$dir")
|
||||||
|
[ -L "$AGENTS_DIR/$name" ] \
|
||||||
|
|| add FAIL missing "$name is in the checkout but has no link in $AGENTS_DIR — re-run install.sh"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- harness wiring ---------------------------------------------------------
|
||||||
|
# Crush and OpenCode keep their single directory link at the canonical dir;
|
||||||
|
# Pi reads it directly and needs no link of its own.
|
||||||
|
for dir in \
|
||||||
|
"$HOME/.config/crush/skills" \
|
||||||
|
"$HOME/.config/opencode/skills"
|
||||||
|
do
|
||||||
|
[ -L "$dir" ] || [ -d "$dir" ] || continue
|
||||||
|
|
||||||
|
if [ -L "$dir" ] && [ ! -e "$dir" ]; then
|
||||||
|
add FAIL dangling "$dir -> $(readlink "$dir") (target does not exist)"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# A real directory where a link belongs holds content nothing manages: it
|
||||||
|
# cannot receive new skills and silently shadows the canonical dir. Since
|
||||||
|
# #27 the fix is not only "move it aside" — it can be added as an extra
|
||||||
|
# source instead, which is how flamingo keeps the official Anthropic set.
|
||||||
|
if [ ! -L "$dir" ] && [ -d "$dir" ]; then
|
||||||
|
add FAIL unmanaged "$dir is a real directory, not a link into the canonical dir — it holds $(count_skills "$dir") unmanaged skill(s); add it to SKILLS_EXTRA_SOURCES and re-run install.sh, or move it aside"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
resolved=$(CDPATH= cd -- "$dir" 2>/dev/null && pwd -P)
|
||||||
|
canonical=$(CDPATH= cd -- "$AGENTS_DIR" 2>/dev/null && pwd -P)
|
||||||
|
if [ -n "$resolved" ] && [ -n "$canonical" ] && [ "$resolved" != "$canonical" ]; then
|
||||||
|
add FAIL outside "$dir resolves to $resolved, not the canonical $canonical"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# --- dangling links anywhere under the harness dirs -------------------------
|
||||||
|
for dir in "$HOME/.config/crush/skills" \
|
||||||
|
"$HOME/.config/opencode/skills" "$HOME/.claude/skills"
|
||||||
|
do
|
||||||
|
[ -d "$dir" ] || continue
|
||||||
|
# Plain glob, not find|while: a while loop on the right of a pipe runs in a
|
||||||
|
# subshell, so add() would increment a counter that dies with it and doctor
|
||||||
|
# would exit 0 having found faults.
|
||||||
|
for link in "$dir"/*; do
|
||||||
|
[ -L "$link" ] || continue
|
||||||
|
[ -e "$link" ] && continue
|
||||||
|
add FAIL dangling "$link -> $(readlink "$link") (target does not exist)"
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
# --- Claude Code: plugin vs leftover per-skill symlinks ---------------------
|
||||||
|
# Plugin skills do NOT override same-named user skills — both stay visible. So
|
||||||
|
# leftover links from the pre-plugin era mean every skill appears twice.
|
||||||
|
CLAUDE_SKILLS="$HOME/.claude/skills"
|
||||||
|
if [ -d "$CLAUDE_SKILLS" ]; then
|
||||||
|
legacy=$(find "$CLAUDE_SKILLS"/ -maxdepth 1 -type l 2>/dev/null | wc -l | tr -d ' ')
|
||||||
|
if [ "$legacy" -gt 0 ]; then
|
||||||
|
add FAIL legacy "$CLAUDE_SKILLS holds $legacy per-skill symlink(s) from the pre-plugin wiring — remove them or every skill loads twice"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Two delivery mechanisms means two copies that can drift apart: the plugin
|
||||||
|
# (Claude Code) and the checkout the other harnesses link to. Same host,
|
||||||
|
# different skill sets, no error — compare them.
|
||||||
|
# Skipped when DOCTOR_CHECKOUT is set: that scopes doctor to an explicit
|
||||||
|
# target, so comparing it against whatever copy the script lives in is
|
||||||
|
# meaningless rather than informative.
|
||||||
|
if [ -z "${DOCTOR_CHECKOUT:-}" ] && [ -f "$SELF_DIR/../.claude-plugin/plugin.json" ] \
|
||||||
|
&& [ "$SELF_CHECKOUT" != "$CHECKOUT" ]; then
|
||||||
|
plugin_count=$(count_skills "$SELF_CHECKOUT/skills")
|
||||||
|
if [ "$plugin_count" != "$EXPECTED" ]; then
|
||||||
|
add FAIL drift "Claude Code plugin serves $plugin_count skills, the other harnesses see $EXPECTED — the two copies have diverged"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
INSTALLED="$HOME/.claude/plugins/installed_plugins.json"
|
||||||
|
if [ -f "$INSTALLED" ]; then
|
||||||
|
grep -q '"skills@mathias"' "$INSTALLED" 2>/dev/null \
|
||||||
|
|| add WARN plugin "skills@mathias is not installed for Claude Code — /plugin marketplace add https://git.d-ma.be/mathias/skills.git"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Claude Code: which GENERATION of the plugin is installed ---------------
|
||||||
|
# Every check above is about symlink paths, which is the wrong shape for the
|
||||||
|
# plugin: it is the only channel Claude Code has, and Claude Code gates updates
|
||||||
|
# on plugin.json's version. A host that never picked up a release loads all the
|
||||||
|
# skills successfully and is simply a generation behind — silently. Found on
|
||||||
|
# koala 2026-08-29: installed 0.6.0, marketplace clone 0.7.0, repo 0.8.5,
|
||||||
|
# doctor said "clean" (#30).
|
||||||
|
#
|
||||||
|
# Offline by construction, like the rest of doctor: "available" means the
|
||||||
|
# newest manifest already on this host — the active checkout and any
|
||||||
|
# marketplace clone — never a fetch.
|
||||||
|
manifest_version() {
|
||||||
|
[ -f "$1" ] || return 1
|
||||||
|
grep -q '"name"[[:space:]]*:[[:space:]]*"skills"' "$1" || return 1
|
||||||
|
sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$1" | head -1
|
||||||
|
}
|
||||||
|
|
||||||
|
# newer_version a b — echoes whichever sorts higher on major.minor.patch.
|
||||||
|
newer_version() {
|
||||||
|
printf '%s\n%s\n' "$1" "$2" | awk -F. '
|
||||||
|
{ raw[NR] = $0; for (i = 1; i <= 3; i++) n[NR, i] = ($i == "" ? 0 : $i + 0) }
|
||||||
|
END {
|
||||||
|
for (i = 1; i <= 3; i++) {
|
||||||
|
if (n[1, i] > n[2, i]) { print raw[1]; exit }
|
||||||
|
if (n[1, i] < n[2, i]) { print raw[2]; exit }
|
||||||
|
}
|
||||||
|
print raw[1]
|
||||||
|
}'
|
||||||
|
}
|
||||||
|
|
||||||
|
installed_v=''
|
||||||
|
if [ -f "$INSTALLED" ]; then
|
||||||
|
installed_v=$(sed -n '/"skills@mathias"/,/\]/p' "$INSTALLED" \
|
||||||
|
| sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1)
|
||||||
|
fi
|
||||||
|
|
||||||
|
available_v=''
|
||||||
|
for manifest in "$CHECKOUT/.claude-plugin/plugin.json" \
|
||||||
|
"$HOME"/.claude/plugins/marketplaces/*/.claude-plugin/plugin.json
|
||||||
|
do
|
||||||
|
v=$(manifest_version "$manifest") || continue
|
||||||
|
[ -n "$v" ] || continue
|
||||||
|
if [ -z "$available_v" ]; then
|
||||||
|
available_v=$v
|
||||||
|
else
|
||||||
|
available_v=$(newer_version "$available_v" "$v")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -n "$installed_v" ] && [ -n "$available_v" ] && [ "$installed_v" != "$available_v" ]; then
|
||||||
|
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
|
||||||
|
|
||||||
|
# --- output -----------------------------------------------------------------
|
||||||
|
if [ "$JSON" -eq 1 ]; then
|
||||||
|
printf '{"checkout":"%s","expected":%s,"plugin":{"installed":"%s","available":"%s"},"findings":[%s]}\n' \
|
||||||
|
"$CHECKOUT" "$EXPECTED" "$installed_v" "$available_v" "${report%,}"
|
||||||
|
elif [ "$findings" -gt 0 ]; then
|
||||||
|
printf 'skills doctor — %s finding(s), checkout %s\n\n%s\n' \
|
||||||
|
"$findings" "$CHECKOUT" "$report"
|
||||||
|
elif [ "$QUIET" -eq 0 ]; then
|
||||||
|
printf 'skills doctor: clean — %s skills, checkout %s\n' "$EXPECTED" "$CHECKOUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ "$findings" -eq 0 ]
|
||||||
Executable
+25
@@ -0,0 +1,25 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# should-skip-release — reads a commit message on stdin. Exits 0 when the
|
||||||
|
# release tagger should skip this commit, 1 when it should tag.
|
||||||
|
#
|
||||||
|
# Skipping is opt-in via a git trailer:
|
||||||
|
#
|
||||||
|
# Skip-Release: true
|
||||||
|
#
|
||||||
|
# It is a TRAILER, matched anchored to its own line, deliberately. The first
|
||||||
|
# version searched the whole message for a bracketed marker as a substring,
|
||||||
|
# and the very commit that introduced the manifest-bump step described that
|
||||||
|
# marker in its body — so the tagger read its own documentation as an
|
||||||
|
# instruction and skipped the release. Success, no tag, no bump, no error
|
||||||
|
# (run 15, 2026-08-23). A marker you cannot mention without triggering is a
|
||||||
|
# footgun, not a mechanism.
|
||||||
|
|
||||||
|
set -u
|
||||||
|
|
||||||
|
msg=$(cat)
|
||||||
|
|
||||||
|
if printf '%s\n' "$msg" | grep -qiE '^[[:space:]]*Skip-Release:[[:space:]]*true[[:space:]]*$'; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit 1
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"SessionStart": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/bin/doctor\" --quiet",
|
||||||
|
"timeout": 5,
|
||||||
|
"statusMessage": "Checking skills wiring..."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"UserPromptSubmit": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/pre-task-ritual.sh\"",
|
||||||
|
"timeout": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PreToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Bash",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/secret-guard.sh\"",
|
||||||
|
"timeout": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+3
@@ -0,0 +1,3 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Surfaces rule 0 on every prompt so the pre-task ritual is visible in context.
|
||||||
|
echo "Rule 0 (non-negotiable): brain_query → load skill → failing test → state success criterion"
|
||||||
Executable
+125
@@ -0,0 +1,125 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# PreToolUse hook (Bash matcher) — hard-blocks command patterns that reveal
|
||||||
|
# secret values into the transcript, rather than relying on remembering an
|
||||||
|
# existence-only check every time.
|
||||||
|
#
|
||||||
|
# Triggered by two real leaks in one session (2026-07-30):
|
||||||
|
# 1. `op item get <name> --format json` — JSON mode has NO masking, unlike
|
||||||
|
# the human-readable table view (`op item get <name>` alone masks
|
||||||
|
# concealed fields with bullets by default). Printed a live Gitea MCP
|
||||||
|
# token into the transcript.
|
||||||
|
# 2. `env | grep -i gitea` (infra#228, 2026-07-27) — same shape: an
|
||||||
|
# inspection command that dumps values instead of checking existence.
|
||||||
|
#
|
||||||
|
# Policy: block ALL direct reads of secret material via these command
|
||||||
|
# shapes, unconditionally, regardless of flags. Existence/rotation must go
|
||||||
|
# through a wrapper script (e.g. infra's scripts/op-safe.sh, scripts/rotate-
|
||||||
|
# *.sh) that strips values before anything reaches stdout — those scripts
|
||||||
|
# call the same underlying commands internally, but as a subprocess of the
|
||||||
|
# script, never as a literal Bash tool call the model composes and can typo.
|
||||||
|
#
|
||||||
|
# Reads PreToolUse hook JSON on stdin, decides allow/deny for the Bash tool.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
INPUT="$(cat)"
|
||||||
|
TOOL_NAME="$(printf '%s' "$INPUT" | jq -r '.tool_name // empty')"
|
||||||
|
[ "$TOOL_NAME" = "Bash" ] || { printf '%s' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}'; exit 0; }
|
||||||
|
|
||||||
|
CMD="$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty')"
|
||||||
|
|
||||||
|
deny() {
|
||||||
|
local reason="$1"
|
||||||
|
jq -n --arg reason "$reason" \
|
||||||
|
'{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: $reason}, systemMessage: $reason}'
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1Password CLI raw reads — block unconditionally, any flags.
|
||||||
|
if printf '%s' "$CMD" | grep -Eqi '(^|[;&|]|\s)op\s+item\s+get\b'; then
|
||||||
|
deny "Blocked: 'op item get' prints secret values to stdout (JSON mode has no masking at all; even table mode can leak via --fields/--reveal). Use a wrapper that strips .fields[].value before output (e.g. scripts/op-safe.sh exists|meta), or op item edit to WRITE without ever printing the value."
|
||||||
|
fi
|
||||||
|
if printf '%s' "$CMD" | grep -Eqi '(^|[;&|]|\s)op\s+item\s+list\b'; then
|
||||||
|
deny "Blocked: 'op item list' can include concealed field previews. Use a wrapper that filters to titles only (e.g. scripts/op-safe.sh list)."
|
||||||
|
fi
|
||||||
|
if printf '%s' "$CMD" | grep -Eqi '(^|[;&|]|\s)op\s+read\b'; then
|
||||||
|
deny "Blocked: 'op read' prints the raw secret value directly. Consume it inside a subprocess only (op run -- <cmd>, or a script that never echoes/captures it to stdout)."
|
||||||
|
fi
|
||||||
|
if printf '%s' "$CMD" | grep -Eqi '(^|[;&|]|\s)op\s+document\s+get\b'; then
|
||||||
|
deny "Blocked: 'op document get' prints document contents (may contain secrets) to stdout. Use a wrapper script instead."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Raw env/secret-file dumps (infra#228/#229 root cause).
|
||||||
|
#
|
||||||
|
# `env` is a dump only when nothing but options and VAR=value assignments
|
||||||
|
# follow it. `env -u VAR cmd` and `env FOO=bar cmd` EXEC a command with a
|
||||||
|
# modified environment and print nothing themselves — piping those into grep
|
||||||
|
# filters the command's own output, not the environment (#25). The earlier
|
||||||
|
# pattern matched any `env` token followed anywhere later by a pipe into
|
||||||
|
# grep, which blocked the documented 1Password write path
|
||||||
|
# (`env -u OP_SERVICE_ACCOUNT_TOKEN op item create`) and cost a workaround.
|
||||||
|
# Denying only the "no command word follows" shape also catches `env FOO=bar`
|
||||||
|
# with no command, which the two previous rules let through.
|
||||||
|
if printf '%s' "$CMD" | grep -Eqi '(^|[;&|]|[[:space:]])env([[:space:]]+-[iu][[:space:]]*[A-Za-z_][A-Za-z0-9_]*|[[:space:]]+-[iu]|[[:space:]]+[A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*)*[[:space:]]*($|[|;&])'; then
|
||||||
|
deny "Blocked: this 'env' prints every variable's value, including secrets (no command word follows it, so it dumps rather than execs). Use an existence-only check: [ -n \"\$VAR\" ] && echo set. Note 'env -u VAR cmd' and 'env FOO=bar cmd' are allowed — they run a command."
|
||||||
|
fi
|
||||||
|
if printf '%s' "$CMD" | grep -Eqi '(^|[;&|]|\s)printenv\b'; then
|
||||||
|
deny "Blocked: printenv dumps all env values. Use: [ -n \"\$VAR\" ] && echo set"
|
||||||
|
fi
|
||||||
|
if printf '%s' "$CMD" | grep -Eqi '(^|[;&|]|\s)cat\b[^;&|]*\.env'; then
|
||||||
|
deny "Blocked: cat of a .env file prints secret values. Use: grep -c '=' file.env (count only) or [ -s file.env ] && echo present"
|
||||||
|
fi
|
||||||
|
if printf '%s' "$CMD" | grep -Eqi '(^|[;&|]|\s)cat\b[^;&|]*(\.llmkeys|\.op-env|\.op-agent-write\.env)\b'; then
|
||||||
|
deny "Blocked: cat of a known secrets file prints raw values. Use: grep -o '^[A-Z_]*=' file (key names only, never the value)."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# kubectl secrets — -o yaml/json prints base64 (trivially reversible).
|
||||||
|
if printf '%s' "$CMD" | grep -Eqi '(^|[;&|]|\s)kubectl\b[^;&|]*\bget\s+secrets?\b[^;&|]*(-o|--output)[[:space:]=]*(yaml|json)'; then
|
||||||
|
deny "Blocked: 'kubectl get secret -o yaml/json' prints base64-encoded values (trivially reversible, same as plaintext). Use 'kubectl describe secret <name>' (shows keys + byte lengths only, never values) for existence checks."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Gitea app.ini — mixes plain config ([queue]/[indexer]/[repository]) with
|
||||||
|
# live secrets ([security].INTERNAL_TOKEN/SECRET_KEY, [oauth2].JWT_SECRET,
|
||||||
|
# [server].LFS_JWT_SECRET) in the SAME file, auto-generated on install and
|
||||||
|
# not git/SOPS-tracked. A context-range grep (-A/-B/-C) doesn't respect
|
||||||
|
# section boundaries and silently pulls a secret line into stdout even when
|
||||||
|
# targeting an unrelated section. Root cause of a real leak, infra sprint
|
||||||
|
# 2026-08-04 (`kubectl exec ... cat app.ini | grep -A15 "^\[queue\]"` pulled
|
||||||
|
# through into [oauth2].JWT_SECRET).
|
||||||
|
# Tight on purpose: requires "cat" immediately followed by a bare path-shaped
|
||||||
|
# token ending in app.ini (no intervening whitespace) so this only fires on
|
||||||
|
# an actual invocation ('cat /data/gitea/conf/app.ini', 'cat app.ini') and
|
||||||
|
# never on prose that merely mentions both words, e.g. a commit message
|
||||||
|
# describing this exact guard (that false-positived once while writing the
|
||||||
|
# fix — commands are matched as raw strings, heredoc bodies included).
|
||||||
|
if printf '%s' "$CMD" | grep -Eq '\bcat[[:space:]]+[A-Za-z0-9_./-]*app\.ini\b'; then
|
||||||
|
deny "Blocked: cat of Gitea's app.ini prints secrets mixed into the same file ([security]/[oauth2]/[server].LFS_JWT_SECRET). Use exact-key checks: grep -c '^KEY = ' app.ini (existence only) — never cat the whole file."
|
||||||
|
fi
|
||||||
|
if printf '%s' "$CMD" | grep -Eq '\bgrep\b[^;&|]*-[A-Za-z]*[ABC][A-Za-z]*[[:space:]]*[0-9][^;&|]*app\.ini\b'; then
|
||||||
|
deny "Blocked: grep -A/-B/-C context flags against app.ini can bleed past section boundaries into [security]/[oauth2]/[server] secrets, even when targeting an unrelated section (this is exactly how the 2026-08-04 leak happened). Use an exact anchor instead: grep '^KEY = ' app.ini, or bound to one section with awk '/^\\[section\\]/,/^\\[/' and confirm the section has no secret keys first."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# sops decrypt output inspected directly — infra sweep-sprint incident,
|
||||||
|
# 2026-08-05: `sops -d ntfy-tokens.enc.yaml > /tmp/plain.yaml` (safe,
|
||||||
|
# redirected to a file) immediately followed by `cat /tmp/plain.yaml | head`
|
||||||
|
# in the SAME command block printed a live ntfy token into the transcript.
|
||||||
|
# Re-encrypting a secret (e.g. switching a file to data-only encryption per
|
||||||
|
# infra#177's pattern) requires decrypting it locally at some point — that
|
||||||
|
# redirect-to-file step alone is fine, the leak is any print command touching
|
||||||
|
# the plaintext afterward. Block the combination unconditionally rather than
|
||||||
|
# trying to track which file a decrypt wrote to across separate tool calls.
|
||||||
|
if printf '%s' "$CMD" | grep -Eq '\bsops\b[^;&|]*(-d\b|--decrypt\b|[[:space:]]decrypt\b)' \
|
||||||
|
&& printf '%s' "$CMD" | grep -Eq '\b(cat|head|tail|less|more|bat|od|xxd)\b'; then
|
||||||
|
deny "Blocked: this command decrypts a sops-encrypted file AND runs a print command (cat/head/tail/etc) in the same invocation — that's exactly the 2026-08-05 ntfy-token leak shape. Decrypt to a temp file (sops -d file > /tmp/x, fine on its own), do whatever transform is needed WITHOUT printing it (e.g. sops -e --encrypted-regex '^(data|stringData)\$' /tmp/x > newfile), then shred -u the temp file. Never cat/head/tail a sops-decrypted file, even to 'just check a few lines'."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# base64 transform of a secret-shaped env var — the documented 2026-06-11
|
||||||
|
# 'defeats op run masking' bypass.
|
||||||
|
if printf '%s' "$CMD" | grep -Eqi '\$\{?[A-Za-z0-9_]*(TOKEN|SECRET|PASSWORD|API_KEY|_KEY|DSN|CREDENTIAL|PAT)[A-Za-z0-9_]*\}?[^;&|]*\|[^;&|]*base64'; then
|
||||||
|
deny "Blocked: piping a secret-shaped variable through base64 to inspect it. base64 is an encoding, not masking — op run's own masking only recognizes the raw value, so this is a known bypass (2026-06-11 incident). Never transform a secret to look at it."
|
||||||
|
fi
|
||||||
|
if printf '%s' "$CMD" | grep -Eqi 'base64[^;&|]*<<<\s*"?\$\{?[A-Za-z0-9_]*(TOKEN|SECRET|PASSWORD|API_KEY|_KEY|DSN|CREDENTIAL|PAT)'; then
|
||||||
|
deny "Blocked: base64 <<< of a secret-shaped variable. Same bypass as above — never transform a secret to inspect it."
|
||||||
|
fi
|
||||||
|
|
||||||
|
jq -n '{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "allow"}}'
|
||||||
+172
-99
@@ -5,25 +5,69 @@
|
|||||||
# curl -fsSL https://git.d-ma.be/mathias/skills/raw/branch/main/install.sh | bash
|
# curl -fsSL https://git.d-ma.be/mathias/skills/raw/branch/main/install.sh | bash
|
||||||
#
|
#
|
||||||
# Pinned to a specific tag:
|
# Pinned to a specific tag:
|
||||||
# SKILLS_REF=v0.1.0 bash install.sh
|
# SKILLS_REF=v0.4.0 bash install.sh
|
||||||
#
|
#
|
||||||
# Idempotent: re-running pulls the latest changes for the configured ref
|
# Idempotent: re-running pulls the latest changes for the configured ref
|
||||||
# (default: main) and re-wires symlinks. Existing correct links are
|
# (default: main) and re-wires the links.
|
||||||
# left alone; mismatched ones are replaced.
|
#
|
||||||
|
# Claude Code is NOT wired here. It installs this repo as a plugin from the
|
||||||
|
# marketplace this repo also hosts:
|
||||||
|
#
|
||||||
|
# /plugin marketplace add https://git.d-ma.be/mathias/skills.git
|
||||||
|
# /plugin install skills@mathias
|
||||||
|
#
|
||||||
|
# That path carries the hooks and MCP server definitions too, which symlinks
|
||||||
|
# cannot. This script covers the harnesses with no plugin system: Crush, Pi
|
||||||
|
# and OpenCode.
|
||||||
|
#
|
||||||
|
# Wiring model — one managed directory, one link per harness:
|
||||||
|
#
|
||||||
|
# ~/.agents/skills/tdd -> $CHECKOUT_DIR/skills/tdd (canonical; Pi reads the dir directly)
|
||||||
|
# ~/.agents/skills/xlsx -> $OTHER_SOURCE/xlsx
|
||||||
|
# ~/.config/crush/skills -> ~/.agents/skills
|
||||||
|
# ~/.config/opencode/skills -> ~/.agents/skills
|
||||||
|
#
|
||||||
|
# ~/.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
|
||||||
|
# skills on the host — and on flamingo the only way to install this library
|
||||||
|
# was to displace Anthropic's official set, which lived in Crush's directory.
|
||||||
|
# A real directory of per-skill links serves several sources at once: total
|
||||||
|
# links are N + one per harness, still not N x harnesses (#23's actual point).
|
||||||
|
#
|
||||||
|
# Extra sources come from SKILLS_EXTRA_SOURCES, a colon-separated list of
|
||||||
|
# directories each holding <skill>/SKILL.md. The checkout is always first and
|
||||||
|
# always wins a name collision — but the collision is logged, never resolved
|
||||||
|
# in silence, because quietly picking one is how stale content wins.
|
||||||
|
#
|
||||||
|
# CHECKOUT_DIR deliberately defaults OUTSIDE any file-synced or
|
||||||
|
# cross-platform-shared directory. Do not "fix" it to point into ~/dev: on a
|
||||||
|
# fleet whose workspace is shared between macOS (/Users/...) and Linux
|
||||||
|
# (/home/...), an absolute link stored there is broken on one side by
|
||||||
|
# construction. See mathias/skills#23.
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
REPO_URL="${SKILLS_REPO_URL:-https://git.d-ma.be/mathias/skills.git}"
|
REPO_URL="${SKILLS_REPO_URL:-https://git.d-ma.be/mathias/skills.git}"
|
||||||
REF="${SKILLS_REF:-main}"
|
REF="${SKILLS_REF:-main}"
|
||||||
CHECKOUT_DIR="${SKILLS_CHECKOUT_DIR:-$HOME/.local/share/skills}"
|
CHECKOUT_DIR="${SKILLS_CHECKOUT_DIR:-$HOME/.local/share/skills}"
|
||||||
CLAUDE_GLOBAL_DIR="${CLAUDE_SKILLS_DIR:-$HOME/.claude/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}"
|
||||||
ANTIGRAVITY_DIR="${ANTIGRAVITY_SKILLS_DIR:-$HOME/.gemini/antigravity/skills}"
|
OPENCODE_DIR="${OPENCODE_SKILLS_DIR:-$HOME/.config/opencode/skills}"
|
||||||
VIBE_SKILLS_DIR="${VIBE_SKILLS_DIR:-$HOME/.vibe/skills}"
|
EXTRA_SOURCES="${SKILLS_EXTRA_SOURCES:-}"
|
||||||
|
# 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.
|
||||||
|
SOURCES_MANIFEST="$(dirname "$AGENTS_DIR")/$(basename "$AGENTS_DIR")-sources"
|
||||||
|
|
||||||
log() { printf '[skills] %s\n' "$*"; }
|
log() { printf '[skills] %s\n' "$*"; }
|
||||||
|
|
||||||
ensure_checkout() {
|
ensure_checkout() {
|
||||||
|
# Wiring an existing working checkout (task install) must not fetch or
|
||||||
|
# move HEAD — that would discard in-progress work.
|
||||||
|
if [ "${SKILLS_SKIP_FETCH:-}" = "1" ]; then
|
||||||
|
[ -d "$CHECKOUT_DIR/skills" ] || { log "no skills/ under $CHECKOUT_DIR"; exit 1; }
|
||||||
|
log "using $CHECKOUT_DIR as-is (SKILLS_SKIP_FETCH=1)"
|
||||||
|
return
|
||||||
|
fi
|
||||||
if [ -d "$CHECKOUT_DIR/.git" ]; then
|
if [ -d "$CHECKOUT_DIR/.git" ]; then
|
||||||
log "updating $CHECKOUT_DIR"
|
log "updating $CHECKOUT_DIR"
|
||||||
git -C "$CHECKOUT_DIR" fetch --tags --quiet
|
git -C "$CHECKOUT_DIR" fetch --tags --quiet
|
||||||
@@ -40,109 +84,138 @@ ensure_checkout() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
list_skills() {
|
count_skills() {
|
||||||
# Every top-level entry that isn't a known meta file.
|
find "$CHECKOUT_DIR/skills" -mindepth 2 -maxdepth 2 -name SKILL.md 2>/dev/null | wc -l | tr -d ' '
|
||||||
(cd "$CHECKOUT_DIR" && ls -1) | grep -Ev '^(Taskfile\.yml|install\.sh|README\.md|SKILLS_INDEX\.md|\.gitea|\.git)$' || true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
link_skill() {
|
# sources — every configured source directory, primary first, one per line.
|
||||||
# link_skill <target_dir> <skill_name>
|
sources() {
|
||||||
local target_dir="$1"
|
printf '%s\n' "$CHECKOUT_DIR/skills"
|
||||||
local skill="$2"
|
[ -n "$EXTRA_SOURCES" ] || return 0
|
||||||
local target="$CHECKOUT_DIR/$skill"
|
# printf '%s\n' deliberately: without the trailing newline `read` hits EOF
|
||||||
local link="$target_dir/$skill"
|
# on the final entry and the loop body never runs for it.
|
||||||
|
printf '%s\n' "$EXTRA_SOURCES" | tr ':' '\n' | while IFS= read -r src; do
|
||||||
if [ -L "$link" ] || [ -e "$link" ]; then
|
[ -n "$src" ] || continue
|
||||||
local current
|
if [ -d "$src" ]; then
|
||||||
current=$(readlink "$link" 2>/dev/null || true)
|
printf '%s\n' "$src"
|
||||||
if [ "$current" = "$target" ]; then
|
else
|
||||||
return
|
log "extra source $src does not exist — skipping"
|
||||||
fi
|
fi
|
||||||
rm -rf "$link"
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# prepare_agents_dir — the canonical location is a real directory now. A host
|
||||||
|
# still on the single-link wiring is migrated in place.
|
||||||
|
prepare_agents_dir() {
|
||||||
|
if [ -L "$AGENTS_DIR" ]; then
|
||||||
|
log "migrating $AGENTS_DIR from the single-link wiring to a per-skill directory"
|
||||||
|
rm -f "$AGENTS_DIR"
|
||||||
|
elif [ -e "$AGENTS_DIR" ] && [ ! -d "$AGENTS_DIR" ]; then
|
||||||
|
log "REFUSING to replace $AGENTS_DIR — it exists and is not a directory"
|
||||||
|
return 1
|
||||||
fi
|
fi
|
||||||
|
mkdir -p "$AGENTS_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
|
# wire_skills — one symlink per skill, from every configured source.
|
||||||
|
wire_skills() {
|
||||||
|
local src_list desired name src dir link target
|
||||||
|
src_list="$(mktemp)"
|
||||||
|
sources > "$src_list"
|
||||||
|
|
||||||
|
desired=""
|
||||||
|
while IFS= read -r src; do
|
||||||
|
[ -d "$src" ] || continue
|
||||||
|
for dir in "$src"/*/; do
|
||||||
|
[ -f "$dir/SKILL.md" ] || continue
|
||||||
|
name="$(basename "$dir")"
|
||||||
|
target="${dir%/}"
|
||||||
|
|
||||||
|
if printf '%s' "$desired" | grep -qx -- "$name"; then
|
||||||
|
log "COLLISION: $name is offered by $target too; keeping $AGENTS_DIR/$name -> $(readlink "$AGENTS_DIR/$name" 2>/dev/null || echo '?')"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
link="$AGENTS_DIR/$name"
|
||||||
|
if [ -L "$link" ]; then
|
||||||
|
if [ "$(readlink "$link")" = "$target" ]; then
|
||||||
|
desired="$desired$name
|
||||||
|
"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
rm -f "$link"
|
||||||
|
elif [ -e "$link" ]; then
|
||||||
|
log "unmanaged: $link is not a symlink — left in place; move it aside to let $target take the name"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
ln -s "$target" "$link"
|
||||||
|
desired="$desired$name
|
||||||
|
"
|
||||||
|
done
|
||||||
|
done < "$src_list"
|
||||||
|
rm -f "$src_list"
|
||||||
|
|
||||||
|
# Removing a skill from a source must remove its link, or the previous
|
||||||
|
# generation's problem (links resolving to nothing, invisible to git)
|
||||||
|
# returns in a new shape.
|
||||||
|
for link in "$AGENTS_DIR"/*; do
|
||||||
|
[ -e "$link" ] || [ -L "$link" ] || continue
|
||||||
|
name="$(basename "$link")"
|
||||||
|
if [ ! -L "$link" ]; then
|
||||||
|
log "unmanaged: $link is not a symlink and no source offers it — left in place"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
printf '%s' "$desired" | grep -qx -- "$name" && continue
|
||||||
|
rm -f "$link"
|
||||||
|
log "pruned $name — no configured source offers it any more"
|
||||||
|
done
|
||||||
|
|
||||||
|
sources > "$SOURCES_MANIFEST"
|
||||||
|
# Reported through a global, not stdout: wire_skills logs collisions and
|
||||||
|
# unmanaged entries as it goes, and capturing its stdout for a count would
|
||||||
|
# swallow exactly the warnings this change exists to surface.
|
||||||
|
LINKED_COUNT=$(printf '%s' "$desired" | grep -c . || true)
|
||||||
|
}
|
||||||
|
|
||||||
|
# link_dir <link_path> <target_path>
|
||||||
|
# Replaces whatever is there — including a directory left behind by the old
|
||||||
|
# per-skill wiring — with a single symlink.
|
||||||
|
link_dir() {
|
||||||
|
local link="$1" target="$2"
|
||||||
|
if [ -L "$link" ]; then
|
||||||
|
[ "$(readlink "$link")" = "$target" ] && return
|
||||||
|
rm -f "$link"
|
||||||
|
elif [ -d "$link" ]; then
|
||||||
|
# Refuse to discard a directory holding anything but symlinks: that
|
||||||
|
# would be someone's real skills, not our old wiring.
|
||||||
|
if find "$link" -mindepth 1 -maxdepth 1 ! -type l | grep -q .; then
|
||||||
|
log "REFUSING to replace $link — it holds non-symlink entries; move them and re-run"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
find "$link" -mindepth 1 -maxdepth 1 -type l -delete
|
||||||
|
rmdir "$link"
|
||||||
|
elif [ -e "$link" ]; then
|
||||||
|
rm -f "$link"
|
||||||
|
fi
|
||||||
|
mkdir -p "$(dirname "$link")"
|
||||||
ln -s "$target" "$link"
|
ln -s "$target" "$link"
|
||||||
log "linked $link → $target"
|
log "linked $link → $target"
|
||||||
}
|
}
|
||||||
|
|
||||||
wire_claude_global() {
|
|
||||||
mkdir -p "$CLAUDE_GLOBAL_DIR"
|
|
||||||
while IFS= read -r skill; do
|
|
||||||
[ -n "$skill" ] || continue
|
|
||||||
link_skill "$CLAUDE_GLOBAL_DIR" "$skill"
|
|
||||||
done < <(list_skills)
|
|
||||||
}
|
|
||||||
|
|
||||||
wire_crush() {
|
|
||||||
# Crush reads skills from ~/.config/crush/skills/<name>/SKILL.md.
|
|
||||||
# Pre-creating the dir is cheap even when Crush isn't installed.
|
|
||||||
mkdir -p "$CRUSH_DIR"
|
|
||||||
while IFS= read -r skill; do
|
|
||||||
[ -n "$skill" ] || continue
|
|
||||||
link_skill "$CRUSH_DIR" "$skill"
|
|
||||||
done < <(list_skills)
|
|
||||||
}
|
|
||||||
|
|
||||||
wire_antigravity() {
|
|
||||||
# Antigravity (Google's VS Code extension) reads global skills from
|
|
||||||
# ~/.gemini/antigravity/skills/<name>/SKILL.md. Our SKILL.md files
|
|
||||||
# already carry the required `name` + `description` YAML frontmatter,
|
|
||||||
# so symlinks are sufficient — no manifest translation step.
|
|
||||||
mkdir -p "$ANTIGRAVITY_DIR"
|
|
||||||
while IFS= read -r skill; do
|
|
||||||
[ -n "$skill" ] || continue
|
|
||||||
link_skill "$ANTIGRAVITY_DIR" "$skill"
|
|
||||||
done < <(list_skills)
|
|
||||||
}
|
|
||||||
|
|
||||||
wire_claude_repo() {
|
|
||||||
# Only wire per-repo when invoked from inside a git repo and it isn't
|
|
||||||
# the skills repo itself.
|
|
||||||
if ! git rev-parse --show-toplevel >/dev/null 2>&1; then
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
local repo
|
|
||||||
repo=$(git rev-parse --show-toplevel)
|
|
||||||
if [ "$repo" = "$CHECKOUT_DIR" ]; then
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
local target_dir="$repo/.claude/skills"
|
|
||||||
mkdir -p "$target_dir"
|
|
||||||
while IFS= read -r skill; do
|
|
||||||
[ -n "$skill" ] || continue
|
|
||||||
link_skill "$target_dir" "$skill"
|
|
||||||
done < <(list_skills)
|
|
||||||
}
|
|
||||||
|
|
||||||
wire_vibe() {
|
|
||||||
# Mistral Vibe reads a flat ~/.vibe/skills/<name>.md — link the SKILL.md
|
|
||||||
# file directly (not the skill directory, as the other harnesses do).
|
|
||||||
mkdir -p "$VIBE_SKILLS_DIR"
|
|
||||||
while IFS= read -r skill; do
|
|
||||||
[ -n "$skill" ] || continue
|
|
||||||
local target="$CHECKOUT_DIR/$skill/SKILL.md"
|
|
||||||
[ -f "$target" ] || continue
|
|
||||||
local link="$VIBE_SKILLS_DIR/$skill.md"
|
|
||||||
if [ -L "$link" ] || [ -e "$link" ]; then
|
|
||||||
local current
|
|
||||||
current=$(readlink "$link" 2>/dev/null || true)
|
|
||||||
if [ "$current" = "$target" ]; then
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
rm -rf "$link"
|
|
||||||
fi
|
|
||||||
ln -s "$target" "$link"
|
|
||||||
log "linked $link → $target"
|
|
||||||
done < <(list_skills)
|
|
||||||
}
|
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
ensure_checkout
|
ensure_checkout
|
||||||
wire_claude_global
|
|
||||||
wire_claude_repo
|
# Canonical location. Pi reads ~/.agents/skills directly, so it needs no
|
||||||
wire_crush
|
# harness-specific link of its own.
|
||||||
wire_antigravity
|
prepare_agents_dir
|
||||||
wire_vibe
|
LINKED_COUNT=0
|
||||||
log "done — $(list_skills | wc -l | tr -d ' ') skill(s) wired at ref=$REF"
|
wire_skills
|
||||||
|
|
||||||
|
link_dir "$CRUSH_DIR" "$AGENTS_DIR"
|
||||||
|
link_dir "$OPENCODE_DIR" "$AGENTS_DIR"
|
||||||
|
|
||||||
|
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"
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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:
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
---
|
||||||
|
name: own-the-task
|
||||||
|
description: Take ownership of a delegated task like a hired employee — interview first, write a handover brief, propose a plan, then execute. Use whenever the user says "take this off my plate", "own this", "act as my AI employee", "interview me first", "ask me questions before you start", or delegates anything open-ended where the real goal, the why, and the definition of done are not yet on the table. Trigger even when "interview" is never said — any delegation where getting the intent wrong would waste the whole effort belongs here.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Own the task
|
||||||
|
|
||||||
|
The user is delegating, not dictating. Value comes from the questions that change the shape of the work, not from a fast answer to the surface request.
|
||||||
|
|
||||||
|
**Intake → Brief → Plan → Execute.** Don't skip ahead. A task looking obvious usually means the real goal hasn't surfaced yet.
|
||||||
|
|
||||||
|
## 1. Intake
|
||||||
|
|
||||||
|
Ask the 5 highest-signal questions — the ones whose answers would most change your approach. **One per turn, wait for the answer.** Skip anything you could infer or look up. Follow up on any gap, contradiction, or opportunity; follow-ups don't count against the five.
|
||||||
|
|
||||||
|
Cover:
|
||||||
|
|
||||||
|
1. **Real goal** — the outcome underneath the stated deliverable.
|
||||||
|
2. **Why** it matters to them.
|
||||||
|
3. **Attention budget** — what they want to be *asked* about vs *told* about afterwards, plus hard constraints (time, money, people, politics, tooling, taste). This work fails by pulling them back in, not by missing a spec.
|
||||||
|
4. **Definition of done** — observable. Then always follow up: *what changes for you once that's true?* Cold, that question gets a shrug; after the observable answer, it surfaces criteria they can't state directly.
|
||||||
|
5. **Failure modes** — what bad looks like, or what previous attempts got wrong. Success criteria are rehearsed; being burned isn't.
|
||||||
|
|
||||||
|
Weak → strong:
|
||||||
|
- "What's your deadline?" → "If this were half-finished on the deadline, what's the half you'd want done?"
|
||||||
|
- "How involved do you want to be?" → "What would you be annoyed to be asked about — and what would you be annoyed to find out afterwards?"
|
||||||
|
- "Any constraints?" → "What's been tried here before, and what made it not stick?"
|
||||||
|
|
||||||
|
If they answer thinly or say "you decide", take the decision, state the assumption, move on.
|
||||||
|
|
||||||
|
## 2. Handover brief
|
||||||
|
|
||||||
|
Write it as if you had amnesia and were briefing your future self — a stranger should be able to pick the task up cold.
|
||||||
|
|
||||||
|
```
|
||||||
|
**Task:** / **Real goal:** / **Why:**
|
||||||
|
**Constraints:** [hard vs soft, marked]
|
||||||
|
**Attention budget:** [what comes back to them, what doesn't]
|
||||||
|
**Definition of done:** / **What a win feels like:**
|
||||||
|
**Failure modes to avoid:**
|
||||||
|
**Open assumptions:** [what you decided for them, so they can correct it]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Plan
|
||||||
|
|
||||||
|
Short: numbered steps with what each produces, the first checkpoint, and what you'll decide yourself (drawn from the attention budget, not guessed). Then ask for a go-ahead — one question, not a menu.
|
||||||
|
|
||||||
|
## 4. Execute
|
||||||
|
|
||||||
|
Decide the small things. Surface only what changes the outcome. If reality contradicts the brief, re-propose early rather than quietly bending the work. Deliver against the definition of done and say plainly where you fell short.
|
||||||
|
|
||||||
|
## Skip this
|
||||||
|
|
||||||
|
For small, well-specified, or factual requests. If the task is nearly specified, ask one or two questions and say why you're going short.
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
---
|
||||||
|
name: secret-hygiene
|
||||||
|
description: Prevent agent sessions from rendering credentials into the transcript, and recover safely when one leaks. Load BEFORE any command that touches credentials - kubectl exec/env/describe, kubectl get secret, whole-file SOPS decryption, token generation or rotation, envFrom inspection, or edits to encrypted config. Also load when a credential has already been exposed and a rotation chain is needed. Triggers - "rotate this token", "decrypt the config", "check the env var", "leaked credential", "did that print a secret", "why is this secret wrong".
|
||||||
|
---
|
||||||
|
|
||||||
|
# Secret hygiene for agent sessions
|
||||||
|
|
||||||
|
Goal: stop the recurring leak pattern where a diagnostic command renders a
|
||||||
|
SOPS-encrypted value or a Kubernetes Secret value into the session
|
||||||
|
transcript, forcing a rotation.
|
||||||
|
|
||||||
|
**Confirmed past leaks** (see `mathias/infra#39` rotation umbrella):
|
||||||
|
- `POSTGRES_PASSWORD` — `kubectl exec ... env` rendered envFrom value
|
||||||
|
- `INFRA_MCP_TOKEN` — `sops -d ingress.enc.yaml` rendered the bearer in the configuration-snippet
|
||||||
|
- `brain_app` postgres DSN — pre-fix ingestion pod log (hyperguild#20)
|
||||||
|
|
||||||
|
Three are pattern, not bug.
|
||||||
|
|
||||||
|
## Rules — never do these
|
||||||
|
|
||||||
|
| Forbidden | Why | Use instead |
|
||||||
|
|---|---|---|
|
||||||
|
| `kubectl exec <pod> -- env` (or `printenv`) | Renders every envFrom + valueFrom.secretKeyRef plaintext | `kubectl describe pod <pod>` — k8s redacts secretKeyRef in describe |
|
||||||
|
| `sops -d <file>` to inspect | Whole-file decrypt dumps everything | `sops --extract '["key"]' <file>` for pinpoint reads |
|
||||||
|
| `kubectl get secret ... -o jsonpath='{.data}'` (whole map) | Returns base64 of every key; trivial to b64-decode the lot | `... -o jsonpath='{.data.SPECIFIC_KEY}'` for the one key needed |
|
||||||
|
| Echoing a generated token to verify length / content | Renders the new token into transcript, defeating the rotation | Verify functionally: `curl ... -H "Authorization: Bearer $(cat /tmp/tokfile)"` — never echo `$NEW_TOK` |
|
||||||
|
| Storing a token in a shell variable then running `echo` / `set` / `env` | Same as above | Pipe through file (mode 600) or stdin to the next consumer |
|
||||||
|
|
||||||
|
## Rules — do these instead
|
||||||
|
|
||||||
|
- **Verify by side-effect, not by value.** If a token rotation should make
|
||||||
|
the old token return 401 and the new one return 200, test those two
|
||||||
|
HTTP responses. Never check `$OLD == $LEAKED` by printing it.
|
||||||
|
- **Pinpoint SOPS reads.** When you need ONE key from a SOPS file, always
|
||||||
|
`sops --extract '["..."]'`. The shape of the key path is safe to log;
|
||||||
|
the value isn't.
|
||||||
|
- **Stash tokens in `/dev/shm/<unique>` mode 600** and pass via filename,
|
||||||
|
not env. `shred -u` after use. Avoid `/tmp` if the system has `/dev/shm`.
|
||||||
|
- **If you must dump a secret to confirm wiring**, immediately rotate it
|
||||||
|
in the same commit train. Treat the dump as "this is now compromised".
|
||||||
|
|
||||||
|
## Rotation chain pattern when a token is already exposed
|
||||||
|
|
||||||
|
If a token was leaked in a prior step of the same session, chain two
|
||||||
|
rotations:
|
||||||
|
|
||||||
|
1. **T1**: replace the leaked value with a new one. The verification step
|
||||||
|
may also leak T1 (acceptable risk).
|
||||||
|
2. **T2**: replace T1 with a fresh value. Run this without any decrypt
|
||||||
|
step that emits to stdout. The new value lives only in the encrypted
|
||||||
|
file + the consumer's secret store (`~/.llmkeys`, the k8s Secret).
|
||||||
|
|
||||||
|
Example T2 path (Python, never decrypts to stdout):
|
||||||
|
|
||||||
|
```python
|
||||||
|
new_t2 = subprocess.check_output(["openssl","rand","-hex","32"]).decode().strip()
|
||||||
|
plaintext = subprocess.check_output(["sops","-d", path]).decode() # in-memory only
|
||||||
|
patched = re.sub(r'Bearer [a-f0-9]+(?=")', f"Bearer {new_t2}", plaintext)
|
||||||
|
# write patched via /dev/shm tmp + sops -e → write back
|
||||||
|
# DO NOT print(patched), DO NOT print(new_t2)
|
||||||
|
```
|
||||||
|
|
||||||
|
After T2 lands via GitOps + Flux, update consumers from the file stash
|
||||||
|
without echoing.
|
||||||
|
|
||||||
|
## Detection ideas (not blocking — telemetry)
|
||||||
|
|
||||||
|
- Add a `bash` pre-hook in Claude harness that refuses obvious patterns
|
||||||
|
(`kubectl exec * env*`, `sops -d *.enc.yaml`). Filed as a follow-up.
|
||||||
|
- Run a periodic grep over `~/.claude/projects/*/sessions/*.jsonl` for
|
||||||
|
high-entropy strings matching `[a-f0-9]{32,}` to catch new leak surface.
|
||||||
|
|
||||||
|
## Reference
|
||||||
|
|
||||||
|
- `gitea.d-ma.be/mathias/infra#39` — umbrella credential rotation issue with confirmed-leak roster.
|
||||||
|
- `gitea.d-ma.be/mathias/infra#44` — POSTGRES_PASSWORD rotation procedure (template for future incidents).
|
||||||
@@ -22,18 +22,28 @@ Screenshot a locally-running web UI without installing a browser on the agent's
|
|||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
|
`shot.sh` ships beside this SKILL.md, so resolve it from wherever the harness
|
||||||
|
installed the skill rather than from a checkout path — an absolute `~/dev/...`
|
||||||
|
went stale the first time the repo moved and cost a caller three hand-rolled
|
||||||
|
Job attempts before they found the working script (#26).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Claude Code installs the plugin; Crush/Pi/OpenCode get ~/.agents/skills.
|
||||||
|
SHOT=$(ls ~/.claude/plugins/marketplaces/*/skills/web-shot/shot.sh \
|
||||||
|
~/.agents/skills/web-shot/shot.sh 2>/dev/null | head -1 || true)
|
||||||
|
[ -n "$SHOT" ] || { echo "web-shot not installed on this host: run install.sh or the plugin install" >&2; exit 1; }
|
||||||
|
|
||||||
# auto-detect Tailscale hostname, screenshot port 8099 root
|
# auto-detect Tailscale hostname, screenshot port 8099 root
|
||||||
~/dev/.skills/web-shot/shot.sh --port 8099
|
"$SHOT" --port 8099
|
||||||
|
|
||||||
# explicit URL
|
# explicit URL
|
||||||
~/dev/.skills/web-shot/shot.sh --url http://flamingo:5173
|
"$SHOT" --url http://flamingo:5173
|
||||||
|
|
||||||
# capture a search state too (types into input[name=q])
|
# capture a search state too (types into input[name=q])
|
||||||
~/dev/.skills/web-shot/shot.sh --port 8181 --search agentsquad
|
"$SHOT" --port 8181 --search agentsquad
|
||||||
|
|
||||||
# different output dir
|
# different output dir
|
||||||
~/dev/.skills/web-shot/shot.sh --port 3000 --out /tmp/shots
|
"$SHOT" --port 3000 --out /tmp/shots
|
||||||
```
|
```
|
||||||
|
|
||||||
## Flags
|
## Flags
|
||||||
@@ -1,371 +0,0 @@
|
|||||||
---
|
|
||||||
name: test-driven-development
|
|
||||||
description: Use when implementing any feature or bugfix, before writing implementation code
|
|
||||||
---
|
|
||||||
|
|
||||||
# Test-Driven Development (TDD)
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Write the test first. Watch it fail. Write minimal code to pass.
|
|
||||||
|
|
||||||
**Core principle:** If you didn't watch the test fail, you don't know if it tests the right thing.
|
|
||||||
|
|
||||||
**Violating the letter of the rules is violating the spirit of the rules.**
|
|
||||||
|
|
||||||
## When to Use
|
|
||||||
|
|
||||||
**Always:**
|
|
||||||
- New features
|
|
||||||
- Bug fixes
|
|
||||||
- Refactoring
|
|
||||||
- Behavior changes
|
|
||||||
|
|
||||||
**Exceptions (ask your human partner):**
|
|
||||||
- Throwaway prototypes
|
|
||||||
- Generated code
|
|
||||||
- Configuration files
|
|
||||||
|
|
||||||
Thinking "skip TDD just this once"? Stop. That's rationalization.
|
|
||||||
|
|
||||||
## The Iron Law
|
|
||||||
|
|
||||||
```
|
|
||||||
NO PRODUCTION CODE WITHOUT A FAILING TEST FIRST
|
|
||||||
```
|
|
||||||
|
|
||||||
Write code before the test? Delete it. Start over.
|
|
||||||
|
|
||||||
**No exceptions:**
|
|
||||||
- Don't keep it as "reference"
|
|
||||||
- Don't "adapt" it while writing tests
|
|
||||||
- Don't look at it
|
|
||||||
- Delete means delete
|
|
||||||
|
|
||||||
Implement fresh from tests. Period.
|
|
||||||
|
|
||||||
## Red-Green-Refactor
|
|
||||||
|
|
||||||
```dot
|
|
||||||
digraph tdd_cycle {
|
|
||||||
rankdir=LR;
|
|
||||||
red [label="RED\nWrite failing test", shape=box, style=filled, fillcolor="#ffcccc"];
|
|
||||||
verify_red [label="Verify fails\ncorrectly", shape=diamond];
|
|
||||||
green [label="GREEN\nMinimal code", shape=box, style=filled, fillcolor="#ccffcc"];
|
|
||||||
verify_green [label="Verify passes\nAll green", shape=diamond];
|
|
||||||
refactor [label="REFACTOR\nClean up", shape=box, style=filled, fillcolor="#ccccff"];
|
|
||||||
next [label="Next", shape=ellipse];
|
|
||||||
|
|
||||||
red -> verify_red;
|
|
||||||
verify_red -> green [label="yes"];
|
|
||||||
verify_red -> red [label="wrong\nfailure"];
|
|
||||||
green -> verify_green;
|
|
||||||
verify_green -> refactor [label="yes"];
|
|
||||||
verify_green -> green [label="no"];
|
|
||||||
refactor -> verify_green [label="stay\ngreen"];
|
|
||||||
verify_green -> next;
|
|
||||||
next -> red;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### RED - Write Failing Test
|
|
||||||
|
|
||||||
Write one minimal test showing what should happen.
|
|
||||||
|
|
||||||
<Good>
|
|
||||||
```typescript
|
|
||||||
test('retries failed operations 3 times', async () => {
|
|
||||||
let attempts = 0;
|
|
||||||
const operation = () => {
|
|
||||||
attempts++;
|
|
||||||
if (attempts < 3) throw new Error('fail');
|
|
||||||
return 'success';
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await retryOperation(operation);
|
|
||||||
|
|
||||||
expect(result).toBe('success');
|
|
||||||
expect(attempts).toBe(3);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
Clear name, tests real behavior, one thing
|
|
||||||
</Good>
|
|
||||||
|
|
||||||
<Bad>
|
|
||||||
```typescript
|
|
||||||
test('retry works', async () => {
|
|
||||||
const mock = jest.fn()
|
|
||||||
.mockRejectedValueOnce(new Error())
|
|
||||||
.mockRejectedValueOnce(new Error())
|
|
||||||
.mockResolvedValueOnce('success');
|
|
||||||
await retryOperation(mock);
|
|
||||||
expect(mock).toHaveBeenCalledTimes(3);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
Vague name, tests mock not code
|
|
||||||
</Bad>
|
|
||||||
|
|
||||||
**Requirements:**
|
|
||||||
- One behavior
|
|
||||||
- Clear name
|
|
||||||
- Real code (no mocks unless unavoidable)
|
|
||||||
|
|
||||||
### Verify RED - Watch It Fail
|
|
||||||
|
|
||||||
**MANDATORY. Never skip.**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm test path/to/test.test.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
Confirm:
|
|
||||||
- Test fails (not errors)
|
|
||||||
- Failure message is expected
|
|
||||||
- Fails because feature missing (not typos)
|
|
||||||
|
|
||||||
**Test passes?** You're testing existing behavior. Fix test.
|
|
||||||
|
|
||||||
**Test errors?** Fix error, re-run until it fails correctly.
|
|
||||||
|
|
||||||
### GREEN - Minimal Code
|
|
||||||
|
|
||||||
Write simplest code to pass the test.
|
|
||||||
|
|
||||||
<Good>
|
|
||||||
```typescript
|
|
||||||
async function retryOperation<T>(fn: () => Promise<T>): Promise<T> {
|
|
||||||
for (let i = 0; i < 3; i++) {
|
|
||||||
try {
|
|
||||||
return await fn();
|
|
||||||
} catch (e) {
|
|
||||||
if (i === 2) throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error('unreachable');
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Just enough to pass
|
|
||||||
</Good>
|
|
||||||
|
|
||||||
<Bad>
|
|
||||||
```typescript
|
|
||||||
async function retryOperation<T>(
|
|
||||||
fn: () => Promise<T>,
|
|
||||||
options?: {
|
|
||||||
maxRetries?: number;
|
|
||||||
backoff?: 'linear' | 'exponential';
|
|
||||||
onRetry?: (attempt: number) => void;
|
|
||||||
}
|
|
||||||
): Promise<T> {
|
|
||||||
// YAGNI
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Over-engineered
|
|
||||||
</Bad>
|
|
||||||
|
|
||||||
Don't add features, refactor other code, or "improve" beyond the test.
|
|
||||||
|
|
||||||
### Verify GREEN - Watch It Pass
|
|
||||||
|
|
||||||
**MANDATORY.**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm test path/to/test.test.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
Confirm:
|
|
||||||
- Test passes
|
|
||||||
- Other tests still pass
|
|
||||||
- Output pristine (no errors, warnings)
|
|
||||||
|
|
||||||
**Test fails?** Fix code, not test.
|
|
||||||
|
|
||||||
**Other tests fail?** Fix now.
|
|
||||||
|
|
||||||
### REFACTOR - Clean Up
|
|
||||||
|
|
||||||
After green only:
|
|
||||||
- Remove duplication
|
|
||||||
- Improve names
|
|
||||||
- Extract helpers
|
|
||||||
|
|
||||||
Keep tests green. Don't add behavior.
|
|
||||||
|
|
||||||
### Repeat
|
|
||||||
|
|
||||||
Next failing test for next feature.
|
|
||||||
|
|
||||||
## Good Tests
|
|
||||||
|
|
||||||
| Quality | Good | Bad |
|
|
||||||
|---------|------|-----|
|
|
||||||
| **Minimal** | One thing. "and" in name? Split it. | `test('validates email and domain and whitespace')` |
|
|
||||||
| **Clear** | Name describes behavior | `test('test1')` |
|
|
||||||
| **Shows intent** | Demonstrates desired API | Obscures what code should do |
|
|
||||||
|
|
||||||
## Why Order Matters
|
|
||||||
|
|
||||||
**"I'll write tests after to verify it works"**
|
|
||||||
|
|
||||||
Tests written after code pass immediately. Passing immediately proves nothing:
|
|
||||||
- Might test wrong thing
|
|
||||||
- Might test implementation, not behavior
|
|
||||||
- Might miss edge cases you forgot
|
|
||||||
- You never saw it catch the bug
|
|
||||||
|
|
||||||
Test-first forces you to see the test fail, proving it actually tests something.
|
|
||||||
|
|
||||||
**"I already manually tested all the edge cases"**
|
|
||||||
|
|
||||||
Manual testing is ad-hoc. You think you tested everything but:
|
|
||||||
- No record of what you tested
|
|
||||||
- Can't re-run when code changes
|
|
||||||
- Easy to forget cases under pressure
|
|
||||||
- "It worked when I tried it" ≠ comprehensive
|
|
||||||
|
|
||||||
Automated tests are systematic. They run the same way every time.
|
|
||||||
|
|
||||||
**"Deleting X hours of work is wasteful"**
|
|
||||||
|
|
||||||
Sunk cost fallacy. The time is already gone. Your choice now:
|
|
||||||
- Delete and rewrite with TDD (X more hours, high confidence)
|
|
||||||
- Keep it and add tests after (30 min, low confidence, likely bugs)
|
|
||||||
|
|
||||||
The "waste" is keeping code you can't trust. Working code without real tests is technical debt.
|
|
||||||
|
|
||||||
**"TDD is dogmatic, being pragmatic means adapting"**
|
|
||||||
|
|
||||||
TDD IS pragmatic:
|
|
||||||
- Finds bugs before commit (faster than debugging after)
|
|
||||||
- Prevents regressions (tests catch breaks immediately)
|
|
||||||
- Documents behavior (tests show how to use code)
|
|
||||||
- Enables refactoring (change freely, tests catch breaks)
|
|
||||||
|
|
||||||
"Pragmatic" shortcuts = debugging in production = slower.
|
|
||||||
|
|
||||||
**"Tests after achieve the same goals - it's spirit not ritual"**
|
|
||||||
|
|
||||||
No. Tests-after answer "What does this do?" Tests-first answer "What should this do?"
|
|
||||||
|
|
||||||
Tests-after are biased by your implementation. You test what you built, not what's required. You verify remembered edge cases, not discovered ones.
|
|
||||||
|
|
||||||
Tests-first force edge case discovery before implementing. Tests-after verify you remembered everything (you didn't).
|
|
||||||
|
|
||||||
30 minutes of tests after ≠ TDD. You get coverage, lose proof tests work.
|
|
||||||
|
|
||||||
## Common Rationalizations
|
|
||||||
|
|
||||||
| Excuse | Reality |
|
|
||||||
|--------|---------|
|
|
||||||
| "Too simple to test" | Simple code breaks. Test takes 30 seconds. |
|
|
||||||
| "I'll test after" | Tests passing immediately prove nothing. |
|
|
||||||
| "Tests after achieve same goals" | Tests-after = "what does this do?" Tests-first = "what should this do?" |
|
|
||||||
| "Already manually tested" | Ad-hoc ≠ systematic. No record, can't re-run. |
|
|
||||||
| "Deleting X hours is wasteful" | Sunk cost fallacy. Keeping unverified code is technical debt. |
|
|
||||||
| "Keep as reference, write tests first" | You'll adapt it. That's testing after. Delete means delete. |
|
|
||||||
| "Need to explore first" | Fine. Throw away exploration, start with TDD. |
|
|
||||||
| "Test hard = design unclear" | Listen to test. Hard to test = hard to use. |
|
|
||||||
| "TDD will slow me down" | TDD faster than debugging. Pragmatic = test-first. |
|
|
||||||
| "Manual test faster" | Manual doesn't prove edge cases. You'll re-test every change. |
|
|
||||||
| "Existing code has no tests" | You're improving it. Add tests for existing code. |
|
|
||||||
|
|
||||||
## Red Flags - STOP and Start Over
|
|
||||||
|
|
||||||
- Code before test
|
|
||||||
- Test after implementation
|
|
||||||
- Test passes immediately
|
|
||||||
- Can't explain why test failed
|
|
||||||
- Tests added "later"
|
|
||||||
- Rationalizing "just this once"
|
|
||||||
- "I already manually tested it"
|
|
||||||
- "Tests after achieve the same purpose"
|
|
||||||
- "It's about spirit not ritual"
|
|
||||||
- "Keep as reference" or "adapt existing code"
|
|
||||||
- "Already spent X hours, deleting is wasteful"
|
|
||||||
- "TDD is dogmatic, I'm being pragmatic"
|
|
||||||
- "This is different because..."
|
|
||||||
|
|
||||||
**All of these mean: Delete code. Start over with TDD.**
|
|
||||||
|
|
||||||
## Example: Bug Fix
|
|
||||||
|
|
||||||
**Bug:** Empty email accepted
|
|
||||||
|
|
||||||
**RED**
|
|
||||||
```typescript
|
|
||||||
test('rejects empty email', async () => {
|
|
||||||
const result = await submitForm({ email: '' });
|
|
||||||
expect(result.error).toBe('Email required');
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**Verify RED**
|
|
||||||
```bash
|
|
||||||
$ npm test
|
|
||||||
FAIL: expected 'Email required', got undefined
|
|
||||||
```
|
|
||||||
|
|
||||||
**GREEN**
|
|
||||||
```typescript
|
|
||||||
function submitForm(data: FormData) {
|
|
||||||
if (!data.email?.trim()) {
|
|
||||||
return { error: 'Email required' };
|
|
||||||
}
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Verify GREEN**
|
|
||||||
```bash
|
|
||||||
$ npm test
|
|
||||||
PASS
|
|
||||||
```
|
|
||||||
|
|
||||||
**REFACTOR**
|
|
||||||
Extract validation for multiple fields if needed.
|
|
||||||
|
|
||||||
## Verification Checklist
|
|
||||||
|
|
||||||
Before marking work complete:
|
|
||||||
|
|
||||||
- [ ] Every new function/method has a test
|
|
||||||
- [ ] Watched each test fail before implementing
|
|
||||||
- [ ] Each test failed for expected reason (feature missing, not typo)
|
|
||||||
- [ ] Wrote minimal code to pass each test
|
|
||||||
- [ ] All tests pass
|
|
||||||
- [ ] Output pristine (no errors, warnings)
|
|
||||||
- [ ] Tests use real code (mocks only if unavoidable)
|
|
||||||
- [ ] Edge cases and errors covered
|
|
||||||
|
|
||||||
Can't check all boxes? You skipped TDD. Start over.
|
|
||||||
|
|
||||||
## When Stuck
|
|
||||||
|
|
||||||
| Problem | Solution |
|
|
||||||
|---------|----------|
|
|
||||||
| Don't know how to test | Write wished-for API. Write assertion first. Ask your human partner. |
|
|
||||||
| Test too complicated | Design too complicated. Simplify interface. |
|
|
||||||
| Must mock everything | Code too coupled. Use dependency injection. |
|
|
||||||
| Test setup huge | Extract helpers. Still complex? Simplify design. |
|
|
||||||
|
|
||||||
## Debugging Integration
|
|
||||||
|
|
||||||
Bug found? Write failing test reproducing it. Follow TDD cycle. Test proves fix and prevents regression.
|
|
||||||
|
|
||||||
Never fix bugs without a test.
|
|
||||||
|
|
||||||
## Testing Anti-Patterns
|
|
||||||
|
|
||||||
When adding mocks or test utilities, read @testing-anti-patterns.md to avoid common pitfalls:
|
|
||||||
- Testing mock behavior instead of real behavior
|
|
||||||
- Adding test-only methods to production classes
|
|
||||||
- Mocking without understanding dependencies
|
|
||||||
|
|
||||||
## Final Rule
|
|
||||||
|
|
||||||
```
|
|
||||||
Production code → test exists and failed first
|
|
||||||
Otherwise → not TDD
|
|
||||||
```
|
|
||||||
|
|
||||||
No exceptions without your human partner's permission.
|
|
||||||
@@ -1,299 +0,0 @@
|
|||||||
# Testing Anti-Patterns
|
|
||||||
|
|
||||||
**Load this reference when:** writing or changing tests, adding mocks, or tempted to add test-only methods to production code.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Tests must verify real behavior, not mock behavior. Mocks are a means to isolate, not the thing being tested.
|
|
||||||
|
|
||||||
**Core principle:** Test what the code does, not what the mocks do.
|
|
||||||
|
|
||||||
**Following strict TDD prevents these anti-patterns.**
|
|
||||||
|
|
||||||
## The Iron Laws
|
|
||||||
|
|
||||||
```
|
|
||||||
1. NEVER test mock behavior
|
|
||||||
2. NEVER add test-only methods to production classes
|
|
||||||
3. NEVER mock without understanding dependencies
|
|
||||||
```
|
|
||||||
|
|
||||||
## Anti-Pattern 1: Testing Mock Behavior
|
|
||||||
|
|
||||||
**The violation:**
|
|
||||||
```typescript
|
|
||||||
// ❌ BAD: Testing that the mock exists
|
|
||||||
test('renders sidebar', () => {
|
|
||||||
render(<Page />);
|
|
||||||
expect(screen.getByTestId('sidebar-mock')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**Why this is wrong:**
|
|
||||||
- You're verifying the mock works, not that the component works
|
|
||||||
- Test passes when mock is present, fails when it's not
|
|
||||||
- Tells you nothing about real behavior
|
|
||||||
|
|
||||||
**your human partner's correction:** "Are we testing the behavior of a mock?"
|
|
||||||
|
|
||||||
**The fix:**
|
|
||||||
```typescript
|
|
||||||
// ✅ GOOD: Test real component or don't mock it
|
|
||||||
test('renders sidebar', () => {
|
|
||||||
render(<Page />); // Don't mock sidebar
|
|
||||||
expect(screen.getByRole('navigation')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
// OR if sidebar must be mocked for isolation:
|
|
||||||
// Don't assert on the mock - test Page's behavior with sidebar present
|
|
||||||
```
|
|
||||||
|
|
||||||
### Gate Function
|
|
||||||
|
|
||||||
```
|
|
||||||
BEFORE asserting on any mock element:
|
|
||||||
Ask: "Am I testing real component behavior or just mock existence?"
|
|
||||||
|
|
||||||
IF testing mock existence:
|
|
||||||
STOP - Delete the assertion or unmock the component
|
|
||||||
|
|
||||||
Test real behavior instead
|
|
||||||
```
|
|
||||||
|
|
||||||
## Anti-Pattern 2: Test-Only Methods in Production
|
|
||||||
|
|
||||||
**The violation:**
|
|
||||||
```typescript
|
|
||||||
// ❌ BAD: destroy() only used in tests
|
|
||||||
class Session {
|
|
||||||
async destroy() { // Looks like production API!
|
|
||||||
await this._workspaceManager?.destroyWorkspace(this.id);
|
|
||||||
// ... cleanup
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// In tests
|
|
||||||
afterEach(() => session.destroy());
|
|
||||||
```
|
|
||||||
|
|
||||||
**Why this is wrong:**
|
|
||||||
- Production class polluted with test-only code
|
|
||||||
- Dangerous if accidentally called in production
|
|
||||||
- Violates YAGNI and separation of concerns
|
|
||||||
- Confuses object lifecycle with entity lifecycle
|
|
||||||
|
|
||||||
**The fix:**
|
|
||||||
```typescript
|
|
||||||
// ✅ GOOD: Test utilities handle test cleanup
|
|
||||||
// Session has no destroy() - it's stateless in production
|
|
||||||
|
|
||||||
// In test-utils/
|
|
||||||
export async function cleanupSession(session: Session) {
|
|
||||||
const workspace = session.getWorkspaceInfo();
|
|
||||||
if (workspace) {
|
|
||||||
await workspaceManager.destroyWorkspace(workspace.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// In tests
|
|
||||||
afterEach(() => cleanupSession(session));
|
|
||||||
```
|
|
||||||
|
|
||||||
### Gate Function
|
|
||||||
|
|
||||||
```
|
|
||||||
BEFORE adding any method to production class:
|
|
||||||
Ask: "Is this only used by tests?"
|
|
||||||
|
|
||||||
IF yes:
|
|
||||||
STOP - Don't add it
|
|
||||||
Put it in test utilities instead
|
|
||||||
|
|
||||||
Ask: "Does this class own this resource's lifecycle?"
|
|
||||||
|
|
||||||
IF no:
|
|
||||||
STOP - Wrong class for this method
|
|
||||||
```
|
|
||||||
|
|
||||||
## Anti-Pattern 3: Mocking Without Understanding
|
|
||||||
|
|
||||||
**The violation:**
|
|
||||||
```typescript
|
|
||||||
// ❌ BAD: Mock breaks test logic
|
|
||||||
test('detects duplicate server', () => {
|
|
||||||
// Mock prevents config write that test depends on!
|
|
||||||
vi.mock('ToolCatalog', () => ({
|
|
||||||
discoverAndCacheTools: vi.fn().mockResolvedValue(undefined)
|
|
||||||
}));
|
|
||||||
|
|
||||||
await addServer(config);
|
|
||||||
await addServer(config); // Should throw - but won't!
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**Why this is wrong:**
|
|
||||||
- Mocked method had side effect test depended on (writing config)
|
|
||||||
- Over-mocking to "be safe" breaks actual behavior
|
|
||||||
- Test passes for wrong reason or fails mysteriously
|
|
||||||
|
|
||||||
**The fix:**
|
|
||||||
```typescript
|
|
||||||
// ✅ GOOD: Mock at correct level
|
|
||||||
test('detects duplicate server', () => {
|
|
||||||
// Mock the slow part, preserve behavior test needs
|
|
||||||
vi.mock('MCPServerManager'); // Just mock slow server startup
|
|
||||||
|
|
||||||
await addServer(config); // Config written
|
|
||||||
await addServer(config); // Duplicate detected ✓
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Gate Function
|
|
||||||
|
|
||||||
```
|
|
||||||
BEFORE mocking any method:
|
|
||||||
STOP - Don't mock yet
|
|
||||||
|
|
||||||
1. Ask: "What side effects does the real method have?"
|
|
||||||
2. Ask: "Does this test depend on any of those side effects?"
|
|
||||||
3. Ask: "Do I fully understand what this test needs?"
|
|
||||||
|
|
||||||
IF depends on side effects:
|
|
||||||
Mock at lower level (the actual slow/external operation)
|
|
||||||
OR use test doubles that preserve necessary behavior
|
|
||||||
NOT the high-level method the test depends on
|
|
||||||
|
|
||||||
IF unsure what test depends on:
|
|
||||||
Run test with real implementation FIRST
|
|
||||||
Observe what actually needs to happen
|
|
||||||
THEN add minimal mocking at the right level
|
|
||||||
|
|
||||||
Red flags:
|
|
||||||
- "I'll mock this to be safe"
|
|
||||||
- "This might be slow, better mock it"
|
|
||||||
- Mocking without understanding the dependency chain
|
|
||||||
```
|
|
||||||
|
|
||||||
## Anti-Pattern 4: Incomplete Mocks
|
|
||||||
|
|
||||||
**The violation:**
|
|
||||||
```typescript
|
|
||||||
// ❌ BAD: Partial mock - only fields you think you need
|
|
||||||
const mockResponse = {
|
|
||||||
status: 'success',
|
|
||||||
data: { userId: '123', name: 'Alice' }
|
|
||||||
// Missing: metadata that downstream code uses
|
|
||||||
};
|
|
||||||
|
|
||||||
// Later: breaks when code accesses response.metadata.requestId
|
|
||||||
```
|
|
||||||
|
|
||||||
**Why this is wrong:**
|
|
||||||
- **Partial mocks hide structural assumptions** - You only mocked fields you know about
|
|
||||||
- **Downstream code may depend on fields you didn't include** - Silent failures
|
|
||||||
- **Tests pass but integration fails** - Mock incomplete, real API complete
|
|
||||||
- **False confidence** - Test proves nothing about real behavior
|
|
||||||
|
|
||||||
**The Iron Rule:** Mock the COMPLETE data structure as it exists in reality, not just fields your immediate test uses.
|
|
||||||
|
|
||||||
**The fix:**
|
|
||||||
```typescript
|
|
||||||
// ✅ GOOD: Mirror real API completeness
|
|
||||||
const mockResponse = {
|
|
||||||
status: 'success',
|
|
||||||
data: { userId: '123', name: 'Alice' },
|
|
||||||
metadata: { requestId: 'req-789', timestamp: 1234567890 }
|
|
||||||
// All fields real API returns
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Gate Function
|
|
||||||
|
|
||||||
```
|
|
||||||
BEFORE creating mock responses:
|
|
||||||
Check: "What fields does the real API response contain?"
|
|
||||||
|
|
||||||
Actions:
|
|
||||||
1. Examine actual API response from docs/examples
|
|
||||||
2. Include ALL fields system might consume downstream
|
|
||||||
3. Verify mock matches real response schema completely
|
|
||||||
|
|
||||||
Critical:
|
|
||||||
If you're creating a mock, you must understand the ENTIRE structure
|
|
||||||
Partial mocks fail silently when code depends on omitted fields
|
|
||||||
|
|
||||||
If uncertain: Include all documented fields
|
|
||||||
```
|
|
||||||
|
|
||||||
## Anti-Pattern 5: Integration Tests as Afterthought
|
|
||||||
|
|
||||||
**The violation:**
|
|
||||||
```
|
|
||||||
✅ Implementation complete
|
|
||||||
❌ No tests written
|
|
||||||
"Ready for testing"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Why this is wrong:**
|
|
||||||
- Testing is part of implementation, not optional follow-up
|
|
||||||
- TDD would have caught this
|
|
||||||
- Can't claim complete without tests
|
|
||||||
|
|
||||||
**The fix:**
|
|
||||||
```
|
|
||||||
TDD cycle:
|
|
||||||
1. Write failing test
|
|
||||||
2. Implement to pass
|
|
||||||
3. Refactor
|
|
||||||
4. THEN claim complete
|
|
||||||
```
|
|
||||||
|
|
||||||
## When Mocks Become Too Complex
|
|
||||||
|
|
||||||
**Warning signs:**
|
|
||||||
- Mock setup longer than test logic
|
|
||||||
- Mocking everything to make test pass
|
|
||||||
- Mocks missing methods real components have
|
|
||||||
- Test breaks when mock changes
|
|
||||||
|
|
||||||
**your human partner's question:** "Do we need to be using a mock here?"
|
|
||||||
|
|
||||||
**Consider:** Integration tests with real components often simpler than complex mocks
|
|
||||||
|
|
||||||
## TDD Prevents These Anti-Patterns
|
|
||||||
|
|
||||||
**Why TDD helps:**
|
|
||||||
1. **Write test first** → Forces you to think about what you're actually testing
|
|
||||||
2. **Watch it fail** → Confirms test tests real behavior, not mocks
|
|
||||||
3. **Minimal implementation** → No test-only methods creep in
|
|
||||||
4. **Real dependencies** → You see what the test actually needs before mocking
|
|
||||||
|
|
||||||
**If you're testing mock behavior, you violated TDD** - you added mocks without watching test fail against real code first.
|
|
||||||
|
|
||||||
## Quick Reference
|
|
||||||
|
|
||||||
| Anti-Pattern | Fix |
|
|
||||||
|--------------|-----|
|
|
||||||
| Assert on mock elements | Test real component or unmock it |
|
|
||||||
| Test-only methods in production | Move to test utilities |
|
|
||||||
| Mock without understanding | Understand dependencies first, mock minimally |
|
|
||||||
| Incomplete mocks | Mirror real API completely |
|
|
||||||
| Tests as afterthought | TDD - tests first |
|
|
||||||
| Over-complex mocks | Consider integration tests |
|
|
||||||
|
|
||||||
## Red Flags
|
|
||||||
|
|
||||||
- Assertion checks for `*-mock` test IDs
|
|
||||||
- Methods only called in test files
|
|
||||||
- Mock setup is >50% of test
|
|
||||||
- Test fails when you remove mock
|
|
||||||
- Can't explain why mock is needed
|
|
||||||
- Mocking "just to be safe"
|
|
||||||
|
|
||||||
## The Bottom Line
|
|
||||||
|
|
||||||
**Mocks are tools to isolate, not things to test.**
|
|
||||||
|
|
||||||
If TDD reveals you're testing mock behavior, you've gone wrong.
|
|
||||||
|
|
||||||
Fix: Test real behavior or question why you're mocking at all.
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user