@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/README.es.md +39 -4
- package/README.md +34 -5
- package/cli/config.mjs +126 -124
- package/cli/init.mjs +303 -276
- package/cli/install.mjs +281 -175
- package/cli/materialize.mjs +103 -102
- package/cli/registry.mjs +139 -136
- package/cli/smoke-fixtures.json +65 -0
- package/cli/task.mjs +140 -140
- package/package.json +1 -1
- package/plugins/gates/.claude-plugin/plugin.json +1 -1
- package/plugins/gates/hooks/ask-adoption.mjs +147 -147
- package/plugins/gates/hooks/doctor.mjs +207 -207
- package/plugins/gates/hooks/gates/atomic-commit/index.mjs +229 -0
- package/plugins/gates/hooks/gates/audit-before-build/index.mjs +110 -88
- package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +50 -50
- package/plugins/gates/hooks/gates/bash-commands/index.mjs +215 -215
- package/plugins/gates/hooks/gates/brief-approved/index.mjs +216 -0
- package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +269 -265
- package/plugins/gates/hooks/gates/capability-map/index.mjs +701 -0
- package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +527 -501
- package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +48 -43
- package/plugins/gates/hooks/gates/feature-catalog/index.mjs +83 -83
- package/plugins/gates/hooks/gates/force-parallel/index.mjs +134 -119
- package/plugins/gates/hooks/gates/forge-flow/index.mjs +134 -134
- package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +187 -187
- package/plugins/gates/hooks/gates/intent-flow/index.mjs +260 -260
- package/plugins/gates/hooks/gates/lint-commit/index.mjs +152 -149
- package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +180 -180
- package/plugins/gates/hooks/gates/never-assume/index.mjs +59 -58
- package/plugins/gates/hooks/gates/no-blocking/index.mjs +163 -148
- package/plugins/gates/hooks/gates/no-coauthor/index.mjs +127 -0
- package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +183 -0
- package/plugins/gates/hooks/gates/protected-paths/index.mjs +149 -144
- package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +91 -89
- package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +263 -159
- package/plugins/gates/hooks/gates/risk-level/index.mjs +265 -263
- package/plugins/gates/hooks/gates/root-cause-first/index.mjs +57 -56
- package/plugins/gates/hooks/gates/root-whitelist/index.mjs +211 -131
- package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +181 -184
- package/plugins/gates/hooks/gates/sdd-specs/index.mjs +256 -256
- package/plugins/gates/hooks/gates/staged-lint/index.mjs +187 -0
- package/plugins/gates/hooks/gates/stop-pending/index.mjs +169 -164
- package/plugins/gates/hooks/gates/test-matrix/index.mjs +187 -187
- package/plugins/gates/hooks/gates/tool-map/index.mjs +168 -143
- package/plugins/gates/hooks/hooks.json +61 -0
- package/plugins/gates/hooks/lib/config.mjs +179 -172
- package/plugins/gates/hooks/lib/hook-io.mjs +367 -357
- package/plugins/gates/hooks/lib/signals.mjs +172 -127
- package/plugins/gates/hooks/wiring-check.mjs +227 -227
- package/plugins/tasks/.claude-plugin/plugin.json +1 -1
- package/plugins/tasks/hooks/hooks.json +26 -26
- package/plugins/tasks/hooks/lib/task-store.mjs +217 -197
- package/plugins/tasks/hooks/register-requests.mjs +145 -145
- package/plugins/tasks/hooks/session-tasks.mjs +108 -108
- 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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
);
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
+
);
|