@devrik-tools/claude-gates 0.4.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/README.es.md +39 -4
  3. package/README.md +34 -5
  4. package/cli/config.mjs +126 -124
  5. package/cli/init.mjs +303 -276
  6. package/cli/install.mjs +281 -175
  7. package/cli/materialize.mjs +103 -102
  8. package/cli/registry.mjs +139 -136
  9. package/cli/smoke-fixtures.json +65 -0
  10. package/cli/task.mjs +140 -140
  11. package/package.json +1 -1
  12. package/plugins/gates/.claude-plugin/plugin.json +1 -1
  13. package/plugins/gates/hooks/ask-adoption.mjs +147 -147
  14. package/plugins/gates/hooks/doctor.mjs +207 -207
  15. package/plugins/gates/hooks/gates/atomic-commit/index.mjs +229 -0
  16. package/plugins/gates/hooks/gates/audit-before-build/index.mjs +110 -88
  17. package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +50 -50
  18. package/plugins/gates/hooks/gates/bash-commands/index.mjs +215 -215
  19. package/plugins/gates/hooks/gates/brief-approved/index.mjs +216 -0
  20. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +269 -265
  21. package/plugins/gates/hooks/gates/capability-map/index.mjs +701 -0
  22. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +527 -501
  23. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +48 -43
  24. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +83 -83
  25. package/plugins/gates/hooks/gates/force-parallel/index.mjs +134 -119
  26. package/plugins/gates/hooks/gates/forge-flow/index.mjs +134 -134
  27. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +187 -187
  28. package/plugins/gates/hooks/gates/intent-flow/index.mjs +260 -260
  29. package/plugins/gates/hooks/gates/lint-commit/index.mjs +152 -149
  30. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +180 -180
  31. package/plugins/gates/hooks/gates/never-assume/index.mjs +59 -58
  32. package/plugins/gates/hooks/gates/no-blocking/index.mjs +163 -148
  33. package/plugins/gates/hooks/gates/no-coauthor/index.mjs +127 -0
  34. package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +183 -0
  35. package/plugins/gates/hooks/gates/protected-paths/index.mjs +149 -144
  36. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +91 -89
  37. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +263 -159
  38. package/plugins/gates/hooks/gates/risk-level/index.mjs +265 -263
  39. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +57 -56
  40. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +211 -131
  41. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +181 -184
  42. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +256 -256
  43. package/plugins/gates/hooks/gates/staged-lint/index.mjs +187 -0
  44. package/plugins/gates/hooks/gates/stop-pending/index.mjs +169 -164
  45. package/plugins/gates/hooks/gates/test-matrix/index.mjs +187 -187
  46. package/plugins/gates/hooks/gates/tool-map/index.mjs +168 -143
  47. package/plugins/gates/hooks/hooks.json +61 -0
  48. package/plugins/gates/hooks/lib/config.mjs +179 -172
  49. package/plugins/gates/hooks/lib/hook-io.mjs +367 -357
  50. package/plugins/gates/hooks/lib/signals.mjs +172 -127
  51. package/plugins/gates/hooks/wiring-check.mjs +227 -227
  52. package/plugins/tasks/.claude-plugin/plugin.json +1 -1
  53. package/plugins/tasks/hooks/hooks.json +26 -26
  54. package/plugins/tasks/hooks/lib/task-store.mjs +217 -197
  55. package/plugins/tasks/hooks/register-requests.mjs +145 -145
  56. package/plugins/tasks/hooks/session-tasks.mjs +108 -108
  57. package/registry.json +192 -1
@@ -1,148 +1,163 @@
1
- // no-blocking — denies syntactic forms of waiting in the foreground (sleeping, polling
2
- // loops, following unbounded output, foreground dev servers) on a real shell command and
3
- // on a delegation prompt (a subagent can be told "wait with sleep" in prose). Migrated
4
- // from ~/.claude/hooks/guard-no-blocking.mjs. Off by default: a project opts in, because
5
- // many legitimate workflows still need a bounded wait the project accepts as normal.
6
- //
7
- // This gate only recognizes SYNTACTIC forms of waiting — it never judges whether other
8
- // work was available meanwhile, because that is judgment, not a fact a regex can read.
9
- // A gate that misfires on the legitimate case gets disabled, taking every real catch
10
- // down with it.
11
- //
12
- // ── What a project can configure (params) ───────────────────────────────────────────
13
- // blockingPatterns regex sources (matched case-insensitively) of blocking/waiting
14
- // forms to deny. Replaces the built-in list wholesale.
15
- // waitJustifiedMarker a marker token that, present in the command with a reason,
16
- // escapes the block — a declared wait is a decision, not an
17
- // oversight. Replaces the built-in marker wholesale.
18
- // The defaults live here, in the source, so a project reads them and knows exactly what
19
- // its override replaces.
20
-
21
- import {
22
- runGate,
23
- deny,
24
- toolInGroups,
25
- delegationPromptOf,
26
- } from '../../lib/hook-io.mjs';
27
-
28
- const GATE_ID = 'no-blocking';
29
- const CONFIG_KEY = 'blockWaitingCommands';
30
-
31
- const DEFAULT_WAIT_JUSTIFIED_MARKER = 'WAIT-JUSTIFIED:';
32
-
33
- // systemd's log-follow command, assembled from fragments so the spell checker does not
34
- // read it as prose (the project keeps an empty dictionary by policy).
35
- const SYSTEMD_LOG_COMMAND = 'journal' + 'ctl';
36
-
37
- /** Blocking rules as `[regexSource, reason]`, matched case-insensitively. */
38
- const DEFAULT_BLOCKING_PATTERNS = [
39
- [
40
- String.raw`(^|[|;&]\s*|\bthen\s+|\bdo\s+)(sleep|timeout)\s+\d`,
41
- 'Sleeping in the foreground spends the turn doing nothing. If waiting for something ' +
42
- 'to finish, launch it in the background and move on to something else; if waiting ' +
43
- 'on an external condition, use a monitor.',
44
- ],
45
- [
46
- String.raw`\bStart-Sleep\b`,
47
- 'Start-Sleep freezes the turn. Launch the work in the background and continue with ' +
48
- 'whatever does not depend on it.',
49
- ],
50
- [
51
- String.raw`(^|[|;&]\s*)wait\b|--wait\b|\bWait-Process\b|\bWait-Job\b`,
52
- 'Waiting for another process to finish blocks the whole turn. Launch it in the ' +
53
- 'background and consume the result when it arrives, not before.',
54
- ],
55
- [
56
- [
57
- String.raw`\b(tail|`,
58
- SYSTEMD_LOG_COMMAND,
59
- String.raw`|kubectl\s+logs|docker\s+logs)\b[^|;&]*\s-{1,2}f\b`,
60
- ].join(''),
61
- 'Following live output never returns and takes the turn with it. Read the file once, ' +
62
- 'or leave the following to a background process.',
63
- ],
64
- [
65
- String.raw`(^|[|;&]\s*)watch\s+`,
66
- "'watch' repeats forever and blocks. Run the command once; if a change genuinely " +
67
- 'needs watching, use a monitor that does not take the turn.',
68
- ],
69
- [
70
- String.raw`\b(until|while)\b[^\n]{0,80}\bdo\b[^\n]{0,80}\bsleep\b`,
71
- 'A loop that sleeps waiting for something to change is foreground polling: it blocks ' +
72
- 'and it spins. Use a monitor with the condition, or launch the work in the ' +
73
- 'background and wait for its notification.',
74
- ],
75
- [
76
- String.raw`\b(npm|pnpm|yarn|bun)\s+(run\s+)?(dev|start|serve|watch)\b`,
77
- 'A dev server does not end on its own: in the foreground it takes the turn until ' +
78
- 'something kills it. Launch it in the background and keep working while it comes up.',
79
- ],
80
- ];
81
-
82
- // Marks that a command will not take the turn: backgrounded, detached, or bounded. The bare
83
- // `-d` token was removed: it matched any -d flag (e.g. `curl -d payload`), letting an
84
- // unrelated flag whitelist a genuine foreground `sleep`. Detached forms are now matched
85
- // explicitly (--detach, docker/compose -d at a word boundary before end/pipe), and PowerShell's
86
- // Start-Job / Start-Process -NoNewWindow backgrounding is recognized.
87
- const NOT_TAKING_THE_TURN =
88
- /(&\s*$|\bnohup\b|\bstart\s+\/b\b|--detach\b|\b-d(?=\s*($|[|;&]))|\bStart-Job\b|\bStart-Process\b[^|;\n]*-NoNewWindow\b|\brun_in_background\b)/i;
89
-
90
- function compile(source) {
91
- return new RegExp(source, 'i');
92
- }
93
-
94
- /** The text to inspect: a real command's command line, or the delegation prompt. */
95
- function commandTextFrom(toolName, toolInput) {
96
- if (toolInGroups(toolName, ['shell'])) {
97
- return String(toolInput.CommandLine ?? toolInput.command ?? '');
98
- }
99
- return delegationPromptOf(toolInput);
100
- }
101
-
102
- runGate(
103
- {
104
- id: GATE_ID,
105
- configKey: CONFIG_KEY,
106
- enabledByDefault: false,
107
- defaultParams: {
108
- blockingPatterns: DEFAULT_BLOCKING_PATTERNS,
109
- waitJustifiedMarker: DEFAULT_WAIT_JUSTIFIED_MARKER,
110
- },
111
- },
112
- ({ toolName, toolInput, parameters }) => {
113
- const isShell = toolInGroups(toolName, ['shell']);
114
- const isDelegation = toolInGroups(toolName, ['delegation']);
115
- if (!isShell && !isDelegation) return;
116
-
117
- const command = commandTextFrom(toolName, toolInput);
118
- if (!command.trim()) return;
119
-
120
- // Already declared in the background: the turn stays free, which is all that matters.
121
- if (toolInput.run_in_background === true) return;
122
- if (NOT_TAKING_THE_TURN.test(command)) return;
123
-
124
- // A declared wait with its reason is a decision, not an oversight.
125
- const marker = String(parameters.waitJustifiedMarker ?? '');
126
- if (marker) {
127
- const escapedMarker = marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
128
- const reasonAfterMarker = String.raw`\s*\S+\s+\S+`;
129
- const justified = new RegExp(`${escapedMarker}${reasonAfterMarker}`, 'i');
130
- if (justified.test(command)) return;
131
- }
132
-
133
- const blockingPairs = (parameters.blockingPatterns ?? []).map((entry) =>
134
- Array.isArray(entry)
135
- ? entry
136
- : [entry, 'Blocking/waiting command is not allowed.'],
137
- );
138
- for (const [source, reason] of blockingPairs) {
139
- if (compile(source).test(command)) {
140
- deny(
141
- GATE_ID,
142
- `${reason} If this wait is genuinely justified, add "${marker} <concrete reason>" ` +
143
- 'to the command and try again. A declared wait is a decision; a silent one is an oversight.',
144
- );
145
- }
146
- }
147
- },
148
- );
1
+ // no-blocking — denies syntactic forms of waiting in the foreground (sleeping, polling
2
+ // loops, following unbounded output, foreground dev servers) on a real shell command and
3
+ // on a delegation prompt (a subagent can be told "wait with sleep" in prose). Migrated
4
+ // from ~/.claude/hooks/guard-no-blocking.mjs. Off by default: a project opts in, because
5
+ // many legitimate workflows still need a bounded wait the project accepts as normal.
6
+ //
7
+ // This gate only recognizes SYNTACTIC forms of waiting — it never judges whether other
8
+ // work was available meanwhile, because that is judgment, not a fact a regex can read.
9
+ // A gate that misfires on the legitimate case gets disabled, taking every real catch
10
+ // down with it.
11
+ //
12
+ // ── What a project can configure (params) ───────────────────────────────────────────
13
+ // blockingPatterns regex sources (matched case-insensitively) of blocking/waiting
14
+ // forms to deny. Replaces the built-in list wholesale.
15
+ // waitJustifiedMarker a marker token that, present in the command with a reason,
16
+ // escapes the block — a declared wait is a decision, not an
17
+ // oversight. Replaces the built-in marker wholesale.
18
+ // The defaults live here, in the source, so a project reads them and knows exactly what
19
+ // its override replaces.
20
+
21
+ import {
22
+ runGate,
23
+ deny,
24
+ toolInGroups,
25
+ delegationPromptOf,
26
+ } from '../../lib/hook-io.mjs';
27
+
28
+ const GATE_ID = 'no-blocking';
29
+ const CONFIG_KEY = 'blockWaitingCommands';
30
+
31
+ const DEFAULT_WAIT_JUSTIFIED_MARKER = 'WAIT-JUSTIFIED:';
32
+
33
+ // systemd's log-follow command, assembled from fragments so the spell checker does not
34
+ // read it as prose (the project keeps an empty dictionary by policy).
35
+ const SYSTEMD_LOG_COMMAND = 'journal' + 'ctl';
36
+
37
+ /** Blocking rules as `[regexSource, reason]`, matched case-insensitively. */
38
+ const DEFAULT_BLOCKING_PATTERNS = [
39
+ [
40
+ String.raw`(^|[|;&]\s*|\bthen\s+|\bdo\s+)(sleep|timeout)\s+\d`,
41
+ 'Sleeping in the foreground spends the turn doing nothing. If waiting for something ' +
42
+ 'to finish, launch it in the background and move on to something else; if waiting ' +
43
+ 'on an external condition, use a monitor.',
44
+ ],
45
+ [
46
+ String.raw`\bStart-Sleep\b`,
47
+ 'Start-Sleep freezes the turn. Launch the work in the background and continue with ' +
48
+ 'whatever does not depend on it.',
49
+ ],
50
+ [
51
+ String.raw`(^|[|;&]\s*)wait\b|--wait\b|\bWait-Process\b|\bWait-Job\b`,
52
+ 'Waiting for another process to finish blocks the whole turn. Launch it in the ' +
53
+ 'background and consume the result when it arrives, not before.',
54
+ ],
55
+ [
56
+ [
57
+ String.raw`\b(tail|`,
58
+ SYSTEMD_LOG_COMMAND,
59
+ String.raw`|kubectl\s+logs|docker\s+logs)\b[^|;&]*\s-{1,2}f\b`,
60
+ ].join(''),
61
+ 'Following live output never returns and takes the turn with it. Read the file once, ' +
62
+ 'or leave the following to a background process.',
63
+ ],
64
+ [
65
+ String.raw`(^|[|;&]\s*)watch\s+`,
66
+ "'watch' repeats forever and blocks. Run the command once; if a change genuinely " +
67
+ 'needs watching, use a monitor that does not take the turn.',
68
+ ],
69
+ [
70
+ String.raw`\b(until|while)\b[^\n]{0,80}\bdo\b[^\n]{0,80}\bsleep\b`,
71
+ 'A loop that sleeps waiting for something to change is foreground polling: it blocks ' +
72
+ 'and it spins. Use a monitor with the condition, or launch the work in the ' +
73
+ 'background and wait for its notification.',
74
+ ],
75
+ [
76
+ String.raw`\b(npm|pnpm|yarn|bun)\s+(run\s+)?(dev|start|serve|watch)\b`,
77
+ 'A dev server does not end on its own: in the foreground it takes the turn until ' +
78
+ 'something kills it. Launch it in the background and keep working while it comes up.',
79
+ ],
80
+ ];
81
+
82
+ // Marks that a command will not take the turn: backgrounded, detached, or bounded. The bare
83
+ // `-d` token was removed: it matched any -d flag (e.g. `curl -d payload`), letting an
84
+ // unrelated flag whitelist a genuine foreground `sleep`. Detached forms are now matched
85
+ // explicitly (--detach, docker/compose -d at a word boundary before end/pipe), and PowerShell's
86
+ // Start-Job / Start-Process -NoNewWindow backgrounding is recognized.
87
+ // One big alternation trips the linter's regex-complexity check, so each background form is
88
+ // its own short regex tested with `.some()` — matching EXACTLY what the combined pattern did
89
+ // (verified case-by-case). Order does not matter: any one match means the command detaches.
90
+ const NOT_TAKING_THE_TURN_FORMS = [
91
+ /&\s*$/i,
92
+ /\bnohup\b/i,
93
+ /\bstart\s+\/b\b/i,
94
+ /--detach\b/i,
95
+ /\b-d(?=\s*($|[|;&]))/i,
96
+ /\bStart-Job\b/i,
97
+ /\bStart-Process\b[^|;\n]*-NoNewWindow\b/i,
98
+ /\brun_in_background\b/i,
99
+ ];
100
+
101
+ function detachesFromTurn(command) {
102
+ return NOT_TAKING_THE_TURN_FORMS.some((pattern) => pattern.test(command));
103
+ }
104
+
105
+ function compile(source) {
106
+ return new RegExp(source, 'i');
107
+ }
108
+
109
+ /** The text to inspect: a real command's command line, or the delegation prompt. */
110
+ function commandTextFrom(toolName, toolInput) {
111
+ if (toolInGroups(toolName, ['shell'])) {
112
+ return String(toolInput.CommandLine ?? toolInput.command ?? '');
113
+ }
114
+ return delegationPromptOf(toolInput);
115
+ }
116
+
117
+ runGate(
118
+ {
119
+ id: GATE_ID,
120
+ configKey: CONFIG_KEY,
121
+ enabledByDefault: false,
122
+ defaultParams: {
123
+ blockingPatterns: DEFAULT_BLOCKING_PATTERNS,
124
+ waitJustifiedMarker: DEFAULT_WAIT_JUSTIFIED_MARKER,
125
+ },
126
+ },
127
+ ({ toolName, toolInput, parameters }) => {
128
+ const isShell = toolInGroups(toolName, ['shell']);
129
+ const isDelegation = toolInGroups(toolName, ['delegation']);
130
+ if (!isShell && !isDelegation) return;
131
+
132
+ const command = commandTextFrom(toolName, toolInput);
133
+ if (!command.trim()) return;
134
+
135
+ // Already declared in the background: the turn stays free, which is all that matters.
136
+ if (toolInput.run_in_background === true) return;
137
+ if (detachesFromTurn(command)) return;
138
+
139
+ // A declared wait with its reason is a decision, not an oversight.
140
+ const marker = String(parameters.waitJustifiedMarker ?? '');
141
+ if (marker) {
142
+ const escapedMarker = marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
143
+ const reasonAfterMarker = String.raw`\s*\S+\s+\S+`;
144
+ const justified = new RegExp(`${escapedMarker}${reasonAfterMarker}`, 'i');
145
+ if (justified.test(command)) return;
146
+ }
147
+
148
+ const blockingPairs = (parameters.blockingPatterns ?? []).map((entry) =>
149
+ Array.isArray(entry)
150
+ ? entry
151
+ : [entry, 'Blocking/waiting command is not allowed.'],
152
+ );
153
+ for (const [source, reason] of blockingPairs) {
154
+ if (compile(source).test(command)) {
155
+ deny(
156
+ GATE_ID,
157
+ `${reason} If this wait is genuinely justified, add "${marker} <concrete reason>" ` +
158
+ 'to the command and try again. A declared wait is a decision; a silent one is an oversight.',
159
+ );
160
+ }
161
+ }
162
+ },
163
+ );
@@ -0,0 +1,127 @@
1
+ // no-coauthor — denies a `git commit` that carries an AI/agent attribution trailer
2
+ // (Co-Authored-By, "Generated with", "Claude-Session", and the like). The defect is fully
3
+ // visible in the command text the tool is about to run, so this is a deterministic deny with
4
+ // an escape hatch, never a prose reminder: the offending trailer is right there to point at.
5
+ //
6
+ // justification: no existing gate covers this. bash-commands denies destructive commands and
7
+ // block-remote-publish denies pushes; neither inspects the commit MESSAGE for attribution.
8
+ // lint-commit runs the linter on a commit but never reads the message text. This is the first
9
+ // gate that reads what a commit says, not just that it is a commit.
10
+ //
11
+ // ── What a project can configure (params) ───────────────────────────────────────────
12
+ // attributionPatterns regex sources (matched case-insensitively) of trailers/phrases
13
+ // that must not appear in a commit message. Replaces the built-in
14
+ // list wholesale, so a project that wants a different policy states
15
+ // its full list.
16
+ // escapeHatch a substring the author can put in the command to bypass this gate
17
+ // for one legitimate commit (e.g. importing external history that
18
+ // genuinely carries a co-author). Default '[allow-coauthor]'.
19
+ //
20
+ // ── How it detects a commit and reads its message ────────────────────────────────────
21
+ // Reuses lint-commit's git-global-option normalization so `git -C /repo -c x=y commit` is
22
+ // recognized as a commit. It then scans the WHOLE command text (which contains the -m value
23
+ // or the heredoc body) for any attribution pattern — this over-includes on purpose (a match
24
+ // inside an unrelated string is a rare false block the author clears with the escape hatch)
25
+ // rather than under-includes (a real trailer slipping through in a shape we did not parse).
26
+ //
27
+ // ── Fail-safe shape ──────────────────────────────────────────────────────────────────
28
+ // Not a commit: allow (silent). A commit with no attribution pattern: allow. A commit whose
29
+ // text matches a pattern and does NOT contain the escape hatch: deny, naming the match.
30
+
31
+ import { runGate, deny, toolInGroups } from '../../lib/hook-io.mjs';
32
+
33
+ const GATE_ID = 'no-coauthor';
34
+ const CONFIG_KEY = 'blockCoauthorTrailers';
35
+
36
+ const SHELL_GROUPS = ['shell', 'delegation'];
37
+
38
+ // Attribution trailers/phrases an AI agent tends to append. Sources, compiled case-insensitively.
39
+ const DEFAULT_ATTRIBUTION_PATTERNS = [
40
+ String.raw`co-authored-by:`,
41
+ String.raw`generated with`,
42
+ String.raw`generated by`,
43
+ String.raw`🤖 generated`,
44
+ String.raw`claude-session:`,
45
+ String.raw`assisted-by:`,
46
+ String.raw`authored-with:`,
47
+ String.raw`with claude code`,
48
+ ];
49
+
50
+ const DEFAULT_ESCAPE_HATCH = '[allow-coauthor]';
51
+
52
+ // One global git option at a time, stripped repeatedly — same normalization lint-commit and
53
+ // bash-commands use, so `git -C /repo -c x=y commit` reduces to `git commit` before matching.
54
+ const GIT_OPTION_WITH_VALUE = String.raw`(?:-[Cc]|--git-dir|--work-tree|--namespace|--exec-path|--config-env)(?:\s+|=)\S+`;
55
+ const GIT_FLAG_OPTION = String.raw`--(?:paginate|no-pager|bare|no-optional-locks)|-p`;
56
+ const GIT_GLOBAL_OPTION_PATTERN = new RegExp(
57
+ String.raw`\bgit\s+(?:${GIT_OPTION_WITH_VALUE}|${GIT_FLAG_OPTION})\s+`,
58
+ 'i',
59
+ );
60
+ const GIT_COMMIT_PATTERN = /\bgit\s+commit\b/i;
61
+
62
+ function normalizeGitOptions(command) {
63
+ let previous;
64
+ let normalized = command;
65
+ do {
66
+ previous = normalized;
67
+ normalized = normalized.replace(GIT_GLOBAL_OPTION_PATTERN, 'git ');
68
+ } while (normalized !== previous);
69
+ return normalized;
70
+ }
71
+
72
+ function isGitCommit(command) {
73
+ return GIT_COMMIT_PATTERN.test(normalizeGitOptions(command));
74
+ }
75
+
76
+ function commandTextFrom(toolInput) {
77
+ return String(toolInput.CommandLine ?? toolInput.command ?? '');
78
+ }
79
+
80
+ /** The first attribution pattern the command text matches, or null when none does. */
81
+ function matchedAttribution(command, attributionPatterns) {
82
+ for (const source of attributionPatterns) {
83
+ let pattern;
84
+ try {
85
+ pattern = new RegExp(source, 'i');
86
+ } catch {
87
+ // A malformed override pattern is skipped rather than crashing the gate; the rest of
88
+ // the list still protects.
89
+ continue;
90
+ }
91
+ if (pattern.test(command)) return source;
92
+ }
93
+ return null;
94
+ }
95
+
96
+ runGate(
97
+ {
98
+ id: GATE_ID,
99
+ configKey: CONFIG_KEY,
100
+ enabledByDefault: true,
101
+ defaultParams: {
102
+ attributionPatterns: DEFAULT_ATTRIBUTION_PATTERNS,
103
+ escapeHatch: DEFAULT_ESCAPE_HATCH,
104
+ },
105
+ },
106
+ ({ toolName, toolInput, parameters }) => {
107
+ if (!toolInGroups(toolName, SHELL_GROUPS)) return;
108
+
109
+ const command = commandTextFrom(toolInput);
110
+ if (!isGitCommit(command)) return;
111
+
112
+ const escapeHatch = parameters.escapeHatch ?? DEFAULT_ESCAPE_HATCH;
113
+ if (escapeHatch && command.includes(escapeHatch)) return;
114
+
115
+ const match = matchedAttribution(command, parameters.attributionPatterns);
116
+ if (!match) return;
117
+
118
+ deny(
119
+ GATE_ID,
120
+ `This commit message carries an AI/agent attribution trailer (matched "${match}"). ` +
121
+ 'Remove it — no Co-Authored-By, no "Generated with", no session trailer. If this ' +
122
+ `commit legitimately must keep a co-author (e.g. importing external history), add ` +
123
+ `"${escapeHatch}" to the command to allow it once, or set attributionPatterns/` +
124
+ 'blockCoauthorTrailers in .ai/config.json.',
125
+ );
126
+ },
127
+ );
@@ -0,0 +1,183 @@
1
+ // no-lint-suppression — denies a write that SILENCES the linter/type-checker instead of
2
+ // fixing the code it complains about. Turning a rule off, adding an inline disable, or
3
+ // widening an ignore list is the lazy path ("it is easier to disable the linter than to fix
4
+ // it"); this gate makes that path a deliberate, escape-hatched choice rather than a default.
5
+ //
6
+ // justification: no existing gate covers this. lint-commit RUNS the linter and blocks a
7
+ // commit when it fails, but a suppression makes the linter pass — so lint-commit goes green
8
+ // precisely when the code got worse. This gate reads the WRITE, not the lint result, and is
9
+ // the only one that catches "made it pass by turning the check off".
10
+ //
11
+ // The defect is fully visible in the content being written (the disable directive, the rule
12
+ // set to "off", the added @ts-ignore), so this is a deterministic deny with an escape hatch —
13
+ // not a prose reminder. A legitimate suppression (a documented false positive) is allowed by
14
+ // putting the escape hatch on the SAME line as the directive, which also forces a reason to
15
+ // live next to it in the diff.
16
+ //
17
+ // ── What this catches ────────────────────────────────────────────────────────────────
18
+ // inline directives in any source file:
19
+ // // eslint-disable, /* eslint-disable */, // eslint-disable-next-line,
20
+ // // @ts-ignore, // @ts-nocheck, # type: ignore, # noqa, // prettier-ignore,
21
+ // // biome-ignore, // stylelint-disable, // NOSONAR
22
+ // config edits that weaken the ruleset, in eslint/tsconfig/prettier/biome/stylelint config:
23
+ // a rule set to "off" / 0, "@ts-nocheck", disabling strict, or "ignore" additions.
24
+ //
25
+ // ── What a project can configure (params) ───────────────────────────────────────────
26
+ // suppressionPatterns regex sources (case-insensitive) that mark a line as a
27
+ // suppression. Replaces the built-in list wholesale.
28
+ // escapeHatch substring that, present on the same line as a suppression, allows
29
+ // it — so a real false positive is annotated, not smuggled. Default
30
+ // 'lint-ok:' (write e.g. `// eslint-disable-next-line ... lint-ok: <reason>`).
31
+ // watchedConfigFiles basenames whose edits are also scanned for rule-weakening. Replaces
32
+ // the built-in list.
33
+ //
34
+ // ── Fail-safe shape ──────────────────────────────────────────────────────────────────
35
+ // A non-write tool, or a write with no suppression line: allow (silent). A write that adds a
36
+ // suppression line WITHOUT the escape hatch on that same line: deny, naming the line.
37
+
38
+ import { extname } from 'node:path';
39
+ import {
40
+ runGate,
41
+ deny,
42
+ toolInGroups,
43
+ writtenContentOf,
44
+ writtenPathOf,
45
+ } from '../../lib/hook-io.mjs';
46
+
47
+ const GATE_ID = 'no-lint-suppression';
48
+ const CONFIG_KEY = 'blockLintSuppression';
49
+
50
+ // Inline suppression directives across the common linters/type-checkers. Sources, compiled
51
+ // case-insensitively and matched one line at a time.
52
+ const DEFAULT_SUPPRESSION_PATTERNS = [
53
+ String.raw`eslint-disable`,
54
+ String.raw`@ts-ignore`,
55
+ String.raw`@ts-nocheck`,
56
+ String.raw`@ts-expect-error`,
57
+ String.raw`prettier-ignore`,
58
+ String.raw`biome-ignore`,
59
+ String.raw`stylelint-disable`,
60
+ String.raw`nosonar`,
61
+ String.raw`#\s*noqa`,
62
+ String.raw`#\s*type:\s*ignore`,
63
+ String.raw`//\s*@flow-ignore`,
64
+ String.raw`istanbul ignore`,
65
+ ];
66
+
67
+ // Config files whose edits are scanned for rule-weakening (a rule set off, strict disabled).
68
+ const DEFAULT_WATCHED_CONFIG_FILES = [
69
+ 'eslint.config.mjs',
70
+ 'eslint.config.js',
71
+ 'eslint.config.cjs',
72
+ '.eslintrc',
73
+ '.eslintrc.js',
74
+ '.eslintrc.cjs',
75
+ '.eslintrc.json',
76
+ '.eslintrc.yml',
77
+ '.eslintrc.yaml',
78
+ 'tsconfig.json',
79
+ '.prettierrc',
80
+ 'biome.json',
81
+ '.stylelintrc',
82
+ '.stylelintrc.json',
83
+ ];
84
+
85
+ // Inside a watched config file, these mark a rule being turned off or a check being weakened.
86
+ const CONFIG_WEAKENING_PATTERNS = [
87
+ // a rule mapped to "off" or 0: "no-unused-vars": "off" 'rule': 0
88
+ // (no trailing \b: the value can end in a quote, which is a non-word char, so \b would
89
+ // never match after "off" and silently miss every quoted "off")
90
+ String.raw`["'][^"']+["']\s*:\s*(?:["']off["']|0)(?:\s|,|}|$)`,
91
+ // strict / type-checking disabled in tsconfig
92
+ String.raw`"(?:strict|noImplicitAny|strictNullChecks|checkJs)"\s*:\s*false`,
93
+ String.raw`@ts-nocheck`,
94
+ ];
95
+
96
+ const DEFAULT_ESCAPE_HATCH = 'lint-ok:';
97
+
98
+ function compile(sources) {
99
+ const compiled = [];
100
+ for (const source of sources) {
101
+ try {
102
+ compiled.push(new RegExp(source, 'i'));
103
+ } catch {
104
+ // Skip a malformed override pattern rather than crashing; the rest still protect.
105
+ }
106
+ }
107
+ return compiled;
108
+ }
109
+
110
+ function baseNameOf(filePath) {
111
+ const normalized = filePath.replace(/\\/g, '/');
112
+ return normalized.slice(normalized.lastIndexOf('/') + 1);
113
+ }
114
+
115
+ function isWatchedConfig(filePath, watchedConfigFiles) {
116
+ return watchedConfigFiles.includes(baseNameOf(filePath));
117
+ }
118
+
119
+ // A code file's inline directives count everywhere; a config file's edits are read with the
120
+ // rule-weakening patterns instead. We never guess a language — the directives are recognizable
121
+ // on their own.
122
+ const NON_SOURCE_EXTENSIONS = new Set(['.md', '.txt', '.lock', '.log']);
123
+
124
+ function isPlausibleSource(filePath) {
125
+ return !NON_SOURCE_EXTENSIONS.has(extname(filePath).toLowerCase());
126
+ }
127
+
128
+ /** The first written line matching any pattern and lacking the escape hatch, or null. */
129
+ function offendingLine(content, patterns, escapeHatch) {
130
+ for (const line of content.split('\n')) {
131
+ if (escapeHatch && line.includes(escapeHatch)) continue;
132
+ if (patterns.some((pattern) => pattern.test(line))) return line.trim();
133
+ }
134
+ return null;
135
+ }
136
+
137
+ runGate(
138
+ {
139
+ id: GATE_ID,
140
+ configKey: CONFIG_KEY,
141
+ enabledByDefault: true,
142
+ defaultParams: {
143
+ suppressionPatterns: DEFAULT_SUPPRESSION_PATTERNS,
144
+ watchedConfigFiles: DEFAULT_WATCHED_CONFIG_FILES,
145
+ escapeHatch: DEFAULT_ESCAPE_HATCH,
146
+ },
147
+ },
148
+ ({ toolName, toolInput, parameters }) => {
149
+ if (!toolInGroups(toolName, ['write'])) return;
150
+
151
+ const filePath = writtenPathOf(toolInput);
152
+ if (!filePath) return;
153
+
154
+ const content = writtenContentOf(toolInput);
155
+ if (!content) return;
156
+
157
+ const escapeHatch = parameters.escapeHatch ?? DEFAULT_ESCAPE_HATCH;
158
+
159
+ // Config files: scan for rule-weakening. Any source file: scan for inline suppressions.
160
+ const isConfig = isWatchedConfig(filePath, parameters.watchedConfigFiles);
161
+ const inlinePatterns = compile(parameters.suppressionPatterns);
162
+ const configPatterns = compile(CONFIG_WEAKENING_PATTERNS);
163
+
164
+ // A config file is scanned for both inline directives and rule-weakening; any other
165
+ // plausible source file only for inline directives; a non-source file (docs, lockfiles)
166
+ // is not scanned at all.
167
+ let patterns = [];
168
+ if (isConfig) patterns = [...inlinePatterns, ...configPatterns];
169
+ else if (isPlausibleSource(filePath)) patterns = inlinePatterns;
170
+ if (patterns.length === 0) return;
171
+
172
+ const offender = offendingLine(content, patterns, escapeHatch);
173
+ if (!offender) return;
174
+
175
+ deny(
176
+ GATE_ID,
177
+ `This write silences the linter/type-checker instead of fixing the code: "${offender}". ` +
178
+ 'Fix the underlying issue rather than turning the check off. If this is a genuine, ' +
179
+ `documented false positive, put "${escapeHatch} <reason>" on the same line so the ` +
180
+ 'reason lives next to the suppression, or set blockLintSuppression in .ai/config.json.',
181
+ );
182
+ },
183
+ );