@devrik-tools/claude-gates 0.7.1 → 0.7.2

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.
@@ -11,13 +11,13 @@
11
11
  "name": "gates",
12
12
  "source": "./plugins/gates",
13
13
  "description": "PreToolUse gates: destructive-command blocks, protected paths, delegation/spec/quality rules, tool-discovery and forge-pipeline enforcement. Which gates run is decided by .ai/config.json (project) or ~/.claude/claude-gates/config.json (global).",
14
- "version": "0.7.1"
14
+ "version": "0.7.2"
15
15
  },
16
16
  {
17
17
  "name": "tasks",
18
18
  "source": "./plugins/tasks",
19
19
  "description": "Deterministic task tracking: the model registers tasks via the CLI, a UserPromptSubmit hook reminds of open tasks every few messages, and a SessionStart hook lists active tasks when a session opens.",
20
- "version": "0.7.1"
20
+ "version": "0.7.2"
21
21
  }
22
22
  ]
23
23
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devrik-tools/claude-gates",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "description": "Installable, deterministic gates (hooks) for Claude Code: block destructive commands, protected paths, and enforce delegation/spec/quality rules. Configurable per project.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gates",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "description": "Deterministic gates for Claude Code: destructive-command blocks, protected paths, delegation briefs, spec-driven flow and session-start validation. Selection lives in config, not in code.",
5
5
  "author": {
6
6
  "name": "Devrik"
@@ -54,7 +54,11 @@ function checkDelegation(toolInput) {
54
54
 
55
55
  deny(
56
56
  CONFIG_KEY,
57
- 'Delegating creation of a new script/checker/gate/hook/linter/tool without evidence of a prior audit. State what you searched and why no existing tool covers this (e.g. "audited and no existing tool...").',
57
+ 'Blocked: this delegation prompt asks to create a new script/checker/gate/hook/linter/' +
58
+ '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.',
58
62
  );
59
63
  }
60
64
 
@@ -85,7 +89,11 @@ function checkWrite(toolInput, parameters) {
85
89
 
86
90
  deny(
87
91
  CONFIG_KEY,
88
- `Creating a new executable tool at ${filePath} without a justification comment (e.g. "justification: ..."). Document why no existing tool covers this before building a new one.`,
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.',
89
97
  );
90
98
  }
91
99
 
@@ -194,21 +194,26 @@ runGate(
194
194
  const treeRoot = contractTreeRootFor(catalogPath);
195
195
  if (!treeRoot) return; // no SDD harness adopted: stay silent
196
196
 
197
- const unapproved = citedFeatures.filter((feature) => {
198
- const contractPath = contractFileFor(treeRoot, feature);
199
- if (!contractPath) return false; // sdd-specs already denies this case
200
- return !isApproved(contractPath);
201
- });
197
+ const unapproved = citedFeatures
198
+ .map((feature) => ({
199
+ feature,
200
+ contractPath: contractFileFor(treeRoot, feature),
201
+ }))
202
+ .filter(({ contractPath }) => contractPath) // no contractPath: sdd-specs already denies that case
203
+ .filter(({ contractPath }) => !isApproved(contractPath));
202
204
 
203
205
  if (unapproved.length === 0) return;
204
206
 
207
+ const fileList = unapproved
208
+ .map(({ feature, contractPath }) => `${feature} -> ${contractPath}`)
209
+ .join('\n ');
205
210
  deny(
206
211
  CONFIG_KEY,
207
- `This implementation delegation cites feature(s) [${unapproved.join(', ')}] ` +
208
- 'whose brief/contract has no recorded approval. Paste the FULL brief into the ' +
209
- 'chat, get an explicit confirmation from the user (not a vague "dale"/"sigamos" ' +
210
- ' an actual sentence confirming they read it), then add this frontmatter to ' +
211
- 'the top of the contract file before relaunching:\n' +
212
+ `This implementation delegation cites unapproved feature(s):\n ${fileList}\n` +
213
+ 'Paste the FULL brief into the chat, get an explicit confirmation from the user ' +
214
+ '(not a vague "dale"/"sigamos" — an actual sentence confirming they read it), ' +
215
+ 'then add this frontmatter to the TOP of that exact contract file before ' +
216
+ 'relaunching (no filesystem exploration needed — the path above is the file to edit):\n' +
212
217
  '---\nstatus: approved\napproved_at: <ISO timestamp>\n' +
213
218
  'approval_quote: "<the user\'s own confirming words>"\n---',
214
219
  );
@@ -3,6 +3,7 @@ import {
3
3
  warn,
4
4
  toolInGroups,
5
5
  writtenContentOf,
6
+ writtenPathOf,
6
7
  } from '../../lib/hook-io.mjs';
7
8
 
8
9
  const GATE_ID = 'diagnosis-before-patch';
@@ -37,12 +38,21 @@ runGate(
37
38
  const patterns = parameters.timeoutPatterns.map(
38
39
  (source) => new RegExp(source, 'i'),
39
40
  );
40
- const touchesTimeout = patterns.some((pattern) => pattern.test(text));
41
- if (!touchesTimeout) return;
41
+ const matched = patterns
42
+ .map((pattern) => pattern.exec(text))
43
+ .find((match) => match !== null);
44
+ if (!matched) return;
42
45
 
46
+ const filePath = writtenPathOf(toolInput) || '(unknown path)';
43
47
  warn(
44
48
  CONFIG_KEY,
45
- 'Diagnosis before patch: you are adjusting a timeout/deadline/retry value. Before changing a value to fix a symptom ("X is slow/fails"), confirm you read the evidence that proves the cause (a log line from the failing provider/process, not a hypothesis). A timeout should measure inactivity, not total time: a process that is progressing should not be cut off.',
49
+ `You are writing '${matched[0].trim()}' into ${filePath} a timeout/deadline/retry ` +
50
+ 'value. Before changing a value to fix a symptom ("X is slow/fails"), confirm you ' +
51
+ 'have the evidence that proves the cause: a log line from the failing provider/' +
52
+ "process, not a hypothesis. If you don't have that log line yet, get it before " +
53
+ 'writing this change — do not guess a new number. A timeout should measure ' +
54
+ 'inactivity, not total time: a process that is still progressing should not be cut ' +
55
+ 'off. This is a warning, not a block — the write proceeds either way.',
46
56
  );
47
57
  },
48
58
  );
@@ -147,7 +147,10 @@ function checkLiveTask(pointerPath) {
147
147
  CONFIG_KEY,
148
148
  `The active task '${slug}' has no contract on disk: none of ` +
149
149
  `${TASK_CONTRACT_FILES.join(', ')} exists under ${taskDirectory}. ` +
150
- 'Write the contract before implementing.',
150
+ `Write ONE of those files there before implementing (this gate's contract root is ` +
151
+ `.ai/pipeline/${slug}/ — a brief.md under .ai/features/${slug}/ satisfies a ` +
152
+ 'different gate, sdd-specs, but NOT this one; if you already wrote a brief there, ' +
153
+ `create a short asserts.md under ${taskDirectory} referencing it).`,
151
154
  );
152
155
  }
153
156
  }
@@ -76,6 +76,7 @@ runGate(
76
76
  ({ toolName, parameters }) => {
77
77
  if (!toolInGroups(toolName, ['execution', 'delegation'])) return;
78
78
 
79
+ const recurrencesFile = join('.ai', 'reincidencias.json');
79
80
  const openRecurrences = loadOpenRecurrences(
80
81
  process.cwd(),
81
82
  parameters.thresholdAppearances,
@@ -85,7 +86,11 @@ runGate(
85
86
  const names = openRecurrences.map((entry) => entry.class).join(', ');
86
87
  deny(
87
88
  CONFIG_KEY,
88
- `Registered recurring issue classes are still open and at/above threshold: ${names}. Resolve or close them before proceeding.`,
89
+ `Registered recurring issue class(es) still open and at/above the ` +
90
+ `${parameters.thresholdAppearances}-occurrence threshold: ${names}. Fix the root ` +
91
+ `cause of the class (not this one instance), then in ${recurrencesFile} set that ` +
92
+ 'class\'s "status" to "closed" (or "cerrada") before proceeding — no other file to ' +
93
+ 'find, this is the only source this gate reads.',
89
94
  );
90
95
  },
91
96
  );
@@ -196,14 +196,50 @@ function isToolLikePath(filePath, parameters) {
196
196
  );
197
197
  }
198
198
 
199
- const DENY_MESSAGE =
200
- 'Do not reinvent the wheel. Before building this, confirm nothing already covers it, ' +
201
- 'auditing in this order: (1) LOCAL the repo and installed deps (search by name and ' +
202
- 'usage, read the manifest); (2) CONTEXT7 resolve the candidate library and read its ' +
203
- 'docs to confirm whether it truly covers the need; (3) WEB — search npm and plugin ' +
204
- 'marketplaces for a maintained package. If it is genuinely absent, record the finding ' +
205
- '(e.g. `claude-gates tool-map add`) and state the audit in the text, so the exploration ' +
206
- 'is not repeated next time.';
199
+ // The exact phrase AUDIT_DONE_PATTERN matches — quoted verbatim in the deny message so the
200
+ // model can copy it instead of guessing a paraphrase the regex might not recognize (a vague
201
+ // "I checked" or "no lo encontré" does NOT match AUDIT_DONE_PATTERN and the gate fires again).
202
+ const AUDIT_DONE_EXAMPLE_PHRASE = 'no existing tool covers this';
203
+
204
+ /** The concrete deny message for a blocked WRITE: names the exact file and gives the exact
205
+ * text to add (copy-pasteable, matches AUDIT_DONE_PATTERN verbatim) no exploration needed
206
+ * to find any of this, it is the gate's own job to hand it over. Adding the phrase also
207
+ * satisfies the tool-map gate (maintainToolMap), which records the file + this same line
208
+ * into .ai/tool-map.json automatically — no separate command to run. */
209
+ function writeDenyMessage(filePath) {
210
+ return (
211
+ `Blocked: '${filePath}' looks like a new tool/helper, and nothing shows the wheel was ` +
212
+ 'checked first. Pick ONE, then retry the exact same write:\n' +
213
+ ` 1. It already exists here or in an installed dep — don't create '${filePath}'; ` +
214
+ 'reuse/import the existing one instead.\n' +
215
+ ' 2. It does not exist anywhere you checked (repo, installed deps, npm/marketplaces) — ' +
216
+ "add ONE line to the file's content stating that, e.g.: " +
217
+ `"// ${AUDIT_DONE_EXAMPLE_PHRASE}" (any phrase matching /${AUDIT_DONE_PATTERN.source}/i ` +
218
+ 'works, this one is guaranteed to). That line also gets this file auto-recorded into ' +
219
+ '.ai/tool-map.json — nothing further to run.\n' +
220
+ 'No filesystem exploration is required to satisfy this gate — the audit is a sentence ' +
221
+ 'in the file or a matching package.json dependency, nothing else.'
222
+ );
223
+ }
224
+
225
+ /** The concrete deny message for a blocked DELEGATION prompt: same two options, but no file
226
+ * name exists yet (only a prompt), so both talk about the prompt text, not a file. */
227
+ function delegationDenyMessage() {
228
+ return (
229
+ "Blocked: this delegation's prompt asks to build a new tool/helper, and nothing in " +
230
+ 'the prompt shows the wheel was checked first. Pick ONE, then relaunch the same ' +
231
+ 'delegation with the prompt updated:\n' +
232
+ ' 1. It already exists (repo, installed dep) — do not delegate a new build; ' +
233
+ 'reference the existing one in the prompt instead.\n' +
234
+ ' 2. It does not exist anywhere you checked — add ONE line to the prompt stating ' +
235
+ `that, e.g.: "${AUDIT_DONE_EXAMPLE_PHRASE}" (any phrase matching ` +
236
+ `/${AUDIT_DONE_PATTERN.source}/i works, this one is guaranteed to). Once the delegate ` +
237
+ 'actually writes the file, include the same phrase in its content so it also gets ' +
238
+ 'auto-recorded into .ai/tool-map.json.\n' +
239
+ 'No filesystem exploration is required to satisfy this gate — the audit is a sentence ' +
240
+ 'in the prompt, or a matching package.json dependency, nothing else.'
241
+ );
242
+ }
207
243
 
208
244
  /** A build is cleared when its text declares an audit, its name is already recorded in the
209
245
  * map, or an installed dependency covers it. `toolBaseName` is used for the last two. */
@@ -224,7 +260,7 @@ function checkDelegation(toolInput, cwd, parameters) {
224
260
  const prompt = delegationPromptOf(toolInput);
225
261
  if (!isBuildIntent(prompt)) return;
226
262
  if (buildIsCleared(prompt, cwd, parameters, null)) return;
227
- deny(CONFIG_KEY, DENY_MESSAGE);
263
+ deny(CONFIG_KEY, delegationDenyMessage());
228
264
  }
229
265
 
230
266
  /** Write: block a new tool-like file that is not cleared by content, map, or an installed dep. */
@@ -237,7 +273,7 @@ function checkWrite(toolInput, cwd, parameters) {
237
273
  const content = writtenContentOf(toolInput);
238
274
  const toolBaseName = basename(filePath).replace(/\.[^.]+$/, '');
239
275
  if (buildIsCleared(content, cwd, parameters, toolBaseName)) return;
240
- deny(CONFIG_KEY, DENY_MESSAGE);
276
+ deny(CONFIG_KEY, writeDenyMessage(filePath));
241
277
  }
242
278
 
243
279
  runGate(
@@ -150,14 +150,14 @@ runGate(
150
150
  for (const script of scripts) {
151
151
  const before = snapshotConfigs(watchedConfigPaths);
152
152
 
153
- let failed = false;
153
+ let failure = null;
154
154
  try {
155
155
  execFileSync(process.execPath, [script], {
156
- stdio: 'ignore',
156
+ stdio: ['ignore', 'ignore', 'pipe'],
157
157
  timeout: 5000,
158
158
  });
159
- } catch {
160
- failed = true;
159
+ } catch (error) {
160
+ failure = error;
161
161
  }
162
162
 
163
163
  if (configsTampered(before)) {
@@ -169,10 +169,16 @@ runGate(
169
169
  return;
170
170
  }
171
171
 
172
- if (failed) {
172
+ if (failure) {
173
+ const stderr = String(failure.stderr ?? '').trim();
174
+ const detail = stderr || failure.message || String(failure);
173
175
  deny(
174
176
  CONFIG_KEY,
175
- `Sub-gate failed: ${relative(projectRoot, script)}. Fix it before continuing.`,
177
+ `Sub-gate '${relative(projectRoot, script)}' failed (exit ` +
178
+ `${failure.status ?? 'unknown'}): ${detail}\n` +
179
+ 'No filesystem exploration is needed — fix that script (open it at the path ' +
180
+ 'above and address the error shown), or remove it from ' +
181
+ `${parameters.rulesDir}/ if it should not run as a gate.`,
176
182
  );
177
183
  return;
178
184
  }
@@ -147,15 +147,31 @@ function parseJsonOrNull(text) {
147
147
  }
148
148
 
149
149
  /** Denies the single first advanced-status feature in the new catalog content that has
150
- * no contract on disk, or does nothing when every advanced feature has one. */
150
+ * no contract on disk, or does nothing when every advanced feature has one. A feature
151
+ * object missing its `name` field is denied too (not silently skipped) — it is malformed
152
+ * catalog data, not a feature this gate has decided has no obligations. */
151
153
  function denyIfAdvancedFeatureLacksContract(features, treeRoot) {
152
154
  for (const feature of features) {
153
- if (!feature?.name || !ADVANCED_STATUSES.has(feature.status)) continue;
155
+ if (!ADVANCED_STATUSES.has(feature?.status)) continue;
156
+ if (!feature?.name) {
157
+ deny(
158
+ CONFIG_KEY,
159
+ `A feature entry has status '${feature.status}' but no 'name' field: ` +
160
+ `${JSON.stringify(feature)}. Every feature needs a 'name' so its contract tree ` +
161
+ '(.ai/features/<name>/) can be located — add it before writing this status.',
162
+ );
163
+ continue;
164
+ }
154
165
  if (contractExistsFor(treeRoot, feature.name)) continue;
166
+ const featureDirectory = join(
167
+ treeRoot ?? join(process.cwd(), '.ai', 'features'),
168
+ feature.name,
169
+ );
155
170
  deny(
156
171
  CONFIG_KEY,
157
172
  `Feature '${feature.name}' is set to '${feature.status}' but has no non-empty ` +
158
- `contract (${CONTRACT_FILES.join(', ')}) under the discovered contract tree.`,
173
+ `contract on disk. Write ONE of these files under ${featureDirectory}/ (any one ` +
174
+ `is enough): ${CONTRACT_FILES.join(', ')}.`,
159
175
  );
160
176
  }
161
177
  }
@@ -172,9 +188,10 @@ function checkCatalogWrite(toolInput, catalogPath, treeRoot) {
172
188
  // hidden behind a malformed payload must not be silently allowed through.
173
189
  deny(
174
190
  CONFIG_KEY,
175
- `The write to ${CATALOG_FILE_NAME} does not parse as JSON. A catalog write that ` +
176
- 'cannot be verified for the spec-contract invariant is not allowed; fix the JSON ' +
177
- 'or write valid content.',
191
+ `The write to ${CATALOG_FILE_NAME} does not parse as JSON (JSON.parse threw). This ` +
192
+ 'is a syntax problem in the content being written, not a missing-field one check ' +
193
+ 'for a trailing comma, unquoted key, or unclosed bracket in what you are about to ' +
194
+ 'write. No filesystem exploration needed; the payload itself is the thing to fix.',
178
195
  );
179
196
  }
180
197
  const features = Array.isArray(parsed?.features) ? parsed.features : [];
@@ -212,11 +229,15 @@ function checkDelegation(toolInput, treeRoot, exemptSubagents) {
212
229
  (feature) => !contractExistsFor(treeRoot, feature),
213
230
  );
214
231
  if (missing.length === citedFeatures.length) {
232
+ const root = treeRoot ?? join(process.cwd(), '.ai', 'features');
233
+ const fileList = missing
234
+ .map((feature) => `${feature} -> ${join(root, feature)}/`)
235
+ .join('\n ');
215
236
  deny(
216
237
  CONFIG_KEY,
217
- `This implementation delegation cites feature(s) [${missing.join(', ')}] with no ` +
218
- `non-empty contract (${CONTRACT_FILES.join(', ')}) on disk. Write the contract ` +
219
- 'before implementing.',
238
+ `This implementation delegation cites feature(s) with no contract on disk:\n ${fileList}\n` +
239
+ `Write ONE of these files in each directory above (any one is enough): ` +
240
+ `${CONTRACT_FILES.join(', ')}. Then relaunch.`,
220
241
  );
221
242
  }
222
243
  }
@@ -178,7 +178,15 @@ const SHELL_WRITE_PATTERNS = [
178
178
  /\b(?:touch|tee)\s+(?:-\S+\s+)*(['"]?)([^\s'"|;&<>]+)\1/g,
179
179
  // cp / mv / install destination is the LAST path; capture the first arg after the command
180
180
  // as a cheap proxy (over-detects the source too, which is acceptable — a gate re-checks).
181
- /\b(?:cp|mv|install)\s+(?:-\S+\s+)*(['"]?)([^\s'"|;&<>]+)\1/g,
181
+ // Anchored to the START of a command (start of string, or right after a separator like
182
+ // `;`/`&&`/`||`/`|`), NOT `\b`, which matched "install" as a bare word anywhere — including
183
+ // as npm/pip/yarn's SUBCOMMAND (`npm install -D daisyui` was misread as the Unix `install`
184
+ // utility, capturing the package name "daisyui" as a phantom root-level file target and
185
+ // tripping root-whitelist on a plain dependency install). `npm install`/`pip install`/
186
+ // `yarn install` never start a shell command with the bare word "install" as argv[0], so
187
+ // anchoring at the command boundary excludes them while still catching a real `install ...`
188
+ // invocation (the coreutils/BSD command) at the start of a command.
189
+ /(?:^|[;&|])\s*(?:cp|mv|install)\s+(?:-\S+\s+)*(['"]?)([^\s'"|;&<>]+)\1/g,
182
190
  ];
183
191
 
184
192
  /**
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tasks",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "description": "Deterministic task tracking for Claude Code: persists tasks the model registers via the CLI, reminds of open tasks on a message counter, and lists active tasks on session start.",
5
5
  "author": {
6
6
  "name": "Devrik"