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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "make-no-mistakes",
"version": "1.4.1",
"description": "The disciplined dev lifecycle — implement issues, review PRs, sync releases, test E2E, manage sessions, and stash secrets via OS-native prompts. One plugin to make no mistakes.",
"version": "1.5.0",
"description": "The disciplined dev lifecycle — implement issues, review PRs, sync releases, test E2E, manage sessions, stash secrets, and enforce manifest-driven tool-call hooks. One plugin to make no mistakes.",
"author": {
"name": "Luis Andres Pena Castillo",
"url": "https://github.com/lapc506"
Expand Down
37 changes: 37 additions & 0 deletions .github/workflows/test-hooks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: Test hooks

on:
pull_request:
paths:
- 'hooks/**'
- 'scripts/build-rules.mjs'
- '.github/workflows/test-hooks.yml'
push:
branches: [main]
paths:
- 'hooks/**'
- 'scripts/build-rules.mjs'

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: '22'

- name: Install dependencies
run: npm ci

- name: Verify rules.json is in sync with rules.yaml
run: |
npm run build-rules
if ! git diff --exit-code hooks/rules/rules.json; then
echo "::error::hooks/rules/rules.json is stale. Run 'npm run build-rules' and commit the result."
exit 1
fi

- name: Run hook smoke tests
run: bash hooks/test-hooks.sh
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,7 @@ dist/
slack-config.json

# Local-only design history and IP-leak guard inputs.
# Each contributor / fork keeps their own copy here. See README.md.
# Each contributor / fork keeps their own copy here. See README.md and
# hooks/rules/README.md (the latter documents the IP-leak guard format —
# opt-in list of client/org names that must never appear in rules.yaml).
.private/
68 changes: 66 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,12 +217,76 @@ make-no-mistakes-toolkit/
├── agents/ # 2 specialized subagents
├── skills/ # 6 auto-activating skills
│ └── */SKILL.md
├── scripts/ # Shared bash utilities
├── hooks/ # Manifest-driven PreToolUse + PostToolUse hooks (v1.5.0+)
│ ├── hooks.json # Claude Code wiring (thin)
│ ├── pre-bash.sh # Bash dispatcher
│ ├── pre-edit.sh # Edit/Write/MultiEdit dispatcher
│ ├── post-slack.sh # Slack message dispatcher (warn-only)
│ ├── test-hooks.sh # Parametrized test runner
│ ├── lib/ # Generic helpers (parse-input, eval-rule)
│ └── rules/
│ ├── rules.yaml # Rules SSoT — humans edit
│ ├── rules.json # Build artifact — runtime reads
│ └── README.md # Contributor guide
├── scripts/ # Shared bash + node utilities
│ └── build-rules.mjs # rules.yaml -> rules.json compiler
├── package.json
└── README.md
```

**Design principle:** Commands for destructive/token-intensive actions (you decide when). Skills for read-only analysis (context-aware, auto-activate). Agents for heavy orchestration (own context window).
**Design principle:** Commands for destructive/token-intensive actions (you decide when). Skills for read-only analysis (context-aware, auto-activate). Agents for heavy orchestration (own context window). Hooks for deterministic guardrails on every tool call (no human in the loop).

## Hooks (v1.5.0+)

When this plugin is enabled, every tool call you make in any repo runs through
the manifest-driven hooks in `hooks/rules/rules.yaml`. The Tier 1 ruleset
ships 10 rules:

**PreToolUse on `Bash` (block by default):**
- `ssh-db-mutation` — blocks `gcloud compute ssh ... --command="...php -r/mysql/set_config..."` (forces use of versioned scripts)
- `prod-ops-no-approval` — blocks `--project=*-prod` operations without explicit acknowledgement
- `destructive-db-ops` — blocks `supabase db reset|push|repair` and inline `DROP/TRUNCATE/DELETE FROM`
- `manual-edge-fn-deploy` — blocks `supabase functions deploy` (forces CI-only deploys)
- `gcloud-missing-project` — warns when a `gcloud` subcommand is missing `--project=`

**PreToolUse on `Edit | Write | MultiEdit` (block):**
- `minified-build-output` — blocks writing minified content to `amd/build/*.min.js` or `dist/*.min.{js,css}`
- `secrets-hardcoded` — blocks hardcoded `password|secret|token|api_key|...` patterns in source files (env.example/test/spec/README/fixtures/mocks paths exempted)

**PostToolUse on Slack messages (warn-only):**
- `slack-unicode-bullets` — warns when `•◦▪▫` bullets are used (use `-` instead)
- `slack-tables-no-codeblock` — warns when markdown tables ship outside ` ``` ` fences (Slack mrkdwn doesn't render bare tables)
- `slack-spanish-tildes` — warns on common Spanish words missing tildes (`migracion` → `migración`, etc.)

### Bypassing a rule

Each blocking rule has a `bypass_marker`. Add the literal string
`// hook-bypass: <marker>` (or `# hook-bypass: <marker>`) anywhere in the
command or content to acknowledge the rule and proceed:

```bash
# Bypass markers shipped in Tier 1:
# // hook-bypass: ssh-db-rule
# // hook-bypass: prod-ops
# // hook-bypass: db-destructive
# // hook-bypass: edge-fn-manual
# // hook-bypass: minified-build
# // hook-bypass: secret-leak
```

Bypasses are explicit acknowledgements — they sit inside the command/content
itself, not as silent flags.

### Adding your own rules

Edit `hooks/rules/rules.yaml`, run `npm run build-rules`, run
`npm run test-hooks` to verify, and commit. See `hooks/rules/README.md` for
the schema and Tier 2 decomposition techniques.

### Disabling all hooks

Remove the plugin from `~/.claude/settings.json` `enabledPlugins`, or set
`CLAUDE_DISABLE_PLUGIN_HOOKS=1` in your shell.

## Bilingual Format

Expand Down
39 changes: 39 additions & 0 deletions hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{
"description": "make-no-mistakes — manifest-driven PreToolUse + PostToolUse hooks. Rules live in hooks/rules/rules.yaml (compiled to rules.json). See hooks/rules/README.md.",
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/pre-bash.sh",
"timeout": 5
}
]
},
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/pre-edit.sh",
"timeout": 5
}
]
}
],
"PostToolUse": [
{
"matcher": "mcp__claude_ai_Slack__slack_send_message|mcp__claude_ai_Slack__slack_send_message_draft",
"hooks": [
{
"type": "command",
"command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/post-slack.sh",
"timeout": 3
}
]
}
]
}
}
150 changes: 150 additions & 0 deletions hooks/lib/eval-rule.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
#!/usr/bin/env bash
# =============================================================================
# eval-rule.sh — Evaluate a single rule from rules.json against the current
# tool_input (read from stdin in JSON form).
#
# Usage:
# printf '%s' "$TOOL_INPUT_JSON" | bash eval-rule.sh <rule_id>
#
# Exit codes:
# 0 rule did not fire, OR rule fired with action=warn (warning to stderr)
# 2 rule fired with action=block
#
# Behavior:
# 1. Source parse-input.sh to extract INPUT_COMMAND/FILE_PATH/CONTENT/TEXT
# from the tool_input JSON on stdin.
# 2. Look up the rule by id in rules.json. Missing rule = exit 0 (no-op).
# 3. If the rule has a bypass_marker and "// hook-bypass: <marker>" or
# "# hook-bypass: <marker>" appears anywhere in the raw input, exit 0.
# 4. For each match condition (AND-chain): check pattern (if present) and
# not_pattern (if present) on the chosen field. If any condition fails,
# the rule does not fire — exit 0.
# 5. All conditions held → write the rule message to stderr and exit
# according to action.
#
# Requires: jq, grep (BRE/ERE — POSIX). No yaml parser at runtime.
# =============================================================================
set -uo pipefail

# Honor the documented kill switch — defense in depth. The dispatchers also
# check this, but eval-rule.sh may be invoked directly (e.g., from tests or
# future tooling) so we re-check here to guarantee the env var disables ALL
# rule evaluation regardless of entry point.
if [ "${CLAUDE_DISABLE_PLUGIN_HOOKS:-0}" = "1" ]; then
exit 0
fi

RULE_ID="${1:?Usage: $0 <rule_id>}"
HOOKS_DIR="$(cd "$(dirname "$0")/.." && pwd)"
RULES_JSON="$HOOKS_DIR/rules/rules.json"

if [ ! -f "$RULES_JSON" ]; then
# Missing manifest is a configuration error, not the user's fault. Surface it
# quietly so we don't block tool calls because of a build issue.
echo "make-no-mistakes: rules.json not found at $RULES_JSON" >&2
exit 0
fi

if ! command -v jq >/dev/null 2>&1; then
echo "make-no-mistakes: jq not installed; hooks disabled" >&2
exit 0
fi

# Source parse-input.sh from the same lib dir we live in.
LIB_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=/dev/null
source "$LIB_DIR/parse-input.sh"

# Resolve the rule. If not found, no-op.
RULE_JSON="$(jq --arg id "$RULE_ID" '.[] | select(.id == $id)' "$RULES_JSON")"
if [ -z "$RULE_JSON" ]; then
exit 0
fi

# Bypass check first — a single explicit acknowledgement short-circuits all
# pattern matching. We accept both "//" and "#" comment styles since rules
# apply to commands (shell, #) and code/content (slash-slash).
#
# `build-rules.mjs` enforces bypass_marker matches ^[a-z0-9-]+$, so the
# value is safe to interpolate directly into an ERE. We re-validate here as
# defense in depth — if the schema gate ever loosens, an attacker rule
# author could otherwise inject ERE special chars (., +, [, etc.) and
# either silently mismatch real bypass comments or trigger a grep error.
BYPASS_MARKER="$(printf '%s' "$RULE_JSON" | jq -r '.bypass_marker // empty')"
if [ -n "$BYPASS_MARKER" ]; then
case "$BYPASS_MARKER" in
*[!a-z0-9-]*)
echo "make-no-mistakes: rule ${RULE_ID} has invalid bypass_marker (must be kebab-case); ignoring bypass." >&2
;;
*)
if printf '%s' "$INPUT_RAW" | grep -qE "(//|#)[[:space:]]*hook-bypass:[[:space:]]*${BYPASS_MARKER}\b"; then
exit 0
fi
;;
esac
fi

# Iterate match conditions. ALL must hold for the rule to fire.
N_CONDITIONS="$(printf '%s' "$RULE_JSON" | jq '.match | length')"
i=0
while [ "$i" -lt "$N_CONDITIONS" ]; do
COND="$(printf '%s' "$RULE_JSON" | jq ".match[$i]")"
FIELD="$(printf '%s' "$COND" | jq -r '.field')"
PATTERN="$(printf '%s' "$COND" | jq -r '.pattern // empty')"
NOT_PATTERN="$(printf '%s' "$COND" | jq -r '.not_pattern // empty')"
FLAGS="$(printf '%s' "$COND" | jq -r '.flags // empty')"

# Resolve which input variable to inspect.
case "$FIELD" in
command) VALUE="$INPUT_COMMAND" ;;
file_path) VALUE="$INPUT_FILE_PATH" ;;
content) VALUE="$INPUT_CONTENT" ;;
text) VALUE="$INPUT_TEXT" ;;
*)
# Unknown field — treat as condition failure (rule won't fire).
exit 0
;;
esac

# Build grep flags. -E always (we author rules in ERE). -q for silent.
GREP_OPTS="-Eq"
case "$FLAGS" in
*i*) GREP_OPTS="-Eqi" ;;
esac

# Check positive pattern: if specified, value must match.
if [ -n "$PATTERN" ]; then
if ! printf '%s' "$VALUE" | grep $GREP_OPTS -- "$PATTERN"; then
exit 0
fi
fi

# Check negative pattern: if specified, value must NOT match.
if [ -n "$NOT_PATTERN" ]; then
if printf '%s' "$VALUE" | grep $GREP_OPTS -- "$NOT_PATTERN"; then
exit 0
fi
fi

i=$((i + 1))
done

# All conditions held — rule fires.
ACTION="$(printf '%s' "$RULE_JSON" | jq -r '.action')"
MESSAGE="$(printf '%s' "$RULE_JSON" | jq -r '.message')"

# Header makes it easy to grep stderr for which rule fired. We deliberately
# do NOT print memory_ref — that field references private personal-memory
# filenames (~/.claude/.../memory/feedback_*.md) that are meaningless to other
# users of this public toolkit. The rule_id is the canonical cross-link.
{
echo ""
echo "[make-no-mistakes:${RULE_ID}] action=${ACTION}"
printf '%s' "$MESSAGE"
} >&2

case "$ACTION" in
block) exit 2 ;;
warn) exit 0 ;;
*) exit 0 ;;
esac
44 changes: 44 additions & 0 deletions hooks/lib/parse-input.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# =============================================================================
# parse-input.sh — Read tool_input JSON from stdin and export field vars.
#
# Used by hooks/{pre-bash,pre-edit,post-slack}.sh dispatchers and the rule
# evaluator. Extracts the four fields rules can target:
#
# INPUT_RAW — the full JSON payload (lazily kept for bypass scan)
# INPUT_COMMAND — tool_input.command (Bash)
# INPUT_FILE_PATH — tool_input.file_path (Edit, Write, MultiEdit)
# INPUT_CONTENT — tool_input.content
# OR tool_input.new_string (Edit/MultiEdit fall back)
# INPUT_TEXT — tool_input.text (Slack)
# OR tool_input.message (Slack draft)
# OR tool_input.content (Slack canvas)
#
# Designed to be sourced, not executed:
# source "${HOOKS_LIB:-$(dirname "$0")/lib}/parse-input.sh"
#
# Requires `jq`. If jq is missing, fields are left empty rather than failing
# the hook — a missing parser shouldn't block the user.
# =============================================================================

# Read full stdin once. Hooks read stdin exactly once per invocation, and we
# need the JSON available for both field extraction and bypass-marker scans.
INPUT_RAW="$(cat)"

INPUT_COMMAND=""
INPUT_FILE_PATH=""
INPUT_CONTENT=""
INPUT_TEXT=""

if command -v jq >/dev/null 2>&1; then
INPUT_COMMAND="$(printf '%s' "$INPUT_RAW" \
| jq -r '.tool_input.command // empty' 2>/dev/null || true)"
INPUT_FILE_PATH="$(printf '%s' "$INPUT_RAW" \
| jq -r '.tool_input.file_path // .tool_input.path // empty' 2>/dev/null || true)"
INPUT_CONTENT="$(printf '%s' "$INPUT_RAW" \
| jq -r '.tool_input.content // .tool_input.new_string // empty' 2>/dev/null || true)"
INPUT_TEXT="$(printf '%s' "$INPUT_RAW" \
| jq -r '.tool_input.text // .tool_input.message // .tool_input.content // empty' 2>/dev/null || true)"
fi

export INPUT_RAW INPUT_COMMAND INPUT_FILE_PATH INPUT_CONTENT INPUT_TEXT
Loading
Loading