Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8359d61e33 | ||
|
|
400d2b9b6b | ||
|
|
4edf42ce9f | ||
|
|
2f53346b8c | ||
|
|
dcc0677270 | ||
|
|
9fd5a054b3 | ||
|
|
da76a6b3d2 | ||
|
|
fc6d5628a7 | ||
|
|
1f7f53e6c0 | ||
|
|
bedb6d278d | ||
|
|
e9ad692418 | ||
|
|
86529d2ac5 | ||
|
|
d6e892ac4c | ||
|
|
4d6ebb626f | ||
|
|
88a4fa407b | ||
|
|
808af7b020 | ||
|
|
40405cd95a | ||
|
|
39ac61c411 | ||
|
|
2cb6b54a5e | ||
|
|
1448963823 | ||
|
|
58687c68a6 |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"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.15.0",
|
||||
"version": "0.19.3",
|
||||
"author": {
|
||||
"name": "Mathias",
|
||||
"url": "https://git.d-ma.be/mathias"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"_comment": "Tailscale Serve on koala, not the public d-ma.be hostnames. The public path exits to Cloudflare and NPM to reach a k3s service on the same machine most sessions run on, which imposes rate limits and message-size caps on tool results. Serve terminates TLS with a real Tailscale cert and proxies to the NodePort over loopback, so traffic reaches the service from koala itself and satisfies the default-deny-ingress NetworkPolicy with no cluster change. Clients need 'tailscale set --accept-dns=true' for the MagicDNS name to resolve; see mathias/skills#24.",
|
||||
"_comment": "Tailscale Serve on koala, not the public d-ma.be hostnames. The public path exits to Cloudflare and NPM to reach a k3s service on the same machine most sessions run on, which imposes rate limits and message-size caps on tool results. Serve terminates TLS with a real Tailscale cert and proxies to the NodePort over loopback, so traffic reaches the service from koala itself and satisfies the default-deny-ingress NetworkPolicy with no cluster change. Clients need 'tailscale set --accept-dns=true' for the MagicDNS name to resolve; see mathias/skills#24. Every ${VAR} below must be one ~/.op-env defines -- bin/check-mcp-env enforces that, because an undefined one sends an empty Authorization header and reports as 'requires authentication', which is indistinguishable from a revoked credential.",
|
||||
"mcpServers": {
|
||||
"brain": {
|
||||
"type": "http",
|
||||
@@ -11,23 +11,24 @@
|
||||
"infra": {
|
||||
"type": "http",
|
||||
"url": "https://koala.tail30472e.ts.net:8444/mcp"
|
||||
},
|
||||
"gitea": {
|
||||
"_comment": "gitea-mcp authenticates by PASS-THROUGH: the bearer is a Gitea PAT, which the server validates against Gitea and then acts as. So no dedicated MCP token is needed, and the AgentSecrets blocker that parked this entry (skills#32, #33) never applied to it -- DMABE_GITEA_API_TOKEN is op://HomeLab and already in the session environment. Verified 2026-09-07: initialize 200 (serverInfo gitea-mcp) and tools/list returns 41 tools. Two DIFFERENT PATs both authenticate while GITEA_MCP_TOKEN 401s, which is what proves this is pass-through validation and not a static-token comparison.",
|
||||
"type": "http",
|
||||
"url": "https://koala.tail30472e.ts.net:8446/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${DMABE_GITEA_API_TOKEN}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"_parked": {
|
||||
"_reason": "cad and gitea-mcp are proxied, reachable and AUTHENTICATING: tailscale serve 8445 -> 127.0.0.1:31877 and 8446 -> 127.0.0.1:30340; an MCP initialize with the correct bearer returns 200 from both (serverInfo gitea-mcp 0.1.0, cad aed2aaf). They stay parked only because nothing puts those two tokens into the session environment. Both live in the 1Password AgentSecrets vault, and by deliberate design (infra#224, closed) no single identity on koala reads both vaults: koala-homelab is HomeLab-only and resolves ~/.op-env, koala-agentsecrets-write is AgentSecrets-only and lives in ~/.op-agent-write.env. Adding an AgentSecrets reference to ~/.op-env does NOT degrade gracefully -- `op run` fails wholesale and every other token, BRAIN_MCP_TOKEN included, goes unresolved with it. Enabling needs a read-scoped AgentSecrets identity in the session-env path; see mathias/skills#32, #33.",
|
||||
"_reason": "cad is proxied, reachable and AUTHENTICATING: tailscale serve 8445 -> 127.0.0.1:31877, and an MCP initialize with the correct bearer returns 200 (serverInfo cad aed2aaf). It stays parked only because nothing puts CAD_MCP_TOKEN into the session environment. It lives in the 1Password AgentSecrets vault, and by deliberate design (infra#224, closed) no single identity on koala reads both vaults: koala-homelab is HomeLab-only and resolves ~/.op-env, koala-agentsecrets-write is AgentSecrets-only and lives in ~/.op-agent-write.env. Adding an AgentSecrets reference to ~/.op-env does NOT degrade gracefully -- `op run` fails wholesale and every other token, BRAIN_MCP_TOKEN included, goes unresolved with it. Enabling needs a read-scoped AgentSecrets identity in the session-env path; see mathias/skills#32, #33. gitea was parked here for the same stated reason and has been unparked: it needs no AgentSecrets token at all, because gitea-mcp is pass-through-authenticated.",
|
||||
"cad": {
|
||||
"type": "http",
|
||||
"url": "https://koala.tail30472e.ts.net:8445/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${CAD_MCP_TOKEN}"
|
||||
}
|
||||
},
|
||||
"gitea": {
|
||||
"type": "http",
|
||||
"url": "https://koala.tail30472e.ts.net:8446/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${GITEA_MCP_STATIC_TOKEN}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,9 +100,11 @@ default to point into `~/dev`.
|
||||
| `AGENTS_SKILLS_DIR` | `$HOME/.agents/skills` |
|
||||
| `CRUSH_SKILLS_DIR` | `$HOME/.config/crush/skills` |
|
||||
| `OPENCODE_SKILLS_DIR` | `$HOME/.config/opencode/skills` |
|
||||
| `ANTIGRAVITY_SKILLS_JSON` | `$HOME/.gemini/config/skills.json` (registers `AGENTS_SKILLS_DIR` as a path entry; needs `jq`) |
|
||||
| `SKILLS_EXTRA_SOURCES` | unset (colon-separated extra source directories) |
|
||||
|
||||
Antigravity and Mistral Vibe targets were removed — unused.
|
||||
The Mistral Vibe target was removed — unused. Antigravity's was removed for
|
||||
the same reason and re-added once it came back into use.
|
||||
|
||||
## Checking a host
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ the SKILL.md, not the table.
|
||||
<!-- BEGIN skills-table -->
|
||||
| Skill | Description | Use when | Surfaces |
|
||||
|-------|-------------|----------|----------|
|
||||
| `adr` | Architecture Decision Records — house format, and how to record a decision you cannot make yet | Recording an architectural decision; revisiting one; or deciding between options where the right answer genuinely is not knowable yet | claude-code, library |
|
||||
| `atdd` | Acceptance Test-Driven Development workflow | Feature development with acceptance criteria | claude-code, library |
|
||||
| `cad-babysit` | Focused human-in-the-loop session working CAD's stuck queue (read-only) | Picking up whatever `cad stuck` flags, with a human present | claude-code, library |
|
||||
| `clean-code` | Clean code principles with Go adaptation | Code review, refactoring, new code | claude-code, library |
|
||||
@@ -21,6 +22,7 @@ the SKILL.md, not the table.
|
||||
| `feature-spec` | Implementation-level feature spec (tighter than spec-driven-dev) | Scoping one feature inside an already-specced project | claude-code, library |
|
||||
| `gitea-ci` | Gitea CI/CD pipeline with act_runner, buildah, k3s, GitHub mirror | Setting up or debugging CI on this infra | claude-code, library |
|
||||
| `grill-me` | Structured plan interrogation — Quick Poke, Full Grill, Pre-mortem | Stress-testing a plan before committing; end of Diamond 1; before promoting to pre-prod | claude-code, library, chat |
|
||||
| `lineage` | Backward decision archaeology — was this tried before, and does the reason it failed still hold | Before proposing something that feels new; when an idea keeps resurfacing; when you inherit code whose reason is gone | claude-code, library |
|
||||
| `own-the-task` | Delegation ownership — Intake → Brief → Plan → Execute | A task is handed over open-ended; \"take this off my plate\"; before a plan exists | claude-code, library, chat |
|
||||
| `planning` | Planning and task breakdown | Sprint planning, task decomposition | claude-code, library |
|
||||
| `playwright` | Playwright E2E testing — setup, HTMX patterns, SSE streams, CI wiring | Adding browser tests to any project with a web UI | claude-code, library |
|
||||
@@ -47,6 +49,7 @@ the SKILL.md, not the table.
|
||||
| `tdd` | Test-Driven Development enforcement | Starting any new feature or fixing a bug | claude-code, library |
|
||||
| `telos-load` | Load TELOS intention substrate at session start | Starting any koala session; before architectural decisions; CAD pipeline entry | claude-code, library |
|
||||
| `test-design` | Dave Farley test design scoring (Farley Index) | Reviewing or writing tests | claude-code, library |
|
||||
| `traceability` | Forward trace — requirement to decision to commit to verification to release, and what cannot be checked | Proving why a change exists; auditing a claim; before an attestation or a compliance ask | claude-code, library |
|
||||
| `trainer` | Two-phase brain curation (score candidates, write past quality gate) | Periodic brain pruning and curation | claude-code, library |
|
||||
| `user-stories` | Elephant Carpaccio story slicing | Breaking down features | claude-code, library |
|
||||
| `web-shot` | Screenshot a running web UI via a koala k3s Playwright job | Visual verification of a running UI when no local browser exists | claude-code, library |
|
||||
|
||||
@@ -138,6 +138,26 @@ tasks:
|
||||
echo "ERROR: missing .claude-plugin/plugin.json" >&2; fail=1
|
||||
fi
|
||||
|
||||
# Every ${VAR} a live .mcp.json server interpolates must be one the
|
||||
# session env-file defines. An undefined one is silent: the header
|
||||
# goes out empty and the server reports "requires authentication",
|
||||
# which reads as a revoked credential. Exit 2 means the check could
|
||||
# not run (no jq, or no env-file — CI has neither); that is reported
|
||||
# and not fatal, but it is never treated as a pass.
|
||||
if [ -x "{{.TASKFILE_DIR}}/bin/check-mcp-env" ]; then
|
||||
# `|| rc=$?` is load-bearing, not style. Task runs this block under
|
||||
# `sh -e`, so a bare invocation exiting non-zero kills the shell
|
||||
# before `case` is reached -- the tolerance below never ran, and the
|
||||
# gate died with `exit status 2` on the commit that added it.
|
||||
rc=0
|
||||
"{{.TASKFILE_DIR}}/bin/check-mcp-env" "{{.TASKFILE_DIR}}/.mcp.json" "$HOME/.op-env" || rc=$?
|
||||
case $rc in
|
||||
0) ;;
|
||||
2) echo "NOTE: MCP env wiring unverified on this host" >&2 ;;
|
||||
*) fail=1 ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
if [ $fail -ne 0 ]; then
|
||||
echo "check FAILED" >&2; exit 1
|
||||
fi
|
||||
|
||||
@@ -61,4 +61,28 @@ if [ -f "$PENDING" ]; then
|
||||
done
|
||||
fi
|
||||
|
||||
# --- the ratchet must actually ratchet ---------------------------------------
|
||||
# "This list only shrinks" was asserted in five places and enforced in none. A
|
||||
# traceability probe falsified it in one run: add an unsectioned skill, append it
|
||||
# to the list, gate returns 0. So compare against the list as committed at HEAD —
|
||||
# a convention nothing checks is a convention, and this repo has spent a day on
|
||||
# what those cost.
|
||||
if [ -f "$PENDING" ]; then
|
||||
if ! git -C "$ROOT" rev-parse --git-dir >/dev/null 2>&1; then
|
||||
echo "WARN: $ROOT is not a git checkout — cannot verify the pending list only shrank" >&2
|
||||
elif ! git -C "$ROOT" cat-file -e HEAD:bin/escalation-pending.txt 2>/dev/null; then
|
||||
: # first commit introducing the file — nothing to compare against yet
|
||||
else
|
||||
before=$(git -C "$ROOT" show HEAD:bin/escalation-pending.txt 2>/dev/null \
|
||||
| sed 's/#.*//' | tr -d '[:blank:]' | grep -v '^$' | sort -u || true)
|
||||
now=$(sed 's/#.*//' "$PENDING" | tr -d '[:blank:]' | grep -v '^$' | sort -u || true)
|
||||
added=$(printf '%s\n' "$now" | grep -vxF "$before" 2>/dev/null || true)
|
||||
for entry in $added; do
|
||||
[ -n "$entry" ] || continue
|
||||
echo "ERROR: '$entry' was added to bin/escalation-pending.txt — the list only shrinks. Write its '## Escalation' section instead of exempting it." >&2
|
||||
fail=1
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
exit $fail
|
||||
|
||||
Executable
+99
@@ -0,0 +1,99 @@
|
||||
#!/bin/sh
|
||||
# check-mcp-env — assert every variable a live MCP server interpolates is one
|
||||
# the session env-file actually defines.
|
||||
#
|
||||
# Why this is a gate and not a comment: the failure is silent. A header of
|
||||
# "Bearer ${FOO}" where FOO is undefined does not error -- it goes out empty,
|
||||
# the server answers 401, and Claude Code reports "requires authentication",
|
||||
# which reads exactly like a revoked credential. On 2026-09-07 that cost a
|
||||
# full investigation on koala before the credential turned out to be fine.
|
||||
#
|
||||
# Servers under `_parked` are exempt by design. Parked means "reachable, but
|
||||
# nothing puts its token in the environment yet" -- flagging those would
|
||||
# report the intended state as a fault.
|
||||
#
|
||||
# Usage: check-mcp-env <mcp.json> <env-file>
|
||||
#
|
||||
# Exit 0 every live server's variables are defined
|
||||
# 1 at least one is not, named on stderr
|
||||
# 2 could not verify (bad usage, unreadable input, jq absent).
|
||||
# Deliberately distinct from 0: an unverified config is not a clean
|
||||
# one, and a gate that cannot run should say so rather than pass.
|
||||
|
||||
set -u
|
||||
|
||||
CONFIG=${1:-}
|
||||
ENVFILE=${2:-}
|
||||
|
||||
if [ -z "$CONFIG" ] || [ -z "$ENVFILE" ]; then
|
||||
echo "usage: check-mcp-env <mcp.json> <env-file>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
echo "SKIP: jq not installed -- MCP env wiring unverified" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [ ! -f "$CONFIG" ]; then
|
||||
echo "ERROR: no such config: $CONFIG" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [ ! -f "$ENVFILE" ]; then
|
||||
# EXPECTED IN CI, and permanently so. The env-file holds live credentials, so
|
||||
# it is not on the runner and must not be -- which means this check can only
|
||||
# ever verify on a host that has one. infra#368 added jq to the runner image
|
||||
# believing that would let CI verify; it removed the jq branch below and
|
||||
# changed nothing else, because this branch is the binding one.
|
||||
#
|
||||
# Said plainly so nobody concludes CI covers this. It does not, it cannot,
|
||||
# and putting ~/.op-env on the runner to make it would be trading a real
|
||||
# secret for a green tick.
|
||||
echo "ERROR: no such env-file: $ENVFILE -- cannot verify MCP variables" >&2
|
||||
echo " (expected in CI: this check only verifies on a host with ~/.op-env)" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Every "<server>\t<VAR>" pair a live server interpolates. `..` walks the whole
|
||||
# value, so a variable is found wherever it is placed, not only under headers.
|
||||
refs=$(jq -r '
|
||||
.mcpServers // {}
|
||||
| to_entries[]
|
||||
| .key as $server
|
||||
| (.value | .. | strings)
|
||||
| select(test("\\$\\{"))
|
||||
| [ scan("\\$\\{([A-Za-z_][A-Za-z0-9_]*)\\}")[] ]
|
||||
| .[]
|
||||
| "\($server)\t\(.)"
|
||||
' "$CONFIG" 2>/dev/null) || {
|
||||
echo "ERROR: $CONFIG is not valid JSON -- MCP env wiring unverified" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
if [ -z "$refs" ]; then
|
||||
echo "check-mcp-env: no interpolated variables in live servers"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
fail=0
|
||||
checked=0
|
||||
for pair in $(printf '%s\n' "$refs" | tr '\t' ':'); do
|
||||
server=${pair%%:*}
|
||||
var=${pair#*:}
|
||||
checked=$((checked + 1))
|
||||
if grep -qE "^[[:space:]]*(export[[:space:]]+)?$var=" "$ENVFILE"; then
|
||||
continue
|
||||
fi
|
||||
printf 'ERROR: MCP server %s interpolates %s, which %s does not define.\n' \
|
||||
"$server" "$var" "$ENVFILE" >&2
|
||||
printf ' It will send an empty Authorization header and report as "requires authentication".\n' >&2
|
||||
fail=1
|
||||
done
|
||||
|
||||
if [ "$fail" -ne 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'check-mcp-env: %d interpolated variable(s) across live MCP servers, all defined in %s\n' \
|
||||
"$checked" "$ENVFILE"
|
||||
+29
-5
@@ -25,15 +25,39 @@ for dir in "$ROOT"/skills/*/; do
|
||||
|
||||
grep -q 'session_log' "$dir/SKILL.md" || continue
|
||||
|
||||
if ! grep -q 'session_id' "$dir/SKILL.md"; then
|
||||
echo "ERROR: skills/$name documents session_log without session_id — the tool's only required field" >&2
|
||||
fail=1
|
||||
# Read the PAYLOAD, not the file. The first version of this gate grepped the
|
||||
# whole SKILL.md for "session_id", so a payload missing the field passed as
|
||||
# long as the word appeared anywhere — including in prose explaining that the
|
||||
# field is required. A traceability probe demonstrated it in one sed.
|
||||
payloads=$(grep -n 'session_log' "$dir/SKILL.md" | grep '{' || true)
|
||||
|
||||
if [ -z "$payloads" ]; then
|
||||
# Mentions the tool but documents no call. Nothing to check, and nothing to
|
||||
# get wrong.
|
||||
continue
|
||||
fi
|
||||
|
||||
if grep -q 'duration_ms:[[:space:]]*<' "$dir/SKILL.md"; then
|
||||
echo "ERROR: skills/$name documents duration_ms as a placeholder an agent cannot measure — omit it or pass a real measurement" >&2
|
||||
printf '%s\n' "$payloads" | while IFS= read -r line; do
|
||||
[ -n "$line" ] || continue
|
||||
body=$(printf '%s' "$line" | sed 's/^[0-9]*://')
|
||||
lineno=$(printf '%s' "$line" | cut -d: -f1)
|
||||
|
||||
printf '%s' "$body" | grep -q 'session_id' \
|
||||
|| echo "ERROR: skills/$name:$lineno documents a session_log payload with no session_id — the tool's only required field" >&2
|
||||
|
||||
# Any spelling of the key, quoted or not, followed by anything that is not a
|
||||
# bare number. A placeholder nobody can fill gets fabricated or dropped; a
|
||||
# real measurement is fine.
|
||||
if printf '%s' "$body" | grep -Eq '"?duration_ms"?[[:space:]]*:[[:space:]]*[^0-9,}[:space:]]'; then
|
||||
echo "ERROR: skills/$name:$lineno documents duration_ms as something other than a real measurement — an agent does not observe a clock; omit it" >&2
|
||||
fi
|
||||
done > "$ROOT/.session_log_errors.$$" 2>&1
|
||||
|
||||
if [ -s "$ROOT/.session_log_errors.$$" ]; then
|
||||
cat "$ROOT/.session_log_errors.$$" >&2
|
||||
fail=1
|
||||
fi
|
||||
rm -f "$ROOT/.session_log_errors.$$"
|
||||
done
|
||||
|
||||
exit $fail
|
||||
|
||||
+51
-2
@@ -17,8 +17,8 @@
|
||||
# /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.
|
||||
# cannot. This script covers the harnesses with no plugin system: Crush, Pi,
|
||||
# OpenCode, and Antigravity.
|
||||
#
|
||||
# Wiring model — one managed directory, one link per harness:
|
||||
#
|
||||
@@ -27,6 +27,13 @@
|
||||
# ~/.config/crush/skills -> ~/.agents/skills
|
||||
# ~/.config/opencode/skills -> ~/.agents/skills
|
||||
#
|
||||
# Antigravity has no symlink-directory discovery — it reads a path-entries
|
||||
# JSON instead (~/.gemini/config/skills.json:
|
||||
# {"entries":[{"path":"..."}]}). One entry points at ~/.agents/skills, so it
|
||||
# sees exactly the same surfaces-filtered, collision-resolved set every other
|
||||
# harness does. Requires jq; skipped with a warning if jq is unavailable —
|
||||
# every other target in this script stays dependency-free on purpose.
|
||||
#
|
||||
# ~/.agents/skills was itself a link to this checkout until #27. That encoded
|
||||
# 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
|
||||
@@ -53,7 +60,12 @@ CHECKOUT_DIR="${SKILLS_CHECKOUT_DIR:-$HOME/.local/share/skills}"
|
||||
AGENTS_DIR="${AGENTS_SKILLS_DIR:-$HOME/.agents/skills}"
|
||||
CRUSH_DIR="${CRUSH_SKILLS_DIR:-$HOME/.config/crush/skills}"
|
||||
OPENCODE_DIR="${OPENCODE_SKILLS_DIR:-$HOME/.config/opencode/skills}"
|
||||
ANTIGRAVITY_SKILLS_JSON="${ANTIGRAVITY_SKILLS_JSON:-$HOME/.gemini/config/skills.json}"
|
||||
EXTRA_SOURCES="${SKILLS_EXTRA_SOURCES:-}"
|
||||
# Overridable so the jq-absent branch is testable on a host that has jq. That
|
||||
# branch is a contract, not a fallback: jq is optional here while every other
|
||||
# target in this script is dependency-free.
|
||||
JQ="${SKILLS_JQ:-jq}"
|
||||
# Recorded beside the managed directory rather than inside it: a stray file in
|
||||
# there would look like a skill to every harness reading the dir.
|
||||
SOURCES_MANIFEST="$(dirname "$AGENTS_DIR")/$(basename "$AGENTS_DIR")-sources"
|
||||
@@ -213,6 +225,42 @@ link_dir() {
|
||||
log "linked $link → $target"
|
||||
}
|
||||
|
||||
# wire_antigravity — register $AGENTS_DIR in Antigravity's skills.json.
|
||||
# Merges rather than overwrites: skills.json is a documented user-editable
|
||||
# config (json_configs.md), so a hand-added entry (e.g. a personal skills
|
||||
# dir) must survive a re-run untouched.
|
||||
wire_antigravity() {
|
||||
if ! command -v "$JQ" >/dev/null 2>&1; then
|
||||
log "Antigravity: jq not found — skipping (needed to merge $ANTIGRAVITY_SKILLS_JSON safely)"
|
||||
return
|
||||
fi
|
||||
|
||||
local existing already updated
|
||||
mkdir -p "$(dirname "$ANTIGRAVITY_SKILLS_JSON")"
|
||||
|
||||
if [ -s "$ANTIGRAVITY_SKILLS_JSON" ]; then
|
||||
existing="$(cat "$ANTIGRAVITY_SKILLS_JSON")"
|
||||
else
|
||||
existing='{}'
|
||||
fi
|
||||
|
||||
already="$(printf '%s' "$existing" | "$JQ" --arg p "$AGENTS_DIR" \
|
||||
'[.entries // [] | .[] | select(.path == $p)] | length' 2>/dev/null)" || {
|
||||
log "Antigravity: $ANTIGRAVITY_SKILLS_JSON is not valid JSON — leaving it untouched"
|
||||
return 1
|
||||
}
|
||||
|
||||
if [ "$already" -gt 0 ]; then
|
||||
log "Antigravity: $AGENTS_DIR already registered in $ANTIGRAVITY_SKILLS_JSON"
|
||||
return
|
||||
fi
|
||||
|
||||
updated="$(printf '%s' "$existing" | "$JQ" --arg p "$AGENTS_DIR" \
|
||||
'.entries = ((.entries // []) + [{path: $p}])')"
|
||||
printf '%s\n' "$updated" > "$ANTIGRAVITY_SKILLS_JSON"
|
||||
log "Antigravity: registered $AGENTS_DIR in $ANTIGRAVITY_SKILLS_JSON"
|
||||
}
|
||||
|
||||
main() {
|
||||
ensure_checkout
|
||||
|
||||
@@ -224,6 +272,7 @@ main() {
|
||||
|
||||
link_dir "$CRUSH_DIR" "$AGENTS_DIR"
|
||||
link_dir "$OPENCODE_DIR" "$AGENTS_DIR"
|
||||
wire_antigravity
|
||||
|
||||
log "done — $LINKED_COUNT skill(s) linked from $(sources | wc -l | tr -d ' ') source(s), ref=$REF"
|
||||
log "Claude Code: install the plugin instead — /plugin marketplace add $REPO_URL"
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
---
|
||||
name: adr
|
||||
surfaces: [claude-code, library]
|
||||
summary: "Architecture Decision Records — house format, and how to record a decision you cannot make yet"
|
||||
use_when: "Recording an architectural decision; revisiting one; or deciding between options where the right answer genuinely is not knowable yet"
|
||||
description: Write an Architecture Decision Record in this estate's format — YAML frontmatter, Status/Context/Decision/Consequences/Validation — including the case where the tradeoff is genuinely context-dependent and forcing a resolution would be worse than preserving the tension. Use when recording or superseding an architectural decision, or when a decision keeps getting re-litigated because nobody wrote down why.
|
||||
---
|
||||
|
||||
# ADR
|
||||
|
||||
## Overview
|
||||
|
||||
An ADR is the *record* of a decision — the artifact someone reads in eight months when
|
||||
they are about to undo it without knowing why it exists. It does not confer the decision;
|
||||
a person with authority does that, and this skill never is that person.
|
||||
|
||||
Two things make one useful, and neither is the prose quality: a **format consistent
|
||||
enough to grep across repos**, and an honest account of **what was not decided**.
|
||||
|
||||
## Before you write
|
||||
|
||||
Both of these change what goes in the ADR, so they are not cross-references, they are
|
||||
steps:
|
||||
|
||||
1. **`lineage`** — was this decided before and reversed? The reason it was dropped is
|
||||
usually the most valuable sentence in your Context, and if an earlier ADR exists,
|
||||
yours supersedes it rather than ignoring it.
|
||||
2. **`grill-me`** (Full Grill) — find the hole now. It is cheaper here than in the
|
||||
Consequences section of the ADR that supersedes yours.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Any choice where a reasonable alternative existed and was not taken
|
||||
- Superseding or amending an earlier ADR (never edit the old one — see Status)
|
||||
- A decision that keeps getting re-litigated, which means the reasoning was never recorded
|
||||
- A tradeoff that is genuinely context-dependent — see *Preserving a productive tension*
|
||||
|
||||
**Do not use for:** implementation choices with no alternative worth naming, or anything
|
||||
a code comment answers. An ADR per function is an ADR nobody reads.
|
||||
|
||||
## The format
|
||||
|
||||
**Where it goes:** `docs/adr/NNNN-kebab-slug.md`, zero-padded to four digits, numbered
|
||||
sequentially within the repo. Frontmatter you cannot locate is not greppable, and half
|
||||
the value of the format is that `docs/adr/*` is a known path in every repo here.
|
||||
|
||||
**The number is repo-local.** `cad` ADR-0002 and `infra` ADR-0002 are different
|
||||
documents. When referring across repos — including in `supersedes` — qualify it:
|
||||
`cad#0002`. Within a repo the bare number is fine.
|
||||
|
||||
Frontmatter first. It exists so the corpus is machine-readable — "which ADRs does 0004
|
||||
supersede", "what is still `proposed`" — and a bespoke header block, however well
|
||||
written, cannot answer those.
|
||||
|
||||
```markdown
|
||||
---
|
||||
adr: 0004
|
||||
title: <the decision, as a sentence — not "database choice">
|
||||
status: proposed | accepted | superseded | amended
|
||||
date: 2026-08-29
|
||||
deciders: <who accepted it, by name — or "pending: <name>" (QUOTED) while proposed>
|
||||
supersedes: <adr number, or null>
|
||||
---
|
||||
|
||||
# ADR-0004 — <same title>
|
||||
|
||||
## Status
|
||||
|
||||
**The frontmatter is authoritative for status and date.** This section exists for the one
|
||||
thing the frontmatter cannot hold: *why* it moved — what superseded it and on what
|
||||
evidence, or what accepting it was contingent on. If it would only restate the YAML,
|
||||
delete it.
|
||||
|
||||
## Context
|
||||
|
||||
The forces. What is true that makes this a decision rather than a default — constraints,
|
||||
the failure that prompted it, what was tried. Name the unknowns explicitly; a decision
|
||||
made under uncertainty is not weaker for saying so, it is auditable.
|
||||
|
||||
## Decision
|
||||
|
||||
One sentence of what was decided, then why *this* option rather than the named
|
||||
alternatives. Address the strongest case against, not the weakest.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:** what this buys.
|
||||
|
||||
**Negative / watch:** what it costs, and the specific thing to watch that would tell you
|
||||
it was wrong. "Some added complexity" is not a consequence; "the event schema is now the
|
||||
coupling surface between cad and every executor — version it deliberately" is.
|
||||
|
||||
## Validation
|
||||
|
||||
How you will know. A link to the experiment or the metric that proves or kills it, and
|
||||
the condition that fires a revisit. An ADR with no validation section is a preference
|
||||
with a template around it.
|
||||
```
|
||||
|
||||
### The frontmatter must actually parse
|
||||
|
||||
It exists to be machine-read, so a block that does not load is worse than no block at
|
||||
all: it drops that ADR silently out of every "what is still open?" query, while looking
|
||||
correct. Two shapes this template used to suggest are invalid YAML, and both were found
|
||||
live in the estate on 2026-09-05 — `openbanking-api` ADR-0001 and `infra` ADR-0019, each
|
||||
copied verbatim from here:
|
||||
|
||||
| Wrong | Why | Right |
|
||||
|---|---|---|
|
||||
| `supersedes: -` | a bare dash is a YAML *sequence entry*, not a string | `supersedes: null` |
|
||||
| `deciders: pending: Mathias` | the second, unquoted colon reads as a nested mapping | `deciders: "pending: Mathias"` |
|
||||
|
||||
Check before committing:
|
||||
|
||||
```sh
|
||||
python3 -c "import yaml,sys;yaml.safe_load(open(sys.argv[1]).read().split('---')[1]);print('ok')" docs/adr/0004-*.md
|
||||
```
|
||||
|
||||
## Preserving a productive tension
|
||||
|
||||
Some tradeoffs are not unresolved because nobody thought hard enough. They are
|
||||
context-dependent, the context is not settled, and picking now means picking blind.
|
||||
Forcing resolution there produces a confident record that is wrong half the time.
|
||||
|
||||
Record the tension instead, in the Decision section:
|
||||
|
||||
```markdown
|
||||
## Decision
|
||||
|
||||
Provisional: **<option A>**, on this reasoning: <what makes it safe to pick now>.
|
||||
|
||||
<The strongest case for the option you did NOT take, and why it does not win today. The
|
||||
plain format demands this and a preserved tension does not get an exemption — the whole
|
||||
point is that the loser is still live.>
|
||||
|
||||
### The tension, preserved
|
||||
|
||||
- **Option A** — <what it assumes, what it costs>
|
||||
- **Option B** — <what it assumes, what it costs>
|
||||
|
||||
**What we do not know:** <the variable that decides it>
|
||||
|
||||
**Preservation strategy:** <what we build now so BOTH stay reachable. This is the
|
||||
technical content of the ADR, not a one-liner — enumerate it. It is usually the part both
|
||||
options need anyway, which is often most of the work: the schema, the interface seam, the
|
||||
idempotency key. Name each piece and say which option it keeps alive.>
|
||||
|
||||
**Resolution triggers:** <plural, and one per unknown. Two independent unknowns do not
|
||||
collapse into one condition, and a trigger may exist that neither unknown implies.>
|
||||
```
|
||||
|
||||
Two rules keep this from becoming an excuse:
|
||||
|
||||
1. **A preserved tension still ships a decision.** "We will decide later" is not one.
|
||||
Prefer the option whose correctness does not depend on the unknown, say so, and mark
|
||||
it provisional.
|
||||
|
||||
**When no such option exists** — and often none does, because a tension is productive
|
||||
precisely when both options are hostage to the same unknown — do not manufacture a
|
||||
reason that one of them is. That reads as reasoned and will not be re-examined, which
|
||||
makes it worse than an honest arbitrary pick. Fall back, in order: the option that is
|
||||
cheaper to reverse; the one with the lower switching cost given your preservation
|
||||
strategy; the one already built. Then **say that is why you picked it.** "Chosen
|
||||
because it is the cheaper mistake" is a real justification and an auditable one.
|
||||
2. **The resolution trigger must be observable by someone who is not you.** "When it
|
||||
becomes a problem" is not a trigger. "When executor count passes five and they are all
|
||||
long-lived services in one cluster" is. A trigger nobody can evaluate is a decision
|
||||
that quietly becomes permanent.
|
||||
|
||||
`cad`'s ADR-0001 does this informally under *"Known refinements (recorded, deliberately
|
||||
not built yet)"* — sovereignty deferred to a 3-class model *"until a client actually opts
|
||||
into an EU provider"*. That clause is the pattern: option, cost, and the event that
|
||||
forces the choice.
|
||||
|
||||
## Status discipline
|
||||
|
||||
- **Never edit an accepted ADR to change its decision.** Write a new one and set
|
||||
`supersedes`. The record of what was believed, and when, is the whole value.
|
||||
- **Amend** for corrections that do not change the decision (a broken link, a clarified
|
||||
consequence). Note the amendment inline with its date.
|
||||
- **`proposed` is not a parking space.** An ADR that sits proposed for a month *with no
|
||||
named decider* is a decision nobody is making; either accept it provisionally or close
|
||||
it as declined. With a named decider it is a decision waiting on a person, which is a
|
||||
different thing and is chased rather than closed.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
| Anti-pattern | Why it fails |
|
||||
|---|---|
|
||||
| Consequences that are all positive | Then it was not a decision, it was an announcement |
|
||||
| "We chose X because it is better" | Names no alternative and no force; unfalsifiable |
|
||||
| Written after the code shipped | Reverse-engineers a justification. Record the reasoning while it is still contested |
|
||||
| One ADR amended repeatedly | Destroys the history the format exists to keep |
|
||||
| No Validation section | Nothing distinguishes a good decision from a lucky one |
|
||||
| A tension "resolved" by picking arbitrarily | The next person re-litigates it, having lost the reasoning |
|
||||
|
||||
## Escalation
|
||||
|
||||
Writes a file, so it acts — but it decides nothing.
|
||||
|
||||
Stop and hand back when: the deciders are not you — which, for an agent, is always. An
|
||||
ADR records a decision someone with authority made; writing one to *create* that
|
||||
authority is how a preference acquires a document number.
|
||||
|
||||
So the deliverable is a draft marked `proposed` with **the person who must accept it named
|
||||
in `deciders` as `pending: <name>`**, and that hand-back stated in the response, not left
|
||||
implied. Naming the decider is the escalation, not a formality — an unnamed one is how a
|
||||
proposal sits for a month.
|
||||
|
||||
That is the intended state for an agent-authored ADR, and it is not the parking-space
|
||||
failure below: a `proposed` ADR with a named decider is waiting on someone; one without is
|
||||
waiting on nobody.
|
||||
|
||||
Also stop when the Context cannot be written without inventing a constraint. A missing
|
||||
force means the decision has not been thought through yet, and an ADR built on an
|
||||
invented one is worse than none: it will be cited.
|
||||
|
||||
## Cross-References
|
||||
|
||||
- `lineage` and `grill-me` — see *Before you write*; they change the content, so they are
|
||||
steps rather than references.
|
||||
- `spec-driven-dev` / `feature-spec` — an ADR is the *why*; a spec is the *what*. If you
|
||||
are describing behaviour, you are writing a spec.
|
||||
@@ -0,0 +1,163 @@
|
||||
---
|
||||
name: lineage
|
||||
surfaces: [claude-code, library]
|
||||
summary: "Backward decision archaeology — was this tried before, and does the reason it failed still hold"
|
||||
use_when: "Before proposing something that feels new; when an idea keeps resurfacing; when you inherit code whose reason is gone"
|
||||
description: Backward decision archaeology. Find out whether an idea was already tried here, why it was dropped, and whether that reason still applies — using git history, decision records and the issue tracker rather than asking whoever is still around. Use before proposing a change that feels novel, when inheriting unexplained code, or when the same idea keeps being re-litigated.
|
||||
---
|
||||
|
||||
# Lineage
|
||||
|
||||
## Overview
|
||||
|
||||
Most "new" ideas in an established codebase are revivals. The valuable question is never
|
||||
"is this a good idea" in the abstract — it is **"was this tried, what killed it, and is
|
||||
the killer still alive?"**
|
||||
|
||||
A past failure is not evidence against trying again. It is context for trying again
|
||||
differently, and the difference between those two readings is usually one commit message.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Before proposing something that feels new in a codebase older than you
|
||||
- When an idea keeps resurfacing and being re-argued from scratch
|
||||
- When you inherit code and cannot tell whether a constraint is load-bearing or vestigial
|
||||
- Before writing an ADR — the reason a thing was dropped belongs in your Context section
|
||||
|
||||
**Do not use for:** code you wrote this week, or a question the current code answers.
|
||||
Archaeology on a live construction site is just reading.
|
||||
|
||||
## The tool
|
||||
|
||||
```sh
|
||||
skills/lineage/lineage.sh <term> [repo-path]
|
||||
```
|
||||
|
||||
Runs the searches people skip because they are fiddly to type. In particular
|
||||
`git log -S<term>`, which finds commits where the term's **occurrence count changed** —
|
||||
so it surfaces the *removal* as well as the introduction. A plain `--grep` never finds a
|
||||
deletion, and the deletion is the whole point.
|
||||
|
||||
It also lists files that once carried the term and no longer exist, and names the two
|
||||
sources it cannot search for you.
|
||||
|
||||
**A no-match is not a finding.** The first version of this tool said a null meant "this is
|
||||
new here"; a pressure test caught it asserting exactly that about the most-discussed
|
||||
removed feature in this repo — the search term merely had a space in it. One string
|
||||
against one repo proves nothing. Try at least three variants (the identifier name, a
|
||||
one-word form, the name it would have had) and the brain and tracker searches before
|
||||
telling anyone an idea is novel. This is the same rule as the anti-patterns table below,
|
||||
and it is there because the tool broke it.
|
||||
|
||||
Note also that `--grep` takes a regex and `-S` takes a literal string, so a term
|
||||
containing `.`, `[`, `*` or `+` matches differently in the two halves of the output.
|
||||
|
||||
## The sequence
|
||||
|
||||
**1. Search on the identifier, not the concept.** If the thing had a name in the code —
|
||||
a function, a flag, a config key — `git log --all -S<name>` typically returns exactly two
|
||||
commits, its birth and its death, on one screen. That is the fastest path to the answer in
|
||||
this whole sequence.
|
||||
|
||||
Path-scoped log (`git log --all -- <file>`) is the weaker move and it is the one people
|
||||
reach for: a function deleted from a file that still exists is the *dominant* removal
|
||||
shape, and the path search buries it under every other commit that touched the file.
|
||||
|
||||
Concept words are the fallback, and they are lossy — a term with a space in it will miss
|
||||
the record entirely. Try several spellings before you conclude anything.
|
||||
|
||||
**2. Read the removal commit's full body, not its subject.**
|
||||
|
||||
```sh
|
||||
git log -1 --format=%B <sha>
|
||||
git show --stat <sha>
|
||||
```
|
||||
|
||||
This is where the answer almost always is. A subject line says *what*; the body says
|
||||
*why*, and the why is what you came for. Read the diff too — what was removed alongside
|
||||
it tells you how load-bearing it was.
|
||||
|
||||
**3. Search outside git — that is where this estate keeps its reasoning.** Two sources,
|
||||
both routinely decisive, neither reachable from a commit log:
|
||||
|
||||
- **The brain** (`brain_query`). Postmortems and generalized rules live there. In the run
|
||||
that shaped this skill, the single best artifact — the rule abstracted from the incident,
|
||||
with detection one-liners — came from the brain and from nowhere else.
|
||||
- **The issue tracker, including comments.** The commit records *that* something was
|
||||
removed; the argument for it is usually in a comment. Search closed issues especially —
|
||||
a closed issue is where a decision usually is.
|
||||
|
||||
A git-only search finds the what and misses the why.
|
||||
|
||||
**4. Test whether the failure conditions still hold — empirically, not from the record.**
|
||||
This is the step that makes the whole exercise worth doing, and it is the one that gets
|
||||
hand-waved. Write the conditions down as separate claims, then **run a command per claim**:
|
||||
|
||||
| Claim from the record | How you check it today |
|
||||
|---|---|
|
||||
| "absolute links break across two path roots" | `readlink` one, `test -e` it on this host |
|
||||
| "it is gitignored, so nobody sees the rot" | `grep` the ignore file |
|
||||
| "the detector does not cover it" | `grep` the detector for the path it scans |
|
||||
|
||||
Twenty seconds each, and it converts *"the record claims X"* into *"X is true right now"* —
|
||||
which is the only form of the answer worth giving. A constraint that has since been
|
||||
removed turns a dead idea live; one that is still true kills a revival on the spot.
|
||||
|
||||
**5. Separate the mechanism from the goal.** A revival proposal usually bundles both, and
|
||||
they can have opposite verdicts: the goal is still worth wanting, the mechanism is the one
|
||||
that failed. Splitting them is often the whole finding — you end up agreeing with the
|
||||
proposer and rejecting their implementation, which is a far more useful answer than yes
|
||||
or no.
|
||||
|
||||
## What "it was tried and failed" can mean
|
||||
|
||||
| Reading | Tell | What to do |
|
||||
|---|---|---|
|
||||
| **Structurally impossible** | The removal commit says *why* it cannot work, not that it broke | Believe it. Look for a different mechanism for the same goal |
|
||||
| **Failed under conditions that changed** | The blocker names a version, a host, a tool that has since moved | Re-open it, citing what changed |
|
||||
| **Failed on execution** | It broke, was reverted, nobody diagnosed it | Genuinely undecided. The revival is legitimate; budget for the diagnosis |
|
||||
| **Never actually tried** | Discussed, an issue exists, no commit | There is no evidence either way. Say so rather than inheriting someone's pessimism |
|
||||
| **Still there, in a different shape** | The idea returned under another name | The most missable one — check before you build it twice |
|
||||
|
||||
## Output
|
||||
|
||||
Answer the question that was asked, then show your work:
|
||||
|
||||
- **Verdict** — tried / not tried / tried and later restored differently
|
||||
- **The decisive artifact** — commit sha, issue number, or ADR, so the reader can check
|
||||
- **What killed it**, quoted from the source rather than paraphrased
|
||||
- **Whether that still applies**, per condition, not as a single yes or no
|
||||
|
||||
If the decisive evidence is one commit message, quote it. A conclusion that makes the
|
||||
reader re-run your search has not saved them anything.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
| Anti-pattern | Why it fails |
|
||||
|---|---|
|
||||
| `--grep` only | Finds discussion, misses deletions. The deletion is the evidence |
|
||||
| Reading the commit subject and stopping | Subjects say what; you need why |
|
||||
| Treating a past failure as a verdict | Conditions change. That is the entire question |
|
||||
| Git-only search | The argument is usually in the issue tracker |
|
||||
| "It was tried and it failed" with no sha | Unverifiable, and it will be re-litigated next quarter |
|
||||
| Assuming absence means it was never considered | It may have been rejected in a conversation. Say what you searched |
|
||||
|
||||
## Escalation
|
||||
|
||||
Read-only. Reports what the record says; changes nothing and revives nothing.
|
||||
|
||||
Stop and hand back when: the record is genuinely silent — no commit, no issue, no ADR.
|
||||
State what you searched and that nothing exists, rather than inferring intent from
|
||||
absence. Someone deciding not to build a thing and nobody ever considering it look
|
||||
identical in git, and only one of them is an argument.
|
||||
|
||||
Also stop when the answer turns on why a *person* did something and the record does not
|
||||
say. Ask them. Reconstructing a colleague's reasoning from a diff is how a guess acquires
|
||||
a citation.
|
||||
|
||||
## Cross-References
|
||||
|
||||
- `adr` — what you find here belongs in the Context section of the ADR you are about to
|
||||
write. If the thing was decided before, your ADR supersedes that one; say so.
|
||||
- `debug` — the same discipline aimed at a failure rather than a decision: evidence
|
||||
before hypothesis, and the removal commit is evidence.
|
||||
Executable
+99
@@ -0,0 +1,99 @@
|
||||
#!/bin/sh
|
||||
# lineage.sh — decision archaeology for one term.
|
||||
#
|
||||
# Answers "why does X exist, was it tried before, and is this 'new' idea a
|
||||
# revival?"
|
||||
#
|
||||
# Its real value is that it makes you run `git log -S` at all — that is a nudge,
|
||||
# not a capability. -S finds commits where the term's occurrence COUNT changed,
|
||||
# so it surfaces a removal; --grep only finds people talking about one. On an
|
||||
# identifier name (`wire_claude_repo`) -S typically returns exactly two commits,
|
||||
# birth and death, which is the fastest path to the answer in this whole file.
|
||||
#
|
||||
# Note the two searches do not match the same way: --grep takes a regex, -S takes
|
||||
# a literal string. A term containing . [ * + behaves differently in each.
|
||||
#
|
||||
# Usage: lineage.sh <term> [repo-path]
|
||||
#
|
||||
# Read-only: nothing here writes, and it never touches the index or worktree.
|
||||
|
||||
set -u
|
||||
|
||||
[ $# -ge 1 ] || { echo "usage: lineage.sh <term> [repo-path]" >&2; exit 2; }
|
||||
TERM_=$1
|
||||
REPO=${2:-.}
|
||||
|
||||
git -C "$REPO" rev-parse --git-dir >/dev/null 2>&1 || {
|
||||
echo "lineage: $REPO is not a git repository" >&2; exit 2; }
|
||||
|
||||
# Say which repo, always. The default is the cwd, and in a worktree-heavy setup
|
||||
# that is routinely not the repo the caller means — a confident null against the
|
||||
# wrong tree is the worst thing this tool can produce.
|
||||
TOPLEVEL=$(git -C "$REPO" rev-parse --show-toplevel 2>/dev/null || printf '%s' "$REPO")
|
||||
printf 'searching: %s term: "%s"\n' "$TOPLEVEL" "$TERM_"
|
||||
|
||||
FMT='%h %ad %an %s'
|
||||
DATE='--date=short'
|
||||
found=0
|
||||
|
||||
section() { printf '\n== %s ==\n' "$1"; }
|
||||
|
||||
# 1. Commits whose MESSAGE mentions it — the stated reasoning.
|
||||
msgs=$(git -C "$REPO" log --all -i --grep="$TERM_" --format="$FMT" $DATE 2>/dev/null || true)
|
||||
if [ -n "$msgs" ]; then
|
||||
found=1
|
||||
section "Commits that talk about it (message)"
|
||||
printf '%s\n' "$msgs"
|
||||
fi
|
||||
|
||||
# 2. Commits where the term was ADDED or REMOVED — the actual history.
|
||||
# -S counts occurrences, so a deletion shows up here and nowhere else.
|
||||
picks=$(git -C "$REPO" log --all -S"$TERM_" --format="$FMT" $DATE 2>/dev/null || true)
|
||||
if [ -n "$picks" ]; then
|
||||
found=1
|
||||
section "Commits where it appeared or disappeared (-S)"
|
||||
printf '%s\n' "$picks"
|
||||
printf '\n Oldest is the introduction. Anything after it that removed the term is\n'
|
||||
printf ' the interesting one: read that commit before proposing this again.\n'
|
||||
fi
|
||||
|
||||
# 3. Files that once contained it and no longer exist.
|
||||
deleted=$(git -C "$REPO" log --all --diff-filter=D --name-only --format='%h' -S"$TERM_" 2>/dev/null \
|
||||
| grep -v '^[0-9a-f]\{7,\}$' | grep -v '^$' | sort -u || true)
|
||||
if [ -n "$deleted" ]; then
|
||||
found=1
|
||||
section "Files deleted that carried it"
|
||||
printf '%s\n' "$deleted"
|
||||
printf '\n Recover one with: git -C %s show <sha>^:<path>\n' "$REPO"
|
||||
fi
|
||||
|
||||
# 4. Decision records naming it.
|
||||
docs=$(git -C "$REPO" grep -l -i "$TERM_" -- 'docs/adr/*' 'docs/decisions/*' 'docs/*.md' 'ADR*' 2>/dev/null || true)
|
||||
if [ -n "$docs" ]; then
|
||||
found=1
|
||||
section "Decision records naming it"
|
||||
printf '%s\n' "$docs"
|
||||
fi
|
||||
|
||||
# This estate keeps most of its reasoning OUTSIDE the repo, so the two searches
|
||||
# this tool cannot run are usually the decisive ones. Say so every time rather
|
||||
# than letting their absence read as a null result.
|
||||
section "Not searched here — do these by hand"
|
||||
printf 'brain: brain_query "%s" — decisions and postmortems live there, not in docs/\n' "$TERM_"
|
||||
printf 'gitea: closed issues and their COMMENTS. The argument for a removal is\n'
|
||||
printf ' usually in a comment; the commit only records that it happened.\n'
|
||||
|
||||
if [ "$found" -eq 0 ]; then
|
||||
printf '\nno match for the exact string "%s" in %s.\n' "$TERM_" "$TOPLEVEL"
|
||||
printf 'This is NOT evidence the idea is new. It is one string against one repo.\n'
|
||||
printf 'Before concluding anything, try: the identifier or function name; a\n'
|
||||
printf 'one-word variant (spaces and punctuation break the match); the name it\n'
|
||||
printf 'would have had; and the brain and issue searches above.\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
printf '\n-- what this cannot tell you --------------------------------------------\n'
|
||||
printf 'Why it was removed, if it was. The commit subject is a summary; read the\n'
|
||||
printf 'body, the PR, and the issue it closed. A past failure under conditions that\n'
|
||||
printf 'no longer hold is not evidence against trying again — it is context for\n'
|
||||
printf 'trying again differently.\n'
|
||||
@@ -0,0 +1,170 @@
|
||||
---
|
||||
name: traceability
|
||||
surfaces: [claude-code, library]
|
||||
summary: "Forward trace — requirement to decision to commit to verification to release, and what cannot be checked"
|
||||
use_when: "Proving why a change exists; auditing a claim; before an attestation or a compliance ask"
|
||||
description: Establish the forward trace for a change — requirement, decision, commit, verification, CI, release, closure — as a chain of links that are each checked rather than asserted. Names the links that cannot be verified from the repository instead of letting them pass as verified. Use when auditing why a change exists, preparing an attestation, or when a claim needs to be provable rather than plausible.
|
||||
---
|
||||
|
||||
# Traceability
|
||||
|
||||
## Overview
|
||||
|
||||
A trace is not a story about how a change came to be. It is a chain of links, each of
|
||||
which is either **checked** or **named as unchecked**. The value is entirely in that
|
||||
distinction: a trace where every link is asserted is a narrative, and narratives are what
|
||||
audits exist to defeat.
|
||||
|
||||
The chain in this estate:
|
||||
|
||||
```
|
||||
issue (requirement) → comment (decision) → commit → test/gate → CI run on that sha
|
||||
→ release tag → closing comment (evidence)
|
||||
```
|
||||
|
||||
Note where the decisions live: **in issue comments**, not issue bodies. A tool that reads
|
||||
issue titles will trace the wrong thing.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Auditing why a change exists, especially one nobody remembers making
|
||||
- Preparing an attestation, a compliance answer, or a client-facing "how do you know"
|
||||
- Before claiming a requirement is satisfied
|
||||
- When a claim is repeated in several places, which is the condition under which nobody
|
||||
checks it
|
||||
|
||||
**Do not use for:** a change you are about to make. That is `adr` (the decision) and
|
||||
`spec-driven-dev` (the requirement). This skill reconstructs; it does not author.
|
||||
|
||||
## The tool
|
||||
|
||||
```sh
|
||||
skills/traceability/trace.sh <sha> [repo-path]
|
||||
```
|
||||
|
||||
Prints what git can prove — the commit, its files, the test files it touched, the release
|
||||
that contains it, every issue it references as a URL — and then prints, every time, the
|
||||
three things git **cannot** prove. That last section is the point of the tool.
|
||||
|
||||
## The sequence
|
||||
|
||||
**1. Run the tool.** It does the mechanical half in one command.
|
||||
|
||||
**2. Open the issues it printed, and read the comments.** The requirement is in the body;
|
||||
the decision is almost always in a comment, and so is the closure record. A trace built
|
||||
from commit messages alone will confidently cite an issue that says something else.
|
||||
|
||||
**3. Match CI on the head sha, not on the branch.** "The pipeline is green" is not
|
||||
evidence about a commit. Find the run whose `head_sha` is this commit and read its
|
||||
conclusion — and check which *job* and which *step* passed, because a run can be red for a
|
||||
reason that has nothing to do with the gate you care about, and green overall while the
|
||||
gate you care about was skipped.
|
||||
|
||||
The estate's `workflow_run_status` returns only the run-level conclusion — no jobs, no
|
||||
steps. For that breakdown you have to drop to the API:
|
||||
|
||||
```sh
|
||||
op run --env-file ~/.op-env -- sh -c 'curl -sS -H "Authorization: token $DMABE_GITEA_API_TOKEN" \
|
||||
"https://git.d-ma.be/api/v1/repos/<owner>/<repo>/actions/runs/<id>/jobs"'
|
||||
```
|
||||
|
||||
Said explicitly because a step whose tooling dead-ends is exactly where a trace becomes
|
||||
"the run was green".
|
||||
|
||||
**4. Check that the tests actually cover the behaviour.** A commit that touched a test
|
||||
file is not a commit that is verified. Read the assertions and ask what would have to
|
||||
break for them to fail. If nothing would, the link is decorative.
|
||||
|
||||
**5. Probe the load-bearing claim — try to falsify it.** This is the step that separates a
|
||||
trace from a summary, and it is the one that finds things.
|
||||
|
||||
Pick the claim the chain rests on, and attack it in a scratch copy:
|
||||
|
||||
```sh
|
||||
git archive <sha> | (mkdir -p /tmp/probe && tar -x -C /tmp/probe)
|
||||
# then break exactly the thing the claim protects, and run the gate
|
||||
```
|
||||
|
||||
If the gate stays green, the claim is false — regardless of how many places assert it.
|
||||
This is not hypothetical: the probe that shaped this skill falsified a "the list only
|
||||
shrinks" claim that was stated in five places in the repository and enforced in none.
|
||||
|
||||
**6. Look for what cites the commit, not only what it cites.** A commit cannot reference
|
||||
an issue filed after it, and that is precisely where an outcome gets falsified. Search the
|
||||
tracker for the sha, across issues *and comments*, and `git log --all --grep=<sha>` for
|
||||
later commits referring back. In the run that shaped this skill, the issue proving the
|
||||
traced commit had *relocated* rather than fixed its defect was filed 90 minutes later and
|
||||
was reachable only this way.
|
||||
|
||||
**7. Report unverifiable links as unverifiable.** Not as weak links, not as caveats at the
|
||||
end. Each gets its own line saying what would be needed to close it.
|
||||
|
||||
## What a broken link looks like
|
||||
|
||||
| Symptom | What it means |
|
||||
|---|---|
|
||||
| The commit cites `#37`, and `#37` describes something else | The reference is decoration; someone renumbered or misremembered |
|
||||
| A claim appears in five places and a check in none | Convention wearing a mechanism's clothes. Probe it |
|
||||
| The gate greps for a heading | Presence is not compliance. A heading with nothing under it passes |
|
||||
| `git describe` says unreleased | Usually stale local tags, not an unreleased commit. `git fetch --tags` first |
|
||||
| The requirement is a bare `#5` with no URL | Recoverable only while the tracker lives. Not traceable from the repo alone |
|
||||
| CI green, but on a different sha | The most common false link, and the easiest to accept by accident |
|
||||
|
||||
## Output
|
||||
|
||||
State the chain, then the gaps, in that order:
|
||||
|
||||
```
|
||||
CHAIN
|
||||
requirement #36 item 3 (defect report) → verified, read: body + comments
|
||||
requirement #19 (standing execution bar) → verified, read
|
||||
decision comment 8509 → verified; names the commit
|
||||
commit b616c51 → verified
|
||||
claim "nine skills, none named session_id" → verified against b616c51^: 9/9
|
||||
claim "session_id is the only required field" → verified against the live tool schema
|
||||
gate bin/check-session-log → RED on parent (17 errors), GREEN on it
|
||||
wiring Taskfile check step 5 → verified; CI runs task check
|
||||
verification tests/check_session_log_test.sh → verified, 4 cases, both directions
|
||||
CI run 4880, job "Check / Test" → verified, head_sha matches, success
|
||||
release v0.13.3 → verified against the REMOTE
|
||||
|
||||
GAPS
|
||||
- The gate greps the file, not the payload. Probe: stripped session_id from
|
||||
every payload, left the word in prose — gate returned 0. FALSIFIED.
|
||||
- Defect relocated, not removed: #39, filed 90 minutes later by the same
|
||||
author, says so. The commit's outcome claim does not hold.
|
||||
```
|
||||
|
||||
Four slots that are not optional, each learned by leaving them out:
|
||||
|
||||
- **More than one requirement, and they differ in kind.** A defect report and a standing
|
||||
policy bar are both requirements and collapsing them hides which one was not met.
|
||||
- **The gate, separately from the test.** A commit that ships a check has three artifacts,
|
||||
not two, and the chain usually rests on the one in the middle.
|
||||
- **The RED/GREEN baseline.** Run the gate against the parent commit. Without it you have
|
||||
shown a gate exists, not that it caught anything.
|
||||
- **The commit's factual claims, checked.** "Nine skills, none named it" is falsifiable in
|
||||
one command. Without a slot for it, "commit → verified" means only "this sha exists".
|
||||
|
||||
Gaps ranked by how much they matter, not by where you found them. A trace whose gap
|
||||
section is empty should be suspicious of itself.
|
||||
|
||||
## Escalation
|
||||
|
||||
Read-only. Reports the chain; changes nothing, and closes no issue on the strength of
|
||||
what it finds.
|
||||
|
||||
Stop and hand back when: a link's evidence is outside anything you can reach — a tracker
|
||||
you cannot read, a CI system you have no access to, a decision made in conversation. Say
|
||||
which link and what would close it. An inferred link presented as a traced one is worse
|
||||
than a gap, because the gap is honest and the inference is citable.
|
||||
|
||||
Also stop before *acting* on a falsified claim. Finding that a gate does not enforce what
|
||||
it says is a finding; deciding what to do about it is the owner's call.
|
||||
|
||||
## Cross-References
|
||||
|
||||
- `lineage` — the same discipline backwards: this asks "does the chain hold", that asks
|
||||
"was it tried before and why did it stop".
|
||||
- `adr` — the decision layer of the chain. A trace that reaches an ADR has found the
|
||||
reasoning; a trace that reaches only a commit message has found a summary of it.
|
||||
Executable
+107
@@ -0,0 +1,107 @@
|
||||
#!/bin/sh
|
||||
# trace.sh — the forward trace for one commit, and an honest list of what it
|
||||
# could not check.
|
||||
#
|
||||
# The chain this estate actually uses:
|
||||
#
|
||||
# issue (requirement) -> comment (decision) -> commit -> test/gate -> CI run
|
||||
# -> release tag -> closing comment
|
||||
#
|
||||
# git can prove the middle of that and nothing else. The issue bodies, the
|
||||
# comments where decisions actually live, and the CI run are all tracker state.
|
||||
# So this prints what git knows and then names, with URLs, the links a human has
|
||||
# to follow — because a link nobody checked must not read like a verified one.
|
||||
#
|
||||
# Usage: trace.sh <sha> [repo-path]
|
||||
#
|
||||
# Read-only.
|
||||
|
||||
set -u
|
||||
|
||||
[ $# -ge 1 ] || { echo "usage: trace.sh <sha> [repo-path]" >&2; exit 2; }
|
||||
SHA=$1
|
||||
REPO=${2:-.}
|
||||
|
||||
git -C "$REPO" rev-parse --git-dir >/dev/null 2>&1 || {
|
||||
echo "trace: $REPO is not a git repository" >&2; exit 2; }
|
||||
git -C "$REPO" rev-parse --verify --quiet "$SHA^{commit}" >/dev/null || {
|
||||
echo "trace: $SHA is not a commit in $REPO" >&2; exit 2; }
|
||||
|
||||
TOP=$(git -C "$REPO" rev-parse --show-toplevel)
|
||||
ORIGIN=$(git -C "$REPO" config --get remote.origin.url 2>/dev/null || true)
|
||||
# Never print the URL as configured: url.insteadOf can expand it into a live
|
||||
# token. Reduce it to owner/repo and use that.
|
||||
SLUG=$(printf '%s' "$ORIGIN" | sed -E 's#\.git$##; s#^.*[/:]([^/]+/[^/]+)$#\1#' 2>/dev/null || true)
|
||||
|
||||
section() { printf '\n== %s ==\n' "$1"; }
|
||||
|
||||
section "Commit"
|
||||
git -C "$REPO" log -1 --format='%h %ad %an%n%n%B' --date=short "$SHA"
|
||||
|
||||
section "Files"
|
||||
git -C "$REPO" show --stat --format='' "$SHA"
|
||||
|
||||
section "Verification it touched"
|
||||
tests=$(git -C "$REPO" show --name-only --format='' "$SHA" \
|
||||
| grep -Ei '(^|/)(tests?|spec)/|_test\.|\.test\.|_spec\.' || true)
|
||||
if [ -n "$tests" ]; then
|
||||
printf '%s\n' "$tests"
|
||||
else
|
||||
printf 'none — this commit changed no test or spec file.\n'
|
||||
printf 'That is not automatically wrong (docs, config), but if it changed behaviour\n'
|
||||
printf 'it means the change has no verification of its own.\n'
|
||||
fi
|
||||
|
||||
section "Released in"
|
||||
tag=$(git -C "$REPO" describe --tags --contains "$SHA" 2>/dev/null | sed 's/[~^].*//' || true)
|
||||
if [ -n "$tag" ]; then
|
||||
printf '%s (LOCAL tags only — confirm with: git ls-remote --tags origin)\n' "$tag"
|
||||
else
|
||||
printf 'no release contains this commit in THIS checkout.\n'
|
||||
printf 'Local tags go stale — run `git fetch --tags` and re-check before\n'
|
||||
printf 'concluding it was never released.\n'
|
||||
fi
|
||||
|
||||
section "Referenced issues"
|
||||
refs=$(git -C "$REPO" log -1 --format='%B' "$SHA" | grep -oE '#[0-9]+' | sort -u || true)
|
||||
if [ -n "$refs" ]; then
|
||||
for r in $refs; do
|
||||
n=${r#\#}
|
||||
if [ -n "$SLUG" ]; then
|
||||
printf '%s -> https://git.d-ma.be/%s/issues/%s\n' "$r" "$SLUG" "$n"
|
||||
else
|
||||
printf '%s\n' "$r"
|
||||
fi
|
||||
done
|
||||
else
|
||||
printf 'none — the commit cites no issue, so its requirement is unrecorded.\n'
|
||||
fi
|
||||
|
||||
section "Issues that cite THIS commit"
|
||||
printf 'Not searchable from the commit — an issue filed after it cannot appear in\n'
|
||||
printf 'its message, and that is exactly where an outcome gets falsified. Check:\n\n'
|
||||
printf ' git -C %s log --all --grep=%s # later commits referring back\n' "$REPO" "$SHA"
|
||||
if [ -n "$SLUG" ]; then
|
||||
printf ' tracker search for "%s" across issues AND comments\n' "$SHA"
|
||||
fi
|
||||
printf '\nIn the run that shaped this tool, the issue that falsified the traced\n'
|
||||
printf 'commit was filed 90 minutes after it and was reachable only this way.\n'
|
||||
|
||||
section "Cannot verify from here"
|
||||
cat <<'TXT'
|
||||
git proves the commit, its files, and which tag contains it. It cannot prove:
|
||||
|
||||
1. That the issues above say what the commit claims they say. The DECISION is
|
||||
usually in a comment, not the issue body — open them and read the comments.
|
||||
2. That CI actually passed on this exact sha. A green pipeline elsewhere is not
|
||||
evidence about this commit; match on head_sha.
|
||||
3. That the tests listed above assert the behaviour this commit added. A test
|
||||
file touched is not a behaviour covered.
|
||||
4. That any gate this commit ships enforces what its message says it does.
|
||||
Probe it: break exactly the thing the claim protects, in a scratch copy,
|
||||
and see whether the gate goes red.
|
||||
|
||||
None of the three is a formality. Each has been the broken link in this estate:
|
||||
a claim asserted in five places and enforced in none, a red run whose gate had
|
||||
passed, and a gate that greps for a heading and calls it a contract.
|
||||
TXT
|
||||
@@ -81,5 +81,47 @@ f=$(fixture comments); skill "$f" alpha no
|
||||
printf '# a comment, and a blank line follow\n\nalpha\n' > "$f/bin/escalation-pending.txt"
|
||||
expect 0 'comments and blank lines in the list are ignored' "$f"
|
||||
|
||||
# --- the ratchet must actually ratchet ---------------------------------------
|
||||
# Five places in this repo claimed the pending list "only shrinks" and nothing
|
||||
# enforced it. A traceability probe falsified that in one run: add an unsectioned
|
||||
# skill, append it to the list, gate returns 0. A convention stated five times is
|
||||
# still a convention.
|
||||
#
|
||||
# Enforcement compares against the list as committed at HEAD, so the fixtures
|
||||
# below need a real git repo.
|
||||
|
||||
git_fixture() {
|
||||
_f="$WORK/$1"
|
||||
mkdir -p "$_f/skills" "$_f/bin"
|
||||
( cd "$_f" && git init -q . && git config user.email t@example.com && git config user.name test )
|
||||
skill "$_f" alpha no
|
||||
printf 'alpha\n' > "$_f/bin/escalation-pending.txt"
|
||||
( cd "$_f" && git add -A && git commit -qm baseline )
|
||||
printf '%s' "$_f"
|
||||
}
|
||||
|
||||
f=$(git_fixture ratchet_same)
|
||||
expect 0 'an unchanged list passes' "$f"
|
||||
|
||||
f=$(git_fixture ratchet_shrink)
|
||||
skill "$f" alpha yes
|
||||
: > "$f/bin/escalation-pending.txt"
|
||||
expect 0 'removing an entry (and writing its section) passes' "$f"
|
||||
|
||||
f=$(git_fixture ratchet_grow)
|
||||
skill "$f" beta no
|
||||
printf 'alpha\nbeta\n' > "$f/bin/escalation-pending.txt"
|
||||
expect 1 'adding a NEW entry fails — the list only shrinks' "$f" 'beta'
|
||||
|
||||
# Outside a git repo the comparison is impossible; that must not be silent.
|
||||
f=$(fixture ratchet_nogit); skill "$f" alpha no
|
||||
printf 'alpha\n' > "$f/bin/escalation-pending.txt"
|
||||
_out=$(sh "$SUT" "$f" 2>&1)
|
||||
if printf '%s' "$_out" | grep -qi 'cannot verify'; then
|
||||
pass=$((pass + 1)); printf ' ok an unverifiable ratchet says so rather than passing silently\n'
|
||||
else
|
||||
fail=$((fail + 1)); printf ' FAIL an unverifiable ratchet says so rather than passing silently\n%s\n' "$_out"
|
||||
fi
|
||||
|
||||
printf '\ncheck_escalation: %d passed, %d failed\n' "$pass" "$fail"
|
||||
[ "$fail" -eq 0 ]
|
||||
|
||||
@@ -63,5 +63,38 @@ expect 1 'documenting duration_ms as wall-clock fails' \
|
||||
"$(fixture clock 'Call `session_log` with `{session_id: "<id>", skill: "alpha", duration_ms: <wall-clock>}`')" \
|
||||
'duration_ms'
|
||||
|
||||
# --- the gate must read the PAYLOAD, not the file ---------------------------
|
||||
# A traceability probe falsified the original: it grepped the whole file for the
|
||||
# string "session_id", so stripping the field from every documented payload and
|
||||
# leaving the word once in prose passed cleanly. The exact regression the gate
|
||||
# exists to prevent was back in the file and the gate said nothing.
|
||||
|
||||
expect 1 'a payload without session_id fails even when prose mentions the word' \
|
||||
"$(fixture prose 'The tool has a required session_id field.
|
||||
|
||||
Call `session_log` with `{skill: "alpha", phase: "red", final_status: "pass"}`')" \
|
||||
'session_id'
|
||||
|
||||
expect 1 'one bad payload among several fails' \
|
||||
"$(fixture mixed 'Call `session_log` with `{session_id: "<id>", skill: "alpha", phase: "red"}`
|
||||
|
||||
Then `session_log` with `{skill: "alpha", phase: "green", final_status: "pass"}`')" \
|
||||
'session_id'
|
||||
|
||||
# duration_ms was banned by matching exactly one spelling of it — the historical
|
||||
# one. Both of these passed the original gate.
|
||||
expect 1 'duration_ms with a JSON-quoted key fails' \
|
||||
"$(fixture jsonkey 'Call `session_log` with `{session_id: "<id>", "duration_ms": <wall-clock>}`')" \
|
||||
'duration_ms'
|
||||
|
||||
expect 1 'duration_ms described in prose rather than angle brackets fails' \
|
||||
"$(fixture prosems 'Call `session_log` with `{session_id: "<id>", duration_ms: elapsed wall-clock ms}`')" \
|
||||
'duration_ms'
|
||||
|
||||
# A real measurement is allowed: the objection is to a placeholder no agent can
|
||||
# fill, not to the field.
|
||||
expect 0 'a duration_ms carrying a real value is allowed' \
|
||||
"$(fixture realms 'Call `session_log` with `{session_id: "<id>", duration_ms: 1430}`')"
|
||||
|
||||
printf '\ncheck_session_log: %d passed, %d failed\n' "$pass" "$fail"
|
||||
[ "$fail" -eq 0 ]
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/bin/sh
|
||||
# `task check` must survive a check that reports "could not verify".
|
||||
#
|
||||
# The regression these exist for: bin/check-mcp-env exits 2 to mean "I could
|
||||
# not run — no jq, or no env-file", which the gate deliberately treats as a
|
||||
# reported NOTE rather than a failure. The Taskfile said exactly that:
|
||||
#
|
||||
# "$DIR/check-mcp-env" "$DIR/.mcp.json" "$HOME/.op-env"
|
||||
# case $? in 0) ;; 2) echo "NOTE: ..." ;; *) fail=1 ;; esac
|
||||
#
|
||||
# and it did not work. Task runs each cmds block under `sh -e`, so a bare
|
||||
# command exiting non-zero aborts the shell *before* `case $?` is ever
|
||||
# reached. The whole gate died with `exit status 2`. Locally the check found
|
||||
# jq and exited 0, so it went green; CI has neither jq nor ~/.op-env, so main
|
||||
# went red on the very commit that added it (skills run #57, 2026-09-07).
|
||||
#
|
||||
# A tolerated exit code that is only tolerated on the host where it never
|
||||
# fires is not tolerated at all. This pins the tolerance itself.
|
||||
#
|
||||
# No mocking: pointing HOME at an empty directory removes ~/.op-env, which is
|
||||
# exactly the condition CI runs under, so check-mcp-env genuinely exits 2.
|
||||
|
||||
set -u
|
||||
|
||||
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
|
||||
pass=0
|
||||
fail=0
|
||||
|
||||
ok() { pass=$((pass + 1)); printf ' ok %s\n' "$1"; }
|
||||
no() { fail=$((fail + 1)); printf ' FAIL %s\n' "$1"; }
|
||||
|
||||
if ! command -v task >/dev/null 2>&1; then
|
||||
printf ' SKIP: task not installed -- cannot exercise the gate\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
# Sanity: the precondition must actually hold, or the test proves nothing.
|
||||
# An empty HOME means no ~/.op-env, so check-mcp-env cannot verify anything.
|
||||
"$ROOT/bin/check-mcp-env" "$ROOT/.mcp.json" "$TMP/.op-env" >/dev/null 2>&1
|
||||
rc=$?
|
||||
if [ "$rc" -eq 2 ]; then
|
||||
ok 'precondition: check-mcp-env exits 2 when the env-file is absent'
|
||||
else
|
||||
no "precondition: expected check-mcp-env to exit 2, got $rc"
|
||||
fi
|
||||
|
||||
# The assertion. `task check` must still pass, and must say why it could not
|
||||
# verify -- silence would mean the check was skipped rather than tolerated.
|
||||
out=$(HOME="$TMP" task --dir "$ROOT" check 2>&1)
|
||||
rc=$?
|
||||
|
||||
if [ "$rc" -eq 0 ]; then
|
||||
ok 'task check passes when MCP env wiring cannot be verified'
|
||||
else
|
||||
no "task check exited $rc when check-mcp-env reported unverifiable"
|
||||
printf '%s\n' "$out" | sed 's/^/ | /' | tail -8
|
||||
fi
|
||||
|
||||
if printf '%s' "$out" | grep -q 'unverified'; then
|
||||
ok 'the unverifiable check is reported, not silently skipped'
|
||||
else
|
||||
no 'no NOTE reported -- an unverifiable check must never look like a pass'
|
||||
fi
|
||||
|
||||
printf ' %d passed, %d failed\n' "$pass" "$fail"
|
||||
[ "$fail" -eq 0 ]
|
||||
+91
-1
@@ -17,6 +17,7 @@ SUT="$ROOT/install.sh"
|
||||
|
||||
pass=0
|
||||
fail=0
|
||||
skipped=0
|
||||
WORK=${TMPDIR:-/tmp}/install_test.$$
|
||||
|
||||
if [ ! -f "$SUT" ]; then
|
||||
@@ -55,6 +56,7 @@ run_install() {
|
||||
AGENTS_SKILLS_DIR="$_f/home/.agents/skills" \
|
||||
CRUSH_SKILLS_DIR="$_f/home/.config/crush/skills" \
|
||||
OPENCODE_SKILLS_DIR="$_f/home/.config/opencode/skills" \
|
||||
ANTIGRAVITY_SKILLS_JSON="$_f/home/.gemini/config/skills.json" \
|
||||
SKILLS_EXTRA_SOURCES="$_extra" \
|
||||
bash "$SUT" 2>&1)
|
||||
rc=$?
|
||||
@@ -156,6 +158,40 @@ else
|
||||
no 'configured sources are recorded for doctor' "$out"
|
||||
fi
|
||||
|
||||
# --- Antigravity: jq is optional, so BOTH branches must be tested -----------
|
||||
# These three cases inherited whatever jq the host happened to have. koala has
|
||||
# it, the CI runner does not, so they passed locally and failed in CI — the
|
||||
# classic works-on-my-machine gate. install.sh treats jq as optional by design,
|
||||
# which means the skip is a contract and needs a test of its own.
|
||||
|
||||
# run_install_nojq <fixture> — same as run_install, with the jq lookup pointed
|
||||
# at a binary that does not exist. Deterministic regardless of the host.
|
||||
run_install_nojq() {
|
||||
_f=$1
|
||||
_out=$(HOME="$_f/home" \
|
||||
SKILLS_SKIP_FETCH=1 \
|
||||
SKILLS_CHECKOUT_DIR="$_f/ck" \
|
||||
AGENTS_SKILLS_DIR="$_f/home/.agents/skills" \
|
||||
CRUSH_SKILLS_DIR="$_f/home/.config/crush/skills" \
|
||||
OPENCODE_SKILLS_DIR="$_f/home/.config/opencode/skills" \
|
||||
SKILLS_JQ="definitely-not-jq-$$" \
|
||||
bash "$SUT" 2>&1)
|
||||
rc=$?
|
||||
printf '%s' "$_out"
|
||||
}
|
||||
|
||||
f=$(new_fixture antigravity_nojq)
|
||||
out=$(run_install_nojq "$f")
|
||||
if [ -e "$f/home/.gemini/config/skills.json" ]; then
|
||||
no 'without jq, no skills.json is written' "$out"
|
||||
elif ! printf '%s' "$out" | grep -qi 'jq not found'; then
|
||||
no 'without jq, the skip is reported' "$out"
|
||||
elif [ ! -L "$f/home/.agents/skills/alpha" ]; then
|
||||
no 'without jq, the other harnesses are still wired' "$out"
|
||||
else
|
||||
ok 'without jq, Antigravity is skipped and the rest still wires'
|
||||
fi
|
||||
|
||||
# --- 8. surfaces: the library link set is not "every skill" -----------------
|
||||
# claude.ai has no shell, no cluster and no checkout, so a chat-only skill
|
||||
# linked into Crush is a skill that can never fire, competing for selection
|
||||
@@ -183,5 +219,59 @@ else
|
||||
no 'a source with no surfaces convention is linked as-is' "$out"
|
||||
fi
|
||||
|
||||
printf '\ninstall: %d passed, %d failed\n' "$pass" "$fail"
|
||||
# --- 9-11. Antigravity's merge behaviour, only where jq exists ---------------
|
||||
# These three assert what jq DOES. install.sh treats jq as optional, so on a
|
||||
# host without it there is nothing to assert — and failing there is what turned
|
||||
# main red: they passed on koala (jq present) and failed on the runner (absent).
|
||||
# A skipped case is honest; a case that fails because the host lacks an optional
|
||||
# dependency is a false red, and false reds are how a suite stops being read.
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
skipped=$((skipped + 3))
|
||||
printf ' SKIP jq absent — Antigravity merge behaviour not covered on this host (3 cases)\n'
|
||||
else
|
||||
# skills.json entries must point at $AGENTS_DIR — that carries the
|
||||
# surfaces-filtering, collision handling and multi-source merge. Pointing at
|
||||
# the checkout directly would give Antigravity a different skill set than
|
||||
# every other harness.
|
||||
f=$(new_fixture antigravity)
|
||||
out=$(run_install "$f")
|
||||
AG="$f/home/.gemini/config/skills.json"
|
||||
if [ ! -f "$AG" ]; then
|
||||
no 'Antigravity skills.json is created' "$out"
|
||||
else
|
||||
registered=$(jq -r --arg p "$f/home/.agents/skills" '[.entries[]? | select(.path == $p)] | length' "$AG")
|
||||
if [ "$registered" = "1" ]; then
|
||||
ok 'Antigravity registers the managed agents dir, not the raw checkout'
|
||||
else
|
||||
no 'Antigravity registers the managed agents dir, not the raw checkout' "$(cat "$AG")"
|
||||
fi
|
||||
fi
|
||||
|
||||
f=$(new_fixture antigravity_idempotent)
|
||||
run_install "$f" >/dev/null
|
||||
out=$(run_install "$f")
|
||||
AG="$f/home/.gemini/config/skills.json"
|
||||
count=$(jq '.entries | length' "$AG" 2>/dev/null || echo "?")
|
||||
if [ "$count" = "1" ]; then
|
||||
ok 'Antigravity wiring is idempotent (no duplicate entries)'
|
||||
else
|
||||
no 'Antigravity wiring is idempotent (no duplicate entries)' "entries=$count"$'\n'"$out"
|
||||
fi
|
||||
|
||||
# A user may hand-edit skills.json to point at their own personal skills dir
|
||||
# (json_configs.md documents this). Our merge must not clobber it.
|
||||
f=$(new_fixture antigravity_preserve)
|
||||
mkdir -p "$f/home/.gemini/config"
|
||||
printf '{"entries":[{"path":"/Users/mathias/personal-skills"}]}' > "$f/home/.gemini/config/skills.json"
|
||||
out=$(run_install "$f")
|
||||
AG="$f/home/.gemini/config/skills.json"
|
||||
personal=$(jq -r '[.entries[]? | select(.path == "/Users/mathias/personal-skills")] | length' "$AG" 2>/dev/null || echo 0)
|
||||
if [ "$personal" = "1" ]; then
|
||||
ok 'Antigravity wiring preserves pre-existing hand-authored entries'
|
||||
else
|
||||
no 'Antigravity wiring preserves pre-existing hand-authored entries' "$(cat "$AG")"$'\n'"$out"
|
||||
fi
|
||||
fi
|
||||
|
||||
printf '\ninstall: %d passed, %d failed, %d skipped\n' "$pass" "$fail" "$skipped"
|
||||
[ "$fail" -eq 0 ]
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/bin/sh
|
||||
# Tests for skills/lineage/lineage.sh — the decision-archaeology helper.
|
||||
#
|
||||
# The techniques this automates are the ones people skip because they are
|
||||
# fiddly to type: `git log -S` to find when a thing was introduced AND removed,
|
||||
# --diff-filter=D to find files that once existed, and grepping decision records
|
||||
# for the term. A skill that only *describes* those is a skill that gets read and
|
||||
# not run (#11, #19's "small robust tools over prose").
|
||||
|
||||
set -u
|
||||
|
||||
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
SUT="$ROOT/skills/lineage/lineage.sh"
|
||||
|
||||
pass=0
|
||||
fail=0
|
||||
WORK=${TMPDIR:-/tmp}/lineage_test.$$
|
||||
|
||||
if [ ! -f "$SUT" ]; then
|
||||
printf 'FAIL: %s does not exist\n' "$SUT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cleanup() { rm -rf "$WORK"; }
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
# A repo where a thing was built, written about, and then removed.
|
||||
mkdir -p "$WORK/repo/docs/adr"
|
||||
cd "$WORK/repo" || exit 1
|
||||
git init -q .
|
||||
git config user.email t@example.com
|
||||
git config user.name test
|
||||
|
||||
printf 'func WidgetCache() {}\n' > cache.go
|
||||
git add -A && git commit -qm "feat: add WidgetCache for lookup speed"
|
||||
|
||||
printf -- '---\nadr: 0001\ntitle: use a WidgetCache\n---\nWe chose WidgetCache over a database index.\n' > docs/adr/0001-widgetcache.md
|
||||
git add -A && git commit -qm "docs: record the WidgetCache decision"
|
||||
|
||||
git rm -q cache.go
|
||||
git commit -qm "revert: drop WidgetCache, it went stale under concurrent writes"
|
||||
|
||||
cd - >/dev/null || exit 1
|
||||
out=$(sh "$SUT" WidgetCache "$WORK/repo" 2>&1)
|
||||
|
||||
check() {
|
||||
if printf '%s' "$out" | grep -q "$1"; then
|
||||
pass=$((pass + 1)); printf ' ok %s\n' "$2"
|
||||
else
|
||||
fail=$((fail + 1)); printf ' FAIL %s\n output had no %s\n%s\n' "$2" "$1" "$out"
|
||||
fi
|
||||
}
|
||||
|
||||
check 'add WidgetCache' 'finds the commit that introduced it'
|
||||
check 'drop WidgetCache' 'finds the commit that removed it — the revival question'
|
||||
check '0001-widgetcache.md' 'finds the decision record naming it'
|
||||
check 'cache.go' 'names the file that no longer exists'
|
||||
|
||||
# A no-match must NOT be reported as evidence the idea is new. The first version
|
||||
# of this tool said "it means this is new here" and a pressure test caught it
|
||||
# asserting exactly that about the most-discussed removed feature in this repo —
|
||||
# the search term simply had a space in it. That is the inference the skill's own
|
||||
# anti-pattern table forbids, printed in the tool's voice.
|
||||
out=$(sh "$SUT" NeverExisted "$WORK/repo" 2>&1)
|
||||
if printf '%s' "$out" | grep -q 'NOT evidence the idea is new' \
|
||||
&& printf '%s' "$out" | grep -qi 'identifier'; then
|
||||
pass=$((pass + 1)); printf ' ok a no-match refuses to imply novelty and suggests other terms\n'
|
||||
else
|
||||
fail=$((fail + 1)); printf ' FAIL a no-match refuses to imply novelty and suggests other terms\n%s\n' "$out"
|
||||
fi
|
||||
|
||||
# The repo actually searched is always stated — a confident null against the
|
||||
# wrong tree is this tool's worst failure mode, and the default is the cwd.
|
||||
out=$(sh "$SUT" WidgetCache "$WORK/repo" 2>&1)
|
||||
if printf '%s' "$out" | grep -q "searching: "; then
|
||||
pass=$((pass + 1)); printf ' ok names the repo it searched\n'
|
||||
else
|
||||
fail=$((fail + 1)); printf ' FAIL names the repo it searched\n%s\n' "$out"
|
||||
fi
|
||||
|
||||
# The sources it cannot reach are named every run, so their absence never reads
|
||||
# as a null result.
|
||||
if printf '%s' "$out" | grep -q 'brain_query' && printf '%s' "$out" | grep -qi 'closed issues'; then
|
||||
pass=$((pass + 1)); printf ' ok names the sources it cannot search\n'
|
||||
else
|
||||
fail=$((fail + 1)); printf ' FAIL names the sources it cannot search\n%s\n' "$out"
|
||||
fi
|
||||
|
||||
printf '\nlineage: %d passed, %d failed\n' "$pass" "$fail"
|
||||
[ "$fail" -eq 0 ]
|
||||
Executable
+118
@@ -0,0 +1,118 @@
|
||||
#!/bin/sh
|
||||
# Tests for bin/check-mcp-env.
|
||||
#
|
||||
# The fault this gate exists for is silent, which is why it needs a gate at
|
||||
# all. An `.mcp.json` server whose Authorization header interpolates a
|
||||
# variable nothing in the session environment defines does not error: the
|
||||
# header goes out empty, the server answers 401, and Claude Code reports
|
||||
# "requires authentication". That is indistinguishable from a revoked or
|
||||
# expired credential, and on 2026-09-07 it cost a full investigation on koala
|
||||
# to establish that the credential was fine and the variable was simply never
|
||||
# set.
|
||||
#
|
||||
# So the property is: every ${VAR} a LIVE server interpolates must be a
|
||||
# variable the session env-file actually defines.
|
||||
#
|
||||
# The `_parked` block is deliberately exempt. Parked servers reference
|
||||
# variables nothing provides on purpose -- that is what parked means -- and a
|
||||
# gate that flagged them would be reporting the intended state as a fault.
|
||||
#
|
||||
# Fixtures drive the checker through argv, so no test touches the real host.
|
||||
|
||||
set -u
|
||||
|
||||
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
CHECK="$ROOT/bin/check-mcp-env"
|
||||
|
||||
pass=0
|
||||
fail=0
|
||||
WORK=${TMPDIR:-/tmp}/mcp_config_test.$$
|
||||
|
||||
if [ ! -f "$CHECK" ]; then
|
||||
printf 'FAIL: %s does not exist\n' "$CHECK"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cleanup() { rm -rf "$WORK"; }
|
||||
trap cleanup EXIT INT TERM
|
||||
mkdir -p "$WORK"
|
||||
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
printf 'SKIP: jq not installed -- bin/check-mcp-env cannot be exercised\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ok <name> / bad <name> <detail>
|
||||
ok() { pass=$((pass+1)); printf ' ok %s\n' "$1"; }
|
||||
bad() { fail=$((fail+1)); printf ' FAIL %s %s\n' "$1" "${2:-}"; }
|
||||
|
||||
# run <mcp-json> <env-file> -> sets $rc and $out
|
||||
run() {
|
||||
out=$("$CHECK" "$1" "$2" 2>&1); rc=$?
|
||||
}
|
||||
|
||||
ENVFILE="$WORK/op-env"
|
||||
cat > "$ENVFILE" <<'ENVEOF'
|
||||
BRAIN_MCP_TOKEN=op://HomeLab/brain/token
|
||||
DMABE_GITEA_API_TOKEN=op://HomeLab/gitea/token
|
||||
ENVEOF
|
||||
|
||||
# --- a live server whose variable IS defined ---------------------------------
|
||||
cat > "$WORK/good.json" <<'JSONEOF'
|
||||
{"mcpServers":{"brain":{"type":"http","url":"https://x/mcp",
|
||||
"headers":{"Authorization":"Bearer ${BRAIN_MCP_TOKEN}"}}}}
|
||||
JSONEOF
|
||||
run "$WORK/good.json" "$ENVFILE"
|
||||
[ "$rc" -eq 0 ] && ok "defined variable passes" || bad "defined variable passes" "rc=$rc $out"
|
||||
|
||||
# --- a live server whose variable is NOT defined ------------------------------
|
||||
# The exact shape that broke: the server looks correct and fails at runtime.
|
||||
cat > "$WORK/missing.json" <<'JSONEOF'
|
||||
{"mcpServers":{"gitea":{"type":"http","url":"https://x/mcp",
|
||||
"headers":{"Authorization":"Bearer ${GITEA_MCP_STATIC_TOKEN}"}}}}
|
||||
JSONEOF
|
||||
run "$WORK/missing.json" "$ENVFILE"
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
case "$out" in
|
||||
*gitea*GITEA_MCP_STATIC_TOKEN*) ok "undefined variable is flagged, naming server and variable" ;;
|
||||
*) bad "undefined variable is flagged, naming server and variable" "report did not name both: $out" ;;
|
||||
esac
|
||||
else
|
||||
bad "undefined variable is flagged, naming server and variable" "rc=0"
|
||||
fi
|
||||
|
||||
# --- a server with no headers at all (the `infra` shape) ----------------------
|
||||
# Unauthenticated by design. Nothing to resolve, so nothing to flag.
|
||||
cat > "$WORK/noauth.json" <<'JSONEOF'
|
||||
{"mcpServers":{"infra":{"type":"http","url":"https://x/mcp"}}}
|
||||
JSONEOF
|
||||
run "$WORK/noauth.json" "$ENVFILE"
|
||||
[ "$rc" -eq 0 ] && ok "header-less server is not flagged" || bad "header-less server is not flagged" "rc=$rc $out"
|
||||
|
||||
# --- parked servers are exempt ------------------------------------------------
|
||||
cat > "$WORK/parked.json" <<'JSONEOF'
|
||||
{"mcpServers":{"brain":{"type":"http","url":"https://x/mcp",
|
||||
"headers":{"Authorization":"Bearer ${BRAIN_MCP_TOKEN}"}}},
|
||||
"_parked":{"_reason":"no identity provides these yet",
|
||||
"cad":{"type":"http","url":"https://x/mcp",
|
||||
"headers":{"Authorization":"Bearer ${CAD_MCP_TOKEN}"}}}}
|
||||
JSONEOF
|
||||
run "$WORK/parked.json" "$ENVFILE"
|
||||
if [ "$rc" -eq 0 ]; then
|
||||
ok "parked server is exempt"
|
||||
else
|
||||
bad "parked server is exempt" "rc=$rc $out"
|
||||
fi
|
||||
|
||||
# --- malformed JSON must not pass silently ------------------------------------
|
||||
# A config the checker cannot read is an unverified config, not a clean one.
|
||||
printf '{"mcpServers":{' > "$WORK/broken.json"
|
||||
run "$WORK/broken.json" "$ENVFILE"
|
||||
[ "$rc" -ne 0 ] && ok "unparseable config does not report success" || bad "unparseable config does not report success" "rc=0"
|
||||
|
||||
# --- a missing env-file is unverifiable, not clean ----------------------------
|
||||
run "$WORK/good.json" "$WORK/no-such-env"
|
||||
[ "$rc" -ne 0 ] && ok "missing env-file does not report success" || bad "missing env-file does not report success" "rc=0"
|
||||
|
||||
printf '\n%s: %d passed, %d failed\n' "$(basename "$0")" "$pass" "$fail"
|
||||
[ "$fail" -eq 0 ]
|
||||
@@ -0,0 +1,103 @@
|
||||
#!/bin/sh
|
||||
# Tests for hooks/pre-task-ritual.sh — the UserPromptSubmit hook that surfaces
|
||||
# rule 0 on every prompt (#44).
|
||||
#
|
||||
# WHY THIS EXISTS, AND WHAT IT DELIBERATELY DOES NOT CLAIM
|
||||
# This hook is three lines and echoes a string. It has no bash-isms, so it
|
||||
# cannot fail the way secret-guard appeared to; that risk was overstated when
|
||||
# this test was requested. What it CAN do is stop working silently, and its
|
||||
# whole job is to be visible -- if the text stops appearing, rule 0 stops being
|
||||
# surfaced on every prompt and nothing announces that.
|
||||
#
|
||||
# THE ASSERTION THAT MATTERS IS REGISTRATION.
|
||||
# A hook file that exists but is not in hooks.json does absolutely nothing, and
|
||||
# looks identical on disk to one that works. So this derives the command from
|
||||
# hooks.json rather than assuming one: the registration is the source of truth
|
||||
# for how the hook is invoked, and a test that invokes it its own way is
|
||||
# testing something the harness never runs.
|
||||
#
|
||||
# That distinction is not hypothetical. secret_guard_test.sh piped its hook into
|
||||
# `sh` while hooks.json runs it with `bash`; on Debian /bin/sh is dash, which
|
||||
# has no `set -o pipefail`, and the suite failed 20/20 in CI against a hook that
|
||||
# was fine in production. The test was wrong, not the hook. Deriving the
|
||||
# invocation removes the opportunity for that class of mistake entirely.
|
||||
|
||||
set -u
|
||||
|
||||
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
HOOK="$ROOT/hooks/pre-task-ritual.sh"
|
||||
REG="$ROOT/hooks/hooks.json"
|
||||
|
||||
pass=0
|
||||
fail=0
|
||||
ok() { pass=$((pass + 1)); printf ' ok %s\n' "$1"; }
|
||||
no() { fail=$((fail + 1)); printf ' FAIL %s\n' "$1"; }
|
||||
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
printf ' SKIP: jq not installed -- cannot read the hook registration\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
[ -f "$HOOK" ] || { printf ' FAIL %s does not exist\n' "$HOOK"; exit 1; }
|
||||
ok 'hook file exists'
|
||||
|
||||
[ -f "$REG" ] || { printf ' FAIL %s does not exist\n' "$REG"; exit 1; }
|
||||
|
||||
# 1. Registered, and on the right event. UserPromptSubmit is what makes it fire
|
||||
# on every prompt; registered under any other event it would still "exist"
|
||||
# and still do nothing useful.
|
||||
CMD=$(jq -r '
|
||||
.hooks.UserPromptSubmit[]?.hooks[]?
|
||||
| select(.command | test("pre-task-ritual"))
|
||||
| .command
|
||||
' "$REG" 2>/dev/null | head -1)
|
||||
|
||||
if [ -n "$CMD" ]; then
|
||||
ok 'registered on UserPromptSubmit in hooks.json'
|
||||
else
|
||||
no 'not registered on UserPromptSubmit -- the file would exist and never run'
|
||||
printf ' %d passed, %d failed\n' "$pass" "$fail"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2. Run it EXACTLY as registered, with the harness variable substituted the way
|
||||
# the harness substitutes it.
|
||||
OUT=$(CLAUDE_PLUGIN_ROOT="$ROOT" sh -c "$CMD" 2>&1)
|
||||
rc=$?
|
||||
|
||||
[ "$rc" -eq 0 ] && ok 'exits 0 as registered' \
|
||||
|| no "exited $rc -- a non-zero UserPromptSubmit hook can block the prompt"
|
||||
|
||||
# 3. The content is the point. A hook that runs and says nothing is the same as
|
||||
# no hook, and that is the failure this pins.
|
||||
if [ -n "$OUT" ]; then
|
||||
ok 'produces output'
|
||||
else
|
||||
no 'produced no output -- rule 0 would silently stop being surfaced'
|
||||
fi
|
||||
|
||||
case "$OUT" in
|
||||
*"Rule 0"*) ok 'names rule 0' ;;
|
||||
*) no "does not name rule 0: '$OUT'" ;;
|
||||
esac
|
||||
|
||||
# Each of the four steps, individually. Naming rule 0 while having quietly lost
|
||||
# a step is the plausible drift, not losing the whole line.
|
||||
for step in brain_query skill 'failing test' 'success criterion'; do
|
||||
case "$OUT" in
|
||||
*"$step"*) ok "states the '$step' step" ;;
|
||||
*) no "lost the '$step' step from the ritual" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# 4. The interpreter named in the registration must actually exist, or the hook
|
||||
# fails on that host with a message nobody reads.
|
||||
INTERP=${CMD%% *}
|
||||
if command -v "$INTERP" >/dev/null 2>&1; then
|
||||
ok "registered interpreter '$INTERP' is available"
|
||||
else
|
||||
no "registered interpreter '$INTERP' not found on this host"
|
||||
fi
|
||||
|
||||
printf ' %d passed, %d failed\n' "$pass" "$fail"
|
||||
[ "$fail" -eq 0 ]
|
||||
@@ -24,7 +24,7 @@ decision_for() {
|
||||
_tool=${2:-Bash}
|
||||
jq -n --arg t "$_tool" --arg c "$_cmd" \
|
||||
'{tool_name: $t, tool_input: {command: $c}}' \
|
||||
| sh "$GUARD" \
|
||||
| "$GUARD" \
|
||||
| jq -r '.hookSpecificOutput.permissionDecision // "MISSING"'
|
||||
}
|
||||
|
||||
@@ -42,6 +42,20 @@ expect() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Invoked as a PROGRAM above, never as `sh "$GUARD"`. Claude Code executes the
|
||||
# hook directly, so its `#!/usr/bin/env bash` shebang decides the interpreter;
|
||||
# piping it into `sh` overrides that and tests something production never runs.
|
||||
#
|
||||
# It is not academic. On Debian /bin/sh is dash, which has no `set -o
|
||||
# pipefail`, so the guard died on line 23 before reading a byte of input and
|
||||
# every deny case returned empty -- 20 of 20 assertions failed in CI while
|
||||
# passing on a host where /bin/sh happens to be bash. A security gate that
|
||||
# tests green and cannot start is the worst of both.
|
||||
if [ ! -x "$GUARD" ]; then
|
||||
printf 'FAIL: %s is not executable -- it must run via its own shebang\n' "$GUARD"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$GUARD" ]; then
|
||||
printf 'FAIL: %s does not exist\n' "$GUARD"
|
||||
exit 1
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/bin/sh
|
||||
# Tests for skills/traceability/trace.sh.
|
||||
#
|
||||
# The RED run for #10 established what a forward trace costs by hand: sixteen
|
||||
# commands, of which the decisive ones were reading issue comments and running a
|
||||
# probe against a claim. This tool does the mechanical half — what git can prove
|
||||
# — and, more importantly, states what it CANNOT prove, so an unverifiable link
|
||||
# never reads as a verified one.
|
||||
|
||||
set -u
|
||||
|
||||
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
SUT="$ROOT/skills/traceability/trace.sh"
|
||||
|
||||
pass=0
|
||||
fail=0
|
||||
WORK=${TMPDIR:-/tmp}/traceability_test.$$
|
||||
|
||||
if [ ! -f "$SUT" ]; then
|
||||
printf 'FAIL: %s does not exist\n' "$SUT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cleanup() { rm -rf "$WORK"; }
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
mkdir -p "$WORK/repo/tests"
|
||||
cd "$WORK/repo" || exit 1
|
||||
git init -q .
|
||||
git config user.email t@example.com
|
||||
git config user.name test
|
||||
# A remote is needed for the tool to turn "#37" into a checkable URL, which is
|
||||
# the point of that section.
|
||||
git remote add origin https://git.d-ma.be/mathias/demo.git
|
||||
|
||||
printf 'baseline\n' > README.md
|
||||
git add -A && git commit -qm "chore: baseline"
|
||||
|
||||
printf 'gate\n' > gate.sh
|
||||
printf 'case\n' > tests/gate_test.sh
|
||||
git add -A && git commit -qm "feat(gate): add the thing
|
||||
|
||||
Implements the decision recorded in #5, filed as #37.
|
||||
|
||||
Closes #37"
|
||||
SHA=$(git rev-parse --short HEAD)
|
||||
git tag -a v1.2.0 -m "release v1.2.0"
|
||||
|
||||
printf 'more\n' >> gate.sh
|
||||
git add -A && git commit -qm "chore: later work"
|
||||
|
||||
cd - >/dev/null || exit 1
|
||||
out=$(sh "$SUT" "$SHA" "$WORK/repo" 2>&1)
|
||||
|
||||
check() {
|
||||
if printf '%s' "$out" | grep -q "$1"; then
|
||||
pass=$((pass + 1)); printf ' ok %s\n' "$2"
|
||||
else
|
||||
fail=$((fail + 1)); printf ' FAIL %s\n output had no %s\n%s\n' "$2" "$1" "$out"
|
||||
fi
|
||||
}
|
||||
|
||||
check '#5' 'lists every issue the commit references, not just the closing one'
|
||||
check '#37' 'lists the closing reference'
|
||||
check 'tests/gate_test.sh' 'names the test files the commit touched'
|
||||
check 'v1.2.0' 'names the release that contains it'
|
||||
check 'Cannot verify' 'states what it could not verify'
|
||||
check 'issues/37' 'emits a checkable URL for the tracker link it cannot follow'
|
||||
|
||||
# A commit in no release must say so — "no tag" and "not checked" are different
|
||||
# answers and only one of them is about the commit.
|
||||
last=$(git -C "$WORK/repo" rev-parse --short HEAD)
|
||||
out=$(sh "$SUT" "$last" "$WORK/repo" 2>&1)
|
||||
if printf '%s' "$out" | grep -qi 'no release contains'; then
|
||||
pass=$((pass + 1)); printf ' ok an unreleased commit is reported as unreleased\n'
|
||||
else
|
||||
fail=$((fail + 1)); printf ' FAIL an unreleased commit is reported as unreleased\n%s\n' "$out"
|
||||
fi
|
||||
|
||||
printf '\ntraceability: %d passed, %d failed\n' "$pass" "$fail"
|
||||
[ "$fail" -eq 0 ]
|
||||
Reference in New Issue
Block a user