@devrik-tools/claude-gates 0.7.2 → 0.9.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 +3 -3
- package/README.es.md +129 -11
- package/README.md +120 -10
- package/cli/doctor.mjs +132 -0
- package/cli/evidence.mjs +78 -0
- package/cli/hooks-manifest.mjs +89 -0
- package/cli/index.mjs +124 -6
- package/cli/init.mjs +97 -3
- package/cli/log.mjs +70 -0
- package/cli/materialize.mjs +36 -2
- package/cli/registry.mjs +12 -1
- package/cli/selection.mjs +18 -0
- package/cli/smoke-fixtures.json +159 -25
- package/cli/task.mjs +104 -11
- package/cli/toggle.mjs +125 -0
- package/package.json +4 -3
- package/plugins/gates/.claude-plugin/plugin.json +1 -1
- package/plugins/gates/hooks/doctor.mjs +47 -1
- package/plugins/gates/hooks/gates/atomic-commit/index.mjs +92 -119
- package/plugins/gates/hooks/gates/audit-before-build/index.mjs +101 -66
- package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +8 -8
- package/plugins/gates/hooks/gates/autonomous-mode/stop.mjs +13 -64
- package/plugins/gates/hooks/gates/bash-commands/index.mjs +186 -163
- package/plugins/gates/hooks/gates/block-remote-publish/index.mjs +100 -101
- package/plugins/gates/hooks/gates/brief-approved/index.mjs +71 -140
- package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +56 -137
- package/plugins/gates/hooks/gates/capability-map/index.mjs +280 -506
- package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +94 -215
- package/plugins/gates/hooks/gates/circuit-breaker/track.mjs +285 -0
- package/plugins/gates/hooks/gates/dependency-skills/index.mjs +127 -46
- package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +81 -19
- package/plugins/gates/hooks/gates/engram-first/index.mjs +41 -0
- package/plugins/gates/hooks/gates/engram-first/session-start.mjs +64 -0
- package/plugins/gates/hooks/gates/engram-first/shared.mjs +90 -0
- package/plugins/gates/hooks/gates/engram-first/stop.mjs +23 -0
- package/plugins/gates/hooks/gates/engram-first/track.mjs +97 -0
- package/plugins/gates/hooks/gates/feature-catalog/index.mjs +156 -50
- package/plugins/gates/hooks/gates/force-parallel/index.mjs +53 -95
- package/plugins/gates/hooks/gates/forge-flow/index.mjs +51 -83
- package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +40 -80
- package/plugins/gates/hooks/gates/intent-flow/index.mjs +23 -149
- package/plugins/gates/hooks/gates/library-docs/index.mjs +338 -0
- package/plugins/gates/hooks/gates/library-docs/track.mjs +95 -0
- package/plugins/gates/hooks/gates/lint-commit/index.mjs +88 -97
- package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +48 -95
- package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +69 -31
- package/plugins/gates/hooks/gates/never-assume/index.mjs +23 -18
- package/plugins/gates/hooks/gates/no-blocking/index.mjs +127 -86
- package/plugins/gates/hooks/gates/no-coauthor/index.mjs +108 -88
- package/plugins/gates/hooks/gates/no-explanatory-comments/index.mjs +281 -0
- package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +170 -110
- package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +46 -77
- package/plugins/gates/hooks/gates/no-reconfirm/index.mjs +85 -47
- package/plugins/gates/hooks/gates/no-trivial-scripts/index.mjs +114 -0
- package/plugins/gates/hooks/gates/protected-paths/index.mjs +137 -90
- package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +95 -51
- package/plugins/gates/hooks/gates/require-monitor/index.mjs +126 -0
- package/plugins/gates/hooks/gates/require-task-split/index.mjs +88 -0
- package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +72 -216
- package/plugins/gates/hooks/gates/risk-level/index.mjs +51 -204
- package/plugins/gates/hooks/gates/root-cause-first/index.mjs +34 -17
- package/plugins/gates/hooks/gates/root-whitelist/index.mjs +160 -95
- package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +196 -97
- package/plugins/gates/hooks/gates/sdd-specs/index.mjs +90 -143
- package/plugins/gates/hooks/gates/staged-lint/index.mjs +98 -106
- package/plugins/gates/hooks/gates/stop-pending/index.mjs +47 -148
- package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +78 -68
- package/plugins/gates/hooks/gates/test-matrix/index.mjs +53 -88
- package/plugins/gates/hooks/gates/tool-map/index.mjs +80 -110
- package/plugins/gates/hooks/hooks.json +152 -42
- package/plugins/gates/hooks/lib/config.mjs +6 -3
- package/plugins/gates/hooks/lib/delegation.mjs +352 -0
- package/plugins/gates/hooks/lib/gate-log.mjs +141 -0
- package/plugins/gates/hooks/lib/git.mjs +262 -0
- package/plugins/gates/hooks/lib/hook-io.mjs +451 -83
- package/plugins/gates/hooks/lib/session-state.mjs +150 -0
- package/plugins/gates/hooks/lib/signals.mjs +18 -13
- package/plugins/gates/hooks/lib/testing.mjs +148 -0
- package/plugins/gates/hooks/lib/tools.mjs +144 -0
- package/plugins/tasks/.claude-plugin/plugin.json +1 -1
- package/plugins/tasks/hooks/lib/task-store.mjs +12 -6
- package/plugins/tasks/hooks/register-requests.mjs +37 -10
- package/registry.json +249 -38
|
@@ -1,117 +1,179 @@
|
|
|
1
|
-
// no-blocking — denies syntactic forms of waiting in the foreground (sleeping, polling
|
|
2
|
-
//
|
|
3
|
-
// on a delegation prompt
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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.
|
|
1
|
+
// no-blocking — denies syntactic forms of waiting in the foreground (sleeping, polling loops,
|
|
2
|
+
// following unbounded output, foreground dev servers, Windows pauses) on a real shell command
|
|
3
|
+
// and on a delegation prompt that orders one. Off by default: a project opts in. Deliberate
|
|
4
|
+
// limits: only SYNTACTIC waiting is recognized, never whether other work was available
|
|
5
|
+
// meanwhile; each command segment is judged on its own, so a detach form (`&`, nohup,
|
|
6
|
+
// Start-Job, --detach) exempts only the segment it is in, and only the real
|
|
7
|
+
// `run_in_background: true` flag exempts the whole call — the words in the command do not.
|
|
20
8
|
|
|
9
|
+
import { hasRealCommandIntent } from '../../lib/delegation.mjs';
|
|
21
10
|
import {
|
|
22
|
-
|
|
11
|
+
compileRegex,
|
|
12
|
+
delegationPromptOf,
|
|
23
13
|
deny,
|
|
14
|
+
escapeRegExp,
|
|
15
|
+
normalizeRulePairs,
|
|
16
|
+
runGate,
|
|
17
|
+
shellCommandOf,
|
|
24
18
|
toolInGroups,
|
|
25
|
-
delegationPromptOf,
|
|
26
19
|
} from '../../lib/hook-io.mjs';
|
|
27
20
|
|
|
28
21
|
const GATE_ID = 'no-blocking';
|
|
29
22
|
const CONFIG_KEY = 'blockWaitingCommands';
|
|
30
23
|
|
|
31
24
|
const DEFAULT_WAIT_JUSTIFIED_MARKER = 'WAIT-JUSTIFIED:';
|
|
25
|
+
const DEFAULT_REASON = 'Blocking/waiting command is not allowed.';
|
|
32
26
|
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
const
|
|
27
|
+
// Where a command may begin inside a segment or a sentence: the start, after a control word,
|
|
28
|
+
// or after a separator a prompt may still carry.
|
|
29
|
+
const COMMAND_START = String.raw`(?:^|[|;&(]\s*|\b(?:then|do|else)\s+)`;
|
|
30
|
+
const FOLLOW_COMMANDS =
|
|
31
|
+
'(?:tail|journalctl|kubectl\\s+logs|docker(?:-compose|\\s+compose)?\\s+logs)';
|
|
36
32
|
|
|
37
|
-
/** Blocking rules as `[regexSource, reason]`, matched case-insensitively. */
|
|
38
33
|
const DEFAULT_BLOCKING_PATTERNS = [
|
|
39
34
|
[
|
|
40
|
-
|
|
35
|
+
`${COMMAND_START}sleep\\s`,
|
|
41
36
|
'Sleeping in the foreground spends the turn doing nothing. If waiting for something ' +
|
|
42
37
|
'to finish, launch it in the background and move on to something else; if waiting ' +
|
|
43
38
|
'on an external condition, use a monitor.',
|
|
44
39
|
],
|
|
40
|
+
[
|
|
41
|
+
`${COMMAND_START}timeout(?:\\.exe)?\\s+(?:/t\\s+)?\\d+(?:\\s+/nobreak)?\\s*$`,
|
|
42
|
+
"'timeout N' with no command is a sleep. Wrap a real command (timeout 120 npm test) " +
|
|
43
|
+
'or launch the wait in the background.',
|
|
44
|
+
],
|
|
45
45
|
[
|
|
46
46
|
String.raw`\bStart-Sleep\b`,
|
|
47
47
|
'Start-Sleep freezes the turn. Launch the work in the background and continue with ' +
|
|
48
48
|
'whatever does not depend on it.',
|
|
49
49
|
],
|
|
50
50
|
[
|
|
51
|
-
String.raw
|
|
51
|
+
String.raw`${COMMAND_START}wait\b|\s--wait\b|\bWait-Process\b|\bWait-Job\b|\bStart-Process\b[^|;\n]*\s-Wait\b`,
|
|
52
52
|
'Waiting for another process to finish blocks the whole turn. Launch it in the ' +
|
|
53
53
|
'background and consume the result when it arrives, not before.',
|
|
54
54
|
],
|
|
55
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(''),
|
|
56
|
+
String.raw`\b${FOLLOW_COMMANDS}\b[^|;&\n]*\s(?:-[a-z]*f[a-z]*|--follow)(?=\s|$)`,
|
|
61
57
|
'Following live output never returns and takes the turn with it. Read the file once, ' +
|
|
62
58
|
'or leave the following to a background process.',
|
|
63
59
|
],
|
|
64
60
|
[
|
|
65
|
-
|
|
61
|
+
`${COMMAND_START}watch\\s+`,
|
|
66
62
|
"'watch' repeats forever and blocks. Run the command once; if a change genuinely " +
|
|
67
63
|
'needs watching, use a monitor that does not take the turn.',
|
|
68
64
|
],
|
|
69
65
|
[
|
|
70
|
-
String.raw`\b(until|while)\b[
|
|
66
|
+
String.raw`\b(?:until|while)\b[\s\S]{0,80}\bdo\b[\s\S]{0,80}\bsleep\b`,
|
|
71
67
|
'A loop that sleeps waiting for something to change is foreground polling: it blocks ' +
|
|
72
68
|
'and it spins. Use a monitor with the condition, or launch the work in the ' +
|
|
73
69
|
'background and wait for its notification.',
|
|
74
70
|
],
|
|
75
71
|
[
|
|
76
|
-
String.raw`\b(npm|pnpm|yarn|bun)\s+(run\s+)?(dev|start|serve|watch)
|
|
72
|
+
String.raw`\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:dev|start|serve|watch)(?=\s|$)`,
|
|
77
73
|
'A dev server does not end on its own: in the foreground it takes the turn until ' +
|
|
78
74
|
'something kills it. Launch it in the background and keep working while it comes up.',
|
|
79
75
|
],
|
|
76
|
+
[
|
|
77
|
+
String.raw`${COMMAND_START}ping\b[^|;&\n]*\s-n\s+\d{2,}\b|${COMMAND_START}pause\b|\bRead-Host\b`,
|
|
78
|
+
'A ping loop, pause or Read-Host waits on the clock or on a keyboard nobody is at. ' +
|
|
79
|
+
'Remove the wait; if something must be awaited, do it in the background.',
|
|
80
|
+
],
|
|
80
81
|
];
|
|
81
82
|
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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,
|
|
83
|
+
// ── Shell segments ──────────────────────────────────────────────────────────────────
|
|
84
|
+
// A lone `&` backgrounds the segment before it; `>&` and `&>` are redirections, not separators.
|
|
85
|
+
const SEPARATOR_PATTERN = /(\|\||&&|\||;|\n|(?<!>)&(?![&>]))/;
|
|
86
|
+
const SHELL_WRAPPER_PATTERN =
|
|
87
|
+
/^(?:sh|bash|zsh|dash|ksh)\s+(-\w+)\s+(?:"([^"]*)"|'([^']*)')/i;
|
|
88
|
+
const DETACHED_SEGMENT_FORMS = [
|
|
92
89
|
/\bnohup\b/i,
|
|
93
90
|
/\bstart\s+\/b\b/i,
|
|
94
91
|
/--detach\b/i,
|
|
95
|
-
|
|
92
|
+
/^docker(?:-compose|\s+compose)?\b[^|;\n]*\s-d(?=\s|$)/i,
|
|
96
93
|
/\bStart-Job\b/i,
|
|
97
|
-
/\bStart-Process\b[^|;\n]*-NoNewWindow\b/i,
|
|
98
|
-
/\brun_in_background\b/i,
|
|
99
94
|
];
|
|
100
95
|
|
|
101
|
-
function
|
|
102
|
-
|
|
96
|
+
function stripGrouping(text) {
|
|
97
|
+
let stripped = text.trim();
|
|
98
|
+
while (stripped.startsWith('(') || stripped.startsWith('$('))
|
|
99
|
+
stripped = stripped.replace(/^\$?\(/, '').trim();
|
|
100
|
+
while (stripped.endsWith(')')) stripped = stripped.slice(0, -1).trimEnd();
|
|
101
|
+
return stripped;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function shellSegments(command) {
|
|
105
|
+
const parts = String(command).split(SEPARATOR_PATTERN);
|
|
106
|
+
const segments = [];
|
|
107
|
+
for (let index = 0; index < parts.length; index += 2) {
|
|
108
|
+
const text = stripGrouping(parts[index]);
|
|
109
|
+
if (!text) continue;
|
|
110
|
+
const sentToBackground = parts[index + 1] === '&';
|
|
111
|
+
const wrapped = SHELL_WRAPPER_PATTERN.exec(text);
|
|
112
|
+
if (wrapped && wrapped[1].includes('c')) {
|
|
113
|
+
for (const inner of shellSegments(wrapped[2] ?? wrapped[3]))
|
|
114
|
+
segments.push({
|
|
115
|
+
text: inner.text,
|
|
116
|
+
detached: sentToBackground || inner.detached,
|
|
117
|
+
});
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const detached =
|
|
121
|
+
sentToBackground ||
|
|
122
|
+
DETACHED_SEGMENT_FORMS.some((form) => form.test(text));
|
|
123
|
+
segments.push({ text, detached });
|
|
124
|
+
}
|
|
125
|
+
return segments;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── Rules ───────────────────────────────────────────────────────────────────────────
|
|
129
|
+
function blockingRules(parameters) {
|
|
130
|
+
return normalizeRulePairs(parameters.blockingPatterns, DEFAULT_REASON)
|
|
131
|
+
.map(({ source, reason }) => ({ pattern: compileRegex(source), reason }))
|
|
132
|
+
.filter((rule) => rule.pattern !== null);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function isJustified(text, marker) {
|
|
136
|
+
if (!marker) return false;
|
|
137
|
+
const reasonAfterMarker = String.raw`\s*\S+\s+\S+`;
|
|
138
|
+
return new RegExp(`${escapeRegExp(marker)}${reasonAfterMarker}`, 'i').test(
|
|
139
|
+
text,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function denyBlocking(reason, marker, matched) {
|
|
144
|
+
deny(
|
|
145
|
+
CONFIG_KEY,
|
|
146
|
+
`${reason} (matched: "${matched.trim()}") Launch it with run_in_background: true, or ` +
|
|
147
|
+
`if this wait is genuinely justified add "${marker} <concrete reason>" to the command. ` +
|
|
148
|
+
'A declared wait is a decision; a silent one is an oversight.',
|
|
149
|
+
);
|
|
103
150
|
}
|
|
104
151
|
|
|
105
|
-
function
|
|
106
|
-
|
|
152
|
+
function checkShellCommand(command, parameters, marker) {
|
|
153
|
+
const foreground = shellSegments(command)
|
|
154
|
+
.filter((segment) => !segment.detached)
|
|
155
|
+
.map((segment) => segment.text);
|
|
156
|
+
if (foreground.length === 0) return;
|
|
157
|
+
// The joined text lets a rule see a loop whose body sits in another segment.
|
|
158
|
+
const candidates =
|
|
159
|
+
foreground.length > 1 ? [...foreground, foreground.join('\n')] : foreground;
|
|
160
|
+
for (const { pattern, reason } of blockingRules(parameters)) {
|
|
161
|
+
for (const candidate of candidates) {
|
|
162
|
+
const match = pattern.exec(candidate);
|
|
163
|
+
if (match) denyBlocking(reason, marker, match[0]);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
107
166
|
}
|
|
108
167
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
168
|
+
function checkDelegationPrompt(prompt, parameters, marker) {
|
|
169
|
+
for (const { pattern, reason } of blockingRules(parameters)) {
|
|
170
|
+
if (hasRealCommandIntent(prompt, pattern))
|
|
171
|
+
denyBlocking(
|
|
172
|
+
`${reason} The delegation prompt orders the wait; a subagent must not do it either.`,
|
|
173
|
+
marker,
|
|
174
|
+
pattern.exec(prompt)?.[0] ?? '',
|
|
175
|
+
);
|
|
113
176
|
}
|
|
114
|
-
return delegationPromptOf(toolInput);
|
|
115
177
|
}
|
|
116
178
|
|
|
117
179
|
runGate(
|
|
@@ -126,38 +188,17 @@ runGate(
|
|
|
126
188
|
},
|
|
127
189
|
({ toolName, toolInput, parameters }) => {
|
|
128
190
|
const isShell = toolInGroups(toolName, ['shell']);
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
if (!
|
|
134
|
-
|
|
135
|
-
// Already declared in the background: the turn stays free, which is all that matters.
|
|
191
|
+
if (!isShell && !toolInGroups(toolName, ['delegation'])) return;
|
|
192
|
+
const text = isShell
|
|
193
|
+
? shellCommandOf(toolInput)
|
|
194
|
+
: delegationPromptOf(toolInput);
|
|
195
|
+
if (!text.trim()) return;
|
|
136
196
|
if (toolInput.run_in_background === true) return;
|
|
137
|
-
if (detachesFromTurn(command)) return;
|
|
138
197
|
|
|
139
|
-
// A declared wait with its reason is a decision, not an oversight.
|
|
140
198
|
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
|
-
}
|
|
199
|
+
if (isJustified(text, marker)) return;
|
|
147
200
|
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
CONFIG_KEY,
|
|
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
|
-
}
|
|
201
|
+
if (isShell) checkShellCommand(text, parameters, marker);
|
|
202
|
+
else checkDelegationPrompt(text, parameters, marker);
|
|
162
203
|
},
|
|
163
204
|
);
|
|
@@ -1,98 +1,116 @@
|
|
|
1
|
-
// no-coauthor — denies a `git commit`
|
|
2
|
-
// (Co-Authored-By, "Generated with
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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.
|
|
1
|
+
// no-coauthor — denies a `git commit` whose MESSAGE carries an AI/agent attribution
|
|
2
|
+
// (Co-Authored-By, Claude-Session, "Generated with Claude"), and a delegation prompt that
|
|
3
|
+
// orders one. The message is what the commit will record: -m values, a heredoc body,
|
|
4
|
+
// --trailer values and a -F file read from the project. Only a command segment that RUNS git
|
|
5
|
+
// commit counts (a `git log | grep Co-Authored-By` is not one). Deliberate limit: a message
|
|
6
|
+
// built from a substitution (`-m "$(cat msg)"`) cannot be read without running it.
|
|
30
7
|
|
|
31
|
-
import {
|
|
8
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
9
|
+
import { resolve } from 'node:path';
|
|
10
|
+
import { projectRootOf } from '../../lib/config.mjs';
|
|
11
|
+
import { hasRealCommandIntent } from '../../lib/delegation.mjs';
|
|
12
|
+
import {
|
|
13
|
+
commitMessageOf,
|
|
14
|
+
isDryRunCommit,
|
|
15
|
+
isGitCommit,
|
|
16
|
+
normalizeGitCommand,
|
|
17
|
+
} from '../../lib/git.mjs';
|
|
18
|
+
import {
|
|
19
|
+
compileRegexList,
|
|
20
|
+
delegationPromptOf,
|
|
21
|
+
deny,
|
|
22
|
+
runGate,
|
|
23
|
+
shellCommandOf,
|
|
24
|
+
toolInGroups,
|
|
25
|
+
} from '../../lib/hook-io.mjs';
|
|
32
26
|
|
|
33
27
|
const GATE_ID = 'no-coauthor';
|
|
34
28
|
const CONFIG_KEY = 'blockCoauthorTrailers';
|
|
35
29
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
// Attribution trailers/phrases an AI agent tends to append. Sources, compiled case-insensitively.
|
|
30
|
+
// Narrow on purpose: "generated by vitest" or "usage with Claude Code" in a subject line is
|
|
31
|
+
// not an attribution; a trailer or a "generated with/by <an agent>" phrase is.
|
|
39
32
|
const DEFAULT_ATTRIBUTION_PATTERNS = [
|
|
40
|
-
String.raw`co-authored-by
|
|
41
|
-
String.raw`generated with`,
|
|
42
|
-
String.raw`generated by`,
|
|
43
|
-
String.raw`🤖 generated`,
|
|
33
|
+
String.raw`co-authored-by\s*[:=]`,
|
|
44
34
|
String.raw`claude-session:`,
|
|
45
35
|
String.raw`assisted-by:`,
|
|
46
36
|
String.raw`authored-with:`,
|
|
47
|
-
String.raw`with claude
|
|
37
|
+
String.raw`generated (?:with|by) (?:claude|ai|copilot|codex|cursor|gemini|chatgpt|an? (?:ai|llm|assistant)|a (?:bot|model))\b`,
|
|
38
|
+
String.raw`🤖 generated`,
|
|
48
39
|
];
|
|
49
40
|
|
|
50
41
|
const DEFAULT_ESCAPE_HATCH = '[allow-coauthor]';
|
|
51
42
|
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
const
|
|
56
|
-
const
|
|
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;
|
|
43
|
+
// ── The commit segment ──────────────────────────────────────────────────────────────
|
|
44
|
+
const SEGMENT_SEPARATOR = /;|&&|\|\||\||\n/;
|
|
45
|
+
const ARGUMENT_PATTERN = /"([^"]*)"|'([^']*)'|(\S+)/g;
|
|
46
|
+
const GIT_BINARY_TOKEN = /(?:^|[\\/])git(?:\.exe)?$/i;
|
|
47
|
+
const WRAPPER_TOKEN = /^(?:\w+=\S*|command|sudo|env)$/i;
|
|
61
48
|
|
|
62
|
-
function
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
} while (normalized !== previous);
|
|
69
|
-
return normalized;
|
|
49
|
+
function runsGit(segment) {
|
|
50
|
+
const tokens = [];
|
|
51
|
+
for (const match of segment.matchAll(ARGUMENT_PATTERN))
|
|
52
|
+
tokens.push(match[1] ?? match[2] ?? match[3]);
|
|
53
|
+
while (tokens.length > 0 && WRAPPER_TOKEN.test(tokens[0])) tokens.shift();
|
|
54
|
+
return tokens.length > 0 && GIT_BINARY_TOKEN.test(tokens[0]);
|
|
70
55
|
}
|
|
71
56
|
|
|
72
|
-
|
|
73
|
-
|
|
57
|
+
// A heredoc body spans lines, so the command is split only after the segment that opens it
|
|
58
|
+
// has been recognized on its first line.
|
|
59
|
+
function runsRealCommit(command) {
|
|
60
|
+
return String(command)
|
|
61
|
+
.split(SEGMENT_SEPARATOR)
|
|
62
|
+
.some((segment) => {
|
|
63
|
+
if (!runsGit(segment)) return false;
|
|
64
|
+
const normalized = normalizeGitCommand(segment);
|
|
65
|
+
return isGitCommit(normalized) && !isDryRunCommit(normalized);
|
|
66
|
+
});
|
|
74
67
|
}
|
|
75
68
|
|
|
76
|
-
function
|
|
77
|
-
return
|
|
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;
|
|
69
|
+
function fileReader(root) {
|
|
70
|
+
return (path) => {
|
|
71
|
+
const resolved = resolve(root, path);
|
|
84
72
|
try {
|
|
85
|
-
|
|
73
|
+
return existsSync(resolved) ? readFileSync(resolved, 'utf8') : '';
|
|
86
74
|
} catch {
|
|
87
|
-
|
|
88
|
-
// the list still protects.
|
|
89
|
-
continue;
|
|
75
|
+
return '';
|
|
90
76
|
}
|
|
91
|
-
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function firstMatch(text, patterns) {
|
|
81
|
+
for (const pattern of patterns) {
|
|
82
|
+
const match = pattern.exec(text);
|
|
83
|
+
if (match) return match[0];
|
|
92
84
|
}
|
|
93
85
|
return null;
|
|
94
86
|
}
|
|
95
87
|
|
|
88
|
+
function denyAttribution(matched, escapeHatch) {
|
|
89
|
+
deny(
|
|
90
|
+
CONFIG_KEY,
|
|
91
|
+
`This commit message carries an AI/agent attribution ("${matched}"). Remove it — no ` +
|
|
92
|
+
'Co-Authored-By, no "Generated with", no session trailer. If this commit legitimately ' +
|
|
93
|
+
`must keep a co-author (e.g. importing external history), add "${escapeHatch}" to the ` +
|
|
94
|
+
`command to allow it once, or set attributionPatterns under ${CONFIG_KEY} in .ai/config.json.`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function checkDelegationPrompt(prompt, patterns, escapeHatch) {
|
|
99
|
+
if (escapeHatch && prompt.includes(escapeHatch)) return;
|
|
100
|
+
const ordered = patterns.find((pattern) =>
|
|
101
|
+
hasRealCommandIntent(prompt, pattern),
|
|
102
|
+
);
|
|
103
|
+
if (ordered) denyAttribution(ordered.exec(prompt)[0], escapeHatch);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function checkShellCommand(command, patterns, escapeHatch, root) {
|
|
107
|
+
if (!runsRealCommit(command)) return;
|
|
108
|
+
if (escapeHatch && command.includes(escapeHatch)) return;
|
|
109
|
+
const message = commitMessageOf(command, { readFile: fileReader(root) });
|
|
110
|
+
const matched = firstMatch(message, patterns);
|
|
111
|
+
if (matched) denyAttribution(matched, escapeHatch);
|
|
112
|
+
}
|
|
113
|
+
|
|
96
114
|
runGate(
|
|
97
115
|
{
|
|
98
116
|
id: GATE_ID,
|
|
@@ -103,25 +121,27 @@ runGate(
|
|
|
103
121
|
escapeHatch: DEFAULT_ESCAPE_HATCH,
|
|
104
122
|
},
|
|
105
123
|
},
|
|
106
|
-
({ toolName, toolInput, parameters }) => {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
const command = commandTextFrom(toolInput);
|
|
110
|
-
if (!isGitCommit(command)) return;
|
|
124
|
+
({ toolName, toolInput, parameters, cwd }) => {
|
|
125
|
+
const isShell = toolInGroups(toolName, ['shell']);
|
|
126
|
+
if (!isShell && !toolInGroups(toolName, ['delegation'])) return;
|
|
111
127
|
|
|
112
|
-
const escapeHatch = parameters.escapeHatch ??
|
|
113
|
-
|
|
128
|
+
const escapeHatch = String(parameters.escapeHatch ?? '');
|
|
129
|
+
const { patterns } = compileRegexList(parameters.attributionPatterns);
|
|
130
|
+
if (patterns.length === 0) return;
|
|
114
131
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
132
|
+
if (isShell) {
|
|
133
|
+
checkShellCommand(
|
|
134
|
+
shellCommandOf(toolInput),
|
|
135
|
+
patterns,
|
|
136
|
+
escapeHatch,
|
|
137
|
+
projectRootOf(cwd) ?? cwd,
|
|
138
|
+
);
|
|
139
|
+
} else {
|
|
140
|
+
checkDelegationPrompt(
|
|
141
|
+
delegationPromptOf(toolInput),
|
|
142
|
+
patterns,
|
|
143
|
+
escapeHatch,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
126
146
|
},
|
|
127
147
|
);
|