@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.
Files changed (83) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/README.es.md +129 -11
  3. package/README.md +120 -10
  4. package/cli/doctor.mjs +132 -0
  5. package/cli/evidence.mjs +78 -0
  6. package/cli/hooks-manifest.mjs +89 -0
  7. package/cli/index.mjs +124 -6
  8. package/cli/init.mjs +97 -3
  9. package/cli/log.mjs +70 -0
  10. package/cli/materialize.mjs +36 -2
  11. package/cli/registry.mjs +12 -1
  12. package/cli/selection.mjs +18 -0
  13. package/cli/smoke-fixtures.json +159 -25
  14. package/cli/task.mjs +104 -11
  15. package/cli/toggle.mjs +125 -0
  16. package/package.json +4 -3
  17. package/plugins/gates/.claude-plugin/plugin.json +1 -1
  18. package/plugins/gates/hooks/doctor.mjs +47 -1
  19. package/plugins/gates/hooks/gates/atomic-commit/index.mjs +92 -119
  20. package/plugins/gates/hooks/gates/audit-before-build/index.mjs +101 -66
  21. package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +8 -8
  22. package/plugins/gates/hooks/gates/autonomous-mode/stop.mjs +13 -64
  23. package/plugins/gates/hooks/gates/bash-commands/index.mjs +186 -163
  24. package/plugins/gates/hooks/gates/block-remote-publish/index.mjs +100 -101
  25. package/plugins/gates/hooks/gates/brief-approved/index.mjs +71 -140
  26. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +56 -137
  27. package/plugins/gates/hooks/gates/capability-map/index.mjs +280 -506
  28. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +94 -215
  29. package/plugins/gates/hooks/gates/circuit-breaker/track.mjs +285 -0
  30. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +127 -46
  31. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +81 -19
  32. package/plugins/gates/hooks/gates/engram-first/index.mjs +41 -0
  33. package/plugins/gates/hooks/gates/engram-first/session-start.mjs +64 -0
  34. package/plugins/gates/hooks/gates/engram-first/shared.mjs +90 -0
  35. package/plugins/gates/hooks/gates/engram-first/stop.mjs +23 -0
  36. package/plugins/gates/hooks/gates/engram-first/track.mjs +97 -0
  37. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +156 -50
  38. package/plugins/gates/hooks/gates/force-parallel/index.mjs +53 -95
  39. package/plugins/gates/hooks/gates/forge-flow/index.mjs +51 -83
  40. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +40 -80
  41. package/plugins/gates/hooks/gates/intent-flow/index.mjs +23 -149
  42. package/plugins/gates/hooks/gates/library-docs/index.mjs +338 -0
  43. package/plugins/gates/hooks/gates/library-docs/track.mjs +95 -0
  44. package/plugins/gates/hooks/gates/lint-commit/index.mjs +88 -97
  45. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +48 -95
  46. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +69 -31
  47. package/plugins/gates/hooks/gates/never-assume/index.mjs +23 -18
  48. package/plugins/gates/hooks/gates/no-blocking/index.mjs +127 -86
  49. package/plugins/gates/hooks/gates/no-coauthor/index.mjs +108 -88
  50. package/plugins/gates/hooks/gates/no-explanatory-comments/index.mjs +281 -0
  51. package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +170 -110
  52. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +46 -77
  53. package/plugins/gates/hooks/gates/no-reconfirm/index.mjs +85 -47
  54. package/plugins/gates/hooks/gates/no-trivial-scripts/index.mjs +114 -0
  55. package/plugins/gates/hooks/gates/protected-paths/index.mjs +137 -90
  56. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +95 -51
  57. package/plugins/gates/hooks/gates/require-monitor/index.mjs +126 -0
  58. package/plugins/gates/hooks/gates/require-task-split/index.mjs +88 -0
  59. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +72 -216
  60. package/plugins/gates/hooks/gates/risk-level/index.mjs +51 -204
  61. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +34 -17
  62. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +160 -95
  63. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +196 -97
  64. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +90 -143
  65. package/plugins/gates/hooks/gates/staged-lint/index.mjs +98 -106
  66. package/plugins/gates/hooks/gates/stop-pending/index.mjs +47 -148
  67. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +78 -68
  68. package/plugins/gates/hooks/gates/test-matrix/index.mjs +53 -88
  69. package/plugins/gates/hooks/gates/tool-map/index.mjs +80 -110
  70. package/plugins/gates/hooks/hooks.json +152 -42
  71. package/plugins/gates/hooks/lib/config.mjs +6 -3
  72. package/plugins/gates/hooks/lib/delegation.mjs +352 -0
  73. package/plugins/gates/hooks/lib/gate-log.mjs +141 -0
  74. package/plugins/gates/hooks/lib/git.mjs +262 -0
  75. package/plugins/gates/hooks/lib/hook-io.mjs +451 -83
  76. package/plugins/gates/hooks/lib/session-state.mjs +150 -0
  77. package/plugins/gates/hooks/lib/signals.mjs +18 -13
  78. package/plugins/gates/hooks/lib/testing.mjs +148 -0
  79. package/plugins/gates/hooks/lib/tools.mjs +144 -0
  80. package/plugins/tasks/.claude-plugin/plugin.json +1 -1
  81. package/plugins/tasks/hooks/lib/task-store.mjs +12 -6
  82. package/plugins/tasks/hooks/register-requests.mjs +37 -10
  83. package/registry.json +249 -38
@@ -1,117 +1,179 @@
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.
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 callthe words in the command do not.
20
8
 
9
+ import { hasRealCommandIntent } from '../../lib/delegation.mjs';
21
10
  import {
22
- runGate,
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
- // 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';
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
- String.raw`(^|[|;&]\s*|\bthen\s+|\bdo\s+)(sleep|timeout)\s+\d`,
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`(^|[|;&]\s*)wait\b|--wait\b|\bWait-Process\b|\bWait-Job\b`,
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
- String.raw`(^|[|;&]\s*)watch\s+`,
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[^\n]{0,80}\bdo\b[^\n]{0,80}\bsleep\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)\b`,
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
- // 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,
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
- /\b-d(?=\s*($|[|;&]))/i,
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 detachesFromTurn(command) {
102
- return NOT_TAKING_THE_TURN_FORMS.some((pattern) => pattern.test(command));
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 compile(source) {
106
- return new RegExp(source, 'i');
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
- /** 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 ?? '');
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
- 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.
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
- 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
- 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` 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.
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 { runGate, deny, toolInGroups } from '../../lib/hook-io.mjs';
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
- const SHELL_GROUPS = ['shell', 'delegation'];
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 code`,
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
- // 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;
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 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;
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
- function isGitCommit(command) {
73
- return GIT_COMMIT_PATTERN.test(normalizeGitOptions(command));
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 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;
69
+ function fileReader(root) {
70
+ return (path) => {
71
+ const resolved = resolve(root, path);
84
72
  try {
85
- pattern = new RegExp(source, 'i');
73
+ return existsSync(resolved) ? readFileSync(resolved, 'utf8') : '';
86
74
  } catch {
87
- // A malformed override pattern is skipped rather than crashing the gate; the rest of
88
- // the list still protects.
89
- continue;
75
+ return '';
90
76
  }
91
- if (pattern.test(command)) return source;
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
- if (!toolInGroups(toolName, SHELL_GROUPS)) return;
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 ?? DEFAULT_ESCAPE_HATCH;
113
- if (escapeHatch && command.includes(escapeHatch)) return;
128
+ const escapeHatch = String(parameters.escapeHatch ?? '');
129
+ const { patterns } = compileRegexList(parameters.attributionPatterns);
130
+ if (patterns.length === 0) return;
114
131
 
115
- const match = matchedAttribution(command, parameters.attributionPatterns);
116
- if (!match) return;
117
-
118
- deny(
119
- CONFIG_KEY,
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
- );
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
  );