@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/README.es.md +69 -9
- package/README.md +68 -7
- 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 +11 -1
- package/cli/selection.mjs +18 -0
- package/cli/smoke-fixtures.json +114 -22
- package/cli/task.mjs +36 -8
- package/cli/toggle.mjs +125 -0
- package/package.json +1 -1
- 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 +98 -212
- 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 +51 -92
- 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 +262 -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/protected-paths/index.mjs +137 -90
- package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +95 -51
- 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 +112 -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 +448 -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 +137 -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 +6 -6
- package/registry.json +204 -36
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// git.mjs — the ONE place that understands how a git command line is spelled, so every gate
|
|
2
|
+
// that judges a commit, a push or a reset reads the same normalized form. Before this module
|
|
3
|
+
// six gates carried byte-identical copies of the option-stripping regex, and every bypass
|
|
4
|
+
// (git.exe, a quoted -C path, a missing global option) had to be fixed six times.
|
|
5
|
+
//
|
|
6
|
+
// ── normalizeGitCommand ─────────────────────────────────────────────────────────────
|
|
7
|
+
// Turns any spelling of the git binary (`git`, `git.exe`, `"C:/Program Files/Git/bin/git.exe"`,
|
|
8
|
+
// `/usr/bin/git`) into the bare word `git`, then strips git's GLOBAL options (`-C <path>`,
|
|
9
|
+
// `-c k=v`, `--git-dir=…`, `--no-pager`, …) that sit between `git` and the subcommand, so a
|
|
10
|
+
// pattern written for `git reset --hard` also catches `git -C "C:/My Repo" reset --hard`.
|
|
11
|
+
// Quoted option values (a path with a space) are consumed whole.
|
|
12
|
+
//
|
|
13
|
+
// Self-contained: Node built-ins only.
|
|
14
|
+
|
|
15
|
+
import { spawnSync } from 'node:child_process';
|
|
16
|
+
|
|
17
|
+
// The git binary in any spelling: bare, with .exe, or as a (possibly quoted) path ending in
|
|
18
|
+
// git/git.exe. Anchored at a command boundary so `digit`/`legit` never match.
|
|
19
|
+
const GIT_BINARY_PATTERN = new RegExp(
|
|
20
|
+
String.raw`(^|[\s;&|(])(?:"[^"\n]*[\\/]git(?:\.exe)?"|'[^'\n]*[\\/]git(?:\.exe)?'|(?:[^\s"';&|(]*[\\/])?git(?:\.exe)?)(?=\s)`,
|
|
21
|
+
'gi',
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
// A quoted or bare argument value.
|
|
25
|
+
const OPTION_VALUE = String.raw`(?:"[^"\n]*"|'[^'\n]*'|\S+)`;
|
|
26
|
+
// Global options that take a value, as `-C <v>`, `-C<v>`... no: git requires a space or `=`.
|
|
27
|
+
const OPTION_WITH_VALUE = String.raw`(?:-[Cc]|--git-dir|--work-tree|--namespace|--exec-path|--config-env|--super-prefix|--attr-source|--list-cmds)(?:=|\s+)${OPTION_VALUE}`;
|
|
28
|
+
// Global flag options (no value).
|
|
29
|
+
const FLAG_OPTION = String.raw`--(?:paginate|no-pager|bare|no-optional-locks|no-replace-objects|literal-pathspecs|glob-pathspecs|noglob-pathspecs|icase-pathspecs|no-advice|no-lazy-fetch|html-path|man-path|info-path)|-[pP]`;
|
|
30
|
+
const GLOBAL_OPTION_PATTERN = new RegExp(
|
|
31
|
+
String.raw`\bgit\s+(?:${OPTION_WITH_VALUE}|${FLAG_OPTION})\s+`,
|
|
32
|
+
'i',
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The command with every git invocation reduced to `git <subcommand> …`: binary spelling
|
|
37
|
+
* canonicalized and global options removed. Non-git text is untouched.
|
|
38
|
+
*/
|
|
39
|
+
export function normalizeGitCommand(command) {
|
|
40
|
+
let normalized = String(command ?? '').replace(GIT_BINARY_PATTERN, '$1git');
|
|
41
|
+
let previous;
|
|
42
|
+
do {
|
|
43
|
+
previous = normalized;
|
|
44
|
+
normalized = normalized.replace(GLOBAL_OPTION_PATTERN, 'git ');
|
|
45
|
+
} while (normalized !== previous);
|
|
46
|
+
return normalized;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Whether the (normalized) command runs `git <subcommand>` anywhere. */
|
|
50
|
+
export function isGitSubcommand(command, subcommand) {
|
|
51
|
+
const pattern = new RegExp(String.raw`\bgit\s+${subcommand}(?![\w-])`, 'i');
|
|
52
|
+
return pattern.test(normalizeGitCommand(command));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A real `git commit` (not `commit-tree`, not `commit-graph`). */
|
|
56
|
+
export function isGitCommit(command) {
|
|
57
|
+
return isGitSubcommand(command, 'commit');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const DRY_RUN_PATTERN = /\bgit\s+commit\b[^;&|]*--dry-run\b/i;
|
|
61
|
+
|
|
62
|
+
/** A `git commit --dry-run` writes nothing: commit gates skip it. */
|
|
63
|
+
export function isDryRunCommit(command) {
|
|
64
|
+
return DRY_RUN_PATTERN.test(normalizeGitCommand(command));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// `--amend` only counts when it is an option OF the commit, not a word later in the line.
|
|
68
|
+
const AMEND_PATTERN = /\bgit\s+commit\b[^;&|]*--amend\b/i;
|
|
69
|
+
|
|
70
|
+
/** A `git commit --amend` reshapes an existing commit; some gates exempt it. */
|
|
71
|
+
export function isAmendCommit(command) {
|
|
72
|
+
return AMEND_PATTERN.test(normalizeGitCommand(command));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const COMMIT_SEGMENT_PATTERN = /\bgit\s+commit\b([^;&|]*)/i;
|
|
76
|
+
const GIT_ADD_PATTERN = /\bgit\s+add\b([^;&|]*)/gi;
|
|
77
|
+
|
|
78
|
+
/** Whether the commit carries `-a`/`--all` (a short-flag cluster like `-am` counts). */
|
|
79
|
+
function commitStagesAll(normalized) {
|
|
80
|
+
const segment = COMMIT_SEGMENT_PATTERN.exec(normalized)?.[1] ?? '';
|
|
81
|
+
return segment.split(/\s+/).some((token) => {
|
|
82
|
+
if (token === '--all') return true;
|
|
83
|
+
return (
|
|
84
|
+
token.startsWith('-') && !token.startsWith('--') && token.includes('a')
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function splitArguments(text) {
|
|
90
|
+
const found = [];
|
|
91
|
+
for (const match of String(text).matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)) {
|
|
92
|
+
found.push(match[1] ?? match[2] ?? match[3]);
|
|
93
|
+
}
|
|
94
|
+
return found;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function runGit(cwd, commandArguments) {
|
|
98
|
+
const result = spawnSync('git', commandArguments, {
|
|
99
|
+
cwd,
|
|
100
|
+
encoding: 'utf8',
|
|
101
|
+
});
|
|
102
|
+
if (result.status !== 0 || typeof result.stdout !== 'string') return null;
|
|
103
|
+
return result.stdout;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function splitNulTerminated(output) {
|
|
107
|
+
return output
|
|
108
|
+
.split('\0')
|
|
109
|
+
.map((entry) => entry.replace(/\\/g, '/').trim())
|
|
110
|
+
.filter((entry) => entry.length > 0);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The paths currently staged (`git diff --cached`), read NUL-terminated so a non-ASCII or
|
|
115
|
+
* space-containing name comes back verbatim instead of quoted/escaped. `filter` is git's
|
|
116
|
+
* `--diff-filter` letters (default ACMR: added/copied/modified/renamed). Null when git could
|
|
117
|
+
* not be queried (not a repo, git missing).
|
|
118
|
+
*/
|
|
119
|
+
export function stagedFiles(cwd, { filter = 'ACMR' } = {}) {
|
|
120
|
+
const output = runGit(cwd, [
|
|
121
|
+
'diff',
|
|
122
|
+
'--cached',
|
|
123
|
+
'--name-only',
|
|
124
|
+
'-z',
|
|
125
|
+
`--diff-filter=${filter}`,
|
|
126
|
+
]);
|
|
127
|
+
return output === null ? null : splitNulTerminated(output);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Porcelain v1 -z entries: `XY path` (rename: `XY new\0old`). Status letters per column.
|
|
131
|
+
const PORCELAIN_STATUS_WIDTH = 2;
|
|
132
|
+
const PORCELAIN_PATH_OFFSET = 3;
|
|
133
|
+
|
|
134
|
+
function parsePorcelain(output) {
|
|
135
|
+
const entries = [];
|
|
136
|
+
const fields = output.split('\0');
|
|
137
|
+
for (let index = 0; index < fields.length; index += 1) {
|
|
138
|
+
const field = fields[index];
|
|
139
|
+
if (!field) continue;
|
|
140
|
+
const status = field.slice(0, PORCELAIN_STATUS_WIDTH);
|
|
141
|
+
const path = field.slice(PORCELAIN_PATH_OFFSET).replace(/\\/g, '/');
|
|
142
|
+
entries.push({ status, path });
|
|
143
|
+
if (status[0] === 'R' || status[0] === 'C') index += 1; // skip the old path
|
|
144
|
+
}
|
|
145
|
+
return entries;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Working-tree changes (`git status --porcelain -z`): [{ status, path }], or null. */
|
|
149
|
+
export function workingTreeChanges(cwd) {
|
|
150
|
+
const output = runGit(cwd, [
|
|
151
|
+
'status',
|
|
152
|
+
'--porcelain',
|
|
153
|
+
'--untracked-files=all',
|
|
154
|
+
'-z',
|
|
155
|
+
]);
|
|
156
|
+
return output === null ? null : parsePorcelain(output);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function isUntrackedOrModified(entry) {
|
|
160
|
+
return entry.status === '??' || entry.status[1] !== ' ';
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function isTrackedModified(entry) {
|
|
164
|
+
return entry.status !== '??' && entry.status[1] !== ' ';
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The files a commit command WILL have staged by the time it runs — which a PreToolUse hook
|
|
169
|
+
* cannot read from the index, because the hook runs before `git add -A && git commit` or
|
|
170
|
+
* `git commit -a` executes. The answer is the union of what is staged now, what a `git add`
|
|
171
|
+
* earlier in the same command line stages, and every modified tracked file when the commit
|
|
172
|
+
* carries `-a`. Explicit `git add <paths>` contribute those paths (a directory contributes
|
|
173
|
+
* everything under it). Null when git cannot be queried.
|
|
174
|
+
*/
|
|
175
|
+
export function effectiveCommitFiles(cwd, command) {
|
|
176
|
+
const staged = stagedFiles(cwd);
|
|
177
|
+
if (staged === null) return null;
|
|
178
|
+
const normalized = normalizeGitCommand(command);
|
|
179
|
+
const files = new Set(staged);
|
|
180
|
+
|
|
181
|
+
const stagesAll = commitStagesAll(normalized);
|
|
182
|
+
const needsWorkingTree = stagesAll || /\bgit\s+add\b/i.test(normalized);
|
|
183
|
+
if (!needsWorkingTree) return [...files];
|
|
184
|
+
|
|
185
|
+
const changes = workingTreeChanges(cwd) ?? [];
|
|
186
|
+
if (stagesAll) {
|
|
187
|
+
for (const entry of changes.filter(isTrackedModified))
|
|
188
|
+
files.add(entry.path);
|
|
189
|
+
}
|
|
190
|
+
for (const match of normalized.matchAll(GIT_ADD_PATTERN)) {
|
|
191
|
+
const addArguments = splitArguments(match[1]).filter(
|
|
192
|
+
(argument) => !argument.startsWith('-'),
|
|
193
|
+
);
|
|
194
|
+
const stagesEverything =
|
|
195
|
+
/\s(?:-A|--all|-a|-u|--update)\b/.test(match[1]) ||
|
|
196
|
+
addArguments.includes('.');
|
|
197
|
+
for (const entry of changes.filter(isUntrackedOrModified)) {
|
|
198
|
+
const covered =
|
|
199
|
+
stagesEverything ||
|
|
200
|
+
addArguments.some(
|
|
201
|
+
(argument) =>
|
|
202
|
+
entry.path === argument ||
|
|
203
|
+
entry.path.startsWith(`${argument.replace(/\/$/, '')}/`),
|
|
204
|
+
);
|
|
205
|
+
if (covered) files.add(entry.path);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return [...files];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Message sources inside a commit command: -m "…", -m '…', --message=…, a heredoc body,
|
|
212
|
+
// --trailer key=value, and -F/--file <path> (read from disk when it exists under cwd).
|
|
213
|
+
const MESSAGE_FLAG_PATTERN =
|
|
214
|
+
/(?:^|\s)(?:-m|--message)(?:=|\s+)(?:"([^"]*)"|'([^']*)'|(\S+))/g;
|
|
215
|
+
// The opener of a heredoc: `<<EOF`, `<<-EOF`, `<<'EOF'`, `<<"EOF"`. The body is then located
|
|
216
|
+
// by plain string search (the closing marker on its own line), not by a backtracking regex.
|
|
217
|
+
const HEREDOC_OPENER_PATTERN = /<<-?\s*['"]?(\w+)['"]?/g;
|
|
218
|
+
|
|
219
|
+
/** The bodies of every heredoc in the command, in order. */
|
|
220
|
+
function heredocBodiesOf(text) {
|
|
221
|
+
const bodies = [];
|
|
222
|
+
for (const match of text.matchAll(HEREDOC_OPENER_PATTERN)) {
|
|
223
|
+
const marker = match[1];
|
|
224
|
+
const bodyStart = text.indexOf('\n', match.index + match[0].length);
|
|
225
|
+
if (bodyStart === -1) continue;
|
|
226
|
+
const lines = text.slice(bodyStart + 1).split('\n');
|
|
227
|
+
const end = lines.findIndex((line) => line.trim() === marker);
|
|
228
|
+
bodies.push((end === -1 ? lines : lines.slice(0, end)).join('\n'));
|
|
229
|
+
}
|
|
230
|
+
return bodies;
|
|
231
|
+
}
|
|
232
|
+
const TRAILER_PATTERN =
|
|
233
|
+
/(?:^|\s)--trailer(?:=|\s+)(?:"([^"]*)"|'([^']*)'|(\S+))/g;
|
|
234
|
+
const MESSAGE_FILE_PATTERN =
|
|
235
|
+
/(?:^|\s)(?:-F|--file)(?:=|\s+)(?:"([^"]*)"|'([^']*)'|(\S+))/g;
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Every piece of commit-message text the command carries, joined by newlines: inline -m
|
|
239
|
+
* values, heredoc bodies, --trailer values, and the contents of a -F file (via
|
|
240
|
+
* `readFile(path)`, injected so the caller decides how to read — default returns '').
|
|
241
|
+
* Returns '' for a command with no recognizable message source.
|
|
242
|
+
*/
|
|
243
|
+
function firstGroup(match) {
|
|
244
|
+
return match[1] ?? match[2] ?? match[3] ?? '';
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function messagePiecesOf(text, pattern, pick = firstGroup) {
|
|
248
|
+
return [...text.matchAll(pattern)].map(pick);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function commitMessageOf(command, { readFile = () => '' } = {}) {
|
|
252
|
+
const text = String(command ?? '');
|
|
253
|
+
const pieces = [
|
|
254
|
+
...messagePiecesOf(text, MESSAGE_FLAG_PATTERN),
|
|
255
|
+
...heredocBodiesOf(text),
|
|
256
|
+
...messagePiecesOf(text, TRAILER_PATTERN),
|
|
257
|
+
...messagePiecesOf(text, MESSAGE_FILE_PATTERN, (match) =>
|
|
258
|
+
String(readFile(firstGroup(match)) ?? ''),
|
|
259
|
+
),
|
|
260
|
+
];
|
|
261
|
+
return pieces.filter((piece) => piece.length > 0).join('\n');
|
|
262
|
+
}
|