diff --git a/.claude/agents/adr-conformance-reviewer.md b/.claude/agents/adr-conformance-reviewer.md index b6c05a0..7b37cb8 100644 --- a/.claude/agents/adr-conformance-reviewer.md +++ b/.claude/agents/adr-conformance-reviewer.md @@ -52,7 +52,7 @@ If only a branch name is given, run `git diff main...HEAD` (or against the named - **Be specific.** "Violates ADR-NNNN" is useless without "because the LLM is acting as a controller (line 42 calls `Servo.set_angle/2` directly) instead of emitting a tool call". - **Don't flag what isn't a violation.** ADRs don't govern every line; if a change is orthogonal, it's orthogonal. False positives erode trust in this reviewer. - **A literal ADR clause may be refined — confirm before flagging.** Before reporting "violates ADR-NNNN Dn", check that no `Refines:` child ADR or reconciliation-layer entry narrows that clause for the case at hand. A flag against a correctly-scoped clause is a false-positive, and false-positives erode trust faster than misses. -- **Consult `docs/adr/corrections.md` before flagging a claim.** A citation, figure, attribution or formula already recorded wrong there is cited by its `C-` number, never restated as a finding; a wrong claim not yet recorded is reported as one, with the register named as its destination. +- **Consult `docs/adr/corrections.md` before flagging a claim.** A citation, figure, attribution or formula already recorded wrong there is cited by the register's own id form (the starter numbers entries `C-NNN`; a register that predates it may number them in prose under per-ADR sections), never restated as a finding; a wrong claim not yet recorded is reported as one, with the register named as its destination. - **Don't propose new ADRs.** That's an `/adr-new` invocation, not a review finding. - **Don't review style, tests, or logging.** Other reviewers cover those. Stay scoped to ADR conformance. diff --git a/.claude/commands/finish-procedure.md b/.claude/commands/finish-procedure.md index 5aa5f5a..d7b8a05 100644 --- a/.claude/commands/finish-procedure.md +++ b/.claude/commands/finish-procedure.md @@ -19,7 +19,7 @@ The shape of a run: read and validate the issue (Steps 1–4), research it execu Verify: - The issue is **open**. If closed, stop and tell the user: *"Issue #$1 is already closed. If you want to re-execute it, either reopen the issue or run rough-in to create a new sub-sub-issue."* -- The title matches one of the cascade issue formats: the standard rough-in `[:F:R] `, or an intake-lane form — bug-lane `[:bug] ` or enhancement-lane `[:enh] ` (externally-sourced work shaped by `/intake` / `/enrich` that skips framing; see `.claude/rules/cbk-conventions.md` § Contribution intake). In every case `` is one of the workstream slugs locked in this project's blueprint (`docs/cbk/blueprint.md` § Workstreams). If the title matches none of these, stop and ask whether this is actually a cascade issue or a different kind that got routed here by mistake. +- The title matches one of the cascade issue formats: the standard rough-in `[:F:R] `, an intake-lane form — bug-lane `[:bug] ` or enhancement-lane `[:enh] ` (externally-sourced work shaped by `/intake` / `/enrich` that skips framing; see `.claude/rules/cbk-conventions.md` § Contribution intake) — or a roughed-in meta `[:meta] ` (a cascade / tooling gap that reaches the executor directly or through its `[::R<#>]` children; the contract's Step 1 names the same four forms). `` is one of the workstream slugs locked in this project's blueprint (`docs/cbk/blueprint.md` § Workstreams) — or, for a meta, the cascade / tooling lane's tag (§ Contribution intake). If the title matches none of these, stop and ask whether this is actually a cascade issue or a different kind that got routed here by mistake. - The labels include `cascade-depth:roughed-in`. If not, same question — confirm with the user before proceeding. **Break-glass check.** Note whether the issue body or the operator's instructions carry a `` marker (or an equivalent agreed marker). If present, Step 9's `pr-review-toolkit:review-pr` invocation is waived per `pr-review.md` § Break-glass override — the rest of Step 9 still runs, `/simplify` is never waived — and the skill's `## Review gate` line (Step 10) records the reason. The marker is read *here*, from a surface that exists at gate time — not from the PR body, which doesn't exist until Step 10. diff --git a/.claude/commands/finish.md b/.claude/commands/finish.md index 4ba83f7..3b6f3a0 100644 --- a/.claude/commands/finish.md +++ b/.claude/commands/finish.md @@ -11,14 +11,14 @@ This command is contract-first. On 2026-09-03 an A/B gave two workhorse-tier exe - **The planning backend**, resolved first from `docs/cbk/scaffold.md` § Cascade metadata (falling back to `.cascade/backends.toml`). On `github-issues`, `$1` is the issue number and the read is `gh issue view $1 --json number,title,body,labels,state,comments` (or the github MCP); on `linear`, `$1` is the planner ID and every read goes through the planner's MCP — never a GitHub read against a planner ID; on `in-repo-markdown` there is no `/finish` (design-doc mode; the scaffold gate disclosed it) — stop and say so. - **The issue** — its body and every comment. The body is the contract; comments are context (provenance, roll-forward notes from earlier runs, corrections the operator posted). -- **The parent framing sub-issue** and its frame (`docs/cbk/frame-NN.md`): the milestone's criteria, § Interface Commitments, § Resolved during this framing, § Key Constraints. Rough-in inherited them; you honour them. An intake-lane issue (`[:bug]`, `[:enh]`) has no frame; its provenance comment stands in. +- **The parent framing sub-issue** and its frame (`docs/cbk/frame-NN.md`): the milestone's criteria, § Interface Commitments, § Resolved during this framing, § Key Constraints. Rough-in inherited them; you honour them. An intake-lane issue (`[:bug]`, `[:enh]`) or a directly roughed-in meta (`[:meta]`) has no frame; its provenance comment stands in. - **Every decision record the issue cites** — the ADRs in `docs/adr/` (follow each `Refines:` chain) and any design-decision ledger the project keeps — in full for every clause cited. - `docs/ARCHITECTURE.md`, `docs/STANDARDS.md`, `CLAUDE.md`, and the rules that bind the diff: `.claude/rules/testing.md`, `.claude/rules/logging.md`, `.claude/rules/pr-review.md`, `.claude/rules/simplification.md`, `.claude/rules/cbk-conventions.md`; at triage, `.claude/rules/pr-review-reference.md` § Apply / Surface calibration and § Path-conditional aggressiveness — a triage is not a file read, so the path-scoped half does not load on its own. - **The repository itself**: the task-runner config, the package manifests and lockfiles, the trees the issue names, and the pinned sources of any package the issue's assumptions cite. Verify every claim you act on by reading or running; existence and absence claims are checked repo-wide from the root. Never assert a package API from memory. A knowledge-backend URL the body cites is resolved only through the configured MCP, with the announcement `.claude/rules/knowledge-backend.md` § HITL announcement discipline requires; without the MCP, surface and proceed. ## Preconditions — checked before anything is planned -1. **State and shape.** The issue is open — a closed one stops the run: say so and ask whether it was closed by mistake. The title is `[:F<#>:R<#>] …`, `[:bug] …` or `[:enh] …` with `` a workstream locked in `docs/cbk/blueprint.md` § Workstreams; the issue carries `cascade-depth:roughed-in`; and the body has exactly these eight headings, in this order: `## Context`, `## Assumptions` (present even when it reads `- None — …`), `## Implementation`, `## Acceptance criteria`, `## Test plan`, `## Done signal`, `## Dependencies`, `## PR contract`. Anything else is surfaced, never normalised away. +1. **State and shape.** The issue is open — a closed one stops the run: say so and ask whether it was closed by mistake. The title is `[:F<#>:R<#>] …`, `[:bug] …`, `[:enh] …` or `[:meta] …` with `` a workstream locked in `docs/cbk/blueprint.md` § Workstreams or the cascade/tooling lane's tag (`cbk-conventions.md` § Contribution intake — a meta reaches this executor roughed-in directly or through its `[::R<#>]` children; the `cascade-depth:framed` label the meta template applies at framing is the pre-rough-in state, not this check's — #58, smaller item 1); the issue carries `cascade-depth:roughed-in`; and the body has exactly these eight headings, in this order: `## Context`, `## Assumptions` (present even when it reads `- None — …`), `## Implementation`, `## Acceptance criteria`, `## Test plan`, `## Done signal`, `## Dependencies`, `## PR contract`. Anything else is surfaced, never normalised away. 2. **Dependencies.** Every identifier under `## Dependencies` is a closed issue whose state reason is *completed* (a Done-type state on linear, never Canceled or Duplicate). One open or otherwise-closed dependency stops the run: name it and its state, and do not offer to proceed anyway — a bypass is a deliberate rough-in revision, not a runtime patch. A cross-axis identifier (a planner ID on github-issues, a bare `#N` on linear) is surfaced, never bridged. 3. **Idempotency.** No open PR carries this issue's close marker (either family) and no branch exists for it. If either does, stop and ask whether to continue there or start fresh. 4. **Break-glass.** A `` marker in the body or the operator's instructions waives the `pr-review-toolkit:review-pr` half of the floor for this run and is recorded on its `## Review gate` line, in the PR body and the hand-off. `/simplify` is never waived; nothing else waives anything. diff --git a/.claude/hooks/analyze-on-edit.sh b/.claude/hooks/analyze-on-edit.sh index d365e0a..35df5d9 100755 --- a/.claude/hooks/analyze-on-edit.sh +++ b/.claude/hooks/analyze-on-edit.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # PostToolUse hook (Edit|Write|MultiEdit matcher) — EXEMPLAR: a project copies -# this hook, wires its analyzer into the case arms below, and registers it via -# the `_example_PostToolUse_analyzer` stanza in settings.json. +# this hook, wires its analyzer into the case arms below, and registers it with +# the `Register:` stanza in this header (never as a top-level settings.json key). # # Runs the project's analyzer on the PACKAGE of every edited source file and # prints only its ERRORS, so a boundary violation surfaces at the edit rather @@ -23,13 +23,25 @@ # Allowed: everything. # Path: registered (once wired) as ${CLAUDE_PROJECT_DIR}/.claude/hooks/… # Tier: ADVISORY. +# Depends: jq (the payload fields), git (the edited file's checkout) and the +# project's analyzer once the case arms are wired — absent, the hook +# skips: exit 0 with a stderr note; the check task is the backstop. +# Register: copy this object into hooks.PostToolUse in .claude/settings.json once the case +# arms are wired — never as a top-level key (cbk-conventions-reference.md § Hook +# authoring: a hook-shaped object outside `hooks` voids the whole settings file): +# { "matcher": "Edit|Write|MultiEdit", +# "hooks": [ { "type": "command", +# "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/analyze-on-edit.sh" } ] } set -uo pipefail + +# Drain stdin before any early exit, or a piping caller's SIGPIPE masks this hook's own +# exit code (cbk-conventions-reference.md § Hook authoring › The stdin / exit contract). +input="$(cat)" # Deliberately NOT `set -e` — see the advisory contract above. command -v jq &>/dev/null || { echo "analyze-on-edit: jq not installed; skipping (advisory)." >&2; exit 0; } -input="$(cat)" tool_name="$(printf '%s' "$input" | jq -r '.tool_name // empty')" file_path="$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty')" cwd="$(printf '%s' "$input" | jq -r '.cwd // empty')" diff --git a/.claude/hooks/detect-forked-agent-memory.sh b/.claude/hooks/detect-forked-agent-memory.sh index 6dd3c08..4fdcef3 100755 --- a/.claude/hooks/detect-forked-agent-memory.sh +++ b/.claude/hooks/detect-forked-agent-memory.sh @@ -15,7 +15,9 @@ # agent-memory-local directory exists — both memory scopes fork the same # way (exit 2 + the remediation on stderr). # Allowed: a clean tree; a second stop after one block (`stop_hook_active`); -# a working directory that is not a git checkout (fail-open with a warning). +# a working directory that is not a git checkout (fail-open with a warning); +# a tree under a directory the project's own ignore rules exclude, or under +# `./.claude-pr` — see Residual below. # Event: Stop only. Not SubagentStop — a finishing subagent has no hand-off # to repair the tree in, and `stop_hook_active` is documented for # Stop; registering there would block every subagent of a review @@ -30,16 +32,34 @@ # 2026-09-06, re-verify after upgrades), so this hook blocks at most # once per stop. A payload without the field is treated as the first # block (the assumption is stated, not silently defaulted). +# Residual: the walk prunes every directory the project's ignore rules exclude, so +# a tree placed by hand or by tooling UNDER an ignored directory (a build +# output, a tool cache) exits 0 — the dispatch that would create one is +# refused by require-repo-root-for-agents.sh, which is the guard for that +# case (#58 item 1's scenario table, reproduced independently 2026-09-13). +# `./.claude-pr` is pruned unconditionally: it is the staging copy a +# hosted review action makes of the branch's `.claude/` tree, committed +# memory included — a copy, not a fork (#58, 2026-09-07 comment). # Path: registered as ${CLAUDE_PROJECT_DIR}/.claude/hooks/… (handlers run # in the current directory — https://code.claude.com/docs/en/hooks). # Tier: STOP. -# No jq dependency: the scan needs none, and the one field this hook reads -# (`stop_hook_active`) is matched with grep — so this backstop works exactly when -# the jq-dependent guards have failed open. No bash-4-only builtins (a stock -# macOS bash is 3.2): the directory list is read with a while loop, not mapfile. +# Depends: NO jq — the one field this hook reads (`stop_hook_active`) is matched +# with grep, so this backstop works exactly when the jq-dependent guards +# have failed open. `git` is required for the root and used for the prune +# list: without it the guard above fails open, and a failing `git +# ls-files`/`check-ignore` leaves the prune list empty, so the scan runs +# unpruned — slower, never blinder. `mktemp` is optional: it holds the +# scan's stderr, and when no scratch file can be created the walk still +# runs and says so rather than reporting a clean tree it could not have +# verified. No bash-4-only builtins (a stock macOS bash is 3.2): the +# directory list is read with a while loop, not mapfile. set -uo pipefail +# Drain stdin before any early exit, or a piping caller's SIGPIPE masks this hook's own +# exit code (cbk-conventions-reference.md § Hook authoring › The stdin / exit contract). +input="$(cat)" + # The scan runs from the checkout's git top-level. CLAUDE_PROJECT_DIR is exported to the hook # process (https://code.claude.com/docs/en/hooks — the same page documents the placeholder); # when it is absent the process's own $PWD may be a subdirectory — the drift case — and a scan @@ -50,27 +70,77 @@ PROJECT_DIR="$(git -C "${CLAUDE_PROJECT_DIR:-$PWD}" rev-parse --show-toplevel 2> exit 0 } -input="$(cat)" # Absent field ⇒ first block. Present and true ⇒ the agent is already continuing from this hook. # Matched with grep, not jq, so fork detection has no environment dependency to fail open on. +# A here-string, never `printf … | grep`: a reader that exits on its first match makes +# pipefail read a real match as NO MATCH (cbk-conventions-reference.md § Hook authoring). active="" -printf '%s' "$input" | grep -Eq '"stop_hook_active"[[:space:]]*:[[:space:]]*true' && active="true" +grep -Eq '"stop_hook_active"[[:space:]]*:[[:space:]]*true' <<<"$input" && active="true" -cd "$PROJECT_DIR" || exit 0 +cd "$PROJECT_DIR" || { + echo "detect-forked-agent-memory: WARNING — cannot enter $PROJECT_DIR; fork detection inactive for this stop." >&2 + echo " Backstop: the verification block's Stop-hook check (cbk-conventions-reference.md § Verification)." >&2 + exit 0 +} # Every directory named agent-memory or agent-memory-local that is not the root's -# own (the `project` and `local` scopes fork identically). -prune stops -# descent into the root's own tree, worktrees (each is its own checkout with its -# own root tree), .git, and build output — add your stack's build directories to -# the list. No -mindepth: as first written, -mindepth 2 exempted depth-1 directories -# from the prune test, so a top-level node_modules was walked and its contents flagged -# (reproduced 2026-09-06; -mindepth 1 would not have, but the option buys nothing here). +# own (the `project` and `local` scopes fork identically). The walk prunes the root's +# own two trees, worktrees (each is its own checkout with its own root tree), .git, +# the hosted review action's staging copy (`./.claude-pr`), and every directory the +# project's own ignore rules exclude — `git ls-files --others --ignored +# --exclude-standard --directory` names them (build output, tool caches, vendored +# trees), so the skip list is .gitignore's, read at run time, never a hand-kept list +# of stack names (the hand-kept list missed a second project's tool cache on its first +# real application — #58 item 1). No -mindepth: as first written, -mindepth 2 +# exempted depth-1 directories from the prune test, so a top-level node_modules was +# walked and its contents flagged (reproduced 2026-09-06). A stray tree under an +# untracked but unignored directory is still found — that is the fork case. +# +# Three rules make an ignore-driven list safe (#58 item 1): +# 1. Never prune a path that could BE or CONTAIN the tree this hook hunts for. A +# project that ignores the memory directory by an unanchored name (`agent-memory/` +# — the natural spelling for the `local` scope) or ignores `.claude/` wholesale +# would otherwise have its own ignore rules hide the fork. +# 2. `-path` takes a glob, and `*`/`?` in it cross `/`. An ignored directory named +# `*` (a legal Unix name) would splice in as `-path './*'`, prune the first +# top-level entry the walk reaches, and report a clean tree with no stderr — +# the silent miss this hook exists to stop. Metacharacters are escaped so an +# entry prunes itself and nothing else. +# 3. `--directory` collapses a directory whose whole subtree is ignored into its +# topmost ancestor, and in a repo with nothing committed yet that ancestor can +# be `packages/` — a directory no ignore rule names. `git check-ignore` keeps +# only the paths the rules actually exclude, so a collapsed ancestor is walked. +prunes=() +while IFS= read -r d; do + d="${d%/}" + [ -n "$d" ] || continue + case "${d##*/}" in .claude|agent-memory|agent-memory-local) continue ;; esac + prunes+=(-o -path "./$(printf '%s' "$d" | sed 's/[][*?\\]/\\&/g')") +done < <( + git ls-files --others --ignored --exclude-standard --directory 2>/dev/null \ + | sed -n 's:/$::p' | git check-ignore --stdin 2>/dev/null || true +) +# find's stderr is kept, not discarded: an unreadable directory makes the scan partial, +# and a partial scan that reports "clean" is the silent miss this hook exists to stop +# (#58 item 2). A redirection to an uncreatable path would abort the walk before find +# ran, leaving forks empty and the tree reported clean — so the path is tested first +# and the walk degrades to unmonitored with a warning (#58, 2026-09-07 comment). +scan_err="$(mktemp 2>/dev/null || printf '%s/.detect-forked-agent-memory.%s.err' "${TMPDIR:-/tmp}" "$$")" +if ! : 2>/dev/null >"$scan_err"; then + echo "detect-forked-agent-memory: WARNING — no scratch file for the scan's stderr ($scan_err); the walk runs unmonitored, so a partial scan cannot be reported." >&2 + scan_err=/dev/null +fi forks=() while IFS= read -r d; do forks+=("$d"); done < <( - find . \( -path './.claude/agent-memory' -o -path './.claude/agent-memory-local' -o -path './.claude/worktrees' -o -name .git \ - -o -name node_modules -o -name target -o -name build -o -name _build \ - -o -name dist -o -name .venv \) -prune -o -type d \( -name agent-memory -o -name agent-memory-local \) -print 2>/dev/null | sort + find . \( -path './.claude/agent-memory' -o -path './.claude/agent-memory-local' -o -path './.claude/worktrees' -o -path './.claude-pr' -o -name .git \ + ${prunes[@]+"${prunes[@]}"} \) -prune -o -type d \( -name agent-memory -o -name agent-memory-local \) -print 2>"$scan_err" | sort ) +if [ -s "$scan_err" ]; then + echo "detect-forked-agent-memory: WARNING — the scan was partial; find could not read:" >&2 + head -n 5 "$scan_err" >&2 + echo " A stray tree under an unreadable directory is missed; fix the permissions and stop again." >&2 +fi +[ "$scan_err" = /dev/null ] || rm -f "$scan_err" [ "${#forks[@]}" -eq 0 ] && exit 0 @@ -90,5 +160,11 @@ $PROJECT_DIR/.claude/agent-memory-local// (pr-review.md § Reviewer precedent memory). Before stopping: move each / directory's files into the root tree, append their pointer lines to the root MEMORY.md for that reviewer, delete the forked tree, and say so in the hand-off. + +If the path above belongs to tooling rather than to a dispatched agent — a harness +staging copy, a container's workspace, a vendored checkout — it is not a fork and +nothing should be moved or deleted. Add it to .gitignore, anchored, and this scan +prunes it from then on (the review action's \`.claude-pr/\` staging copy is the +exercised case and is pruned unconditionally). MSG exit 2 diff --git a/.claude/hooks/format-on-edit.sh b/.claude/hooks/format-on-edit.sh index 31d9168..cd3c20b 100755 --- a/.claude/hooks/format-on-edit.sh +++ b/.claude/hooks/format-on-edit.sh @@ -12,15 +12,24 @@ # Advisory-only contract: exit 0 ALWAYS. Formatting failures surface on stderr # as non-fatal notes; they never block the tool call. # Tier: ADVISORY (see the registry comment in .claude/settings.json). +# Register: copy this object into hooks.PostToolUse in .claude/settings.json once the case +# arms are wired — never as a top-level key (cbk-conventions-reference.md § Hook +# authoring: a hook-shaped object outside `hooks` voids the whole settings file): +# { "matcher": "Edit|Write|MultiEdit", +# "hooks": [ { "type": "command", +# "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/format-on-edit.sh" } ] } set -uo pipefail + +# Drain stdin before any early exit, or a piping caller's SIGPIPE masks this hook's own +# exit code (cbk-conventions-reference.md § Hook authoring › The stdin / exit contract). +input="$(cat)" # Note: deliberately NOT using `set -e` — see the advisory contract above. PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$PWD}" command -v jq &>/dev/null || { echo "format-on-edit: jq not installed; skipping (advisory)." >&2; exit 0; } -input="$(cat)" tool_name="$(printf '%s' "$input" | jq -r '.tool_name // empty')" file_path="$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty')" diff --git a/.claude/hooks/guard-pr-state.sh b/.claude/hooks/guard-pr-state.sh index efe0616..8ca2a1a 100755 --- a/.claude/hooks/guard-pr-state.sh +++ b/.claude/hooks/guard-pr-state.sh @@ -17,6 +17,10 @@ set -uo pipefail +# Drain stdin before any early exit, or a piping caller's SIGPIPE masks this hook's own +# exit code (cbk-conventions-reference.md § Hook authoring › The stdin / exit contract). +input="$(cat)" + if ! command -v jq &>/dev/null; then echo "guard-pr-state: WARNING — jq not installed; PR-state guard DISABLED." >&2 echo " Until fixed, the only backstop is the prose rule in /finish and" >&2 @@ -24,7 +28,6 @@ if ! command -v jq &>/dev/null; then exit 0 fi -input="$(cat)" tool_name="$(printf '%s' "$input" | jq -r '.tool_name // empty')" command="$(printf '%s' "$input" | jq -r '.tool_input.command // empty')" @@ -34,7 +37,9 @@ command="$(printf '%s' "$input" | jq -r '.tool_input.command // empty')" # Token-anchored + flag-tolerant: matches `gh pr merge`, `gh -R o/r pr ready`, # `gh pr -R o/r close 5`, etc. Over-matching (the phrase quoted inside another # command) costs one extra confirmation — acceptable for an ask-gate. -if printf '%s' "$command" | grep -Eq '(^|[;&|[:space:]])gh([[:space:]]+[^[:space:]]+)*[[:space:]]+pr([[:space:]]+[^[:space:]]+)*[[:space:]]+(ready|merge|close|reopen)([[:space:]]|$|[;&|])'; then +# A here-string, never `printf … | grep`: a reader that exits on its first match makes +# pipefail read a real match as NO MATCH (cbk-conventions-reference.md § Hook authoring). +if grep -Eq '(^|[;&|[:space:]])gh([[:space:]]+[^[:space:]]+)*[[:space:]]+pr([[:space:]]+[^[:space:]]+)*[[:space:]]+(ready|merge|close|reopen)([[:space:]]|$|[;&|])' <<<"$command"; then cat <<'EOF' { "hookSpecificOutput": { diff --git a/.claude/hooks/protect-immutable-adrs.sh b/.claude/hooks/protect-immutable-adrs.sh index 21985c8..83d14a8 100755 --- a/.claude/hooks/protect-immutable-adrs.sh +++ b/.claude/hooks/protect-immutable-adrs.sh @@ -22,6 +22,10 @@ # Tier: HARD-DENY (see the registry comment in .claude/settings.json). set -uo pipefail + +# Drain stdin before any early exit, or a piping caller's SIGPIPE masks this hook's own +# exit code (cbk-conventions-reference.md § Hook authoring › The stdin / exit contract). +input="$(cat)" # Note: deliberately NOT using `set -e` — we fail open (exit 0) on environment # defects rather than abort with cryptic stderr that blocks all tool calls. @@ -35,7 +39,6 @@ if ! command -v jq &>/dev/null; then exit 0 fi -input="$(cat)" tool_name="$(printf '%s' "$input" | jq -r '.tool_name // empty')" file_path="$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty')" diff --git a/.claude/hooks/protect-lock-files.sh b/.claude/hooks/protect-lock-files.sh index 8e849bb..202cf92 100755 --- a/.claude/hooks/protect-lock-files.sh +++ b/.claude/hooks/protect-lock-files.sh @@ -22,6 +22,10 @@ # Tier: HARD-DENY (see the registry comment in .claude/settings.json). set -uo pipefail + +# Drain stdin before any early exit, or a piping caller's SIGPIPE masks this hook's own +# exit code (cbk-conventions-reference.md § Hook authoring › The stdin / exit contract). +input="$(cat)" # Note: deliberately NOT using `set -e` — we want to exit 0 (fail-open) on # environment defects rather than abort with cryptic stderr that blocks all # tool calls. @@ -37,7 +41,6 @@ if ! command -v jq &>/dev/null; then exit 0 fi -input="$(cat)" tool_name="$(printf '%s' "$input" | jq -r '.tool_name // empty')" file_path="$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty')" @@ -54,7 +57,7 @@ rel="${file_path#"$PROJECT_DIR/"}" # Match the common lock file shapes anywhere in the repo. case "$(basename "$rel")" in - uv.lock|pnpm-lock.yaml|package-lock.json|yarn.lock|Cargo.lock|Gemfile.lock|poetry.lock|composer.lock|mix.lock) + uv.lock|pnpm-lock.yaml|package-lock.json|yarn.lock|Cargo.lock|Gemfile.lock|poetry.lock|composer.lock|mix.lock|pubspec.lock) cat >&2 <&2 </dev/null; then exit 0 fi -input="$(cat)" tool_name="$(printf '%s' "$input" | jq -r '.tool_name // empty')" command="$(printf '%s' "$input" | jq -r '.tool_input.command // empty')" # The Bash tool's payload carries the call's working directory; the branch @@ -54,7 +57,9 @@ PROJECT_DIR="${cwd:-${CLAUDE_PROJECT_DIR:-$PWD}}" # repo's branch — costs one wrongly BLOCKED call with a loud message the # operator can override by running the commit themselves; under-matching # would be a silent bypass, which is worse for a deny-tier guard. -if ! printf '%s' "$command" | grep -Eq '(^|[;&|[:space:]])git([[:space:]]+[^[:space:]]+)*[[:space:]]+commit([[:space:]]|$)'; then +# A here-string, never `printf … | grep`: a reader that exits on its first match makes +# pipefail read a real match as NO MATCH (cbk-conventions-reference.md § Hook authoring). +if ! grep -Eq '(^|[;&|[:space:]])git([[:space:]]+[^[:space:]]+)*[[:space:]]+commit([[:space:]]|$)' <<<"$command"; then exit 0 fi diff --git a/.claude/hooks/require-knowledge-backend-ok.sh b/.claude/hooks/require-knowledge-backend-ok.sh index ee53efd..ec0eee4 100755 --- a/.claude/hooks/require-knowledge-backend-ok.sh +++ b/.claude/hooks/require-knowledge-backend-ok.sh @@ -17,7 +17,10 @@ # Tier: ASK-GATE (see the registry comment in .claude/settings.json). set -uo pipefail -cat > /dev/null # consume stdin; the decision is unconditional for matched tools +# Drain stdin first (cbk-conventions-reference.md § Hook authoring › The stdin / exit contract); +# the decision is unconditional for matched tools, so the payload is read and not inspected. +input="$(cat)" +: "$input" cat <<'EOF' { "hookSpecificOutput": { diff --git a/.claude/hooks/require-repo-root-for-agents.sh b/.claude/hooks/require-repo-root-for-agents.sh index e309728..621cb5a 100755 --- a/.claude/hooks/require-repo-root-for-agents.sh +++ b/.claude/hooks/require-repo-root-for-agents.sh @@ -30,18 +30,33 @@ # the shell was allowed, with the payload cwd still at the root (dated # observation, 2026-09-07). The confirmed deny is a session launched # from a subdirectory (the verification block's payload dry-run); the -# remedy there is to relaunch the session from the root. +# remedy there is to relaunch the session from the root. The hooks +# reference states the OPPOSITE of the observation above — "cwd follows +# Claude: … the new directory after Claude runs cd" +# (https://code.claude.com/docs/en/hooks § Reference scripts by path, +# read 2026-09-07). The two disagree; this guard judges whichever cwd +# the payload carries and is correct under either reading. RE-VERIFY +# TRIGGER: a dispatch made after `cd ` that is denied means the +# page's reading now holds — update this paragraph (#58, S3). # Path: registered as ${CLAUDE_PROJECT_DIR}/.claude/hooks/… — handlers run # in the current directory (https://code.claude.com/docs/en/hooks), so # a bare relative path would not resolve from the very subdirectory # this guard exists to block. # Tier: HARD-DENY. +# Depends: jq (the payload's tool_name and cwd) and git (the top-level of that +# cwd) — absent, the guard fails open: exit 0 with a stderr warning +# naming the backstop, detect-forked-agent-memory.sh (Stop tier, +# needs no jq). # # Hook receives JSON on stdin. Exit 2 + stderr blocks. Fail-open on # environment defects (missing jq, not a git checkout): exit 0 with a loud # stderr warning naming the surviving backstop, mirroring protect-main-branch.sh. set -uo pipefail + +# Drain stdin before any early exit, or a piping caller's SIGPIPE masks this hook's own +# exit code (cbk-conventions-reference.md § Hook authoring › The stdin / exit contract). +input="$(cat)" # Deliberately NOT `set -e` — fail-open on environment defects rather than # aborting with cryptic stderr that blocks every dispatch. @@ -52,7 +67,6 @@ if ! command -v jq &>/dev/null; then exit 0 fi -input="$(cat)" tool_name="$(printf '%s' "$input" | jq -r '.tool_name // empty')" case "$tool_name" in Task|Agent|Workflow) ;; diff --git a/.claude/rules/cbk-conventions-reference.md b/.claude/rules/cbk-conventions-reference.md index 2079e4e..3f0c968 100644 --- a/.claude/rules/cbk-conventions-reference.md +++ b/.claude/rules/cbk-conventions-reference.md @@ -268,7 +268,7 @@ A `.gitignore` entry is **anchored by default** — `/build/`, `/.env`, `/target - **Any-depth entries are deliberate and marked.** An entry meant to match at every depth (`**/node_modules/`, `*.pyc`, `.DS_Store`) carries a one-line comment saying so; an unmarked unanchored entry is a defect. - **Per-language sections are scoped to the language's directory home** once one is declared — a Python section under `/services/api/` writes `/services/api/__pycache__/`, not `__pycache__/`. Before a home is declared, the section says which. - **A commit that changes `.gitignore` states its pin assertions in the body**: which path each new entry is meant to match, and one path it must *not* match. `git check-ignore -v ` is the test; the assertion is what makes a later reader able to re-run it. -- **Harness transients are ignored by anchored path** — the agent's scratch and memory-local trees (`/.claude/agent-memory-local/`, the session scratchpad if it is ever placed in-tree), never by a bare name that would also hide a real directory. +- **Harness transients are ignored by anchored path** — the agent's scratch and memory-local trees (`/.claude/agent-memory-local/`, the session scratchpad if it is ever placed in-tree) and a hosted review action's staging copy of the branch's tooling (`/.claude-pr/` — it carries a copy of the committed memory tree, which the Stop-tier fork detector prunes unconditionally and the ignore-driven prune covers once the entry exists; #58, 2026-09-07 comment), never by a bare name that would also hide a real directory. - **No shipped reviewer restates this.** The `cascade-rule-reviewer` names the section in scope; the rule lives here once. ## Methodology — choice space @@ -393,16 +393,16 @@ Moved from the contract's § `[skip ci]` rule on 2026-09-07 (consulted when a `. The shape a hook follows, stated once (the registry comment in `.claude/settings.json` and the hook headers cite this section; they do not restate it). The three hooks authored with this section carry every line; the earlier five carry `Blocked:`/`Allowed:` and `Tier:` (the line the verification block asserts) and approximate the rest — bring a hook up to the full shape when you next edit it. -- **Header.** Event and matcher on line 2; *why* in one paragraph with its dated source (a platform page or a dated real-run observation, never memory); `Blocked:` / `Allowed:` lines; a `Timing:` line when the guard reads state before the tool runs (a compound command is judged on the state at entry — create the branch and make the first commit in separate calls; and the payload `cwd` is the session's launch directory, which a shell `cd` does not move — a real dispatch, 2026-09-07 — so a launch-directory guard is answered by relaunching the session from the root); `Path:` naming the placeholder registration; `Tier:` on every hook. -- **The stdin / exit contract.** JSON on stdin (`tool_name`, `tool_input`, and the common fields, `cwd` among them — `https://code.claude.com/docs/en/hooks-guide` § How hooks work). Exit 2 + stderr blocks (deny), exit 0 allows; an ask-gate prints `hookSpecificOutput.permissionDecision: "ask"` with a reason and exits 0 (`https://code.claude.com/docs/en/hooks`: allow / deny / ask / defer). Matching hooks in one group run in parallel — never rely on order between two hooks on the same event. `Stop` takes no matcher; `SubagentStop` matches agent types, and a finishing subagent has no hand-off — a repair-before-stop hook belongs on `Stop`. Verified 2026-09-06; re-verify after harness upgrades. +- **Header.** Event and matcher on line 2; *why* in one paragraph with its dated source (a platform page or a dated real-run observation, never memory); `Blocked:` / `Allowed:` lines; a `Timing:` line when the guard reads state before the tool runs (a compound command is judged on the state at entry — create the branch and make the first commit in separate calls; and the payload `cwd` is the session's launch directory, which a shell `cd` does not move — a real dispatch, 2026-09-07 — so a launch-directory guard is answered by relaunching the session from the root); `Path:` naming the placeholder registration; `Tier:` on every hook; a `Depends:` line naming each external dependency (jq, git, mktemp, a sourced helper) and what its absence costs — fail open, degrade unpruned, degrade unmonitored — so a rewrite that adds a dependency cannot leave the header claiming none (#58, 2026-09-07 comment). +- **The stdin / exit contract.** **Drain stdin before anything that can exit** — `input="$(cat)"` is the first statement after `set -uo pipefail`, above the dependency check and every other early exit. A hook that exits unread leaves its caller holding an open pipe with no reader, so the caller takes SIGPIPE; the masking needs a shell pipeline under `pipefail` (`printf … | hook`), where the writer's 141 becomes the *pipeline's* status and hides the hook's own exit code, so a guard that failed open correctly reads as a crash. That is exactly the shape of this file's § Verification dry-runs, the hook fixture, and any script that pipes a payload in; the harness's registered call is not that shape (a `command` entry with no `args` runs as one process with the payload written to its stdin — `https://code.claude.com/docs/en/hooks`, read 2026-09-21), and draining first is the contract regardless, because the test and verification surfaces are where a hook's exit code is read as its verdict. A payload smaller than the pipe buffer lets the writer finish first, so the race is one a small-payload test almost always wins — a regression test for a race must not itself be a race. **And never decide on the status of a pipeline whose reader can exit first.** `printf '%s' "$x" | grep -Eq …` reads like a string test, but grep exits on its first matching *line*, so a multi-line subject past the buffer leaves the writer to take SIGPIPE and `pipefail` turns a match into a 141 the `if` reads as *no match* — a guard bypassed with no warning, the one fail-open shape the next bullet forbids. A single long line hides it (grep must reach EOF to complete the line), so a filler-on-one-line probe reads clean. Feed the subject as a here-string (`grep -Eq … <<<"$x"`), which keeps grep's line semantics byte-for-byte and leaves nothing still writing when the reader exits (bash hands a small subject a pipe it has already filled and spills a large one to an unlinked temp file), and cap a report inside its producer (awk's own counter), never with `| head -N`. Measured on a real application, 2026-09-18: a >64 KiB multi-line commit body bypassed the main-branch deny outright and a long `gh pr merge --body` skipped the PR-state ask-gate (you-are-hear #81 → PR #82). Both halves are enforced by `.claude/workflows/tests/hook-contract-fixture.sh`, which reads every hook: one structural check asserts the drain is the first statement, the other that no hook pipes into an early-exiting reader — a tripwire over known spellings, not a proof, so a new hook still earns a behavioural case. Then the wire contract: JSON on stdin (`tool_name`, `tool_input`, and the common fields, `cwd` among them — `https://code.claude.com/docs/en/hooks-guide` § How hooks work). Exit 2 + stderr blocks (deny), exit 0 allows; an ask-gate prints `hookSpecificOutput.permissionDecision: "ask"` with a reason and exits 0 (`https://code.claude.com/docs/en/hooks`: allow / deny / ask / defer). Matching hooks in one group run in parallel — never rely on order between two hooks on the same event. `Stop` takes no matcher; `SubagentStop` matches agent types, and a finishing subagent has no hand-off — a repair-before-stop hook belongs on `Stop`. Verified 2026-09-06; re-verify after harness upgrades. - **Fail-open, with its backstop named.** On an environment defect (no `jq`, not a git checkout, an unset variable) a guard exits 0 with a stderr warning that names the surviving backstop — the CI lint, the Stop-tier hook, the verification block — never fails closed into a universal block. `set -uo pipefail`, deliberately not `set -e`; no bash-4-only builtins (`mapfile`), because a stock macOS bash is 3.2 and a "command not found" there is a fail-closed. - **Project-relative paths, in placeholder form.** The registry names `${CLAUDE_PROJECT_DIR}/.claude/hooks/.sh` — handlers run in the current directory (`https://code.claude.com/docs/en/hooks`), so a bare relative path is not found from a subdirectory, which is where a launch-directory guard must fire. The script derives its root from `CLAUDE_PROJECT_DIR` (falling back to `$PWD`) or, for a file-scoped hook, from the edited file's checkout (`git -C "$(dirname "$file")" rev-parse --show-toplevel`) — inside a worktree the project dir stays where the session started while the file lives in the worktree. - **Four tiers** — hard-deny, ask-gate, advisory, stop — per § HITL gate load-bearing heuristics › Mechanize the gates; the registry comment names every hook under its tier with its mechanism and dated source, and states prerequisites and the canonical dry-run once. - **The mutation table and the hook registry are two views of one list** — see `cbk-conventions.md` § Mutation discipline; the verification block checks that its paragraph names every registered hook. -- **Exemplar stanzas ship commented out.** An advisory hook the project must wire to its stack (a formatter, an analyzer) ships unregistered with its registration as an `_example_PostToolUse_*` key beside `hooks` — copied in after the case arms are filled — so a fresh checkout never runs a formatter it does not have. +- **No hook-shaped object outside `hooks`, ever.** An advisory hook the project must wire to its stack (a formatter, an analyzer) ships unregistered, and its registration stanza lives in the hook's own header (`Register:`), copied into `hooks.PostToolUse` after the case arms are filled — so a fresh checkout never runs a formatter it does not have. JSON has no comments, and a "commented-out" stanza approximated by a live top-level object is a defect: any top-level key that is not `hooks` (nor one of the harness's own settings keys) whose value carries `matcher` or a non-empty `hooks` is a fatal settings diagnostic, after which the parser returns no settings at all — every guard, `enabledMcpjsonServers` and `enabledPlugins` inert, silently, with the verification block green (Claude Code 2.1.270–2.1.278: found on a real application by single-variable probe, 2026-09-13; the fatal path read from the 2.1.278 bundle, 2026-09-21; re-verify after upgrades). The block asserts the invariant on every run. - **An axis-conditional guard is inert when its axis is off, and follows its rule file.** A guard whose matcher names tools of an axis the project turned off (the knowledge-backend ask-gate on a `none` project) never fires because those tools are not connected, so leaving it registered costs nothing; the kit's default for that axis is nonetheless to delete the rule, the hook and its stanza together at the bootstrap disposition pass (`cbk-conventions.md` § Rule loading and the instruction budget) — inert-if-kept is the safety property, not the recommendation. - **An edit-time analyzer under the advisory contract.** `analyze-on-edit.sh` runs the project's analyzer on the package of every edited file and prints only *errors* to stderr, exit 0 always — a boundary violation surfaces at the edit, not at the `check` task. Its case arms are the project's; the exemplar ships with them commented, inside the `case`, so uncommenting is the whole wiring; placeholders are bare `ALL_CAPS` words, never ``, because `<` and `>` are shell syntax once the arm is live. -- **Verify by payload.** When a hook is authored or changed, each branch it has is exercised by piping a crafted JSON payload and asserting the exact exit (only 2 denies — a crash is not a block), plus one real dispatch for a guard whose matcher is a dated observation; the table of expected and observed exits goes in the PR body. The verification block re-runs a subset on every run — the launch-root guard's deny and allow payloads, and the Stop hook against the live tree — while a state-mutating dry-run (a stray memory tree, a commit on `main`) runs against a fixture or a throwaway clone at authoring time. +- **Verify by payload.** When a hook is authored or changed, each branch it has is exercised by piping a crafted JSON payload and asserting the exact exit (only 2 denies — a crash is not a block), plus one real dispatch for a guard whose matcher is a dated observation; the branches a payload can reach are asserted in `.claude/workflows/tests/hook-contract-fixture.sh` (durable, run by the block), and the PR body's table is for the branches only a mutation can reach, named as such (#58 item 4). The verification block re-runs a subset on every run — the launch-root guard's deny and allow payloads, and the Stop hook against the live tree — while a state-mutating dry-run (a stray memory tree, a commit on `main`) runs against a fixture or a throwaway clone at authoring time. ## Recommended planning-backend settings @@ -465,10 +465,16 @@ Workspace deviations from kit recommendation (if any): The kit's brownfield detection (run by scaffold's Stage 2 when knowledge backend = Notion) will surface what already exists in the operator's Notion. Record the post-detection state here so later phases inherit it rather than re-detecting cold. +## Syncing the kit + +A target that recorded its **Kit commit** (scaffold's Cascade metadata table) syncs to a newer kit as a three-way merge, not a hand-reconciliation: for every file, `git merge-file ` — base is the kit at the install sha, ours the project's copy, theirs the new kit. On a real application 47 of 48 files the project had customized auto-resolved (pure kit drift, derivable from the base); the conflict hunks were the two big rule files, where the kit's generalized text and the project's filled text both had to survive (#58, second application, item 10). The file-by-file table — classify each file as copy / add / merge / keep, then note what each merge must preserve — is the reusable artifact; write it before the branch. Two traps: a project sub-block re-homed into this file from a pre-split contract must split its own retired-vocabulary literals (or the block's `absent` check matches it), and byte-identity for `copy` rows is asserted against the target sha, so a fix a project needs ahead of the kit is filed upstream and carried as a named exception, never silently patched into a copy. Record the new sha in the table when the sync merges. + ## Verification Two audiences share one block. **Kit-repo checks** hold on the kit's own tree and on any target project's copy of `.claude/`; **project checks** hold only in a filled-in target project and skip themselves when `docs/cbk/scaffold.md` is absent. A red check is a defect in the check until proven otherwise: a suite with a permanently red line is a suite nobody runs, which is worse than no suite. Every check says what it catches. Run the block after major edits to cascade skills, to the rules, or to a project's filled-in copy of this file. +**Run it** through `.claude/workflows/tests/run-verification-block.sh`: it performs the documented extraction and adds two fail-loud rails the block cannot carry for itself — an empty extraction is red (a plain `bash -e` on an empty file exits 0), and an exit 0 that never printed `verification: done` is red. The kit's CI runs it on every pull request; a target wires the same script as the body of a task its check command depends on (a check nobody re-runs is a belief with a date on it — #58, second application, item 7). The block stays fail-fast: every red is fixed, or the check is narrowed in the project's own copy with an inline comment saying why — a "recorded" red cannot reach the sentinels. + ```bash # ═══ KIT-REPO CHECKS — must be green on the kit tree and in every target project ═══ @@ -490,7 +496,12 @@ absent grep -rnE "notion\.(so|site)/[0-9a-f]{16,}" .claude/skills/ # Pre-refactor "opinionated-profile" vocabulary must not appear anywhere in kit content (the # constant + two axes refactor removed the concept); the pattern splits its literal so this # line never matches itself. -absent grep -rn -i "opinionate[d] profile\|opinionated_profil[e]" .claude/ README.md .mcp.json.example +# The example env file is an operand only where it exists: a target that commits `.mcp.json` instead +# names that file here, and a project sub-block re-homed from a pre-split contract must split its own +# retired-vocabulary literal (as this comment does: opinionate[d] profile) or this very line matches +# it (#58, second application, item 1). +mcpx=""; [ -f .mcp.json.example ] && mcpx=.mcp.json.example +absent grep -rn -i "opinionate[d] profile\|opinionated_profil[e]" .claude/ README.md $mcpx # CLAUDE.md points at this file as a backticked mention — deliberately NOT an `@` import, # which would expand this whole file into every session at launch (memory docs). @@ -574,6 +585,9 @@ node .claude/workflows/tests/review-sweep-accounting.mjs || { echo "review-sweep node .claude/workflows/tests/finish-ab-shape.mjs || { echo "finish-ab.js does not parse or its panel guard regressed"; exit 1; } node .claude/workflows/tests/load-workflow-shape.mjs || { echo "load-workflow.mjs regressed"; exit 1; } bash .claude/workflows/tests/agent-cost-fixture.sh || { echo "agent-cost.py regressed on the fixture"; exit 1; } +# Every hook honours the stdin/exit contract (§ Hook authoring): structural checks over .claude/hooks/*.sh +# plus one over-buffer probe per decision site. Runs in throwaway trees; never touches this checkout. +bash .claude/workflows/tests/hook-contract-fixture.sh || { echo "a hook violates the stdin/exit contract (the fixture names it)"; exit 1; } # Every dispatch names its model and its effort (orchestration.md § The role ladder): agent definitions # carry both, except a model without the dial, which carries none (the frontmatter is read once per file). for a in .claude/agents/*.md; do fm=$(sed -n '2,/^---$/p' "$a"); grep -q '^model:' <<<"$fm" || { echo "$a names no model"; exit 1; }; if grep -q '^model: haiku' <<<"$fm"; then absent grep -n '^effort:' <<<"$fm"; else grep -q '^effort:' <<<"$fm" || { echo "$a names no effort (orchestration.md § The role ladder)"; exit 1; }; fi; done @@ -595,21 +609,30 @@ absent grep -niE "(if|when) in doub[t],? (use|reach for)" .claude/rules/tooling. # The bootstrap checklist prompts for the orchestration posture (#34). grep -q 'Orchestration posture' .claude/skills/scaffold/references/bootstrap_checklist_template.md || { echo "the bootstrap checklist lacks the orchestration-posture row"; exit 1; } # Hook registry ⇔ files ⇔ table: every shipped guard is registered at least once and the advisory -# exemplars stay unregistered; every command field in settings.json — registered or an exemplar -# stanza — is in placeholder form, exists and is executable; every hook header carries its Tier: -# line; the registry comment names every hook and all four tiers; and the conventions' two-views -# paragraph (cbk-conventions.md § Mutation discipline) names every registered hook. +# exemplars stay unregistered on the kit tree (a target asserts its own wiring in the project +# sub-block, ADVISORY_WIRED); every command field in settings.json is in placeholder form, exists +# and is executable; every hook header carries its Tier: line; the registry comment names every hook +# and all four tiers; and the conventions' two-views paragraph (cbk-conventions.md § Mutation +# discipline) names every registered hook. hookcomment=$(jq -r '._comment_hooks' .claude/settings.json) -for h in .claude/hooks/*.sh; do b=$(basename "$h"); n=$(jq -r '[.hooks[][] | .hooks[] | .command] | map(select(endswith("'"$b"'"))) | length' .claude/settings.json); case "$b" in format-on-edit.sh|analyze-on-edit.sh) [ "$n" -eq 0 ] || { echo "advisory exemplar $b is registered"; exit 1; };; *) [ "$n" -ge 1 ] || { echo "$b is not registered"; exit 1; };; esac; grep -q "$b" <<<"$hookcomment" || { echo "registry comment does not name $b"; exit 1; }; grep -q '^# Tier:' "$h" || { echo "$b has no Tier: line in its header"; exit 1; }; done +# No hook-shaped object under any top-level key other than hooks, at ANY depth (§ Hook authoring): the +# harness discards the whole file on one, silently, and its loader reads a matcher or a non-empty hooks +# nested as readily as flat. An empty key read is red, never a vacuous pass. +[ "$(jq -r 'keys | length' .claude/settings.json)" -ge 1 ] || { echo "settings.json: no top-level keys read — the check below would pass vacuously"; exit 1; } +bad=$(jq -r 'to_entries[] | select(.key != "hooks") | select([.value | .. | objects | select(has("matcher") or ((.hooks | type) == "array" and (.hooks | length) > 0) or ((.hooks | type) == "object" and (.hooks | length) > 0))] | length > 0) | .key' .claude/settings.json); [ -z "$bad" ] || { echo "settings.json: hook-shaped object(s) outside hooks void the whole file (flat or nested, under): $bad"; exit 1; } +absent grep -n '"_example_PostToolUse_' .claude/settings.json +for h in .claude/hooks/*.sh; do b=$(basename "$h"); n=$(jq -r '[.hooks[][] | .hooks[] | .command] | map(select(endswith("'"$b"'"))) | length' .claude/settings.json); case "$b" in format-on-edit.sh|analyze-on-edit.sh) if [ ! -f docs/cbk/scaffold.md ]; then [ "$n" -eq 0 ] || { echo "advisory exemplar $b is registered (the kit tree ships it unregistered; a target declares ADVISORY_WIRED in its sub-block)"; exit 1; }; fi;; *) [ "$n" -ge 1 ] || { echo "$b is not registered"; exit 1; };; esac; grep -q "$b" <<<"$hookcomment" || { echo "registry comment does not name $b"; exit 1; }; grep -q '^# Tier:' "$h" || { echo "$b has no Tier: line in its header"; exit 1; }; done [ "$(jq -r '.. | objects | select(has("command")) | .command' .claude/settings.json | wc -l)" -ge 1 ] || { echo "settings.json carries no hook commands at all"; exit 1; } jq -r '.. | objects | select(has("command")) | .command' .claude/settings.json | sort -u | while read -r c; do case "$c" in '${CLAUDE_PROJECT_DIR}/'*) ;; *) echo "hook command is not in placeholder form (handlers run in the current directory): $c"; exit 1;; esac; c="${c/\$\{CLAUDE_PROJECT_DIR\}/.}"; [ -x "$c" ] || { echo "hook command missing or not executable: $c"; exit 1; }; done for t in HARD-DENY ASK-GATE ADVISORY STOP; do grep -q "$t" <<<"$hookcomment" || { echo "registry comment lacks the $t tier"; exit 1; }; done jq -r '[.hooks[][] | .hooks[] | .command][]' .claude/settings.json | sort -u | while read -r c; do b=$(basename "$c"); grep -q "$b" .claude/rules/cbk-conventions.md || { echo "cbk-conventions.md § Mutation discipline does not name the registered hook $b"; exit 1; }; done # The launch-root guard, on its branches (crafted payloads; read-only). Only exit 2 denies, so the # blocking case asserts 2 exactly; `|| rc=$?` keeps -e satisfied while the status stays testable. -rc=0; printf '{"tool_name":"Agent","tool_input":{},"cwd":"%s/docs"}' "$PWD" | .claude/hooks/require-repo-root-for-agents.sh >/dev/null 2>&1 || rc=$? -[ "$rc" -eq 2 ] || { echo "launch-root guard did not DENY a subdirectory dispatch (exit $rc; only 2 blocks)"; exit 1; } -printf '{"tool_name":"Agent","tool_input":{},"cwd":"%s"}' "$PWD" | .claude/hooks/require-repo-root-for-agents.sh >/dev/null 2>&1 || { echo "launch-root guard blocked a root dispatch"; exit 1; } +# stderr is kept and printed on failure: the hook's own reason is the diagnostic (#58 item 11). +rc=0; err=$(printf '{"tool_name":"Agent","tool_input":{},"cwd":"%s/docs"}' "$PWD" | .claude/hooks/require-repo-root-for-agents.sh 2>&1 >/dev/null) || rc=$? +[ "$rc" -eq 2 ] || { echo "launch-root guard did not DENY a subdirectory dispatch (exit $rc; only 2 blocks). The hook said:"; printf ' %s\n' "$err"; exit 1; } +rc=0; err=$(printf '{"tool_name":"Agent","tool_input":{},"cwd":"%s"}' "$PWD" | .claude/hooks/require-repo-root-for-agents.sh 2>&1 >/dev/null) || rc=$? +[ "$rc" -eq 0 ] || { echo "launch-root guard blocked a root dispatch (exit $rc). The hook said:"; printf ' %s\n' "$err"; exit 1; } # No reviewer-memory tree outside the root — asked of the Stop hook itself with a crafted payload, # so the exclusion list has one home (it exits 2 while a stray tree exists; the gate's own check). printf '{"stop_hook_active":false}' | CLAUDE_PROJECT_DIR="$PWD" .claude/hooks/detect-forked-agent-memory.sh || { echo "a reviewer-memory tree exists outside the root (detect-forked-agent-memory.sh blocked; its stderr above names the paths)"; exit 1; } @@ -669,6 +692,11 @@ echo "verification: kit sub-block complete" # ═══ PROJECT CHECKS — a filled-in target project only; skipped on the kit tree ═══ if [ -f docs/cbk/scaffold.md ]; then + # Advisory hooks the project wired (§ Hook authoring): name each registered one here, space-separated. + # Each named hook must be registered exactly once; each unnamed one zero times. Default: none wired. + ADVISORY_WIRED="${ADVISORY_WIRED:-}" + for b in format-on-edit.sh analyze-on-edit.sh; do n=$(jq -r '[.hooks[][] | .hooks[] | .command] | map(select(endswith("'"$b"'"))) | length' .claude/settings.json); case " $ADVISORY_WIRED " in *" $b "*) [ "$n" -eq 1 ] || { echo "advisory hook $b is declared wired but registered $n times"; exit 1; };; *) [ "$n" -eq 0 ] || { echo "advisory hook $b is registered but not declared in ADVISORY_WIRED"; exit 1; };; esac; done + # Layout: the project's own artifacts follow the layout it chose. Flat is the default; # a project that chose the nested layout inverts this line. absent test -d docs/cbk/framings diff --git a/.claude/rules/cbk-conventions.md b/.claude/rules/cbk-conventions.md index 66c95e9..c3c829c 100644 --- a/.claude/rules/cbk-conventions.md +++ b/.claude/rules/cbk-conventions.md @@ -231,12 +231,15 @@ Cascade events being append-only is structurally important: the cascade IS the a | Naming a branch | `/--` | | Closing an issue from a PR | `Closes -N` (Linear) or `Closes #N` (GitHub) in PR body | | Adding an ADR | `adr-new` skill (auto-syncs indexes) | -| Adding a `## Pre-flight checks` row to a frame | Append-only edit to the frame's `## Pre-flight checks` table | | Skipping CI on a docs-only commit | Append `[skip ci]` to commit message subject | | Mid-session gate trimming | See § HITL gate load-bearing heuristics | | Phase exit | Run the `## Phase exit checklist` from the relevant cascade skill | | Knowledge backend operations (Notion reads/writes, HITL discipline, brownfield detection, lazy provisioning) | See `.claude/rules/knowledge-backend.md`; project-specific values in § Knowledge backend above | +## Syncing the kit + +Three-way `git merge-file` against the recorded **Kit commit**; the file-by-file table first. → `cbk-conventions-reference.md` § Syncing the kit. + ## Verification → *Moved to* `cbk-conventions-reference.md` § Verification *(path-scoped; see § Rule loading and the instruction budget).* diff --git a/.claude/rules/knowledge-backend.md b/.claude/rules/knowledge-backend.md index 2d2ecb4..7ef737b 100644 --- a/.claude/rules/knowledge-backend.md +++ b/.claude/rules/knowledge-backend.md @@ -227,7 +227,7 @@ Operator can edit, decline, or commit. These announcements are non-negotiable. Bypassing them silently — even for "obvious" reads or "trivial" writes — trains the operator to ignore the next ad-hoc surfacing, which is the next failure mode. -**Hook enforcement layer.** Prose alone cannot stop a session where a broad permissions allowlist would auto-approve the write tool. The kit ships `.claude/hooks/require-knowledge-backend-ok.sh` (registered in `settings.json` against the knowledge-backend MCP's write-verb tool names — Notion's as the v1 reference) which returns a deterministic permission "ask" on every matched write: the forced permission prompt is the per-action approval, and read tools stay unmatched so read-primary behavior is unaffected. +**Hook enforcement layer.** Prose alone cannot stop a session where a broad permissions allowlist would auto-approve the write tool. The kit ships `.claude/hooks/require-knowledge-backend-ok.sh` (registered in `settings.json` against the knowledge-backend MCP's mutating tool names — Notion's `create|update|move|duplicate|convert|delete|upload|spawn|send|stop` as the v1 reference, dated 2026-09-21; the matcher is a dated observation, re-verified when the MCP's tool list changes) which returns a deterministic permission "ask" on every matched write: the forced permission prompt is the per-action approval, and read tools stay unmatched so read-primary behavior is unaffected. ## Inheritance discipline diff --git a/.claude/rules/logging.md b/.claude/rules/logging.md index 57a4980..44120f3 100644 --- a/.claude/rules/logging.md +++ b/.claude/rules/logging.md @@ -5,7 +5,7 @@ paths: # Logging Rules -> **Path-scoped — stamp the glob at install.** The `paths:` block above makes this rule load only when a matching file is read, not every session (`https://code.claude.com/docs/en/memory`). As shipped it carries a placeholder: replace `` with the project's source extension(s) — one entry per extension (e.g. `"**/*.py"`, `"**/*.ts"`). A placeholder glob matches nothing, so the rule would never load; the conventions' verification block flags an unfilled placeholder. +> **Path-scoped.** The `paths:` block above makes this rule load only when a matching file is read, not every session (`https://code.claude.com/docs/en/memory`). The globs are stamped at install — one entry per source extension (e.g. `"**/*.py"`, `"**/*.ts"`); until stamped they are bracketed placeholders that match nothing, which the conventions' verification block flags. This callout describes the mechanism and stays as it reads after stamping. Operational rules for structured logging. Your `docs/STANDARDS.md` establishes the principle; this file is the implementation contract. When writing or modifying logging code, follow these rules — adapted to whatever logging stack your project uses. diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index 68d0bf0..2f0b201 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -8,7 +8,7 @@ paths: # Testing Rules -> **Path-scoped — stamp the globs at install.** This rule loads only when a test file or a test directory is read (`https://code.claude.com/docs/en/memory`). Replace `` with the project's test-file extension(s) and drop the directory patterns the project does not use. A placeholder glob matches nothing; the conventions' verification block flags it. Anything a test-writing session needs *before* it opens a test file — the project's test-side trace-tag form, for one — is restated here rather than left in a rule this session never loads. +> **Path-scoped.** This rule loads only when a test file or a test directory is read (`https://code.claude.com/docs/en/memory`). The globs are stamped at install — the project's test-file extension(s), and only the directory patterns it uses; until stamped they are placeholders the conventions' verification block flags. A stack whose unit tests live inline in source files (a `#[cfg(test)]` module) has no test-file extension: stamp the test directories alone and accept that inline modules reach this rule only through their directory. Anything a test-writing session needs *before* it opens a test file — the project's test-side trace-tag form, for one — is restated here rather than left in a rule this session never loads. This callout stays as it reads after stamping. Operational rules for tests. The principle (logic modules get real tests; boundary impls get conformance + mocks; tests are part of done) lives in your `docs/STANDARDS.md`; this file is the implementation contract — when, in what order, and to what shape. diff --git a/.claude/settings.json b/.claude/settings.json index 4020b94..d11bf62 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/claude-code-settings.json", - "_comment_hooks": "Guards in FOUR tiers; each tier names its mechanism and its dated source; prerequisites and the canonical dry-run are stated once at the end (cbk-conventions-reference.md § HITL gate load-bearing heuristics › Mechanize the gates; the authoring shape is § Hook authoring). Every command uses ${CLAUDE_PROJECT_DIR}/… — handlers run in the current directory (code.claude.com/docs/en/hooks, verified 2026-09-06), so a bare relative path is not found from a subdirectory. HARD-DENY (PreToolUse, exit 2) for actions never legitimate for the agent: protect-immutable-adrs.sh blocks Edit/Write/MultiEdit on existing docs/adr/NNNN-*.md; protect-lock-files.sh blocks edits to lock files (change them via the package manager); protect-main-branch.sh blocks `git commit` on main/master (branch-first per cbk-conventions.md § Branch naming; judged on the branch at entry, so branch creation and the first commit are separate calls); require-repo-root-for-agents.sh blocks Task/Agent/Workflow dispatch from any directory but the checkout's git top-level (the memory-root mechanism and its dated source are stated once in pr-review.md § Reviewer precedent memory); the `Task|Agent|Workflow` matcher is a dated observation of the tool names the harness reports, with the older name kept so a rename degrades to a no-op. ASK-GATE (PreToolUse, permissionDecision \"ask\") for one-way doors legitimate only when operator-instructed — the permission prompt IS the per-action HITL approval, and it fires even when a broad allowlist entry would otherwise auto-approve: guard-pr-state.sh intercepts gh pr ready/merge/close/reopen; require-knowledge-backend-ok.sh intercepts knowledge-backend MCP writes (Notion matcher shipped as the v1 reference — adjust the regex to your MCP's tool names; when the knowledge axis is `none` the guard is inert because its tools are not connected, and it follows the rule file's disposition at bootstrap). ADVISORY (PostToolUse, exit 0 always) ships as unregistered exemplars with their stanzas beside `hooks`: format-on-edit.sh (`_example_PostToolUse_formatter`) and analyze-on-edit.sh (`_example_PostToolUse_analyzer` — the project's analyzer on the edited file's package, errors only; measured under a second per package on a real run, 2026-09-05) — copy a stanza into hooks.PostToolUse after wiring its case arms to your stack. STOP (Stop, exit 2 blocks the stop once): detect-forked-agent-memory.sh exits 2 while a `.claude/agent-memory/` tree exists anywhere but the root, so the agent that forked it moves it before handing off; `stop_hook_active` prevents a loop, and the harness overrides a Stop hook after eight consecutive blocks (code.claude.com/docs/en/hooks-guide § Limitations and troubleshooting, verified 2026-09-06). Registered on Stop only: on SubagentStop it would block every finishing subagent, which has no hand-off. All guards fail open (exit 0 + stderr warning naming the surviving backstop) on environment defects, so a missing jq can't turn a targeted guard into a universal block. Prerequisites: jq, git, bash 3.2+. Canonical dry-run: pipe a crafted JSON payload into the script and assert the exit (the verification block runs the launch-root guard's payloads on every run; the Stop hook's fixture run is the procedure in cbk-conventions-reference.md § Hook authoring › Verify by payload, recorded in the PR body's table).", + "_comment_hooks": "Guards in FOUR tiers; each tier names its mechanism and its dated source; prerequisites and the canonical dry-run are stated once at the end (cbk-conventions-reference.md § HITL gate load-bearing heuristics › Mechanize the gates; the authoring shape is § Hook authoring). Every command uses ${CLAUDE_PROJECT_DIR}/… — handlers run in the current directory (code.claude.com/docs/en/hooks, verified 2026-09-06), so a bare relative path is not found from a subdirectory. HARD-DENY (PreToolUse, exit 2) for actions never legitimate for the agent: protect-immutable-adrs.sh blocks Edit/Write/MultiEdit on existing docs/adr/NNNN-*.md; protect-lock-files.sh blocks edits to lock files (change them via the package manager); protect-main-branch.sh blocks `git commit` on main/master (branch-first per cbk-conventions.md § Branch naming; judged on the branch at entry, so branch creation and the first commit are separate calls); require-repo-root-for-agents.sh blocks Task/Agent/Workflow dispatch from any directory but the checkout's git top-level (the memory-root mechanism and its dated source are stated once in pr-review.md § Reviewer precedent memory); the `Task|Agent|Workflow` matcher is a dated observation of the tool names the harness reports, with the older name kept so a rename degrades to a no-op. ASK-GATE (PreToolUse, permissionDecision \"ask\") for one-way doors legitimate only when operator-instructed — the permission prompt IS the per-action HITL approval, and it fires even when a broad allowlist entry would otherwise auto-approve: guard-pr-state.sh intercepts gh pr ready/merge/close/reopen; require-knowledge-backend-ok.sh intercepts knowledge-backend MCP writes (Notion matcher shipped as the v1 reference, dated 2026-09-21 — the live tool set added upload-skill (replaces a page's body), spawn-session / send-message-to-session (drive an agent that writes) and stop-session; a matcher is a dated observation of the vendor's tool names, so re-verify it when the MCP's tool list changes; adjust the regex to your MCP's tool names; when the knowledge axis is `none` the guard is inert because its tools are not connected, and it follows the rule file's disposition at bootstrap). ADVISORY (PostToolUse, exit 0 always) ships as two unregistered exemplars, format-on-edit.sh and analyze-on-edit.sh (the analyzer runs the project's analyzer on the edited file's package, errors only; measured under a second per package on a real run, 2026-09-05); each hook's header carries the registration stanza to copy into hooks.PostToolUse once its case arms are wired. NO exemplar object lives in this file: a top-level key holding a matcher/hooks object makes the harness discard the whole settings file — every guard, enabledMcpjsonServers and enabledPlugins inert, silently (Claude Code 2.1.270–2.1.278; single-variable probe on a real application 2026-09-13, the parser's fatal diagnostic read from the 2.1.278 bundle 2026-09-21), and the verification block asserts none is present. STOP (Stop, exit 2 blocks the stop once): detect-forked-agent-memory.sh exits 2 while a `.claude/agent-memory/` or `.claude/agent-memory-local/` tree exists anywhere but the root (both memory scopes fork the same way), so the agent that forked it moves it before handing off; `stop_hook_active` prevents a loop, and the harness overrides a Stop hook after eight consecutive blocks (code.claude.com/docs/en/hooks-guide § Limitations and troubleshooting, verified 2026-09-06). Registered on Stop only: on SubagentStop it would block every finishing subagent, which has no hand-off. All guards fail open (exit 0 + stderr warning naming the surviving backstop) on environment defects, so a missing jq can't turn a targeted guard into a universal block. Prerequisites: jq, git, bash 3.2+. Canonical dry-run: pipe a crafted JSON payload into the script and assert the exit (the verification block runs the launch-root guard's payloads on every run; the Stop hook's fixture run is the procedure in cbk-conventions-reference.md § Hook authoring › Verify by payload, recorded in the PR body's table).", "hooks": { "PreToolUse": [ { @@ -30,7 +30,7 @@ ] }, { - "matcher": "mcp__(plugin_Notion_)?notion__notion-(create|update|move|duplicate|convert|delete).*", + "matcher": "mcp__(plugin_Notion_)?notion__notion-(create|update|move|duplicate|convert|delete|upload|spawn|send|stop).*", "hooks": [ { "type": "command", @@ -59,26 +59,6 @@ } ] }, - "_example_PostToolUse_formatter": { - "_comment": "Copy this object into hooks.PostToolUse after wiring .claude/hooks/format-on-edit.sh's case arms to your stack. Advisory tier: exit 0 always.", - "matcher": "Edit|Write|MultiEdit", - "hooks": [ - { - "type": "command", - "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/format-on-edit.sh" - } - ] - }, - "_example_PostToolUse_analyzer": { - "_comment": "Copy this object into hooks.PostToolUse after wiring .claude/hooks/analyze-on-edit.sh's case arm to your analyzer (measure it first: under a second per package, or it belongs in the check task). Advisory tier: exit 0 always; errors only.", - "matcher": "Edit|Write|MultiEdit", - "hooks": [ - { - "type": "command", - "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/analyze-on-edit.sh" - } - ] - }, "_comment_enabledPlugins": "Plugins the cascade kit expects. Install each via Claude Code's plugin system before running /finish: pr-review-toolkit (the review pass dispatches pr-review-toolkit:review-pr) and commit-commands (staging/commit wrappers used by the kit's examples). `/simplify` — /finish's simplify pass — is built into Claude Code as a bundled skill (verified against the installed CLI bundle, Claude Code 2.1.263, 2026-09-06; re-verify after harness upgrades) and is deliberately NOT listed here: add \"simplify\": true only if your install ships it as a plugin. Rename entries to match installed plugin IDs if yours differ.", "enabledPlugins": { "pr-review-toolkit": true, diff --git a/.claude/skills/adr-new/SKILL.md b/.claude/skills/adr-new/SKILL.md index 19ef9ce..807f2f5 100644 --- a/.claude/skills/adr-new/SKILL.md +++ b/.claude/skills/adr-new/SKILL.md @@ -48,7 +48,7 @@ When invoked, **propose** every input below from what the operator already said Then fill in: ADR number, title, status (`Accepted` for new decisions, `Proposed` if user wants HITL gate first), date (today, ISO format), deciders, related ADRs, the relation slots the inputs filled (`Supersedes:` / `Refines:` / `Extends:` / `Promotes:` — delete the unused lines), context, options considered, decision, consequences. 3. **Update `docs/adr/README.md` index** — the canonical surface. - - Add the row to the index table in number order; the status cell carries the grain and the parent inline (`Accepted · Refines ADR-0007 (D2)`, `Accepted · Extends ADR-0003 (D1)`). + - Add the row to the index table in number order — **in the form the existing rows already use.** Read them first: which cell carries the relation grain and the parent (the kit's starter index puts it in the Status cell — `Accepted · Refines ADR-0007 (D2)` — while an index that predates the starter may carry it as a Title-cell parenthetical, with another separator, or in prose), and write the new row exactly that way. The separator is the index's convention, not this skill's; two real indexes already contradicted the pinned form two different ways (#58, 2026-09-07 comment). - If wholly superseding, mark the old ADR's status field in the index as `Superseded by ADR-${NNNN}` (don't edit the old ADR file itself; the index expresses supersession). A clause-scoped supersession annotates the parent's row (`Accepted · Dn superseded by ADR-${NNNN}`); a refine or extend leaves the parent's row as it was. 4. **Update every other index the conventions name.** `.claude/rules/cbk-conventions.md` § ADR index sync is the one home for the target list — read it now and walk it; this skill states no count. The blueprint's § Stack decisions table is append-only and gets a one-line bullet **only when the ADR changes a stack decision**; a decision that is not about the stack does not touch it. Where a project keeps an open-questions list on an index surface, an ADR that resolves one removes it there. @@ -70,10 +70,11 @@ When invoked, **propose** every input below from what the operator already said **See also** `docs/adr/corrections.md` — a wrong *claim* in an accepted ADR (a citation, a figure, an attribution, a formula) is none of these grains; it is an append-only register entry, and the ADR stays as written. -A new ADR connects to an existing one through one of two relationships. Both are recorded as header fields and both preserve the parent's immutability — neither ever edits the parent file. +A new ADR connects to an existing one through one of the relationships below (`Supersedes:`, `Refines:`, `Extends:`, and the clause-scoped form of the first); a `Promotes:` slot connects to a frozen corpus, not to an ADR. All are header fields and all preserve the parent's immutability — none ever edits the parent file. - **`Supersedes: ADR-NNNN`** — the new ADR *replaces* the parent's decision. The parent's status becomes `Superseded by ADR-MMMM`; new code follows the new ADR. This is the relationship the interactive **Supersedes?** input captures, and the one Step 3's index-marking handles. - **`Refines: ADR-NNNN (Dn, …)`** — the new ADR *clause-level-clarifies or narrows* a specific decision `Dn` in the parent **without invalidating it**. The parent stays `Accepted`; both parent and child are consulted when evaluating conformance. Use this when implementation reveals that an accepted clause was written too generally and needs a scoped reading (e.g. "this rule applies only to "), not a reversal. +- **`Promotes: `** — the decision is lifted from a frozen pre-cascade corpus (consultation's frozen-corpus ingestion; `cbk-conventions.md` § Multi-surface facts names the corpus + errata pair). The corpus entry stays as written and the ADR becomes the decision's record home; the slot is the back-pointer. Not a relation to another ADR, so it carries no grain. **Clause-scoped supersession.** Supersession can also target a single clause rather than a whole ADR: `Supersedes: ADR-NNNN Dn` reverses only decision `Dn` of the parent while the parent's other clauses stand. The parent's status stays `Accepted` (it is not wholly superseded); the child's index row names the specific clause it replaces. diff --git a/.claude/skills/blueprint/references/templates/claude-review.yml b/.claude/skills/blueprint/references/templates/claude-review.yml index 11801e1..e063658 100644 --- a/.claude/skills/blueprint/references/templates/claude-review.yml +++ b/.claude/skills/blueprint/references/templates/claude-review.yml @@ -111,6 +111,14 @@ jobs: fetch-depth: 0 ref: ${{ github.event.pull_request.head.sha }} + - name: Fetch the base ref (so `git diff origin/...HEAD` resolves) + # `ref: head.sha` above checks out only the PR head; without this the reviewer's first diff + # against the base fails ("no merge base") and it spends turns retrying fetches its allowlist + # refuses (#58 addendum, item 12). The ref name is read from an env var, never interpolated. + env: + BASE_REF: ${{ github.event.pull_request.base.ref }} + run: git fetch --no-tags origin "+refs/heads/${BASE_REF}:refs/remotes/origin/${BASE_REF}" + - name: Pick model + effort from labels id: model # Constraint 5 — label-driven proportionality. Precedence: claude-deep-review > @@ -151,8 +159,16 @@ jobs: show_full_output: true # track_progress is unsupported on `labeled` events (the action fails). track_progress: ${{ github.event.action != 'labeled' && 'true' || 'false' }} + # Named twice on purpose — here and in the job's `permissions:` block. The job block scopes + # the workflow's own token; this input is what the action requests when it exchanges its OIDC + # token for an app token (`parseAdditionalPermissions` in anthropics/claude-code-action + # `src/github/token.ts`, read 2026-09-21). Without `checks: read` here, `gh pr checks` fails + # `Resource not accessible by integration` and the review takes the PR body's claim of a green + # gate at face value (observed 2026-09-18). `statuses: read` only when the rollup carries commit + # statuses (a non-Actions CI, a deploy bot) — measure with `gh api repos/{owner}/{repo}/commits//status`. additional_permissions: | actions: read + checks: read prompt: | You are reviewing a pull request against []. Read `CLAUDE.md` first @@ -162,6 +178,11 @@ jobs: Enumerate every changed file (`gh pr diff ${{ github.event.pull_request.number }} --name-only`) and walk each one; read the surrounding file, not only the hunk, when context matters. + If the diff exceeds [N] changed files (a sync of vendored or kit-copied trees, a generated + bulk), you cannot walk every file inside the turn cap: review the authored set the PR body + names and every file that is not a byte-identical copy of its source, post the summary FIRST + with the list of files you did not read, and say so in the verdict line. A capped run that + posted nothing is the failure this clause prevents (#58 addendum, item 11). Then post, **in this order**: diff --git a/.claude/skills/blueprint/references/templates/claude.yml b/.claude/skills/blueprint/references/templates/claude.yml index 5334e33..af0d7e7 100644 --- a/.claude/skills/blueprint/references/templates/claude.yml +++ b/.claude/skills/blueprint/references/templates/claude.yml @@ -40,6 +40,7 @@ jobs: pull-requests: read issues: read actions: read + checks: read # `gh pr checks` is allowlisted below; the scope must match id-token: write if: | (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || @@ -77,8 +78,10 @@ jobs: uses: anthropics/claude-code-action@[full-commit-sha] # v[version] with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + # named twice — see claude-review.yml's note at its additional_permissions additional_permissions: | actions: read + checks: read claude_args: | --model [] --fallback-model [] diff --git a/.claude/skills/framing/references/procedure.md b/.claude/skills/framing/references/procedure.md index 6dca25a..14d1d4e 100644 --- a/.claude/skills/framing/references/procedure.md +++ b/.claude/skills/framing/references/procedure.md @@ -104,7 +104,7 @@ The **rough issues** list for each milestone is the seed that rough-in's Step 4 - ✅ *"Define the Verifier trait with associated types for Input, Context, Error per IC-1 from this framing"* — intent, points at the IC that locks the shape - ❌ *"Create `crates/core-engine/src/verifier.rs` with `pub trait Verifier { type Input; type Context; type Error; fn verify(...) -> Result<(), Self::Error>; }`"* — prescription, inlines a signature rough-in should either derive from IC-1 or leave to plan mode -The intent phrasing matters because rough-in's Implementation sections (the thing handed to Claude Code plan mode) inherit their shape from framing's rough issues. If framing produces prescriptive rough issues, rough-in naturally inherits that prescriptive framing and produces over-specified Implementation sections. If framing produces intent-shaped rough issues, rough-in has room to apply its own eight-properties discipline without fighting against framing's seeds. See rough-in's the rough-in skill's `references/plan-mode-prompts.md` § Property 8 for the downstream discipline; framing's job is to set that discipline up for success by starting with intents. +The intent phrasing matters because rough-in's Implementation sections (the thing handed to Claude Code plan mode) inherit their shape from framing's rough issues. If framing produces prescriptive rough issues, rough-in naturally inherits that prescriptive framing and produces over-specified Implementation sections. If framing produces intent-shaped rough issues, rough-in has room to apply its own eight-properties discipline without fighting against framing's seeds. See the rough-in skill's `references/plan-mode-prompts.md` § Property 8 for the downstream discipline; framing's job is to set that discipline up for success by starting with intents. **Rough issue count per milestone**: the same "review-unit discipline" that rough-in applies to R-issues applies (transitively) to framing's rough issues. Rough-in will ultimately produce **2-6 R-issues per milestone** for Claude-Code-executed workflows (see rough-in's Step 4). Framing's rough-issue list should therefore land in roughly the same range — 2-6 intents per milestone is typical. If framing's draft has 8+ rough issues for a milestone, that's a signal the milestone is too big (split it) or framing is pre-decomposing work that rough-in will naturally collapse (merge adjacent intents). If framing's draft has 1 rough issue for a milestone, either the milestone is truly atomic (legitimate, note and proceed) or the milestone is too small (fold into a sibling). diff --git a/.claude/skills/rough-in/references/finish-command.md b/.claude/skills/rough-in/references/finish-command.md index d3a4a5f..1eab521 100644 --- a/.claude/skills/rough-in/references/finish-command.md +++ b/.claude/skills/rough-in/references/finish-command.md @@ -94,14 +94,14 @@ This command is contract-first. On 2026-09-03 an A/B gave two workhorse-tier exe - **The planning backend**, resolved first from `docs/cbk/scaffold.md` § Cascade metadata (falling back to `.cascade/backends.toml`). On `github-issues`, `$1` is the issue number and the read is `gh issue view $1 --json number,title,body,labels,state,comments` (or the github MCP); on `linear`, `$1` is the planner ID and every read goes through the planner's MCP — never a GitHub read against a planner ID; on `in-repo-markdown` there is no `/finish` (design-doc mode; the scaffold gate disclosed it) — stop and say so. - **The issue** — its body and every comment. The body is the contract; comments are context (provenance, roll-forward notes from earlier runs, corrections the operator posted). -- **The parent framing sub-issue** and its frame (`docs/cbk/frame-NN.md`): the milestone's criteria, § Interface Commitments, § Resolved during this framing, § Key Constraints. Rough-in inherited them; you honour them. An intake-lane issue (`[:bug]`, `[:enh]`) has no frame; its provenance comment stands in. +- **The parent framing sub-issue** and its frame (`docs/cbk/frame-NN.md`): the milestone's criteria, § Interface Commitments, § Resolved during this framing, § Key Constraints. Rough-in inherited them; you honour them. An intake-lane issue (`[:bug]`, `[:enh]`) or a directly roughed-in meta (`[:meta]`) has no frame; its provenance comment stands in. - **Every decision record the issue cites** — the ADRs in `docs/adr/` (follow each `Refines:` chain) and any design-decision ledger the project keeps — in full for every clause cited. - `docs/ARCHITECTURE.md`, `docs/STANDARDS.md`, `CLAUDE.md`, and the rules that bind the diff: `.claude/rules/testing.md`, `.claude/rules/logging.md`, `.claude/rules/pr-review.md`, `.claude/rules/simplification.md`, `.claude/rules/cbk-conventions.md`; at triage, `.claude/rules/pr-review-reference.md` § Apply / Surface calibration and § Path-conditional aggressiveness — a triage is not a file read, so the path-scoped half does not load on its own. - **The repository itself**: the task-runner config, the package manifests and lockfiles, the trees the issue names, and the pinned sources of any package the issue's assumptions cite. Verify every claim you act on by reading or running; existence and absence claims are checked repo-wide from the root. Never assert a package API from memory. A knowledge-backend URL the body cites is resolved only through the configured MCP, with the announcement `.claude/rules/knowledge-backend.md` § HITL announcement discipline requires; without the MCP, surface and proceed. ## Preconditions — checked before anything is planned -1. **State and shape.** The issue is open — a closed one stops the run: say so and ask whether it was closed by mistake. The title is `[:F<#>:R<#>] …`, `[:bug] …` or `[:enh] …` with `` a workstream locked in `docs/cbk/blueprint.md` § Workstreams; the issue carries `cascade-depth:roughed-in`; and the body has exactly these eight headings, in this order: `## Context`, `## Assumptions` (present even when it reads `- None — …`), `## Implementation`, `## Acceptance criteria`, `## Test plan`, `## Done signal`, `## Dependencies`, `## PR contract`. Anything else is surfaced, never normalised away. +1. **State and shape.** The issue is open — a closed one stops the run: say so and ask whether it was closed by mistake. The title is `[:F<#>:R<#>] …`, `[:bug] …`, `[:enh] …` or `[:meta] …` with `` a workstream locked in `docs/cbk/blueprint.md` § Workstreams or the cascade/tooling lane's tag (`cbk-conventions.md` § Contribution intake — a meta reaches this executor roughed-in directly or through its `[::R<#>]` children; the `cascade-depth:framed` label the meta template applies at framing is the pre-rough-in state, not this check's — #58, smaller item 1); the issue carries `cascade-depth:roughed-in`; and the body has exactly these eight headings, in this order: `## Context`, `## Assumptions` (present even when it reads `- None — …`), `## Implementation`, `## Acceptance criteria`, `## Test plan`, `## Done signal`, `## Dependencies`, `## PR contract`. Anything else is surfaced, never normalised away. 2. **Dependencies.** Every identifier under `## Dependencies` is a closed issue whose state reason is *completed* (a Done-type state on linear, never Canceled or Duplicate). One open or otherwise-closed dependency stops the run: name it and its state, and do not offer to proceed anyway — a bypass is a deliberate rough-in revision, not a runtime patch. A cross-axis identifier (a planner ID on github-issues, a bare `#N` on linear) is surfaced, never bridged. 3. **Idempotency.** No open PR carries this issue's close marker (either family) and no branch exists for it. If either does, stop and ask whether to continue there or start fresh. 4. **Break-glass.** A `` marker in the body or the operator's instructions waives the `pr-review-toolkit:review-pr` half of the floor for this run and is recorded on its `## Review gate` line, in the PR body and the hand-off. `/simplify` is never waived; nothing else waives anything. diff --git a/.claude/skills/rough-in/references/finish-procedure.md b/.claude/skills/rough-in/references/finish-procedure.md index ce096d0..aa19b92 100644 --- a/.claude/skills/rough-in/references/finish-procedure.md +++ b/.claude/skills/rough-in/references/finish-procedure.md @@ -30,7 +30,7 @@ The shape of a run: read and validate the issue (Steps 1–4), research it execu Verify: - The issue is **open**. If closed, stop and tell the user: *"Issue #$1 is already closed. If you want to re-execute it, either reopen the issue or run rough-in to create a new sub-sub-issue."* -- The title matches one of the cascade issue formats: the standard rough-in `[:F:R] `, or an intake-lane form — bug-lane `[:bug] ` or enhancement-lane `[:enh] ` (externally-sourced work shaped by `/intake` / `/enrich` that skips framing; see `.claude/rules/cbk-conventions.md` § Contribution intake). In every case `` is one of the workstream slugs locked in this project's blueprint (`docs/cbk/blueprint.md` § Workstreams). If the title matches none of these, stop and ask whether this is actually a cascade issue or a different kind that got routed here by mistake. +- The title matches one of the cascade issue formats: the standard rough-in `[:F:R] `, an intake-lane form — bug-lane `[:bug] ` or enhancement-lane `[:enh] ` (externally-sourced work shaped by `/intake` / `/enrich` that skips framing; see `.claude/rules/cbk-conventions.md` § Contribution intake) — or a roughed-in meta `[:meta] ` (a cascade / tooling gap that reaches the executor directly or through its `[::R<#>]` children; the contract's Step 1 names the same four forms). `` is one of the workstream slugs locked in this project's blueprint (`docs/cbk/blueprint.md` § Workstreams) — or, for a meta, the cascade / tooling lane's tag (§ Contribution intake). If the title matches none of these, stop and ask whether this is actually a cascade issue or a different kind that got routed here by mistake. - The labels include `cascade-depth:roughed-in`. If not, same question — confirm with the user before proceeding. **Break-glass check.** Note whether the issue body or the operator's instructions carry a `` marker (or an equivalent agreed marker). If present, Step 9's `pr-review-toolkit:review-pr` invocation is waived per `pr-review.md` § Break-glass override — the rest of Step 9 still runs, `/simplify` is never waived — and the skill's `## Review gate` line (Step 10) records the reason. The marker is read *here*, from a surface that exists at gate time — not from the PR body, which doesn't exist until Step 10. diff --git a/.claude/skills/rough-in/references/templates/rough-in-spec-template.md b/.claude/skills/rough-in/references/templates/rough-in-spec-template.md index 145f80a..4eb868a 100644 --- a/.claude/skills/rough-in/references/templates/rough-in-spec-template.md +++ b/.claude/skills/rough-in/references/templates/rough-in-spec-template.md @@ -253,7 +253,7 @@ with expected output, or a "the smoke test in CI passes" statement. ``