From 940350d5fa0a944fcb0a19825c6d8c16242a4e38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Pe=C3=B1a?= Date: Sun, 10 May 2026 00:03:13 -0600 Subject: [PATCH 1/2] feat(rules): add 5 anti-foot-shoot block rules (Tier 1) Adds a new rule family - Anti foot-shoot (Tier 1) - covering the high-cost mistakes the team has already paid for. Each rule maps 1:1 to a feedback memory whose intent is "never do this", and ships a bypass marker for the rare cases where the action is intentional. The 5 new rules: | Rule | Action | Tool | Memory | |---|---|---|---| | block-playwright-headless | block | Bash | feedback_no_headless.md | | block-git-force-push-no-lease | block | Bash | feedback_resolve_merge_conflicts.md | | block-git-rebase-skip | block | Bash | feedback_resolve_merge_conflicts.md | | block-standup-not-in-escritorio | block | Edit/Write/MultiEdit | feedback_standup_desktop.md | | block-goodnight-not-in-escritorio | block | Edit/Write/MultiEdit | feedback_goodnight_desktop.md | Coverage notes: - block-playwright-headless: matches `playwright test` (npx, pnpm, yarn, bare) and requires --headed / --ui / --debug on the same line. - block-git-force-push-no-lease: positive match on `-f` or `--force` plus defense-in-depth negative match on `--force-with-lease` for pathological orderings like `git push -f --force-with-lease`. - block-git-rebase-skip: matches `git rebase --skip` exactly; --continue and --abort are unaffected. - block-{standup,goodnight}-not-in-escritorio: pattern + not_pattern on the same file_path field. Both `~/Escritorio` (Spanish locale) and `~/Desktop` (English / aliasing) are accepted. Tests: 99 -> 128 passing (+29 new). Each rule covers positive matches, negative matches, and bypass behavior. Version bump: 1.8.0 -> 1.9.0 in package.json, plugin.json, and marketplace.json (top-level + nested plugin entry). --- .claude-plugin/marketplace.json | 4 +- .claude-plugin/plugin.json | 2 +- hooks/rules/README.md | 15 + hooks/rules/rules.json | 382 ++++++++++++++++++ hooks/rules/rules.yaml | 312 ++++++++++++++ .../design.md | 63 +++ .../proposal.md | 42 ++ .../tasks.md | 11 + package.json | 2 +- 9 files changed, 829 insertions(+), 4 deletions(-) create mode 100644 openspec/changes/2026-05-hooks-tier-1-foot-shoot-prevention/design.md create mode 100644 openspec/changes/2026-05-hooks-tier-1-foot-shoot-prevention/proposal.md create mode 100644 openspec/changes/2026-05-hooks-tier-1-foot-shoot-prevention/tasks.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index fe6be04..819df21 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,7 +1,7 @@ { "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", "name": "make-no-mistakes", - "version": "1.8.0", + "version": "1.9.0", "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.", "owner": { "name": "Luis Andres Pena Castillo", @@ -11,7 +11,7 @@ { "name": "make-no-mistakes", "description": "Dev lifecycle orchestrator: disciplined Linear issue execution with worktree isolation, PR review with Greptile gating, team release sync, E2E test generation and execution, test suite previewer, security pentesting, MoSCoW + RICE prioritization, cross-platform secret stash via OS-native GUI prompts (zenity / kdialog / osascript / Get-Credential), and session management. 18 commands, 6 auto-activating skills, 2 specialized agents.", - "version": "1.8.0", + "version": "1.9.0", "author": { "name": "Luis Andres Pena Castillo", "email": "lapc506@users.noreply.github.com" diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 364094a..da774ab 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "make-no-mistakes", - "version": "1.8.0", + "version": "1.9.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", diff --git a/hooks/rules/README.md b/hooks/rules/README.md index 9a1901e..0c0b29f 100644 --- a/hooks/rules/README.md +++ b/hooks/rules/README.md @@ -161,6 +161,21 @@ Adding a new family is fine — just keep ids unique and follow the schema. migrations directory when migrations failed to auto-run after a teammate's PR merged. +- **Anti foot-shoot (Tier 1)** (`block-playwright-headless`, + `block-git-force-push-no-lease`, `block-git-rebase-skip`, + `block-standup-not-in-escritorio`, `block-goodnight-not-in-escritorio`) + — hard stops on the high-cost mistakes that already burned the team: + headless E2E runs (a Playwright invocation without `--headed` / + `--ui` / `--debug` is blocked per `feedback_no_headless.md`), bare + `git push --force` / `-f` without `--force-with-lease` (silent + collaborator-push overwrites, per `feedback_resolve_merge_conflicts.md`), + `git rebase --skip` (silent commit drop, same memory), + and writes of `daily-standup*.md` / `next-day-*.md` / `goodnight-*.md` + outside `~/Escritorio` (per the desktop-handoff memories + `feedback_standup_desktop.md` and `feedback_goodnight_desktop.md`). + Each rule ships a kebab-case bypass marker for the rare cases where + the action is intentional and documented. + ## Tier 2 — decomposing non-deterministic memories Many narrative-style guidelines can be converted to deterministic rules diff --git a/hooks/rules/rules.json b/hooks/rules/rules.json index 221762e..8a23bfe 100644 --- a/hooks/rules/rules.json +++ b/hooks/rules/rules.json @@ -1376,5 +1376,387 @@ "expected_exit": 0 } ] + }, + { + "id": "block-playwright-headless", + "description": "Block `playwright test` invocations that omit --headed / --ui / --debug (memory feedback_no_headless.md — browser must be visible during E2E)", + "applies_to": [ + "Bash" + ], + "match": [ + { + "field": "command", + "pattern": "\\bplaywright[[:space:]]+test\\b" + }, + { + "field": "command", + "not_pattern": "(--headed|--ui|--debug)" + } + ], + "action": "block", + "bypass_marker": "playwright-headless-allowed", + "memory_ref": "feedback_no_headless.md", + "message": "BLOCKED: `playwright test` without --headed / --ui / --debug.\n\nPer feedback_no_headless.md the browser must be visible during E2E:\nheadless runs hide visual regressions and UI race conditions, and\nthe in-focus requirement only holds when a window is up.\n\nUse one of:\n - --headed browser visible\n - --ui Playwright UI mode (interactive)\n - --debug step-through debugger\n\nBypass marker (playwright-headless-allowed) is reserved for\nexplicitly-documented CI runs (e.g. a Vercel preview workflow).\n", + "tests": [ + { + "name": "blocks-playwright-test-bare", + "input": { + "tool_input": { + "command": "npx playwright test" + } + }, + "expected_exit": 2 + }, + { + "name": "blocks-playwright-test-config", + "input": { + "tool_input": { + "command": "npx playwright test --config=playwright.config.ts" + } + }, + "expected_exit": 2 + }, + { + "name": "allows-playwright-headed", + "input": { + "tool_input": { + "command": "npx playwright test --headed" + } + }, + "expected_exit": 0 + }, + { + "name": "allows-playwright-ui", + "input": { + "tool_input": { + "command": "npx playwright test --ui" + } + }, + "expected_exit": 0 + }, + { + "name": "allows-playwright-debug", + "input": { + "tool_input": { + "command": "npx playwright test --debug" + } + }, + "expected_exit": 0 + }, + { + "name": "allows-non-playwright-test", + "input": { + "tool_input": { + "command": "npm test" + } + }, + "expected_exit": 0 + }, + { + "name": "allows-bypass-marker", + "input": { + "tool_input": { + "command": "npx playwright test # hook-bypass: playwright-headless-allowed" + } + }, + "expected_exit": 0 + } + ] + }, + { + "id": "block-git-force-push-no-lease", + "description": "Block `git push --force` / `-f` without `--force-with-lease` (overwrites collaborator pushes silently)", + "applies_to": [ + "Bash" + ], + "match": [ + { + "field": "command", + "pattern": "git[[:space:]]+push.*(-f\\b|--force([[:space:]]|$))" + }, + { + "field": "command", + "not_pattern": "--force-with-lease" + } + ], + "action": "block", + "bypass_marker": "git-force-push-bare-allowed", + "memory_ref": "feedback_resolve_merge_conflicts.md", + "message": "BLOCKED: `git push --force` without --force-with-lease.\n\nBare --force overwrites the remote without verifying that what is\nthere is what you last fetched — it silently destroys collaborator\npushes. --force-with-lease aborts when the remote has advanced since\nyour last fetch, which is the safety net you want.\n\nUse:\n git push --force-with-lease origin \n\nBypass marker (git-force-push-bare-allowed) ONLY when you have\nverified that no other contributor has pushed to the branch.\n", + "tests": [ + { + "name": "blocks-force-short-flag", + "input": { + "tool_input": { + "command": "git push -f origin feature-x" + } + }, + "expected_exit": 2 + }, + { + "name": "blocks-force-long-flag", + "input": { + "tool_input": { + "command": "git push --force origin feature-x" + } + }, + "expected_exit": 2 + }, + { + "name": "allows-force-with-lease", + "input": { + "tool_input": { + "command": "git push --force-with-lease origin feature-x" + } + }, + "expected_exit": 0 + }, + { + "name": "allows-no-force-push", + "input": { + "tool_input": { + "command": "git push origin feature-x" + } + }, + "expected_exit": 0 + }, + { + "name": "allows-bypass-marker", + "input": { + "tool_input": { + "command": "git push --force origin feature-x # hook-bypass: git-force-push-bare-allowed" + } + }, + "expected_exit": 0 + } + ] + }, + { + "id": "block-git-rebase-skip", + "description": "Block `git rebase --skip` (drops the conflicting commit silently)", + "applies_to": [ + "Bash" + ], + "match": [ + { + "field": "command", + "pattern": "git[[:space:]]+rebase[[:space:]]+--skip\\b" + } + ], + "action": "block", + "bypass_marker": "git-rebase-skip-acknowledged", + "memory_ref": "feedback_resolve_merge_conflicts.md", + "message": "BLOCKED: `git rebase --skip` drops the conflicting commit silently.\n\nPer feedback_resolve_merge_conflicts.md: never skip; resolve each\nconflict one by one, verify the result, and `git rebase --continue`.\n\nCorrect moves:\n - Resolve manually, `git add`, `git rebase --continue`\n - If the commit is genuinely obsolete, `git rebase --abort` and\n cherry-pick the commits you want\n - Bypass marker (git-rebase-skip-acknowledged) ONLY with explicit\n acknowledgement of the data loss\n", + "tests": [ + { + "name": "blocks-rebase-skip", + "input": { + "tool_input": { + "command": "git rebase --skip" + } + }, + "expected_exit": 2 + }, + { + "name": "allows-rebase-continue", + "input": { + "tool_input": { + "command": "git rebase --continue" + } + }, + "expected_exit": 0 + }, + { + "name": "allows-rebase-abort", + "input": { + "tool_input": { + "command": "git rebase --abort" + } + }, + "expected_exit": 0 + }, + { + "name": "allows-non-rebase-command", + "input": { + "tool_input": { + "command": "git status" + } + }, + "expected_exit": 0 + }, + { + "name": "allows-bypass-marker", + "input": { + "tool_input": { + "command": "git rebase --skip # hook-bypass: git-rebase-skip-acknowledged" + } + }, + "expected_exit": 0 + } + ] + }, + { + "id": "block-standup-not-in-escritorio", + "description": "Block `daily-standup*.md` writes anywhere outside ~/Escritorio (memory feedback_standup_desktop.md)", + "applies_to": [ + "Edit", + "Write", + "MultiEdit" + ], + "match": [ + { + "field": "file_path", + "pattern": "daily-standup.*\\.md$", + "not_pattern": "(/Escritorio/|/Desktop/)" + } + ], + "action": "block", + "bypass_marker": "standup-non-desktop-acknowledged", + "memory_ref": "feedback_standup_desktop.md", + "message": "BLOCKED: daily-standup file being written outside ~/Escritorio.\n\nPer feedback_standup_desktop.md the standup lives in ~/Escritorio,\nNEVER inside a repo (commit-by-accident risk + cross-repo\nfragmentation). The standup-post-slack skill always resolves the\npath from ~/Escritorio.\n\nUse the absolute path:\n ~/Escritorio/daily-standup.md\n(or ~/Desktop/daily-standup.md on English-locale machines).\n\nBypass marker is intentionally NOT recommended — there is no\nlegitimate path outside the desktop.\n", + "tests": [ + { + "name": "blocks-standup-in-repo", + "input": { + "tool_input": { + "file_path": "/home/user/repo/docs/daily-standup.md", + "content": "# Standup 2026-05-09" + } + }, + "expected_exit": 2 + }, + { + "name": "blocks-standup-in-tmp", + "input": { + "tool_input": { + "file_path": "/tmp/daily-standup.md", + "content": "# Standup" + } + }, + "expected_exit": 2 + }, + { + "name": "allows-standup-in-escritorio", + "input": { + "tool_input": { + "file_path": "/home/user/Escritorio/daily-standup.md", + "content": "# Standup" + } + }, + "expected_exit": 0 + }, + { + "name": "allows-standup-in-desktop-english", + "input": { + "tool_input": { + "file_path": "/home/user/Desktop/daily-standup.md", + "content": "# Standup" + } + }, + "expected_exit": 0 + }, + { + "name": "allows-non-standup-file", + "input": { + "tool_input": { + "file_path": "/home/user/repo/docs/notes.md", + "content": "# Notes" + } + }, + "expected_exit": 0 + }, + { + "name": "allows-bypass-marker", + "input": { + "tool_input": { + "file_path": "/tmp/daily-standup.md", + "content": "# Standup // hook-bypass: standup-non-desktop-acknowledged" + } + }, + "expected_exit": 0 + } + ] + }, + { + "id": "block-goodnight-not-in-escritorio", + "description": "Block /goodnight handoff files (`next-day-*.md` / `goodnight-*.md`) anywhere outside ~/Escritorio (memory feedback_goodnight_desktop.md)", + "applies_to": [ + "Edit", + "Write", + "MultiEdit" + ], + "match": [ + { + "field": "file_path", + "pattern": "(next-day-|goodnight-).*\\.md$", + "not_pattern": "(/Escritorio/|/Desktop/)" + } + ], + "action": "block", + "bypass_marker": "goodnight-non-desktop-acknowledged", + "memory_ref": "feedback_goodnight_desktop.md", + "message": "BLOCKED: /goodnight handoff file being written outside ~/Escritorio.\n\nPer feedback_goodnight_desktop.md the /goodnight skill must save\nnext-day handoff files to ~/Escritorio, NOT inside .claude/next-day/\nor any repo path.\n\nUse the absolute path:\n ~/Escritorio/.md\n(or ~/Desktop/.md on English-locale machines).\n\nBypass marker is intentionally NOT recommended.\n", + "tests": [ + { + "name": "blocks-next-day-in-claude-dir", + "input": { + "tool_input": { + "file_path": "/home/user/.claude/next-day/next-day-2026-05-09.md", + "content": "# Handoff" + } + }, + "expected_exit": 2 + }, + { + "name": "blocks-goodnight-in-tmp", + "input": { + "tool_input": { + "file_path": "/tmp/goodnight-context.md", + "content": "# Goodnight" + } + }, + "expected_exit": 2 + }, + { + "name": "allows-next-day-in-escritorio", + "input": { + "tool_input": { + "file_path": "/home/user/Escritorio/next-day-2026-05-09.md", + "content": "# Handoff" + } + }, + "expected_exit": 0 + }, + { + "name": "allows-goodnight-in-desktop", + "input": { + "tool_input": { + "file_path": "/home/user/Desktop/goodnight-2026-05-09.md", + "content": "# Handoff" + } + }, + "expected_exit": 0 + }, + { + "name": "allows-non-handoff-file", + "input": { + "tool_input": { + "file_path": "/home/user/repo/docs/notes.md", + "content": "# Notes" + } + }, + "expected_exit": 0 + }, + { + "name": "allows-bypass-marker", + "input": { + "tool_input": { + "file_path": "/tmp/next-day-2026-05-09.md", + "content": "# Handoff // hook-bypass: goodnight-non-desktop-acknowledged" + } + }, + "expected_exit": 0 + } + ] } ] diff --git a/hooks/rules/rules.yaml b/hooks/rules/rules.yaml index 21127ae..0e7b81a 100644 --- a/hooks/rules/rules.yaml +++ b/hooks/rules/rules.yaml @@ -1101,3 +1101,315 @@ tool_input: command: "curl -X POST https://example.supabase.co/rest/v1/profiles -d '{\"id\":1}' # hook-bypass: curl-supabase-rest-mutation" expected_exit: 0 + +# ----------------------------------------------------------------------------- +# Anti foot-shoot (Tier 1) — block rules for the high-cost mistakes that +# already burned the team. Each rule maps 1:1 to a feedback memory whose +# intent is "never do this", not "be careful": +# - feedback_no_headless.md -> block-playwright-headless +# - feedback_resolve_merge_conflicts.md -> block-git-force-push-no-lease, +# block-git-rebase-skip +# - feedback_standup_desktop.md -> block-standup-not-in-escritorio +# - feedback_goodnight_desktop.md -> block-goodnight-not-in-escritorio +# +# All five are `block` (not `warn`). Each ships a kebab-case bypass_marker +# for the rare cases where the action is intentional (and ideally +# documented in a commit message or PR body). +# ----------------------------------------------------------------------------- + +- id: block-playwright-headless + description: Block `playwright test` invocations that omit --headed / --ui / --debug (memory feedback_no_headless.md — browser must be visible during E2E) + applies_to: [Bash] + match: + # Two AND-chained conditions on `command`. The first asserts the + # invocation is a playwright test run; the second asserts none of the + # opt-in visible-runner flags are present. `\b` boundaries on + # `playwright` exclude unrelated tools like playwright-extra-cli. + - field: command + pattern: '\bplaywright[[:space:]]+test\b' + - field: command + not_pattern: '(--headed|--ui|--debug)' + action: block + bypass_marker: playwright-headless-allowed + memory_ref: feedback_no_headless.md + message: | + BLOCKED: `playwright test` without --headed / --ui / --debug. + + Per feedback_no_headless.md the browser must be visible during E2E: + headless runs hide visual regressions and UI race conditions, and + the in-focus requirement only holds when a window is up. + + Use one of: + - --headed browser visible + - --ui Playwright UI mode (interactive) + - --debug step-through debugger + + Bypass marker (playwright-headless-allowed) is reserved for + explicitly-documented CI runs (e.g. a Vercel preview workflow). + tests: + - name: blocks-playwright-test-bare + input: + tool_input: + command: 'npx playwright test' + expected_exit: 2 + - name: blocks-playwright-test-config + input: + tool_input: + command: 'npx playwright test --config=playwright.config.ts' + expected_exit: 2 + - name: allows-playwright-headed + input: + tool_input: + command: 'npx playwright test --headed' + expected_exit: 0 + - name: allows-playwright-ui + input: + tool_input: + command: 'npx playwright test --ui' + expected_exit: 0 + - name: allows-playwright-debug + input: + tool_input: + command: 'npx playwright test --debug' + expected_exit: 0 + - name: allows-non-playwright-test + input: + tool_input: + command: 'npm test' + expected_exit: 0 + - name: allows-bypass-marker + input: + tool_input: + command: 'npx playwright test # hook-bypass: playwright-headless-allowed' + expected_exit: 0 + +- id: block-git-force-push-no-lease + description: Block `git push --force` / `-f` without `--force-with-lease` (overwrites collaborator pushes silently) + applies_to: [Bash] + match: + # Positive: a push command using -f or --force (the latter must be + # followed by whitespace or end-of-string so we don't accidentally + # match --force-with-lease, which has a `-` after `--force`). + - field: command + pattern: 'git[[:space:]]+push.*(-f\b|--force([[:space:]]|$))' + # Negative: defense-in-depth — even if the author squeezed both flags + # onto the line (`git push -f origin main --force-with-lease`), + # presence of --force-with-lease aborts the fire. + - field: command + not_pattern: '--force-with-lease' + action: block + bypass_marker: git-force-push-bare-allowed + memory_ref: feedback_resolve_merge_conflicts.md + message: | + BLOCKED: `git push --force` without --force-with-lease. + + Bare --force overwrites the remote without verifying that what is + there is what you last fetched — it silently destroys collaborator + pushes. --force-with-lease aborts when the remote has advanced since + your last fetch, which is the safety net you want. + + Use: + git push --force-with-lease origin + + Bypass marker (git-force-push-bare-allowed) ONLY when you have + verified that no other contributor has pushed to the branch. + tests: + - name: blocks-force-short-flag + input: + tool_input: + command: 'git push -f origin feature-x' + expected_exit: 2 + - name: blocks-force-long-flag + input: + tool_input: + command: 'git push --force origin feature-x' + expected_exit: 2 + - name: allows-force-with-lease + input: + tool_input: + command: 'git push --force-with-lease origin feature-x' + expected_exit: 0 + - name: allows-no-force-push + input: + tool_input: + command: 'git push origin feature-x' + expected_exit: 0 + - name: allows-bypass-marker + input: + tool_input: + command: 'git push --force origin feature-x # hook-bypass: git-force-push-bare-allowed' + expected_exit: 0 + +- id: block-git-rebase-skip + description: Block `git rebase --skip` (drops the conflicting commit silently) + applies_to: [Bash] + match: + - field: command + pattern: 'git[[:space:]]+rebase[[:space:]]+--skip\b' + action: block + bypass_marker: git-rebase-skip-acknowledged + memory_ref: feedback_resolve_merge_conflicts.md + message: | + BLOCKED: `git rebase --skip` drops the conflicting commit silently. + + Per feedback_resolve_merge_conflicts.md: never skip; resolve each + conflict one by one, verify the result, and `git rebase --continue`. + + Correct moves: + - Resolve manually, `git add`, `git rebase --continue` + - If the commit is genuinely obsolete, `git rebase --abort` and + cherry-pick the commits you want + - Bypass marker (git-rebase-skip-acknowledged) ONLY with explicit + acknowledgement of the data loss + tests: + - name: blocks-rebase-skip + input: + tool_input: + command: 'git rebase --skip' + expected_exit: 2 + - name: allows-rebase-continue + input: + tool_input: + command: 'git rebase --continue' + expected_exit: 0 + - name: allows-rebase-abort + input: + tool_input: + command: 'git rebase --abort' + expected_exit: 0 + - name: allows-non-rebase-command + input: + tool_input: + command: 'git status' + expected_exit: 0 + - name: allows-bypass-marker + input: + tool_input: + command: 'git rebase --skip # hook-bypass: git-rebase-skip-acknowledged' + expected_exit: 0 + +- id: block-standup-not-in-escritorio + description: Block `daily-standup*.md` writes anywhere outside ~/Escritorio (memory feedback_standup_desktop.md) + applies_to: [Edit, Write, MultiEdit] + match: + # Single condition with both pattern (must match standup filename) and + # not_pattern (must NOT live under /Escritorio/ or /Desktop/). The + # localized "Escritorio" handles the user's Spanish-locale desktop; + # "Desktop" handles English-locale or aliasing setups. + - field: file_path + pattern: 'daily-standup.*\.md$' + not_pattern: '(/Escritorio/|/Desktop/)' + action: block + bypass_marker: standup-non-desktop-acknowledged + memory_ref: feedback_standup_desktop.md + message: | + BLOCKED: daily-standup file being written outside ~/Escritorio. + + Per feedback_standup_desktop.md the standup lives in ~/Escritorio, + NEVER inside a repo (commit-by-accident risk + cross-repo + fragmentation). The standup-post-slack skill always resolves the + path from ~/Escritorio. + + Use the absolute path: + ~/Escritorio/daily-standup.md + (or ~/Desktop/daily-standup.md on English-locale machines). + + Bypass marker is intentionally NOT recommended — there is no + legitimate path outside the desktop. + tests: + - name: blocks-standup-in-repo + input: + tool_input: + file_path: '/home/user/repo/docs/daily-standup.md' + content: '# Standup 2026-05-09' + expected_exit: 2 + - name: blocks-standup-in-tmp + input: + tool_input: + file_path: '/tmp/daily-standup.md' + content: '# Standup' + expected_exit: 2 + - name: allows-standup-in-escritorio + input: + tool_input: + file_path: '/home/user/Escritorio/daily-standup.md' + content: '# Standup' + expected_exit: 0 + - name: allows-standup-in-desktop-english + input: + tool_input: + file_path: '/home/user/Desktop/daily-standup.md' + content: '# Standup' + expected_exit: 0 + - name: allows-non-standup-file + input: + tool_input: + file_path: '/home/user/repo/docs/notes.md' + content: '# Notes' + expected_exit: 0 + - name: allows-bypass-marker + input: + tool_input: + file_path: '/tmp/daily-standup.md' + content: '# Standup // hook-bypass: standup-non-desktop-acknowledged' + expected_exit: 0 + +- id: block-goodnight-not-in-escritorio + description: Block /goodnight handoff files (`next-day-*.md` / `goodnight-*.md`) anywhere outside ~/Escritorio (memory feedback_goodnight_desktop.md) + applies_to: [Edit, Write, MultiEdit] + match: + - field: file_path + pattern: '(next-day-|goodnight-).*\.md$' + not_pattern: '(/Escritorio/|/Desktop/)' + action: block + bypass_marker: goodnight-non-desktop-acknowledged + memory_ref: feedback_goodnight_desktop.md + message: | + BLOCKED: /goodnight handoff file being written outside ~/Escritorio. + + Per feedback_goodnight_desktop.md the /goodnight skill must save + next-day handoff files to ~/Escritorio, NOT inside .claude/next-day/ + or any repo path. + + Use the absolute path: + ~/Escritorio/.md + (or ~/Desktop/.md on English-locale machines). + + Bypass marker is intentionally NOT recommended. + tests: + - name: blocks-next-day-in-claude-dir + input: + tool_input: + file_path: '/home/user/.claude/next-day/next-day-2026-05-09.md' + content: '# Handoff' + expected_exit: 2 + - name: blocks-goodnight-in-tmp + input: + tool_input: + file_path: '/tmp/goodnight-context.md' + content: '# Goodnight' + expected_exit: 2 + - name: allows-next-day-in-escritorio + input: + tool_input: + file_path: '/home/user/Escritorio/next-day-2026-05-09.md' + content: '# Handoff' + expected_exit: 0 + - name: allows-goodnight-in-desktop + input: + tool_input: + file_path: '/home/user/Desktop/goodnight-2026-05-09.md' + content: '# Handoff' + expected_exit: 0 + - name: allows-non-handoff-file + input: + tool_input: + file_path: '/home/user/repo/docs/notes.md' + content: '# Notes' + expected_exit: 0 + - name: allows-bypass-marker + input: + tool_input: + file_path: '/tmp/next-day-2026-05-09.md' + content: '# Handoff // hook-bypass: goodnight-non-desktop-acknowledged' + expected_exit: 0 diff --git a/openspec/changes/2026-05-hooks-tier-1-foot-shoot-prevention/design.md b/openspec/changes/2026-05-hooks-tier-1-foot-shoot-prevention/design.md new file mode 100644 index 0000000..8a11f6a --- /dev/null +++ b/openspec/changes/2026-05-hooks-tier-1-foot-shoot-prevention/design.md @@ -0,0 +1,63 @@ +# Design — Tier 1 anti-foot-shoot block rules + +## 1. block-playwright-headless + +**Pattern**: positive `\bplaywright[[:space:]]+test\b` + negative +`(--headed|--ui|--debug)`. + +Two AND-chained conditions: the command must contain `playwright test` +AND must NOT contain any of the three opt-in flags. The `\b` word +boundary on `playwright` prevents matching unrelated names (e.g. +`playwright-extra`, `playwright-cli`). + +`npx playwright test`, `pnpm playwright test`, `yarn playwright test` +all match. Bypass marker `playwright-headless-allowed` for documented CI +runs. + +## 2. block-git-force-push-no-lease + +**Pattern**: positive `git[[:space:]]+push.*(-f\b|--force([[:space:]]|$))` ++ negative `--force-with-lease`. + +Note that the positive pattern's `--force([[:space:]]|$)` already excludes +`--force-with-lease` (no space after `--force` in the latter). The negative +clause is a defense-in-depth safeguard for pathological orderings like +`git push -f origin main --force-with-lease`, where the `-f` would match +positively but the `--force-with-lease` correctly aborts the fire. + +Bypass marker `git-force-push-bare-allowed`. + +## 3. block-git-rebase-skip + +**Pattern**: `git[[:space:]]+rebase[[:space:]]+--skip\b`. + +Straightforward — `--skip` drops the conflicting commit silently. `--continue` +and `--abort` are the safe alternatives. Bypass marker +`git-rebase-skip-acknowledged` for explicit acknowledgement of data loss. + +## 4 + 5. block-{standup,goodnight}-not-in-escritorio + +Both apply to `Edit, Write, MultiEdit`. Use `file_path` field with a +positive pattern matching the filename and a negative pattern excluding +`~/Escritorio` / `~/Desktop`: + +- standup: `daily-standup.*\.md$` not in `(/Escritorio/|/Desktop/)`. +- goodnight: `(next-day-|goodnight-).*\.md$` not in `(/Escritorio/|/Desktop/)`. + +Both rules apply pattern + not_pattern on the same `file_path` condition +(supported by the schema). Bypass markers respectively +`standup-non-desktop-acknowledged` and `goodnight-non-desktop-acknowledged`, +both documented as NOT recommended (the underlying skills always resolve +to `~/Escritorio`). + +## Test coverage + +Each rule: ≥5 tests including positive matches across realistic +invocations, negative matches that should NOT fire, and bypass-marker. +Total new tests: ~25. + +## No schema changes + +All 5 rules use existing fields (`command`, `file_path`) and the existing +matcher semantics. No changes to `parse-input.sh`, `eval-rule.sh`, or +the dispatchers. diff --git a/openspec/changes/2026-05-hooks-tier-1-foot-shoot-prevention/proposal.md b/openspec/changes/2026-05-hooks-tier-1-foot-shoot-prevention/proposal.md new file mode 100644 index 0000000..065d213 --- /dev/null +++ b/openspec/changes/2026-05-hooks-tier-1-foot-shoot-prevention/proposal.md @@ -0,0 +1,42 @@ +# Proposal — Tier 1 anti-foot-shoot block rules + +## Why + +5 deterministic block rules fill important gaps in the manifest where the +existing memories explicitly call for a hard stop, not a nudge. Each rule +maps to a specific `feedback_*.md` memory whose intent is "never do this": + +- `feedback_no_headless.md` — playwright must run with a visible browser. +- `feedback_resolve_merge_conflicts.md` — never `--force` push without + `--force-with-lease`; never `git rebase --skip` (silent commit loss). +- `feedback_standup_desktop.md` + `feedback_goodnight_desktop.md` — the + daily-standup and /goodnight handoff files always live in `~/Escritorio/`. + +The previous PR (PR #16) added one warn rule. This PR adds five **block** +rules covering the foot-shooting class of mistakes that the team has +already paid for in lost work or correction time. + +## What + +Five new rules in `hooks/rules/rules.yaml`: + +1. `block-playwright-headless` — block `playwright test` invocations that + omit `--headed`, `--ui`, or `--debug`. Bypass marker for explicit CI runs. +2. `block-git-force-push-no-lease` — block `git push --force` (or `-f`) + without `--force-with-lease`. +3. `block-git-rebase-skip` — block `git rebase --skip`. +4. `block-standup-not-in-escritorio` — block writes to `daily-standup*.md` + files anywhere except `~/Escritorio` / `~/Desktop`. +5. `block-goodnight-not-in-escritorio` — block writes to `next-day-*.md` + or `goodnight-*.md` outside `~/Escritorio` / `~/Desktop`. + +Each rule ships ≥4 tests covering positive matches, negative matches, and +bypass-marker behavior. + +## Impact + +- `hooks/rules/rules.yaml` — append the 5 new rules + tests +- `hooks/rules/rules.json` — regenerated artifact +- `hooks/rules/README.md` — extend the family list with the new "Anti + foot-shoot (Tier 1)" group +- 3 manifests bumped `1.8.0 -> 1.9.0` diff --git a/openspec/changes/2026-05-hooks-tier-1-foot-shoot-prevention/tasks.md b/openspec/changes/2026-05-hooks-tier-1-foot-shoot-prevention/tasks.md new file mode 100644 index 0000000..fb8035b --- /dev/null +++ b/openspec/changes/2026-05-hooks-tier-1-foot-shoot-prevention/tasks.md @@ -0,0 +1,11 @@ +# Tasks — Tier 1 anti-foot-shoot block rules + +- [x] Append 5 rules to `hooks/rules/rules.yaml` with ≥5 tests each +- [x] Run `npm run build-rules` to regenerate `hooks/rules/rules.json` + (target: 19 -> 24 rules) +- [x] Run `npm run test-hooks` (target: 95 -> ~120+ tests) +- [x] Update `hooks/rules/README.md` with the new family entry +- [x] Bump version `1.8.0 -> 1.9.0` in 3 manifests +- [x] Commit + push + open PR + tag Greptile +- [x] Loop until Greptile 5/5 +- [x] `gh pr merge --squash --delete-branch` diff --git a/package.json b/package.json index 259d3da..b9f4717 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@lapc506/make-no-mistakes", - "version": "1.8.0", + "version": "1.9.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 (no SSH+DB, no manual prod, no minified build, no secret leaks, Slack format). OpenCode + Claude Code plugin.", "type": "module", "main": "./dist/index.js", From b0d1d9c2d2115b37b238836e4f4a9ac07a5233c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Pe=C3=B1a?= Date: Sun, 10 May 2026 00:09:59 -0600 Subject: [PATCH 2/2] fix(rules): close 3 Greptile findings on Tier 1 block rules 1. **Edit/MultiEdit bypass reachability documented and tested** (block-standup-not-in-escritorio + block-goodnight-not-in-escritorio). The dispatcher's bypass scan already runs against the full raw tool_input JSON via `parse-input.sh INPUT_RAW`, so a marker in any string-valued field (`new_string`, `old_string`, `edits[].new_string`) short-circuits the rule. Add explicit `allows-bypass-marker-edit-tool` and `allows-bypass-marker-multiedit-tool` tests for both rules and a clarifying comment so a future maintainer doesn't reintroduce the reachability concern. Renames `allows-bypass-marker` -> `allows-bypass-marker-write-tool` to keep the tool-shape symmetry. 2. **Combined --force + --force-with-lease defense-in-depth tested** (block-git-force-push-no-lease). Add `allows-force-with-lease-and-force-combined` with the exact pathological-ordering command the design doc calls out, locking in the not_pattern safety net against future regex refactors. 3. **pnpm dlx / yarn dlx playwright invocations tested** (block-playwright-headless). Add `blocks-pnpm-dlx-playwright-headless`, `blocks-yarn-dlx-playwright-headless`, and `allows-pnpm-dlx-playwright-headed`. The pattern already covers these forms; the tests pin that behavior. Tests: 128 -> 136 passing (+8). --- hooks/rules/rules.json | 92 +++++++++++++++++++++++++++++++++++++++++- hooks/rules/rules.yaml | 58 +++++++++++++++++++++++++- 2 files changed, 146 insertions(+), 4 deletions(-) diff --git a/hooks/rules/rules.json b/hooks/rules/rules.json index 8a23bfe..5ab22be 100644 --- a/hooks/rules/rules.json +++ b/hooks/rules/rules.json @@ -1452,6 +1452,33 @@ }, "expected_exit": 0 }, + { + "name": "blocks-pnpm-dlx-playwright-headless", + "input": { + "tool_input": { + "command": "pnpm dlx playwright test" + } + }, + "expected_exit": 2 + }, + { + "name": "blocks-yarn-dlx-playwright-headless", + "input": { + "tool_input": { + "command": "yarn dlx playwright test" + } + }, + "expected_exit": 2 + }, + { + "name": "allows-pnpm-dlx-playwright-headed", + "input": { + "tool_input": { + "command": "pnpm dlx playwright test --headed" + } + }, + "expected_exit": 0 + }, { "name": "allows-bypass-marker", "input": { @@ -1520,6 +1547,15 @@ }, "expected_exit": 0 }, + { + "name": "allows-force-with-lease-and-force-combined", + "input": { + "tool_input": { + "command": "git push -f origin main --force-with-lease" + } + }, + "expected_exit": 0 + }, { "name": "allows-bypass-marker", "input": { @@ -1666,7 +1702,7 @@ "expected_exit": 0 }, { - "name": "allows-bypass-marker", + "name": "allows-bypass-marker-write-tool", "input": { "tool_input": { "file_path": "/tmp/daily-standup.md", @@ -1674,6 +1710,32 @@ } }, "expected_exit": 0 + }, + { + "name": "allows-bypass-marker-edit-tool", + "input": { + "tool_input": { + "file_path": "/tmp/daily-standup.md", + "old_string": "# Old", + "new_string": "# Standup // hook-bypass: standup-non-desktop-acknowledged" + } + }, + "expected_exit": 0 + }, + { + "name": "allows-bypass-marker-multiedit-tool", + "input": { + "tool_input": { + "file_path": "/tmp/daily-standup.md", + "edits": [ + { + "old_string": "# A", + "new_string": "# B // hook-bypass: standup-non-desktop-acknowledged" + } + ] + } + }, + "expected_exit": 0 } ] }, @@ -1748,7 +1810,7 @@ "expected_exit": 0 }, { - "name": "allows-bypass-marker", + "name": "allows-bypass-marker-write-tool", "input": { "tool_input": { "file_path": "/tmp/next-day-2026-05-09.md", @@ -1756,6 +1818,32 @@ } }, "expected_exit": 0 + }, + { + "name": "allows-bypass-marker-edit-tool", + "input": { + "tool_input": { + "file_path": "/tmp/next-day-2026-05-09.md", + "old_string": "# Old", + "new_string": "# Handoff // hook-bypass: goodnight-non-desktop-acknowledged" + } + }, + "expected_exit": 0 + }, + { + "name": "allows-bypass-marker-multiedit-tool", + "input": { + "tool_input": { + "file_path": "/tmp/next-day-2026-05-09.md", + "edits": [ + { + "old_string": "# A", + "new_string": "# B // hook-bypass: goodnight-non-desktop-acknowledged" + } + ] + } + }, + "expected_exit": 0 } ] } diff --git a/hooks/rules/rules.yaml b/hooks/rules/rules.yaml index 0e7b81a..947bfe7 100644 --- a/hooks/rules/rules.yaml +++ b/hooks/rules/rules.yaml @@ -1177,6 +1177,21 @@ tool_input: command: 'npm test' expected_exit: 0 + - name: blocks-pnpm-dlx-playwright-headless + input: + tool_input: + command: 'pnpm dlx playwright test' + expected_exit: 2 + - name: blocks-yarn-dlx-playwright-headless + input: + tool_input: + command: 'yarn dlx playwright test' + expected_exit: 2 + - name: allows-pnpm-dlx-playwright-headed + input: + tool_input: + command: 'pnpm dlx playwright test --headed' + expected_exit: 0 - name: allows-bypass-marker input: tool_input: @@ -1234,6 +1249,11 @@ tool_input: command: 'git push origin feature-x' expected_exit: 0 + - name: allows-force-with-lease-and-force-combined + input: + tool_input: + command: 'git push -f origin main --force-with-lease' + expected_exit: 0 - name: allows-bypass-marker input: tool_input: @@ -1347,12 +1367,31 @@ file_path: '/home/user/repo/docs/notes.md' content: '# Notes' expected_exit: 0 - - name: allows-bypass-marker + - name: allows-bypass-marker-write-tool input: tool_input: file_path: '/tmp/daily-standup.md' content: '# Standup // hook-bypass: standup-non-desktop-acknowledged' expected_exit: 0 + # Edit and MultiEdit expose `new_string` / `old_string` / `edits[]` instead + # of `content`, so we lock in that the bypass marker placed in any of those + # fields still short-circuits the rule. The dispatcher scans the entire raw + # tool_input JSON for the marker, so any string-valued field is reachable. + - name: allows-bypass-marker-edit-tool + input: + tool_input: + file_path: '/tmp/daily-standup.md' + old_string: '# Old' + new_string: '# Standup // hook-bypass: standup-non-desktop-acknowledged' + expected_exit: 0 + - name: allows-bypass-marker-multiedit-tool + input: + tool_input: + file_path: '/tmp/daily-standup.md' + edits: + - old_string: '# A' + new_string: '# B // hook-bypass: standup-non-desktop-acknowledged' + expected_exit: 0 - id: block-goodnight-not-in-escritorio description: Block /goodnight handoff files (`next-day-*.md` / `goodnight-*.md`) anywhere outside ~/Escritorio (memory feedback_goodnight_desktop.md) @@ -1407,9 +1446,24 @@ file_path: '/home/user/repo/docs/notes.md' content: '# Notes' expected_exit: 0 - - name: allows-bypass-marker + - name: allows-bypass-marker-write-tool input: tool_input: file_path: '/tmp/next-day-2026-05-09.md' content: '# Handoff // hook-bypass: goodnight-non-desktop-acknowledged' expected_exit: 0 + - name: allows-bypass-marker-edit-tool + input: + tool_input: + file_path: '/tmp/next-day-2026-05-09.md' + old_string: '# Old' + new_string: '# Handoff // hook-bypass: goodnight-non-desktop-acknowledged' + expected_exit: 0 + - name: allows-bypass-marker-multiedit-tool + input: + tool_input: + file_path: '/tmp/next-day-2026-05-09.md' + edits: + - old_string: '# A' + new_string: '# B // hook-bypass: goodnight-non-desktop-acknowledged' + expected_exit: 0