Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .claude/hooks/lib.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Shared helpers for the PreToolUse guards.

# JSON-escapes its argument on stdout without jq - deny() has to work when jq is the very
# thing that is missing. Denial reasons are literals authored in these scripts, so backslash,
# quote and the whitespace that would otherwise break the string are the whole alphabet.
json_escape() {
printf '%s' "$1" \
| LC_ALL=C sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' \
| LC_ALL=C tr '\t\r' ' ' \
| LC_ALL=C awk 'BEGIN { ORS = "" } NR > 1 { print "\\n" } { print }'
}

deny() {
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"%s"}}\n' \
"$(json_escape "$1")"
exit 0
}

# Fail closed. Without jq, read_command() returns empty, every guard clears its own
# `[ -n "${CMD}" ]` check and exits 0 - a silent pass on exactly the commands these guards
# exist to stop, with nothing printed to say the protection is gone. A guard that cannot
# read the command has not checked it, so it denies instead.
command -v jq >/dev/null 2>&1 || deny "Blocked by repository policy: the agent guards cannot run because 'jq' is not installed, and a guard that cannot read the command has not checked it. Install jq (macOS: brew install jq; Debian/Ubuntu: apt-get install jq - see https://github.com/repository-standards/core/blob/main/docs/method/prerequisites.md) and retry."

read_command() {
jq -r '.tool_input.command // ""'
}

# Splits a command line into segments on ; && || | and newlines.
#
# Every guard evaluates segments independently. Judging the whole string lets one harmless segment
# vouch for a dangerous one - `psql -h localhost -c 'select 1' && psql -h prod -c 'DROP TABLE x'`
# reads as local because `localhost` appears somewhere in it.
split_segments() {
printf '%s' "$1" | sed -E 's/(\|\||&&|[;|])/\n/g'
}

# Matches only when the host ends at the match, so localhost.evil.example.com is not loopback.
LOCAL_HOST_RE='(localhost|127\.0\.0\.1|\[::1\]|::1)([:/[:space:]"'"'"']|$)'
32 changes: 32 additions & 0 deletions .claude/hooks/no-ci-secret-writes.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Denies changes to CI secrets and variables.
set -uo pipefail

DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Fail closed on a missing or unreadable lib.sh: without it deny() is undefined, read_command
# is undefined, CMD comes out empty and the guard exits 0 - protection gone, nothing printed.
. "${DIR}/lib.sh" || {
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"Blocked by repository policy: the agent guard could not load .claude/hooks/lib.sh, so this command was never checked."}}\n'
exit 0
}

CMD=$(read_command)
[ -n "${CMD}" ] || exit 0

while IFS= read -r segment; do
[ -n "${segment}" ] || continue

if printf '%s' "${segment}" | grep -qiE 'gh[[:space:]]+(secret|variable)[[:space:]]+(set|delete|remove)'; then
deny "Blocked by repository policy: CI secrets and variables are not edited from here. Rotating or changing one is a deliberate human action in the repository settings."
fi

if printf '%s' "${segment}" | grep -qi 'gh[[:space:]]\+api' \
&& printf '%s' "${segment}" | grep -qiE 'secrets|variables' \
&& printf '%s' "${segment}" | grep -qiE '(-X|--method)[[:space:]=]*(DELETE|PATCH|PUT|POST)|(^|[[:space:]])-(f|F)[[:space:]]|--field|--raw-field|--input'; then
deny "Blocked by repository policy: CI secrets and variables are not edited from here. Rotating or changing one is a deliberate human action in the repository settings."
fi
done <<EOF
$(split_segments "${CMD}")
EOF

exit 0
35 changes: 35 additions & 0 deletions .claude/hooks/no-force-push.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Denies force-push and history rewriting. A plain git push is untouched.
set -uo pipefail

DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Fail closed on a missing or unreadable lib.sh: without it deny() is undefined, read_command
# is undefined, CMD comes out empty and the guard exits 0 - protection gone, nothing printed.
. "${DIR}/lib.sh" || {
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"Blocked by repository policy: the agent guard could not load .claude/hooks/lib.sh, so this command was never checked."}}\n'
exit 0
}

CMD=$(read_command)
[ -n "${CMD}" ] || exit 0

PUSH_RE='(^|[^[:alnum:]_])git([[:space:]]+(-C[[:space:]]+[^[:space:]]+|-c[[:space:]]+[^[:space:]]+|--[^[:space:]]+|-[a-zA-Z]+))*[[:space:]]+push([[:space:]]|$)'
# `--force` with no tail restriction on purpose: git accepts any unambiguous abbreviation of a long
# option, so --force-with-l and --force-if-inc are real force-pushes. Every one of them starts with
# --force (--forc alone is ambiguous and git rejects it), and no benign push option shares the prefix.
FORCE_RE='(--force|(^|[[:space:]])-[a-zA-Z]*f([[:space:]]|$)|(^|[[:space:]])\+[A-Za-z0-9_./-]+)'

# Per segment, so a force flag in one command cannot be attributed to a `git push` in another - and
# so an apostrophe in a commit message cannot swallow the flag the way whole-string quote-stripping
# did (`git commit -m "it's fine" ; git push --force origin main # don't tell`).
while IFS= read -r segment; do
[ -n "${segment}" ] || continue
printf '%s' "${segment}" | grep -qE "${PUSH_RE}" || continue
if printf '%s' "${segment}" | grep -qE "${FORCE_RE}"; then
deny "Blocked by repository policy: never force-push or rewrite published history (--force, --force-with-lease, --force-if-includes, -f, +refspec). A normal git push is allowed. If a force-push is genuinely needed, hand the exact command to a human."
fi
done <<EOF
$(split_segments "${CMD}")
EOF

exit 0
53 changes: 53 additions & 0 deletions .claude/hooks/no-remote-db-writes.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# Denies any write against a database that is not on this machine.
set -uo pipefail

DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Fail closed on a missing or unreadable lib.sh: without it deny() is undefined, read_command
# is undefined, CMD comes out empty and the guard exits 0 - protection gone, nothing printed.
. "${DIR}/lib.sh" || {
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"Blocked by repository policy: the agent guard could not load .claude/hooks/lib.sh, so this command was never checked."}}\n'
exit 0
}

CMD=$(read_command)
[ -n "${CMD}" ] || exit 0

WRITE_RE='\b(insert|update|delete|drop|alter|create|truncate|grant|revoke|merge|vacuum|reindex|cluster|refresh)\b|\bcopy\b.*\bfrom\b|(^|[[:space:]])-f[[:space:]]|--file|(^|[^<])<($|[^<])'

while IFS= read -r segment; do
[ -n "${segment}" ] || continue

remote=0

# A connection URI whose host is not loopback.
urls=$(printf '%s' "${segment}" | grep -oiE "postgres(ql)?://[^[:space:]\"']+" || true)
if [ -n "${urls}" ]; then
if printf '%s\n' "${urls}" \
| sed -E 's#^postgres(ql)?://([^@/]*@)?##' \
| grep -viE "^${LOCAL_HOST_RE}" \
| grep -q .; then
remote=1
fi
fi

# A psql/pgcli invocation with an explicit non-loopback -h/--host. The flag match is
# case-sensitive on purpose: psql takes a lowercase -h, while -H is curl's header flag, and
# reading one as the other denies a local psql whose argument merely came from a curl call.
if printf '%s' "${segment}" | grep -qiE '(^|[[:space:];|&])(psql|pgcli)([[:space:]]|$)'; then
hosts=$(printf '%s' "${segment}" \
| grep -oE '(^|[[:space:]])(-h|--host)[[:space:]=]+[^[:space:]]+' \
| sed -E 's/.*(-h|--host)[[:space:]=]+//' || true)
if [ -n "${hosts}" ] && printf '%s\n' "${hosts}" | grep -viE "^${LOCAL_HOST_RE}" | grep -q .; then
remote=1
fi
fi

if [ "${remote}" = 1 ] && printf '%s' "${segment}" | grep -qiE "${WRITE_RE}"; then
deny "Blocked by repository policy: never WRITE to a remote database - no DDL, no DML, no migration CLI, no executing .sql files. Read-only SELECT is fine. Ship a schema change as a reviewed .sql file under database/schema/ for a human to apply."
fi
done <<EOF
$(split_segments "${CMD}")
EOF

exit 0
66 changes: 66 additions & 0 deletions .claude/skills/add-to-backlog/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
---
name: add-to-backlog
description: Use when work surfaces that does not belong to this change - a bug mentioned in passing ("btw the export is broken"), "we should fix that too", "park it", "not now but do not lose it". Files one well-formed row with its source, the role that must act and what done looks like, ordered against what is already there, without interrupting what you were doing.
---

# add-to-backlog

You are mid-change and you notice work that does not belong in this change - a missing
spec, a decision that should be recorded, drift you are not fixing now, dead code. Do not
silently do it (scope creep) and do not lose it (evaporation). File it in the repo's backlog ledger (`backlog.md` or `docs/backlog.md`, per the manifest).

This operationalizes the rules in `docs/backlog.md` - every item has a **source** and a
**definition of done**, and the list stays ordered and de-duplicated.

## Three automatic triggers

Three moments in the spec workflow file items here without being asked:

- **After `/spec-impact`** - a ripple the analysis found (an affected capability, a
needed ADR/BDR, a code area) that the current change deliberately will not
address: one item per unaddressed ripple, source = the impact analysis.
- **After `/spec-update`** - target-state deltas the current change will not build:
one item per unbuilt delta, source = the spec diff.
- **After `/spec-reconcile`** - code<->spec drift findings not fixed in the current
change: one item per finding, source = the drift finding.

In all three cases the agent writes well-formed rows itself using the format below,
de-duplicating against existing items first.

## Steps

1. **Check it is a real item.** It must trace to a source: a spec delta, a code<->spec
drift finding, a missing decision, onboarding, or an explicit request. A vague wish
with no source is not a backlog item - drop it.

2. **De-duplicate.** Scan the ledger; if the item (or a superset of it) is already
there, stop - do not file a second one. Sharpen the existing row instead if needed.

3. **Place and name it.** Pick the epic it belongs to (or note a genuinely new epic).
Give it a stable, scoped id (`SPEC-3`, `ADR-auth`, `DRIFT-2`) that will not be reused.

4. **Write the row** with every column the backlog declares: `id`, `title`, `cap` (the
capability it touches, or `-`), `persona` (from `docs/personas.md` - an item that serves
no persona is parked, not queued), `owner` (the **role** that must act: `product`,
`architect`, `dev` or `agent`), `why` (one line), `DoD` (the observable finish line -
"spec is buildable", "ADR Accepted", "drift resolved"), `status: todo`. A row missing
`cap`, `persona` or `owner` fails the backlog's own Definition of Ready, so it cannot be
pulled - writing it short only moves the work to whoever pulls it. Slot it by
**risk x leverage** (money / security / external contracts / data integrity first; then
churn), not at the bottom by default.

Leave `assignee` empty - a pool item is nobody's yet (ADR-030). Offer a `size` of `S`, `M`
or `L` if the shape is clear enough to be worth one, and skip it otherwise: an unsized row
is a normal row, not an incomplete one. If it looks like an `L`, say so and suggest the
split now, while the work is still fresh in mind - that is the whole job the field does
(ADR-029).

5. **Do not do the work now**, and do not make the decision here - "write an ADR for X"
is a backlog item; the decision itself is made in the ADR when the item is worked.

## Not this

- Not a dumping ground for vague ideas - no source, no DoD, no item.
- Not a duplicate tracker - reconcile with the existing row instead of adding another.
- Not a second issue tracker kept in sync by hand - this is the in-repo, agent-first
view; mirror to an external tracker only if the team already lives there.
78 changes: 78 additions & 0 deletions .claude/skills/adr-write/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
---
name: adr-write
description: Use when a technical choice gets made that someone will argue about again - "we went with Postgres over Mongo", "we're dropping the queue", "let's use Fastify", "why did we do it this way?". Drafts the decision record from what you say plus what the code and discovery notes already show, then asks only what neither can answer.
---

# adr-write

A technical decision was made. This writes the record **with** the user rather than handing
them a template - they say what they remember, the agent drafts, they correct.

## Is it an ADR at all?

Two questions, in this order:

1. **Would someone reasonably have chosen otherwise?** If not, it is a convention, and it
belongs in `AGENTS.md` where the next person will look. One obviously-right answer does
not need a record; it needs a line in the rules.
2. **Who would overrule it - an architect or a product owner?** An architect means ADR. A
product owner means **BDR** and `bdr-write` is the skill. The test itself is R5, and the
standard works it through in [decision records (by reference)](https://github.com/repository-standards/core/blob/main/docs/tree/docs-decision-records.md).
If both would overrule it, write the BDR and let the ADR reference it.

Say which way you routed and why, in one line. A user who disagrees will say so, and that is
cheaper than discovering it in review.

## Draft first, ask second

**Read before asking.** The code already shows what was chosen - the dependency, the
migration, the config. `docs/discovery/` may hold the meeting where it was argued. An
existing spec may state the consequence. Draft from those, then ask only what they cannot
answer, and say where each drafted part came from so the user can correct a wrong inference
instead of re-typing a right one.

## What an ADR wants, and what to ask for each

- **Context - the forces.** Not "we needed a database". What made this contestable: the
constraint, the deadline, the thing that broke, the disagreement. Ask: *what made this a
decision rather than an obvious step?*
- **Options considered - at most three, with why the losers lost.** This is the part people
skip and the part that pays. A record listing one option is a note. Ask: *what else was on
the table, and what killed it?* If genuinely nothing else was considered, write that -
"no alternative was evaluated" is a fact a future reader needs.
- **Decision.** One paragraph, in the present tense, as a rule the repo now follows.
- **Consequences.** What this makes easy, what it makes hard, and what someone now has to
live with. Push for the second and third: a consequences section with only upsides is
advertising.
- **Confirmation - how compliance is verified.** A guard, a test, a review step, or honestly
"review only". This is what stops the record being decoration.
- **Revisit when.** The concrete signal that reopens it - a scale threshold, a version, a
cost. Not "if it becomes a problem". Ask: *what would have to be true for us to change our
minds?*

## Then

Number it `ADR-NNN` - gapless, never reused. Find the next free number by **reading the
directory** (`ls docs/decision-records/adr/` or wherever this repo's ADRs actually live),
never the README table's row count and never a remembered count - the two can disagree, and
`scripts/decision-records-check.mjs` exists because a stale or missing index row let them.
Do this **as of the latest `main`**, right before you write the file: `git fetch` and
re-check the number is still free immediately before committing, not only when you started -
two branches minting the same number from an older `main` is a real collision this project
has hit, and rebasing late narrows the window but does not close it. Copy
`docs/decision-records/adr/_template.md`, add the row to `adr/README.md`, set
`Status: Accepted` when the user confirms.

**Never edit an accepted record into a different decision.** If this supersedes one, the old
record keeps its text, flips to `Superseded`, and names this one in its `Superseded by` row.

## Not this

- **Do not write a record for a decision nobody made.** If the user is thinking out loud, it
is an idea (`docs/ideas/`) until they approve it - `Proposed` is for a decision awaiting
ratification, not for a maybe.
- **Do not invent the options.** Reconstructing what was on the table is the user's memory or
the discovery dossier's, and an invented rejected option is worse than none: it will be
cited later as if it were considered.
- **Do not bury the decision in the context.** If a reader cannot answer "what did we decide"
from the Decision section alone, it is not written yet.
Loading
Loading