@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,51 +1,38 @@
1
- // atomic-commit — denies a `git commit` that is not atomic: one that mixes too many distinct
2
- // NATURES of change (code + tests + deps + config in one shot) or stages more reviewable files
3
- // than a commit should carry. It never commits or groups for you (that is a tool, not a gate,
4
- // and the user already has guard-autocommit for it); it only OBJECTS when the staged set is not
5
- // a cohesive, reviewable unit, so the split happens before the commit lands.
6
- //
7
- // justification: no existing gate covers this. lint-commit/staged-lint run the linter on a
8
- // commit; no-coauthor reads the message; none look at the SHAPE of the staged set. This is the
9
- // first gate that judges whether a commit is atomic.
10
- //
11
- // ── How it decides (deterministic) ───────────────────────────────────────────────────
12
- // On a real `git commit`, it lists the staged files and classifies each by nature (deps,
13
- // generated, assets, docs, config, types, tests, tooling, or code). Docs/assets/generated do
14
- // NOT count toward the size or the mix — they are legitimately committed alongside anything and
15
- // are not reviewed line by line (same rule guard-autocommit uses). Among the files that DO
16
- // count, it blocks when either:
17
- // · the number of distinct counted natures exceeds `maxNatures` (default 2 — e.g. code+tests
18
- // is fine, but code+tests+deps+config in one commit is not atomic), or
19
- // · the number of counted files exceeds `maxFiles` (default 12) — too large to review.
20
- // A commit that is already scoped (`--amend`, a merge, or the escape hatch) is left alone.
21
- //
22
- // ── What a project can configure (params) ───────────────────────────────────────────
23
- // maxFiles max counted files in one commit. Default 12.
24
- // maxNatures max distinct counted natures in one commit. Default 2.
25
- // escapeHatch substring in the command that allows one deliberately broad commit.
26
- // Default '[wip]'.
27
- // natures the classification table (name + regex source + counts flag). Replaces the
28
- // built-in list wholesale.
29
- //
30
- // ── Fail-safe shape ──────────────────────────────────────────────────────────────────
31
- // Not a commit, an --amend, or nothing staged: allow (silent). git not queryable: allow (the
32
- // gate cannot judge and must not block a legitimate commit on a git error). Otherwise: deny
33
- // with the offending mix/size and how to split.
34
-
35
- import { spawnSync } from 'node:child_process';
36
- import { runGate, deny, toolInGroups } from '../../lib/hook-io.mjs';
1
+ // atomic-commit — denies a `git commit` that is not atomic: one mixing more distinct NATURES
2
+ // of change (code + tests + deps + config) than `maxNatures`, or carrying more reviewable
3
+ // files than `maxFiles`. It judges what the commit WILL contain (the staged set, plus what a
4
+ // `git add …` in the same command line or `-a` stages, plus staged deletions); docs, assets
5
+ // and generated files count toward neither. An `--amend` reshapes an existing commit and is
6
+ // left alone, as is a `--dry-run`. Only a command segment that RUNS git commit counts. git
7
+ // not queryable: allow the gate cannot judge and must not block on a git error.
8
+
9
+ import { projectRootOf } from '../../lib/config.mjs';
10
+ import {
11
+ effectiveCommitFiles,
12
+ isAmendCommit,
13
+ isDryRunCommit,
14
+ isGitCommit,
15
+ normalizeGitCommand,
16
+ stagedFiles,
17
+ } from '../../lib/git.mjs';
18
+ import {
19
+ compileRegex,
20
+ deny,
21
+ runGate,
22
+ shellCommandOf,
23
+ toolInGroups,
24
+ } from '../../lib/hook-io.mjs';
37
25
 
38
26
  const GATE_ID = 'atomic-commit';
39
27
  const CONFIG_KEY = 'blockNonAtomicCommits';
40
28
 
41
- const SHELL_GROUPS = ['shell'];
42
29
  const DEFAULT_MAX_FILES = 12;
43
30
  const DEFAULT_MAX_NATURES = 2;
44
31
  const DEFAULT_ESCAPE_HATCH = '[wip]';
45
32
 
46
- // Nature table, evaluated in order (first match wins), mirroring guard-autocommit's catalog so
47
- // a project that uses both sees the same grouping. `counts:false` = committed alongside anything
48
- // and not counted toward size/mix (docs, assets, generated artifacts).
33
+ // First match wins. `counts:false` = committed alongside anything, never reviewed line by
34
+ // line. A `hooks/` or `models/` folder is application code (React hooks, MVC models), not
35
+ // tooling or types: types are `*.d.ts` and `types/` folders only.
49
36
  const DEFAULT_NATURES = [
50
37
  {
51
38
  name: 'deps',
@@ -74,7 +61,7 @@ const DEFAULT_NATURES = [
74
61
  },
75
62
  {
76
63
  name: 'types',
77
- source: String.raw`(^|/)(types?|interfaces?|models?|schemas?)/|\.d\.ts$`,
64
+ source: String.raw`(^|/)types?/|\.d\.ts$`,
78
65
  counts: true,
79
66
  },
80
67
  {
@@ -84,84 +71,66 @@ const DEFAULT_NATURES = [
84
71
  },
85
72
  {
86
73
  name: 'tooling',
87
- source: String.raw`(^|/)(scripts|hooks|\.ai|\.claude|\.github)/`,
74
+ source: String.raw`(^|/)(scripts|\.ai|\.claude|\.github)/`,
88
75
  counts: true,
89
76
  },
90
77
  ];
91
78
  const DEFAULT_NATURE = { name: 'code', counts: true };
92
79
 
93
- // git-global-option normalization, shared with the other commit gates.
94
- const GIT_OPTION_WITH_VALUE = String.raw`(?:-[Cc]|--git-dir|--work-tree|--namespace|--exec-path|--config-env)(?:\s+|=)\S+`;
95
- const GIT_FLAG_OPTION = String.raw`--(?:paginate|no-pager|bare|no-optional-locks)|-p`;
96
- const GIT_GLOBAL_OPTION_PATTERN = new RegExp(
97
- String.raw`\bgit\s+(?:${GIT_OPTION_WITH_VALUE}|${GIT_FLAG_OPTION})\s+`,
98
- 'i',
99
- );
100
- const GIT_COMMIT_PATTERN = /\bgit\s+commit\b/i;
101
- // An --amend or a merge commit is a deliberate, already-scoped operation — not this gate's.
102
- const EXEMPT_COMMIT_PATTERN = /--amend\b|\bgit\s+merge\b/i;
103
-
104
- function normalizeGitOptions(command) {
105
- let previous;
106
- let normalized = command;
107
- do {
108
- previous = normalized;
109
- normalized = normalized.replace(GIT_GLOBAL_OPTION_PATTERN, 'git ');
110
- } while (normalized !== previous);
111
- return normalized;
112
- }
113
-
114
- function isPlainCommit(command) {
115
- const normalized = normalizeGitOptions(command);
116
- return (
117
- GIT_COMMIT_PATTERN.test(normalized) &&
118
- !EXEMPT_COMMIT_PATTERN.test(normalized)
119
- );
120
- }
121
-
122
- function commandTextFrom(toolInput) {
123
- return String(toolInput.CommandLine ?? toolInput.command ?? '');
80
+ // ── The commit segment ──────────────────────────────────────────────────────────────
81
+ const SEGMENT_SEPARATOR = /;|&&|\|\||\||\n/;
82
+ const ARGUMENT_PATTERN = /"([^"]*)"|'([^']*)'|(\S+)/g;
83
+ const GIT_BINARY_TOKEN = /(?:^|[\\/])git(?:\.exe)?$/i;
84
+ const WRAPPER_TOKEN = /^(?:\w+=\S*|command|sudo|env)$/i;
85
+
86
+ function runsGit(segment) {
87
+ const tokens = [];
88
+ for (const match of segment.matchAll(ARGUMENT_PATTERN))
89
+ tokens.push(match[1] ?? match[2] ?? match[3]);
90
+ while (tokens.length > 0 && WRAPPER_TOKEN.test(tokens[0])) tokens.shift();
91
+ return tokens.length > 0 && GIT_BINARY_TOKEN.test(tokens[0]);
124
92
  }
125
93
 
126
- /** The staged files (added/copied/modified/renamed), or [] when git cannot be queried. */
127
- function stagedFiles(cwd) {
128
- const result = spawnSync(
129
- 'git',
130
- ['diff', '--cached', '--name-only', '--diff-filter=ACMR'],
131
- { cwd, encoding: 'utf8' },
132
- );
133
- if (result.status !== 0 || !result.stdout) return [];
134
- return result.stdout
135
- .split(/\r?\n/)
136
- .map((line) => line.trim())
137
- .filter((line) => line.length > 0)
138
- .map((line) => line.replace(/\\/g, '/'));
94
+ function runsJudgedCommit(command) {
95
+ return String(command)
96
+ .split(SEGMENT_SEPARATOR)
97
+ .some((segment) => {
98
+ if (!runsGit(segment)) return false;
99
+ const normalized = normalizeGitCommand(segment);
100
+ return (
101
+ isGitCommit(normalized) &&
102
+ !isDryRunCommit(normalized) &&
103
+ !isAmendCommit(normalized)
104
+ );
105
+ });
139
106
  }
140
107
 
108
+ // ── Natures ─────────────────────────────────────────────────────────────────────────
141
109
  function compileNatures(natures) {
142
- return natures.map((nature) => ({
143
- name: nature.name,
144
- counts: nature.counts !== false,
145
- pattern: (() => {
146
- try {
147
- return new RegExp(nature.source, 'i');
148
- } catch {
149
- return null;
150
- }
151
- })(),
152
- }));
110
+ const compiled = [];
111
+ for (const nature of natures) {
112
+ if (!nature || typeof nature !== 'object') continue;
113
+ if (typeof nature.name !== 'string' || typeof nature.source !== 'string')
114
+ continue;
115
+ const pattern = compileRegex(nature.source);
116
+ if (!pattern) continue;
117
+ compiled.push({
118
+ name: nature.name,
119
+ counts: nature.counts !== false,
120
+ pattern,
121
+ });
122
+ }
123
+ return compiled;
153
124
  }
154
125
 
155
126
  function natureOf(filePath, compiledNatures) {
156
- for (const nature of compiledNatures) {
157
- if (nature.pattern && nature.pattern.test(filePath)) return nature;
158
- }
159
- return DEFAULT_NATURE;
127
+ return (
128
+ compiledNatures.find((nature) => nature.pattern.test(filePath)) ??
129
+ DEFAULT_NATURE
130
+ );
160
131
  }
161
132
 
162
- /** Classifies the staged files: the counted (reviewable) ones and the distinct natures among
163
- * them. Docs/assets/generated (counts:false) are excluded from both. */
164
- function classifyStaged(files, compiledNatures) {
133
+ function classify(files, compiledNatures) {
165
134
  const counted = [];
166
135
  const countedNatures = new Set();
167
136
  for (const file of files) {
@@ -173,6 +142,13 @@ function classifyStaged(files, compiledNatures) {
173
142
  return { counted, countedNatures };
174
143
  }
175
144
 
145
+ function commitFiles(root, command) {
146
+ const files = effectiveCommitFiles(root, command);
147
+ if (files === null) return null;
148
+ const deletions = stagedFiles(root, { filter: 'D' }) ?? [];
149
+ return [...new Set([...files, ...deletions])];
150
+ }
151
+
176
152
  runGate(
177
153
  {
178
154
  id: GATE_ID,
@@ -185,26 +161,23 @@ runGate(
185
161
  natures: DEFAULT_NATURES,
186
162
  },
187
163
  },
188
- ({ toolName, toolInput, parameters }) => {
189
- if (!toolInGroups(toolName, SHELL_GROUPS)) return;
164
+ ({ toolName, toolInput, parameters, cwd }) => {
165
+ if (!toolInGroups(toolName, ['shell'])) return;
190
166
 
191
- const command = commandTextFrom(toolInput);
192
- if (!isPlainCommit(command)) return;
167
+ const command = shellCommandOf(toolInput);
168
+ if (!runsJudgedCommit(command)) return;
193
169
 
194
- const escapeHatch = parameters.escapeHatch ?? DEFAULT_ESCAPE_HATCH;
170
+ const escapeHatch = String(parameters.escapeHatch ?? '');
195
171
  if (escapeHatch && command.includes(escapeHatch)) return;
196
172
 
197
- const cwd = process.cwd();
198
- const files = stagedFiles(cwd);
199
- if (files.length === 0) return; // nothing staged (or git unqueryable): nothing to judge
173
+ const files = commitFiles(projectRootOf(cwd) ?? cwd, command);
174
+ if (!files || files.length === 0) return;
200
175
 
201
- const compiledNatures = compileNatures(
202
- parameters.natures ?? DEFAULT_NATURES,
176
+ const { counted, countedNatures } = classify(
177
+ files,
178
+ compileNatures(parameters.natures),
203
179
  );
204
- const { counted, countedNatures } = classifyStaged(files, compiledNatures);
205
-
206
- const maxFiles = parameters.maxFiles ?? DEFAULT_MAX_FILES;
207
- const maxNatures = parameters.maxNatures ?? DEFAULT_MAX_NATURES;
180
+ const { maxFiles, maxNatures } = parameters;
208
181
 
209
182
  if (countedNatures.size > maxNatures) {
210
183
  deny(
@@ -220,7 +193,7 @@ runGate(
220
193
  if (counted.length > maxFiles) {
221
194
  deny(
222
195
  CONFIG_KEY,
223
- `This commit stages ${counted.length} reviewable files (max ${maxFiles}) — too large ` +
196
+ `This commit carries ${counted.length} reviewable files (max ${maxFiles}) — too large ` +
224
197
  `to review as one unit. Split it into smaller, cohesive commits (docs/assets/` +
225
198
  `generated files are not counted). Add "${escapeHatch}" for one deliberately broad commit.`,
226
199
  );
@@ -1,5 +1,24 @@
1
+ // audit-before-build — creating a new executable tool (a file under a tool folder, or a
2
+ // delegation asking to build one) must state that existing tools were audited. Editing an
3
+ // existing file is never a build; the exemption is what lets the gates maintain themselves.
4
+
1
5
  import { existsSync } from 'node:fs';
2
- import { runGate, deny, TOOL_GROUPS } from '../../lib/hook-io.mjs';
6
+ import { isAbsolute, join } from 'node:path';
7
+ import { projectRootOf } from '../../lib/config.mjs';
8
+ import {
9
+ runGate,
10
+ deny,
11
+ toolInGroups,
12
+ writtenPathOf,
13
+ writtenContentOf,
14
+ delegationPromptOf,
15
+ } from '../../lib/hook-io.mjs';
16
+ import { isBuildIntent } from '../../lib/signals.mjs';
17
+ import {
18
+ AUDIT_DONE_PATTERN,
19
+ hasAuditEvidence,
20
+ isToolPath,
21
+ } from '../../lib/tools.mjs';
3
22
 
4
23
  const GATE_ID = 'audit-before-build';
5
24
  const CONFIG_KEY = 'requireAuditBeforeBuilding';
@@ -13,87 +32,103 @@ const DEFAULT_EXECUTABLE_EXTENSIONS = [
13
32
  '.sh',
14
33
  '.ps1',
15
34
  ];
16
- const DEFAULT_TOOL_FOLDERS = ['scripts/', 'hooks/', 'tools/'];
35
+ const DEFAULT_TOOL_FOLDERS = ['scripts', 'hooks', 'tools'];
36
+ // Vendored or VCS-internal trees are never the project's own new tool.
37
+ const FOREIGN_SEGMENTS = new Set(['.git', 'node_modules']);
38
+ const AUDIT_EXAMPLE_PHRASE = 'no existing tool covers this';
17
39
 
18
- // A verb ("create", "escribir", ...) followed within 60 non-period characters by a tool noun
19
- // ("script", "gate", ...) signals intent to build a new tool. Split into two smaller patterns
20
- // (checked one after the other, within a bounded window) instead of one combined alternation:
21
- // the single-regex form crossed sonarjs's regex-complexity budget, and this reads easier too.
22
- const NEW_TOOL_VERB_PATTERN =
23
- /\b(?:create|write|build|add|cre[aá]r?|escrib(?:e|ir|í)|constru(?:ye|ir)|agreg[aá]r?)\b/giu;
24
- const NEW_TOOL_NOUN_PATTERN =
25
- /\b(?:script|verifier|checker|gate|hook|linter|tool|verificador|chequeador|herramienta)\b/iu;
26
- const NEW_TOOL_NOUN_WINDOW = 60;
40
+ // "the build script" is a noun phrase, and "add tests for the gate" builds a test, not a
41
+ // gate; both would otherwise read as creation verb + tool noun.
42
+ const BUILD_AS_NOUN_PATTERN =
43
+ /\b(?:the|a|an|this|that|our|your|my|el|la|un|una|este|esta)\s+build\b/giu;
44
+ const CREATION_VERBS = [
45
+ 'write',
46
+ 'create',
47
+ 'build',
48
+ 'implement',
49
+ 'add',
50
+ 'make',
51
+ 'escribe',
52
+ 'escribi',
53
+ 'escribir',
54
+ 'crea',
55
+ 'construye',
56
+ 'construir',
57
+ 'implementa',
58
+ 'implementar',
59
+ 'agrega',
60
+ 'arma',
61
+ 'hace',
62
+ 'genera',
63
+ 'generar',
64
+ 'armá',
65
+ 'hacé',
66
+ 'creá',
67
+ 'agregá',
68
+ 'generá',
69
+ 'escribí',
70
+ 'construí',
71
+ ];
72
+ const OBJECT_QUALIFIERS =
73
+ 'a|the|some|new|more|unos|unas|los|las|más|nuevos|nuevas';
74
+ const TEST_OBJECT_NOUNS = 'tests?|specs?|pruebas?';
75
+ const TEST_OBJECT_PATTERN = new RegExp(
76
+ String.raw`\b(?:${CREATION_VERBS.join('|')})\s+(?:(?:${OBJECT_QUALIFIERS})\s+){0,2}(?:${TEST_OBJECT_NOUNS})\b`,
77
+ 'giu',
78
+ );
27
79
 
28
- function hasNewToolIntent(text) {
29
- NEW_TOOL_VERB_PATTERN.lastIndex = 0;
30
- let match;
31
- while ((match = NEW_TOOL_VERB_PATTERN.exec(text)) !== null) {
32
- const afterVerb = match.index + match[0].length;
33
- let window = text.slice(afterVerb, afterVerb + NEW_TOOL_NOUN_WINDOW);
34
- const dotIndex = window.indexOf('.');
35
- if (dotIndex !== -1) window = window.slice(0, dotIndex);
36
- if (NEW_TOOL_NOUN_PATTERN.test(window)) return true;
37
- if (match.index === NEW_TOOL_VERB_PATTERN.lastIndex)
38
- NEW_TOOL_VERB_PATTERN.lastIndex += 1;
39
- }
40
- return false;
80
+ function hasNewToolIntent(prompt) {
81
+ const masked = prompt
82
+ .replace(BUILD_AS_NOUN_PATTERN, 'it')
83
+ .replace(TEST_OBJECT_PATTERN, 'tests');
84
+ return isBuildIntent(masked);
41
85
  }
42
86
 
43
- const AUDIT_EVIDENCE_PATTERN =
44
- /already exists|no existing tool|no plugin|audited and|justification:|no existe una herramienta|no existe la herramienta|no hay plugin|ya existe|busque? si ya existe|verifiqu[eé] que no (hay|existe)|audit[eé] herramientas|justificacion:|justificaci[oó]n:/i;
45
-
46
- const INLINE_JUSTIFICATION_PATTERN =
47
- /justification:|no-reinvent|audit:|justificacion:|justificaci[oó]n:/i;
48
-
49
87
  function checkDelegation(toolInput) {
50
- const prompt = toolInput?.prompt;
51
- if (typeof prompt !== 'string') return;
88
+ const prompt = delegationPromptOf(toolInput);
52
89
  if (!hasNewToolIntent(prompt)) return;
53
- if (AUDIT_EVIDENCE_PATTERN.test(prompt)) return;
90
+ if (hasAuditEvidence(prompt)) return;
54
91
 
55
92
  deny(
56
93
  CONFIG_KEY,
57
94
  'Blocked: this delegation prompt asks to create a new script/checker/gate/hook/linter/' +
58
95
  'tool, and nothing in the prompt shows a prior audit. No filesystem exploration is ' +
59
- 'needed to fix this — add ONE phrase to the prompt matching ' +
60
- `/${AUDIT_EVIDENCE_PATTERN.source}/i, e.g.: "audited and no existing tool covers this" ` +
61
- '(this exact phrase is guaranteed to match). Then relaunch the same delegation.',
96
+ `needed to fix this — add ONE phrase to the prompt such as "${AUDIT_EXAMPLE_PHRASE}" ` +
97
+ `(any phrase matching /${AUDIT_DONE_PATTERN.source}/iu works). Then relaunch the same delegation.`,
62
98
  );
63
99
  }
64
100
 
65
- function isNewToolFile(filePath, parameters) {
66
- if (!filePath) return false;
67
- const inToolFolder = parameters.toolFolders.some((folder) =>
68
- filePath.includes(folder),
69
- );
70
- if (!inToolFolder) return false;
71
- return parameters.executableExtensions.some((extension) =>
72
- filePath.endsWith(extension),
73
- );
101
+ function stripTrailingSlash(folder) {
102
+ let name = String(folder);
103
+ while (name.endsWith('/') || name.endsWith('\\')) name = name.slice(0, -1);
104
+ return name;
74
105
  }
75
106
 
76
- function checkWrite(toolInput, parameters) {
77
- const rawPath = toolInput?.file_path ?? '';
78
- const filePath = rawPath.replace(/\\/g, '/');
79
- if (!isNewToolFile(filePath, parameters)) return;
80
-
81
- // Editing an EXISTING file is not building a new tool — only creation needs the audit.
82
- // A file already on disk (any tool that carries file_path) is an edit, so it is allowed.
83
- // This is what makes maintaining the gates themselves possible with the gates active.
84
- if (rawPath && existsSync(rawPath)) return;
107
+ function isForeignPath(filePath) {
108
+ return filePath
109
+ .split('/')
110
+ .some((segment) => FOREIGN_SEGMENTS.has(segment.toLowerCase()));
111
+ }
85
112
 
86
- const content = toolInput?.content ?? toolInput?.new_string;
87
- if (typeof content !== 'string') return;
88
- if (INLINE_JUSTIFICATION_PATTERN.test(content)) return;
113
+ function checkWrite(toolInput, parameters, root) {
114
+ const rawPath = writtenPathOf(toolInput);
115
+ const filePath = rawPath.replace(/\\/g, '/');
116
+ if (!filePath || isForeignPath(filePath)) return;
117
+ const isTool = isToolPath(filePath, {
118
+ folders: parameters.toolFolders.map(stripTrailingSlash),
119
+ extensions: parameters.executableExtensions,
120
+ namePatterns: [],
121
+ });
122
+ if (!isTool) return;
123
+ if (existsSync(isAbsolute(rawPath) ? rawPath : join(root, rawPath))) return;
124
+ if (hasAuditEvidence(writtenContentOf(toolInput))) return;
89
125
 
90
126
  deny(
91
127
  CONFIG_KEY,
92
- `Blocked: '${filePath}' is a new executable tool with no justification comment in its ` +
93
- 'content. No filesystem exploration is needed to fix this — add ONE line to the ' +
94
- `file's content matching /${INLINE_JUSTIFICATION_PATTERN.source}/i, e.g.: ` +
95
- '"// justification: no existing tool covers this" (this exact phrase is guaranteed ' +
96
- 'to match). Then retry the exact same write.',
128
+ `Blocked: '${filePath}' is a new executable tool with no audit statement in its ` +
129
+ 'content. No filesystem exploration is needed to fix this — add ONE comment line ' +
130
+ `such as "// justification: ${AUDIT_EXAMPLE_PHRASE}" (any phrase matching ` +
131
+ `/${AUDIT_DONE_PATTERN.source}/iu works). Then retry the exact same write.`,
97
132
  );
98
133
  }
99
134
 
@@ -107,12 +142,12 @@ runGate(
107
142
  toolFolders: DEFAULT_TOOL_FOLDERS,
108
143
  },
109
144
  },
110
- ({ toolName, toolInput, parameters }) => {
111
- if (TOOL_GROUPS.delegation.includes(toolName)) {
145
+ ({ toolName, toolInput, parameters, cwd }) => {
146
+ if (toolInGroups(toolName, ['delegation'])) {
112
147
  checkDelegation(toolInput);
113
148
  return;
114
149
  }
115
- if (!TOOL_GROUPS.write.includes(toolName)) return;
116
- checkWrite(toolInput, parameters);
150
+ if (!toolInGroups(toolName, ['write'])) return;
151
+ checkWrite(toolInput, parameters, projectRootOf(cwd) ?? cwd);
117
152
  },
118
153
  );
@@ -28,14 +28,14 @@ const CONFIG_KEY = 'autonomousMode';
28
28
  const QUESTION_GROUPS = ['question'];
29
29
 
30
30
  const DENY_MESSAGE =
31
- 'Autonomous mode is ON for this project: do not ask the user, decide instead. The question ' +
32
- 'popup was intercepted and never shown. Now, in your VISIBLE reply, leave a one-line trace ' +
33
- 'so the user sees what was suppressed and what you chose e.g. "[autonomous] I was going ' +
34
- 'to ask <the question>; I decided <choice>, assuming <reversible assumption>." Then take ' +
35
- 'that best, goal-aligned decision and proceed. Only a genuinely irreversible or dangerous ' +
36
- 'choice (deleting data, money, production) justifies stopping and then stop in prose ' +
37
- 'saying exactly what and why, do not reach for the popup again. To let questions through, ' +
38
- 'set "autonomousMode": false in .ai/config.json.';
31
+ 'Autonomous mode is ON. Do not ask the user. Decide and proceed. ' +
32
+ 'Use this order: existing project decisions > existing patterns > requirements > project goals > your judgment. ' +
33
+ 'Keep decisions consistent with the project. Do not invent new patterns or technologies unless needed. ' +
34
+ 'When several choices work, pick the one best for the project. Make reasonable reversible assumptions instead of asking. ' +
35
+ 'In your visible reply, leave one line: "[autonomous] I was going to ask <question>; I chose <decision>, assuming <assumption>." ' +
36
+ 'Only stop for genuinely irreversible or dangerous actions (data deletion, money, destructive production changes). ' +
37
+ 'If stopping, explain what and why. Never ask the popup again. ' +
38
+ 'To enable questions, set "autonomousMode": false in .ai/config.json.';
39
39
 
40
40
  runGate(
41
41
  {
@@ -1,37 +1,17 @@
1
- // autonomous-mode (Stop half) — closes the hole its PreToolUse half cannot reach. The
2
- // PreToolUse gate (index.mjs) blocks the AskUserQuestion TOOL, but nothing stops the
3
- // assistant from ending its turn with a question in plain PROSE and when it does, the
4
- // session just sits there waiting for a human who is supposed to be away. No hook can read
5
- // chat text to detect that a turn ended on a question. So this Stop hook takes the only
6
- // deterministic lever available: when autonomous mode is ON and the turn is ending, it
7
- // re-injects an instruction to NOT wait — decide, delegate, and keep going on everything
8
- // that does not strictly require the user's own explicit decision.
9
- //
10
- // ── What the re-injected message says (per the user's own framing) ───────────────────
11
- // In autonomous mode work is delegated to the assistant. It must only leave pending the ONE
12
- // thing that genuinely needs the user's exclusive, explicit decision (irreversible/dangerous
13
- // — deleting data, money, production). Everything else: proceed. If there are OTHER tasks it
14
- // can advance, advance them; isolate only the truly blocking one and state it in prose, then
15
- // stop. It must not end the turn on a prose question just to wait.
16
- //
17
- // ── stop_hook_active loop-guard (mandatory) ──────────────────────────────────────────
18
- // Claude Code re-invokes Stop hooks with stop_hook_active=true after a block. Blocking again
19
- // would loop forever. So when stop_hook_active === true this hook unconditionally allows the
20
- // stop: the reminder is injected exactly once per cycle. After that one nudge, if the
21
- // assistant still ends the turn, it genuinely had nothing to proceed on (or a real blocker),
22
- // and the session must be allowed to end — a frozen session is worse than one missed nudge.
23
- //
24
- // ── Fail-safe (mandatory for a Stop hook) ────────────────────────────────────────────
25
- // Everything is wrapped so that no config, no project, a corrupt payload, or any unexpected
26
- // error allows the stop. A broken Stop hook must never hang a session.
1
+ // autonomous-mode (Stop half) — the PreToolUse gate blocks the question TOOL, but nothing
2
+ // stops the assistant from ending its turn on a question in plain prose, leaving the
3
+ // session waiting for a human who is away. No hook can read chat text, so this Stop hook
4
+ // re-injects, once per cycle, the instruction to decide and keep going. The loop guard
5
+ // (stop_hook_active) and fail-open shape come from runStopHook: after one nudge, a real
6
+ // end of turn goes through.
27
7
 
28
- import { isGateEnabled } from '../../lib/config.mjs';
29
- import { readHookPayload } from '../../lib/hook-io.mjs';
8
+ import { block, runStopHook } from '../../lib/hook-io.mjs';
30
9
 
10
+ const GATE_ID = 'autonomous-mode';
31
11
  const CONFIG_KEY = 'autonomousMode';
32
12
 
33
13
  const CONTINUE_MESSAGE =
34
- '[autonomous] Autonomous mode is ON — do not end the turn to wait for the user. Work here ' +
14
+ 'Autonomous mode is ON — do not end the turn to wait for the user. Work here ' +
35
15
  'is delegated to you. Leave PENDING only the one thing that genuinely needs the user’s own ' +
36
16
  'explicit, exclusive decision (a truly irreversible or dangerous choice: deleting data, ' +
37
17
  'money, production). For everything else: decide the best goal-aligned option, state the ' +
@@ -41,38 +21,7 @@ const CONTINUE_MESSAGE =
41
21
  'If you have truly nothing left to do and nothing needs the user, ending is fine — this ' +
42
22
  'reminder fires once, so a real end will go through on the next stop.';
43
23
 
44
- function block(reason) {
45
- process.stdout.write(JSON.stringify({ decision: 'block', reason }));
46
- process.exit(0);
47
- }
48
-
49
- function allow() {
50
- process.exit(0);
51
- }
52
-
53
- function main() {
54
- try {
55
- const rawPayload = readHookPayload();
56
- if (rawPayload === null) return allow();
57
-
58
- let payload;
59
- try {
60
- payload = JSON.parse(rawPayload);
61
- } catch {
62
- return allow(); // unparseable Stop payload: fail-safe, never hang the session
63
- }
64
-
65
- // Loop-guard: this hook already nudged once this cycle. Never block again.
66
- if (payload?.stop_hook_active === true) return allow();
67
-
68
- // Only act when the project turned autonomous mode ON. Off by default (registry).
69
- if (!isGateEnabled(CONFIG_KEY, false, process.cwd())) return allow();
70
-
71
- return block(CONTINUE_MESSAGE);
72
- } catch {
73
- // A broken Stop hook must never hang a session.
74
- return allow();
75
- }
76
- }
77
-
78
- main();
24
+ runStopHook(
25
+ { id: GATE_ID, configKey: CONFIG_KEY, enabledByDefault: false },
26
+ () => block(CONFIG_KEY, CONTINUE_MESSAGE),
27
+ );