@devrik-tools/claude-gates 0.7.2 → 0.8.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 (78) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/README.es.md +69 -9
  3. package/README.md +68 -7
  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 +11 -1
  12. package/cli/selection.mjs +18 -0
  13. package/cli/smoke-fixtures.json +114 -22
  14. package/cli/task.mjs +36 -8
  15. package/cli/toggle.mjs +125 -0
  16. package/package.json +1 -1
  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 +98 -212
  29. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +127 -46
  30. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +81 -19
  31. package/plugins/gates/hooks/gates/engram-first/index.mjs +41 -0
  32. package/plugins/gates/hooks/gates/engram-first/session-start.mjs +64 -0
  33. package/plugins/gates/hooks/gates/engram-first/shared.mjs +90 -0
  34. package/plugins/gates/hooks/gates/engram-first/stop.mjs +23 -0
  35. package/plugins/gates/hooks/gates/engram-first/track.mjs +97 -0
  36. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +156 -50
  37. package/plugins/gates/hooks/gates/force-parallel/index.mjs +51 -92
  38. package/plugins/gates/hooks/gates/forge-flow/index.mjs +51 -83
  39. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +40 -80
  40. package/plugins/gates/hooks/gates/intent-flow/index.mjs +23 -149
  41. package/plugins/gates/hooks/gates/library-docs/index.mjs +262 -0
  42. package/plugins/gates/hooks/gates/library-docs/track.mjs +95 -0
  43. package/plugins/gates/hooks/gates/lint-commit/index.mjs +88 -97
  44. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +48 -95
  45. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +69 -31
  46. package/plugins/gates/hooks/gates/never-assume/index.mjs +23 -18
  47. package/plugins/gates/hooks/gates/no-blocking/index.mjs +127 -86
  48. package/plugins/gates/hooks/gates/no-coauthor/index.mjs +108 -88
  49. package/plugins/gates/hooks/gates/no-explanatory-comments/index.mjs +281 -0
  50. package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +170 -110
  51. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +46 -77
  52. package/plugins/gates/hooks/gates/no-reconfirm/index.mjs +85 -47
  53. package/plugins/gates/hooks/gates/protected-paths/index.mjs +137 -90
  54. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +95 -51
  55. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +72 -216
  56. package/plugins/gates/hooks/gates/risk-level/index.mjs +51 -204
  57. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +34 -17
  58. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +160 -95
  59. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +196 -97
  60. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +90 -143
  61. package/plugins/gates/hooks/gates/staged-lint/index.mjs +98 -106
  62. package/plugins/gates/hooks/gates/stop-pending/index.mjs +47 -148
  63. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +78 -68
  64. package/plugins/gates/hooks/gates/test-matrix/index.mjs +53 -88
  65. package/plugins/gates/hooks/gates/tool-map/index.mjs +80 -110
  66. package/plugins/gates/hooks/hooks.json +112 -42
  67. package/plugins/gates/hooks/lib/config.mjs +6 -3
  68. package/plugins/gates/hooks/lib/delegation.mjs +352 -0
  69. package/plugins/gates/hooks/lib/gate-log.mjs +141 -0
  70. package/plugins/gates/hooks/lib/git.mjs +262 -0
  71. package/plugins/gates/hooks/lib/hook-io.mjs +448 -83
  72. package/plugins/gates/hooks/lib/session-state.mjs +150 -0
  73. package/plugins/gates/hooks/lib/signals.mjs +18 -13
  74. package/plugins/gates/hooks/lib/testing.mjs +137 -0
  75. package/plugins/gates/hooks/lib/tools.mjs +144 -0
  76. package/plugins/tasks/.claude-plugin/plugin.json +1 -1
  77. package/plugins/tasks/hooks/lib/task-store.mjs +6 -6
  78. package/registry.json +204 -36
@@ -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
  );
@@ -0,0 +1,281 @@
1
+ import { existsSync, readFileSync, statSync } from 'node:fs';
2
+ import { extname, isAbsolute, join } from 'node:path';
3
+ import { projectRootOf } from '../../lib/config.mjs';
4
+ import {
5
+ compileRegexList,
6
+ deny,
7
+ runGate,
8
+ toolInGroups,
9
+ writtenContentOf,
10
+ writtenPathOf,
11
+ } from '../../lib/hook-io.mjs';
12
+ import { withUnicodeWordBoundary } from '../../lib/signals.mjs';
13
+
14
+ const GATE_ID = 'no-explanatory-comments';
15
+ const CONFIG_KEY = 'blockExplanatoryComments';
16
+
17
+ const SLASH_EXTENSIONS = [
18
+ '.js',
19
+ '.mjs',
20
+ '.cjs',
21
+ '.ts',
22
+ '.tsx',
23
+ '.jsx',
24
+ '.vue',
25
+ '.svelte',
26
+ '.go',
27
+ '.rs',
28
+ '.java',
29
+ '.kt',
30
+ '.cs',
31
+ '.swift',
32
+ '.c',
33
+ '.cc',
34
+ '.cpp',
35
+ '.h',
36
+ '.hpp',
37
+ '.php',
38
+ '.scss',
39
+ '.css',
40
+ ];
41
+ const HASH_EXTENSIONS = ['.py', '.sh', '.bash', '.ps1', '.rb', '.pl', '.r'];
42
+ const DASH_EXTENSIONS = ['.sql', '.lua'];
43
+
44
+ const DEFAULT_DECISION_MARKERS = [
45
+ 'because|why|so that|otherwise|instead of|rather than|trade-?offs?|decision|deliberate(?:ly)?|intentional(?:ly)?|on purpose',
46
+ "workaround|known (?:issue|limitation)|limitation|invariant|must(?: not| never)?|never|cannot|can't|do not|don't|avoid|guard(?:s|ed)?|edge cases?|race|compat\\p{L}*|legacy|deprecated|perf\\p{L}*|security|unsafe|fail-?(?:safe|open|closed)",
47
+ 'porque|por qu[eé]|para que|si no|en vez de|en lugar de|decisi[oó]n|deliberad\\p{L}*|intencional\\p{L}*|a prop[oó]sito|limitaci[oó]n|invariante|nunca|no debe|evita\\p{L}*|compatibilidad|rendimiento|seguridad|casos? borde|justificaci[oó]n',
48
+ ];
49
+ const DEFAULT_ESCAPE_HATCH = 'comment-ok:';
50
+ const MAX_REPORTED = 5;
51
+ const MAX_LINE_LENGTH = 90;
52
+
53
+ const DIRECTIVE_PREFIXES = [
54
+ '#!',
55
+ '#pragma',
56
+ '#include',
57
+ '#define',
58
+ '#if',
59
+ '#endif',
60
+ '#else',
61
+ '#region',
62
+ '#endregion',
63
+ '#[',
64
+ '@license',
65
+ 'spdx-',
66
+ 'eslint',
67
+ 'prettier',
68
+ 'cspell',
69
+ '@ts-',
70
+ 'c8 ',
71
+ 'istanbul ',
72
+ 'noqa',
73
+ 'pylint',
74
+ 'pyright',
75
+ 'nolint',
76
+ 'type:',
77
+ 'neutral-spanish:allow',
78
+ 'lint-ok:',
79
+ 'justification:',
80
+ 'justificación:',
81
+ 'justificacion:',
82
+ 'http://',
83
+ 'https://',
84
+ ];
85
+ const DIRECTIVE_WORD_PATTERN = /^(?:todo|fixme|hack|note|xxx)\b/i;
86
+ const JSDOC_TAG_PATTERN =
87
+ /^@(?:type|typedef|template|deprecated|throws|see|example)\b|^@(?:param|returns?) \{/i;
88
+
89
+ function isDirective(line) {
90
+ const lowered = line.trim().toLowerCase();
91
+ if (DIRECTIVE_PREFIXES.some((prefix) => lowered.startsWith(prefix)))
92
+ return true;
93
+ return (
94
+ DIRECTIVE_WORD_PATTERN.test(lowered) || JSDOC_TAG_PATTERN.test(lowered)
95
+ );
96
+ }
97
+
98
+ function commentStyleOf(filePath) {
99
+ const extension = extname(filePath).toLowerCase();
100
+ if (SLASH_EXTENSIONS.includes(extension)) return 'slash';
101
+ if (HASH_EXTENSIONS.includes(extension)) return 'hash';
102
+ if (DASH_EXTENSIONS.includes(extension)) return 'dash';
103
+ return null;
104
+ }
105
+
106
+ function stripLeading(text, characters) {
107
+ let start = 0;
108
+ while (start < text.length && characters.includes(text[start])) start += 1;
109
+ return text.slice(start);
110
+ }
111
+
112
+ function stripSlashMarkers(text) {
113
+ let body = text.trim();
114
+ if (body.endsWith('*/')) body = body.slice(0, -'*/'.length);
115
+ while (body.endsWith('*')) body = body.slice(0, -1);
116
+ if (body.startsWith('//')) body = stripLeading(body, '/');
117
+ else if (body.startsWith('/*')) body = stripLeading(body.slice(1), '*');
118
+ else body = stripLeading(body, '*');
119
+ return body.trim();
120
+ }
121
+
122
+ function slashComments(content) {
123
+ const units = [];
124
+ let block = null;
125
+ let lineRun = [];
126
+ const flushRun = () => {
127
+ if (lineRun.length > 0) units.push(lineRun);
128
+ lineRun = [];
129
+ };
130
+ for (const rawLine of content.split(/\r?\n/)) {
131
+ const line = rawLine.trim();
132
+ if (block) {
133
+ block.push(stripSlashMarkers(line));
134
+ if (line.includes('*/')) {
135
+ units.push(block);
136
+ block = null;
137
+ }
138
+ continue;
139
+ }
140
+ const blockStart = line.indexOf('/*');
141
+ const quoteAt = line.search(/['"`]/);
142
+ if (blockStart !== -1 && (quoteAt === -1 || quoteAt > blockStart)) {
143
+ flushRun();
144
+ block = [stripSlashMarkers(line.slice(blockStart))];
145
+ if (line.indexOf('*/', blockStart + 2) !== -1) {
146
+ units.push(block);
147
+ block = null;
148
+ }
149
+ continue;
150
+ }
151
+ const lineStart = line.indexOf('//');
152
+ const isCommentLine = line.startsWith('//');
153
+ const isTrailing =
154
+ lineStart > 0 &&
155
+ !/['"`]/.test(line.slice(0, lineStart)) &&
156
+ !line.slice(0, lineStart).includes(':');
157
+ if (isCommentLine) {
158
+ lineRun.push(stripSlashMarkers(line));
159
+ continue;
160
+ }
161
+ flushRun();
162
+ if (isTrailing) units.push([stripSlashMarkers(line.slice(lineStart))]);
163
+ }
164
+ flushRun();
165
+ if (block) units.push(block);
166
+ return units;
167
+ }
168
+
169
+ function prefixComments(content, prefix) {
170
+ const units = [];
171
+ let run = [];
172
+ for (const rawLine of content.split(/\r?\n/)) {
173
+ const line = rawLine.trim();
174
+ if (line.startsWith(prefix)) {
175
+ run.push(line.slice(prefix.length).trim());
176
+ continue;
177
+ }
178
+ if (run.length > 0) units.push(run);
179
+ run = [];
180
+ const at = line.indexOf(` ${prefix}`);
181
+ if (at > 0 && !/['"]/.test(line.slice(0, at)))
182
+ units.push([line.slice(at + prefix.length + 1).trim()]);
183
+ }
184
+ if (run.length > 0) units.push(run);
185
+ return units;
186
+ }
187
+
188
+ export function commentUnitsOf(content, filePath) {
189
+ const style = commentStyleOf(filePath);
190
+ if (style === 'slash') return slashComments(content);
191
+ if (style === 'hash') return prefixComments(content, '#');
192
+ if (style === 'dash') return prefixComments(content, '--');
193
+ return [];
194
+ }
195
+
196
+ function unitKey(unit) {
197
+ return unit
198
+ .map((line) => line.trim())
199
+ .filter(Boolean)
200
+ .join('\n');
201
+ }
202
+
203
+ function isAllowed(unit, markers, escapeHatch) {
204
+ const text = unitKey(unit);
205
+ if (!text) return true;
206
+ const lowered = text.toLowerCase();
207
+ if (escapeHatch && lowered.includes(escapeHatch.toLowerCase())) return true;
208
+ if (unit.some((line) => isDirective(line))) return true;
209
+ return markers.some((pattern) => pattern.test(text));
210
+ }
211
+
212
+ function previousContent(toolInput, absolutePath) {
213
+ if (typeof toolInput.old_string === 'string') return toolInput.old_string;
214
+ if (Array.isArray(toolInput.edits))
215
+ return toolInput.edits
216
+ .map((edit) => String(edit?.old_string ?? ''))
217
+ .join('\n');
218
+ if (!existsSync(absolutePath)) return '';
219
+ try {
220
+ return statSync(absolutePath).isFile()
221
+ ? readFileSync(absolutePath, 'utf8')
222
+ : '';
223
+ } catch {
224
+ return '';
225
+ }
226
+ }
227
+
228
+ function excerpt(unit) {
229
+ const text = unit.filter(Boolean).join(' ');
230
+ return text.length > MAX_LINE_LENGTH
231
+ ? `${text.slice(0, MAX_LINE_LENGTH)}…`
232
+ : text;
233
+ }
234
+
235
+ runGate(
236
+ {
237
+ id: GATE_ID,
238
+ configKey: CONFIG_KEY,
239
+ enabledByDefault: true,
240
+ defaultParams: {
241
+ decisionMarkers: DEFAULT_DECISION_MARKERS,
242
+ escapeHatch: DEFAULT_ESCAPE_HATCH,
243
+ },
244
+ },
245
+ ({ toolName, toolInput, parameters, cwd }) => {
246
+ if (!toolInGroups(toolName, ['write'])) return;
247
+ const filePath = writtenPathOf(toolInput);
248
+ if (!commentStyleOf(filePath)) return;
249
+ const content = writtenContentOf(toolInput);
250
+ if (!content) return;
251
+ const root = projectRootOf(cwd) ?? cwd;
252
+ const absolutePath = isAbsolute(filePath) ? filePath : join(root, filePath);
253
+ const before = new Set(
254
+ commentUnitsOf(previousContent(toolInput, absolutePath), filePath).map(
255
+ unitKey,
256
+ ),
257
+ );
258
+ const markers = compileRegexList(
259
+ parameters.decisionMarkers,
260
+ 'iu',
261
+ ).patterns.map((pattern) => withUnicodeWordBoundary(pattern.source));
262
+ const offending = commentUnitsOf(content, filePath)
263
+ .filter((unit) => !before.has(unitKey(unit)))
264
+ .filter((unit) => !isAllowed(unit, markers, parameters.escapeHatch));
265
+ if (offending.length === 0) return;
266
+ const shown = offending
267
+ .slice(0, MAX_REPORTED)
268
+ .map((unit) => `"${excerpt(unit)}"`);
269
+ const more =
270
+ offending.length > MAX_REPORTED
271
+ ? ` (+${offending.length - MAX_REPORTED} more)`
272
+ : '';
273
+ deny(
274
+ CONFIG_KEY,
275
+ `This write adds ${offending.length} comment(s) that explain what the code does instead of recording a ` +
276
+ `decision: ${shown.join('; ')}${more}. Make the code say it (a clearer name, a small extracted function) ` +
277
+ 'and delete the comment, or keep only the WHY (start with because/porque, so that/para que, instead of/en vez de, ' +
278
+ `a trade-off or a known limitation). For a documented exception add "${parameters.escapeHatch} <reason>" in the comment.`,
279
+ );
280
+ },
281
+ );