From 6cacf0baf4105259a976f47d47bb002a50881ac0 Mon Sep 17 00:00:00 2001 From: MCKRUZ Date: Fri, 31 Jul 2026 14:40:55 -0400 Subject: [PATCH] docs: the nudge-hook pattern, with a worked example, and the approver carve-out cross-reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #7. Closes #33. #7 — the kit documented only how a hook *refuses*. Adds the advisory variant: a PreToolUse hook that returns no permissionDecision, emits additionalContext, and lets the call through. The rule it settles is gate-vs-nudge — gate when the violation is mechanically provable, nudge when the judgement belongs to the agent or a wrong block would cost more than a missed reminder. Two corrections to the issue as filed. The MAH nudges it harvests fire on PreToolUse, not PostToolUse, and the difference is load-bearing: a reminder that arrives after the edit is too late to change it. And the contract itself is not in the reference — the docs describe additionalContext but never the omit-the-decision case — so it was probed rather than inferred, and the README says so. Ships sensitive-edit-nudge.{ps1,sh} as the worked example: fires on auth/identity/ security/migrations/infra paths, reminds the agent the change is risk:high. Installed but deliberately unregistered — the mechanism is the kit's, the policy is the team's. Message is ASCII-only and the JSON is [ordered] because the two twins must emit identical bytes, and PowerShell mangled an em dash and shuffled hashtable keys. #33 — anti-patterns.md stated the sole-approver rule as unconditional, the one place it appeared without pointing at the single-maintainer carve-out. Both statements were true in their own scope, which is exactly why a reader could not tell which governed. Verified: hook contract probed live; both twins across nine payloads (sensitive paths, Windows separators, near-miss directories, malformed JSON, empty stdin) emit identical bytes and fail open; the shipped script fired end-to-end on a gated path and stayed silent on an ordinary one; check_standard.py clean; 37 tests pass. Self-approved under the solo-maintainer carve-out in docs/team.md: internal repo, no second maintainer, mechanical ladder green, and named here rather than taken silently. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014aesqvsMqJpzEdpDXDTjvF --- GOLD-STANDARD.html | 4 +- GOLD-STANDARD.md | 4 +- PROGRESS.md | 10 ++-- docs/anti-patterns.html | 2 +- docs/anti-patterns.md | 3 +- kit/README.md | 2 +- kit/hooks/README.md | 75 ++++++++++++++++++++++++++++++ kit/hooks/sensitive-edit-nudge.ps1 | 50 ++++++++++++++++++++ kit/hooks/sensitive-edit-nudge.sh | 35 ++++++++++++++ 9 files changed, 176 insertions(+), 9 deletions(-) create mode 100644 kit/hooks/sensitive-edit-nudge.ps1 create mode 100755 kit/hooks/sensitive-edit-nudge.sh diff --git a/GOLD-STANDARD.html b/GOLD-STANDARD.html index 4096843..25dd7de 100644 --- a/GOLD-STANDARD.html +++ b/GOLD-STANDARD.html @@ -1220,7 +1220,8 @@

6. The harness standard

│ ├── agents/ # planner, architect, grader, security-reviewer, │ │ # build-error-resolver, debugger (+ ux-reviewer via a frontend pack) │ └── hooks/ # stop-gate, review-gate, save-review-receipt (.ps1 + .sh each); -│ # stop-gate blocks finishing on a red build/tests +│ # stop-gate blocks finishing on a red build/tests; +│ # sensitive-edit-nudge = advisory example, installed unregistered ├── .github/ │ ├── RAILS.md # operator's guide + shakedown drills │ ├── CODEOWNERS @@ -1515,6 +1516,7 @@

10. The kit (what's in this repo)

│ ├── agents/ # planner, architect, grader, security-reviewer, │ │ # build-error-resolver, debugger │ ├── hooks/ # stop-gate, review-gate, save-review-receipt (.ps1 + .sh each) +│ │ # + sensitive-edit-nudge (advisory example, unregistered) │ ├── workflows/ # ci.yml, grader.yml, correctness.yml, security.yml, deploy-dev.yml │ │ # (+ eval-regression.yml, eval-suite.yml for agentic specs — §11) │ ├── packs/ # composable additions: stacks/dotnet, cicd/github, cicd/azure-devops, diff --git a/GOLD-STANDARD.md b/GOLD-STANDARD.md index 0931a52..dd80c43 100644 --- a/GOLD-STANDARD.md +++ b/GOLD-STANDARD.md @@ -370,7 +370,8 @@ client-repo/ │ ├── agents/ # planner, architect, grader, security-reviewer, │ │ # build-error-resolver, debugger (+ ux-reviewer via a frontend pack) │ └── hooks/ # stop-gate, review-gate, save-review-receipt (.ps1 + .sh each); -│ # stop-gate blocks finishing on a red build/tests +│ # stop-gate blocks finishing on a red build/tests; +│ # sensitive-edit-nudge = advisory example, installed unregistered ├── .github/ │ ├── RAILS.md # operator's guide + shakedown drills │ ├── CODEOWNERS @@ -547,6 +548,7 @@ intent-driven-development/ # cloned locally as delivery-standard/ on some ma │ ├── agents/ # planner, architect, grader, security-reviewer, │ │ # build-error-resolver, debugger │ ├── hooks/ # stop-gate, review-gate, save-review-receipt (.ps1 + .sh each) +│ │ # + sensitive-edit-nudge (advisory example, unregistered) │ ├── workflows/ # ci.yml, grader.yml, correctness.yml, security.yml, deploy-dev.yml │ │ # (+ eval-regression.yml, eval-suite.yml for agentic specs — §11) │ ├── packs/ # composable additions: stacks/dotnet, cicd/github, cicd/azure-devops, diff --git a/PROGRESS.md b/PROGRESS.md index 7e59e58..ddfb073 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -362,12 +362,14 @@ arc finished: history (see the plugin STATUS block above). Open engineering items are tracked as GitHub issues on `MCKRUZ/intent-driven-development` -(open as of 2026-07-16): +(open as of 2026-07-31): -- **#4** — Angular frontend pack (angular-aware ux-reviewer) - **#5** — harvest remaining approved MAH items into the kit - **#6** — bring MAH onto the kit (agents first, or full installer run) -- **#7** — nudge-hook pattern paragraph in `kit/hooks/README.md` -- **#8** — live shakedown of the committed MCP server set on first real `/sdlc-setup` +- **#11** — deploy promotion + rollback workflows, and a dependency-update axis +- **#12** — fleet observability: gate outcomes visible across installed repos + +Closed since the last revision of this list: #4 (Angular frontend pack), #8 (MCP server +shakedown), #7 (nudge-hook pattern) and #33 (the sole-approver cross-reference). Matt works through this incrementally, reviewing rendered HTML between steps. diff --git a/docs/anti-patterns.html b/docs/anti-patterns.html index 4cb2ec3..7abc0ce 100644 --- a/docs/anti-patterns.html +++ b/docs/anti-patterns.html @@ -154,7 +154,7 @@

2. Authors grading themselves

  • Symptom: The "review" is the same agent (or the person who drove it) confirming its own work. Approvals are instant, nothing ever bounces back, and the grader and the author are effectively the same.
  • Root cause: You can't trust the thing that wrote the code to vouch for it, and a self-review quietly violates that.
  • Fix: The grader is a fresh agent that did not write the code; the human Checker is never the change's author. On HIGH risk, a security-reviewer pass precedes a named human sign-off.
  • -
  • Prevent: The author is never the sole approver — a hard rule at every team size. The Orchestrator/Checker swap makes it real on a small pod; subagents keep the grader separate by construction.
  • +
  • Prevent: The author is never the sole approver — a hard rule at every team size (the one carve-out, for single-maintainer internal repos, is written down in the team). The Orchestrator/Checker swap makes it real on a small pod; subagents keep the grader separate by construction.
  • diff --git a/docs/anti-patterns.md b/docs/anti-patterns.md index 55fc1ad..c1ed2be 100644 --- a/docs/anti-patterns.md +++ b/docs/anti-patterns.md @@ -36,7 +36,8 @@ Checking theater: letting the thing that wrote the code also approve it. quietly violates that. - **Fix:** The grader is a **fresh agent that did not write the code**; the human Checker is never the change's author. On HIGH risk, a security-reviewer pass precedes a named human sign-off. -- **Prevent:** **The author is never the sole approver** — a hard rule at every team size. The +- **Prevent:** **The author is never the sole approver** — a hard rule at every team size (the one + carve-out, for single-maintainer internal repos, is written down in [the team](team.md)). The Orchestrator/Checker swap makes it real on a small pod; subagents keep the grader separate by construction. diff --git a/kit/README.md b/kit/README.md index be4671b..30094ca 100644 --- a/kit/README.md +++ b/kit/README.md @@ -54,7 +54,7 @@ so install here unless you also repoint the references. | `mcp.json` | `./.mcp.json` | Team MCP servers (context7, sequential-thinking, playwright); packs merge additions (dotnet → microsoft-learn, github → github, azure-devops → azure-devops). npx-launched servers are version-pinned; the HTTP-hosted ones (context7, microsoft-learn, github) run server-side and cannot be pinned. No secrets — auth is always per-developer. Each developer approves the set once on first open. | | `HARNESS.md` | `./docs/harness.md` | The developer-facing tour: what each installed piece does and why, per layer. Point new team members here first. | | `ONBOARDING.md` | `./ONBOARDING.md` | The day-1 checklist: tools, auth, MCP approval, executable bits, repo secrets, unfilled setup tokens. Repo root, because that is where a new developer looks. `/sdlc-doctor` checks everything on it and prints the fix. | -| `hooks/*` | `./.claude/hooks/` | `stop-gate`, `review-gate`, `save-review-receipt` (`.ps1` + `.sh`). | +| `hooks/*` | `./.claude/hooks/` | `stop-gate`, `review-gate`, `save-review-receipt` (`.ps1` + `.sh`), plus `sensitive-edit-nudge` — an advisory (non-blocking) example, installed but not registered. | | `agents/*` | `./.claude/agents/` | `planner`, `architect`, `grader`, `security-reviewer`, `build-error-resolver`, `debugger` — model-tiered; see `agents/README.md`. | | `skills/*` | `./.claude/skills/` | `spec-writer`, `test-writer`, `api-pattern`, `pr-writer`, `eval-builder`, `diagnose`. | | `workflows/{ci,grader,correctness,security,deploy-dev,eval-regression,eval-suite}.yml` | `./.github/workflows/` | The five rails + the two eval workflows. | diff --git a/kit/hooks/README.md b/kit/hooks/README.md index b0b170f..c4f028c 100644 --- a/kit/hooks/README.md +++ b/kit/hooks/README.md @@ -17,6 +17,8 @@ Copy into the target repo so the layout is: /.claude/hooks/review-gate.sh /.claude/hooks/save-review-receipt.ps1 /.claude/hooks/save-review-receipt.sh +/.claude/hooks/sensitive-edit-nudge.ps1 <- example nudge: installed, not registered +/.claude/hooks/sensitive-edit-nudge.sh ``` Add this to the repo `.gitignore` (receipts are per-clone evidence, never committed): @@ -37,6 +39,7 @@ evidence exists." | `stop-gate.{ps1,sh}` | `Stop` | The build **and tests** are green before a turn ends (tests opt-out: `RAILS_STOP_RUN_TESTS=0`). | | `review-gate.{ps1,sh}` | `PreToolUse` (Bash) | `git push` / `gh pr create` is blocked until per-commit review receipts exist. | | `save-review-receipt.{ps1,sh}` | (manual) | Writes a commit-bound receipt the review gate looks for. | +| `sensitive-edit-nudge.{ps1,sh}` | `PreToolUse` (Edit/Write) | **Nothing.** It advises rather than refuses — a worked example of the nudge pattern below. Ships **unregistered**. | ## Hook contracts (verified — do not "improve" these) @@ -52,8 +55,76 @@ These are subtle and easy to get wrong. They are the load-bearing part of the ki - **PreToolUse block:** write `{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"..."}}` to stdout, then `exit 0`. (`hookSpecificOutput` *is* valid here — the opposite of Stop.) +- **PreToolUse advisory (a *nudge*):** emit + `{"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":"..."}}` with **no + `permissionDecision` field at all**, then `exit 0`. The call proceeds through the normal + permission flow and the string is injected as a system reminder next to the tool result — Claude + reads it, the user never sees a transcript entry. (Verified by probe, 2026-07-31: the tool ran + *and* the reminder arrived. The reference documents the field but not the omit-the-decision case, + so it was tested rather than inferred.) `additionalContext` is **ignored** when + `permissionDecision` is `"defer"`. `PostToolUse` accepts the same field if you want the reminder + *after* the fact instead. - **Allow (either event):** emit nothing and `exit 0`. +## Advisory nudges (the non-blocking variant) + +Every other hook in this directory **refuses**. A nudge doesn't: it fires on the same +events, returns no decision, and drops a sentence into the agent's context at the moment +the action is about to happen. + +The argument for it is the one already made above — a written instruction is the class of +thing an agent forgets — but the conclusion is different. Not every rule earns a hard +stop. A gate has to be *certain*, because a false positive wedges the session and teaches +the team to reach for the bypass. Plenty of real guidance can't meet that bar: the +condition isn't mechanically decidable, or the rule is right most of the time rather than +always. Without a middle option those rules go in `CLAUDE.md` and get forgotten. A nudge +delivers them at the point of use instead — a sticky note that appears when the agent +reaches for the thing. + +**Gate when** the rule is absolute *and* the violation is mechanically provable: the build +is red, the receipt is missing. **Nudge when** the judgement belongs to the agent, or when +a wrong block would cost more than a missed reminder. + +Two shapes, and the choice is just *who filters*: + +- **Hook-filtered** — the script decides. `sensitive-edit-nudge` reads the target path and + stays silent unless it matches a security-sensitive tree. Use this whenever the trigger + is something a script can actually evaluate. +- **Model-filtered** — the hook fires every time and the *message* carries the condition + ("…skip this if the edit is docs or comments"). Use it when the hook can't tell — it + cannot know whether an edit touches a real symbol — and hand that judgement to the + agent rather than guessing at it. + +**A nudge is not free.** Every fire spends context, on every matching tool call, for the +rest of the session. Keep the message to a sentence or two, and push the condition into +the hook whenever the hook can express it — a chatty unconditional nudge is a tax on the +whole session and gets tuned out exactly like a noisy alert. + +### The shipped example + +`sensitive-edit-nudge.{ps1,sh}` fires when the agent is about to edit an auth, identity, +security, migrations, or `infra/` path, and reminds it that the change is `risk:high` and +owes a security-reviewer pass before the named human sign-off. It is **installed but not +registered** — a nudge encodes a specific team's rule, so the kit ships the mechanism and +leaves the policy to you. Retune `RAILS_NUDGE_PATH_REGEX` / `RAILS_NUDGE_MESSAGE`, or copy +the script as a starting point, then register it: + +```json +"PreToolUse": [ + { + "matcher": "Edit|Write|MultiEdit", + "hooks": [ + { "type": "command", "command": "pwsh", + "args": ["-NoProfile", "-ExecutionPolicy", "Bypass", + "-File", "${CLAUDE_PROJECT_DIR}/.claude/hooks/sensitive-edit-nudge.ps1"] } + ] + } +] +``` + +It fails open like everything else here: unparseable payload, missing `jq`, no +`file_path` — it stays silent and exits 0 rather than interrupting the turn. + ## Permissions model (settings.json) Claude Code evaluates permissions in the order **deny → ask → allow**, and **`deny` @@ -108,6 +179,8 @@ All optional. Defaults assume a .NET solution under `src/`. | `RAILS_REVIEW_SRC_REGEX` | review-gate | `^src/.*\.(cs\|csproj\|…\|ts\|html)$` | Which changed paths count as gated source. | | `RAILS_REVIEW_KINDS` | review-gate, save-review-receipt | `code-review,simplify` | Which reviews must have receipts. | | `RAILS_SKIP_REVIEW_GATE` | review-gate | unset | `1` = documented, auditable emergency bypass. | +| `RAILS_NUDGE_PATH_REGEX` | sensitive-edit-nudge | `Auth`/`Identity`/`Security`/`SecurityAttributes`/`Migrations` segments, plus `infra/` | Which edited paths trigger the nudge. Matched against the path with `\` normalized to `/`. | +| `RAILS_NUDGE_MESSAGE` | sensitive-edit-nudge | the security sign-off reminder | The reminder text injected into the agent's context. | `CLAUDE_PROJECT_DIR` is set by Claude Code and used to locate the repo root; scripts fall back to the current directory if it is absent. @@ -145,6 +218,8 @@ shell features, or with the `.sh` twins as shown above. | `stop-gate` scripts | **Stable** | Logic depends only on git + dotnet. | | `review-gate` scripts | **Stable machinery, policy-coupled** | Assumes the review workflow is `/code-review` + `/simplify` and that receipts come from `save-review-receipt`. Retune `RAILS_REVIEW_KINDS` for a different workflow. | | `save-review-receipt` scripts | **Stable** | Binds + timestamps a receipt; it cannot judge review quality — that is on the reviewer. | +| PreToolUse advisory contract | **Stable** | Probe-verified; the omit-the-decision case is not spelled out in the reference, so re-check it if hook behavior ever surprises you. | +| `sensitive-edit-nudge` scripts | **Template** | The mechanism is stable; the regex and the message are one team's policy and are meant to be replaced. Not registered by default. | | Permission globs | **Template** | The deny/ask/allow *shape* is stable; the specific paths are placeholders to confirm per repo. | ## Coverage boundary (read this) diff --git a/kit/hooks/sensitive-edit-nudge.ps1 b/kit/hooks/sensitive-edit-nudge.ps1 new file mode 100644 index 0000000..26065e5 --- /dev/null +++ b/kit/hooks/sensitive-edit-nudge.ps1 @@ -0,0 +1,50 @@ +#Requires -Version 7 +<# + Advisory PreToolUse nudge — the non-blocking counterpart to the gates in this directory. + + Fires when the agent is about to edit a security-sensitive path and reminds it what the + standard requires there. It never blocks: it emits `additionalContext` with no + `permissionDecision`, so the tool call proceeds through the normal permission flow and the + string arrives as a system reminder next to the tool result. + + This one is shipped as a worked example and is deliberately NOT registered in settings.json. + Copy it, retune the regex and the message for your repo, then register it (see README.md, + "Advisory nudges"). +#> + +$ErrorActionPreference = 'Stop' + +# Paths the standard treats as security-sensitive. Same trees settings.json gates with `ask`. +$defaultPattern = '(^|/)(Auth|Identity|Security|SecurityAttributes|Migrations)(/|$)|(^|/)infra/' +$pattern = if ($env:RAILS_NUDGE_PATH_REGEX) { $env:RAILS_NUDGE_PATH_REGEX } else { $defaultPattern } + +# ASCII only, deliberately: this file is copied into client repos and must emit the same bytes +# under both runtimes. A non-ASCII literal here decodes differently depending on how the host +# reads the script, and the bash twin would stop being faithful. +$defaultMessage = 'Delivery-standard nudge: this path is security-sensitive. Treat the change as risk:high, and run the security-reviewer agent over it before you finish: the security pass precedes the named human sign-off, and `security-review` is a required merge check. Disregard if this edit is only comments, docs, or test fixtures.' +$message = if ($env:RAILS_NUDGE_MESSAGE) { $env:RAILS_NUDGE_MESSAGE } else { $defaultMessage } + +try { + $raw = [Console]::In.ReadToEnd() + if ([string]::IsNullOrWhiteSpace($raw)) { exit 0 } + + $path = ($raw | ConvertFrom-Json).tool_input.file_path + if ([string]::IsNullOrWhiteSpace($path)) { exit 0 } + + if (($path -replace '\\', '/') -notmatch $pattern) { exit 0 } + + # [ordered] matters: a plain hashtable emits keys in an arbitrary order, so the twins would + # disagree byte-for-byte from run to run and no test could pin either one down. + [ordered]@{ + hookSpecificOutput = [ordered]@{ + hookEventName = 'PreToolUse' + additionalContext = $message + } + } | ConvertTo-Json -Compress -Depth 5 +} +catch { + # Fail open, per this kit's rule: a hook that cannot compute an answer stays silent + # rather than wedging the session. A missed nudge costs a reminder; a wedged hook costs the turn. +} + +exit 0 diff --git a/kit/hooks/sensitive-edit-nudge.sh b/kit/hooks/sensitive-edit-nudge.sh new file mode 100755 index 0000000..9004080 --- /dev/null +++ b/kit/hooks/sensitive-edit-nudge.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Faithful bash twin of sensitive-edit-nudge.ps1 — identical logic and contract. +# +# Advisory PreToolUse nudge: reminds the agent what the standard requires when it is about to +# edit a security-sensitive path. Never blocks — emits `additionalContext` with no +# `permissionDecision`, so the tool call proceeds and the string arrives as a system reminder. +# +# Shipped as a worked example, deliberately NOT registered in settings.json. See README.md, +# "Advisory nudges". + +set -uo pipefail + +# Paths the standard treats as security-sensitive. Same trees settings.json gates with `ask`. +DEFAULT_PATTERN='(^|/)(Auth|Identity|Security|SecurityAttributes|Migrations)(/|$)|(^|/)infra/' +PATTERN="${RAILS_NUDGE_PATH_REGEX:-$DEFAULT_PATTERN}" + +# ASCII only, deliberately: must match the .ps1 twin byte-for-byte under both runtimes. +DEFAULT_MESSAGE='Delivery-standard nudge: this path is security-sensitive. Treat the change as risk:high, and run the security-reviewer agent over it before you finish: the security pass precedes the named human sign-off, and `security-review` is a required merge check. Disregard if this edit is only comments, docs, or test fixtures.' +MESSAGE="${RAILS_NUDGE_MESSAGE:-$DEFAULT_MESSAGE}" + +# Fail open when the tooling isn't there, same as the gates. +command -v jq >/dev/null 2>&1 || exit 0 + +payload="$(cat)" +[ -n "$payload" ] || exit 0 + +path="$(printf '%s' "$payload" | jq -r '.tool_input.file_path // empty' 2>/dev/null)" || exit 0 +[ -n "$path" ] || exit 0 + +printf '%s' "${path//\\//}" | grep -qE "$PATTERN" || exit 0 + +jq -nc --arg msg "$MESSAGE" \ + '{hookSpecificOutput:{hookEventName:"PreToolUse",additionalContext:$msg}}' + +exit 0