@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
@@ -0,0 +1,352 @@
1
+ // delegation.mjs — what every gate that judges a subagent delegation shares: who the
2
+ // subagent is, whether it is exempt, whether the prompt asks for implementation or a
3
+ // read-only query, which risk LEVEL it declares, and whether it really intends to mutate a
4
+ // high-impact area. Before this module each of eight gates carried its own copy of these,
5
+ // with eight divergent verb lists, two case-sensitivity rules for exemptions, and two
6
+ // contradictory ways to read a LEVEL.
7
+ //
8
+ // Self-contained: Node built-ins only (plus signals.mjs, this plugin's own).
9
+
10
+ import { MUTATION_RISK_SIGNAL, withUnicodeWordBoundary } from './signals.mjs';
11
+
12
+ /** Subagent types that, by declared purpose, never mutate (exempt unless the prompt says
13
+ * otherwise — see isReadOnlySubagent). */
14
+ export const DEFAULT_READ_ONLY_SUBAGENTS = [
15
+ 'explore',
16
+ 'claude-code-guide',
17
+ 'plan',
18
+ ];
19
+
20
+ /** Subagent types the spec-driven gates exempt (planning/review/QA roles). */
21
+ export const DEFAULT_EXEMPT_SUBAGENTS = [
22
+ 'explore',
23
+ 'plan',
24
+ 'scout',
25
+ 'revision',
26
+ 'contraste',
27
+ 'test-planner',
28
+ 'qa',
29
+ 'ui',
30
+ 'ux',
31
+ ];
32
+
33
+ /** The subagent type a delegation names, lowercased; '' when absent. */
34
+ export function subagentTypeOf(toolInput) {
35
+ if (!toolInput || typeof toolInput !== 'object') return '';
36
+ return String(toolInput.subagent_type ?? toolInput.subagentType ?? '')
37
+ .trim()
38
+ .toLowerCase();
39
+ }
40
+
41
+ /** Whether the delegation's subagent type is in `names` (case-insensitive both sides). */
42
+ export function isSubagentNamedIn(toolInput, names) {
43
+ const type = subagentTypeOf(toolInput);
44
+ if (!type || !Array.isArray(names)) return false;
45
+ return names.some((name) => String(name).trim().toLowerCase() === type);
46
+ }
47
+
48
+ /**
49
+ * A whitelisted read-only subagent name exempts a call ONLY when the prompt carries no
50
+ * mutation-risk signal: the name is a declared label, never a verified capability a hook
51
+ * can check, so real risk in the text outranks it.
52
+ */
53
+ export function isReadOnlySubagent(toolInput, prompt, readOnlySubagents) {
54
+ if (!isSubagentNamedIn(toolInput, readOnlySubagents)) return false;
55
+ return !MUTATION_RISK_SIGNAL.test(String(prompt ?? ''));
56
+ }
57
+
58
+ // ── Verbs ───────────────────────────────────────────────────────────────────────────
59
+ // One vocabulary, Spanish (tú/vos/infinitive forms) and English, for "this delegation
60
+ // asks the subagent to CHANGE something". Broad on purpose: a miss here means a gate never
61
+ // runs (a silent hole), a false hit only means a cheap check runs.
62
+ const IMPLEMENTATION_VERB_SOURCES =
63
+ 'implementa|implementar|implement(á|é)|agreg(a|á)|agregar|añad(e|í)|añadir|cre(a|á)|crear|' +
64
+ 'arregl(a|á)|arreglar|cambi(a|á)|cambiar|migr(a|á)|migrar|escrib(í|e)|escribir|' +
65
+ 'corrige|corregir|correg(í|ir)|constru(ye|í)|construir|modific(a|á)|modificar|' +
66
+ 'refactoriz(a|á)|refactorizar|elimin(a|á)|eliminar|borr(a|á)|borrar|reescrib(e|í)|reescribir|' +
67
+ 'desplieg(a|á)|desplegar|renombr(a|á)|renombrar|edit(a|á)|editar|actualiz(a|á)|actualizar|' +
68
+ 'hac(e|er|é)|resuelve|resolv(é|er)|soluciona|solucion(á|ar)|encárgate|encargate|' +
69
+ 'ocúpate|ocupate|instal(a|á)|instalar|configur(a|á)|configurar|reemplaz(a|á)|reemplazar|' +
70
+ 'implement(?:s|ed|ing)?|writ(?:e|es|ing|ten)|creat(?:e|es|ed|ing)|fix(?:es|ed|ing)?|' +
71
+ 'build(?:s|ing)?|built|refactor(?:s|ed|ing)?|migrat(?:e|es|ed|ing)|add(?:s|ed|ing)?|' +
72
+ 'remov(?:e|es|ed|ing)|delet(?:e|es|ed|ing)|modif(?:y|ies|ied|ying)|rewrit(?:e|es|ing|ten)|' +
73
+ 'deploy(?:s|ed|ing)?|updat(?:e|es|ed|ing)|chang(?:e|es|ed|ing)|renam(?:e|es|ed|ing)|' +
74
+ 'edit(?:s|ed|ing)?|replac(?:e|es|ed|ing)|convert(?:s|ed|ing)?|install(?:s|ed|ing)?|' +
75
+ 'configur(?:e|es|ed|ing)|extend(?:s|ed|ing)?|patch(?:es|ed|ing)?|upgrad(?:e|es|ed|ing)';
76
+
77
+ export const IMPLEMENTATION_VERBS = withUnicodeWordBoundary(
78
+ IMPLEMENTATION_VERB_SOURCES,
79
+ );
80
+
81
+ const QUERY_VERB_SOURCES =
82
+ 'explica|explic(á|ar)|qu[eé]|c[oó]mo|muestra|mostr(á|ar)|analiza|analiz(á|ar)|investiga|' +
83
+ 'investig(á|ar)|revisa|revis(á|ar)|audita|audit(á|ar)|diagnostica|diagnostic(á|ar)|' +
84
+ 'busca|buscar|explora|explorar|lee|leer|compara|comparar|averigua|averiguar|' +
85
+ 'explain|what|how|show|analy[sz]e|investigate|review|audit|diagnose|search|explore|read|compare';
86
+
87
+ export const QUERY_VERBS = withUnicodeWordBoundary(QUERY_VERB_SOURCES);
88
+
89
+ /** Whether the prompt carries an implementation (mutating) verb. */
90
+ export function isImplementationRequest(prompt) {
91
+ return IMPLEMENTATION_VERBS.test(String(prompt ?? ''));
92
+ }
93
+
94
+ /** A query with no implementation verb at all (implementation verbs take priority). */
95
+ export function isExemptQuery(prompt) {
96
+ const text = String(prompt ?? '');
97
+ if (IMPLEMENTATION_VERBS.test(text)) return false;
98
+ return QUERY_VERBS.test(text);
99
+ }
100
+
101
+ // ── Documentary deliverables ────────────────────────────────────────────────────────
102
+ const DOCUMENTARY_DELIVERABLE_PATTERN = withUnicodeWordBoundary(
103
+ 'documento|documentaci[oó]n|reporte|informe|diagrama|readme|wiki|changelog|' +
104
+ 'p[aá]gina de documentaci[oó]n|archivo html|p[aá]gina html|markdown|document|documentation|report',
105
+ );
106
+ const DOCUMENTARY_EXTENSION_PATTERN = /\.(md|mdx|html?|adoc|rst|txt)(?!\w)/iu;
107
+ const VERB_OBJECT_WINDOW = 60;
108
+
109
+ /** Whether the verb at `verbMatch` acts on a documentary deliverable (a doc describing an
110
+ * area is not mutating that area). */
111
+ export function verbProducesDocument(text, verbMatch) {
112
+ const from = verbMatch.index;
113
+ const objectOfVerb = text.slice(
114
+ from,
115
+ from + verbMatch[0].length + VERB_OBJECT_WINDOW,
116
+ );
117
+ return (
118
+ DOCUMENTARY_DELIVERABLE_PATTERN.test(objectOfVerb) ||
119
+ DOCUMENTARY_EXTENSION_PATTERN.test(objectOfVerb)
120
+ );
121
+ }
122
+
123
+ /** Whether the prompt as a whole asks for a document rather than a code change. */
124
+ export function isDocumentaryRequest(prompt) {
125
+ const text = String(prompt ?? '');
126
+ const verbs = allMatches(IMPLEMENTATION_VERBS, text);
127
+ if (verbs.length === 0) return false;
128
+ return verbs.every((verb) => verbProducesDocument(text, verb));
129
+ }
130
+
131
+ // ── Text helpers ────────────────────────────────────────────────────────────────────
132
+ const MAX_QUOTED_LENGTH = 300;
133
+
134
+ /**
135
+ * Removes fenced code blocks and double-quoted strings (up to 300 chars) so quoted or
136
+ * templated text is not mistaken for the prompt's own intent. Single quotes are NOT
137
+ * stripped: in English prose they are apostrophes (don't, user's), and stripping between
138
+ * them erased arbitrary spans of the real prompt.
139
+ */
140
+ export function stripQuoted(text) {
141
+ return String(text ?? '')
142
+ .replace(/```[\s\S]*?```/g, ' ')
143
+ .replace(new RegExp(`"[^"\\n]{0,${MAX_QUOTED_LENGTH}}"`, 'g'), ' ');
144
+ }
145
+
146
+ /** Every match of `pattern` in `text` (a global clone is used, the original is untouched). */
147
+ export function allMatches(pattern, text) {
148
+ const flags = pattern.flags.includes('g')
149
+ ? pattern.flags
150
+ : `${pattern.flags}g`;
151
+ return [...String(text ?? '').matchAll(new RegExp(pattern.source, flags))];
152
+ }
153
+
154
+ // ── High-impact intent ──────────────────────────────────────────────────────────────
155
+ export const DEFAULT_HIGH_IMPACT_PATTERNS = [
156
+ 'dinero|pago|cobro|money|payment',
157
+ 'auth|autenticaci[oó]n|authentication|permiso|permission|credencial|credential|token|' +
158
+ 'sesi[oó]n|session|seguridad|security|PII',
159
+ 'borrar|delete|drop|migraci[oó]n|migration|schema',
160
+ 'contrato|contract|irreversible|producci[oó]n|production|API p[uú]blica|public api',
161
+ ];
162
+ const CO_OCCURRENCE_WINDOW = 100;
163
+
164
+ /**
165
+ * One boundary-wrapped pattern from a list of regex sources. Returns null for an empty or
166
+ * non-array list (meaning: nothing is high-impact), and skips a malformed source instead
167
+ * of throwing — one typo in config must not disable the gate or block everything.
168
+ */
169
+ export function buildHighImpactPattern(sources) {
170
+ if (!Array.isArray(sources)) return null;
171
+ const valid = sources.filter((source) => {
172
+ if (typeof source !== 'string' || !source.trim()) return false;
173
+ try {
174
+ new RegExp(source, 'iu');
175
+ return true;
176
+ } catch {
177
+ return false;
178
+ }
179
+ });
180
+ if (valid.length === 0) return null;
181
+ return withUnicodeWordBoundary(
182
+ valid.map((source) => `(?:${source})`).join('|'),
183
+ );
184
+ }
185
+
186
+ /**
187
+ * Two-stage intent check: a cheap prefilter (verb AND signal somewhere), then, on the
188
+ * prompt with quoted text removed, a near co-occurrence of an implementation verb and a
189
+ * high-impact signal where the verb's object is not a documentary deliverable.
190
+ */
191
+ export function hasRealSensitiveMutation(prompt, highImpactPattern) {
192
+ const raw = String(prompt ?? '');
193
+ if (!highImpactPattern) return false;
194
+ if (!(IMPLEMENTATION_VERBS.test(raw) && highImpactPattern.test(raw)))
195
+ return false;
196
+
197
+ const text = stripQuoted(raw);
198
+ const verbs = allMatches(IMPLEMENTATION_VERBS, text);
199
+ const signals = allMatches(highImpactPattern, text);
200
+ for (const verb of verbs) {
201
+ if (verbProducesDocument(text, verb)) continue;
202
+ for (const signal of signals) {
203
+ if (Math.abs(signal.index - verb.index) <= CO_OCCURRENCE_WINDOW)
204
+ return true;
205
+ }
206
+ }
207
+ return false;
208
+ }
209
+
210
+ // ── Declared LEVEL ──────────────────────────────────────────────────────────────────
211
+ export const LEVELS = Object.freeze({
212
+ QUESTION: 'QUESTION',
213
+ MICRO: 'MICRO',
214
+ STANDARD: 'STANDARD',
215
+ HIGH_RISK: 'HIGH-RISK',
216
+ });
217
+ export const DEMANDING_LEVELS = new Set([LEVELS.STANDARD, LEVELS.HIGH_RISK]);
218
+ export const EXEMPT_LEVELS = new Set([LEVELS.QUESTION, LEVELS.MICRO]);
219
+
220
+ // A level is "declared" when one of the four tokens sits within LEVEL_WINDOW characters after
221
+ // the word level/nivel/clasificación — across a colon, markdown bold, a short qualifier ("de
222
+ // riesgo") or a line break — so `**LEVEL**\nSTANDARD` reads, while a stray token far from
223
+ // the keyword does not count as a declaration.
224
+ const LEVEL_KEYWORD_PATTERN = /nivel|level|clasificaci[oó]n|classification/giu;
225
+ const LEVEL_TOKEN_PATTERN =
226
+ /(QUESTION|MICRO|STANDARD|HIGH[-_ ]?RISK)(?![\p{L}\p{N}])/iu;
227
+ const LEVEL_WINDOW = 60;
228
+
229
+ function normalizeLevelToken(token) {
230
+ const upper = token.toUpperCase();
231
+ return upper.startsWith('HIGH') ? LEVELS.HIGH_RISK : upper;
232
+ }
233
+
234
+ /** Every LEVEL token declared in the prompt, in order of appearance. */
235
+ export function declaredLevelsOf(prompt) {
236
+ const text = String(prompt ?? '');
237
+ const levels = [];
238
+ for (const keyword of text.matchAll(LEVEL_KEYWORD_PATTERN)) {
239
+ const start = keyword.index + keyword[0].length;
240
+ const token = LEVEL_TOKEN_PATTERN.exec(
241
+ text.slice(start, start + LEVEL_WINDOW),
242
+ );
243
+ if (token) levels.push(normalizeLevelToken(token[1]));
244
+ }
245
+ return levels;
246
+ }
247
+
248
+ /**
249
+ * The level that GOVERNS the prompt: the last one declared (a later declaration revises an
250
+ * earlier one), or null when none is declared. A decoy `LEVEL: MICRO` placed before a real
251
+ * `LEVEL: HIGH-RISK` therefore does not exempt anything.
252
+ */
253
+ export function operativeLevelOf(prompt) {
254
+ const levels = declaredLevelsOf(prompt);
255
+ return levels.length === 0 ? null : levels.at(-1);
256
+ }
257
+
258
+ /** Whether the declared levels contradict each other (a demanding and an exempt level). */
259
+ export function hasAmbiguousLevel(prompt) {
260
+ const levels = new Set(declaredLevelsOf(prompt));
261
+ return (
262
+ [...levels].some((level) => DEMANDING_LEVELS.has(level)) &&
263
+ [...levels].some((level) => EXEMPT_LEVELS.has(level))
264
+ );
265
+ }
266
+
267
+ // ── Harness paths ───────────────────────────────────────────────────────────────────
268
+ // Work ON the agent harness itself (Claude Code config, this plugin's own wiring, agent
269
+ // instructions) is exempt from the spec-driven pipeline gates. Deliberately NARROW: a bare
270
+ // `hooks/` or `settings.json` also lives in ordinary app code (React hooks, .vscode) and
271
+ // exempting those turned the gates off for every React/Vue project.
272
+ const HARNESS_PATH_PATTERNS = [
273
+ /(?:^|[\s"'`(\\/])\.claude[\\/]/i,
274
+ /(?:^|[\s"'`(\\/])\.ai[\\/]/i,
275
+ /(?:^|[\s"'`(\\/])\.claude-plugin[\\/]/i,
276
+ /(?:^|[\s"'`(\\/])(?:CLAUDE|AGENTS)\.md(?!\w)/i,
277
+ /(?:^|[\s"'`(\\/])hooks[\\/]hooks\.json(?!\w)/i,
278
+ /(?:^|[\s"'`(\\/])registry\.json(?!\w)/i,
279
+ ];
280
+
281
+ /** Whether the prompt targets the agent harness rather than the product. */
282
+ export function isHarnessWork(prompt) {
283
+ const text = String(prompt ?? '');
284
+ return HARNESS_PATH_PATTERNS.some((pattern) => pattern.test(text));
285
+ }
286
+
287
+ // ── Instruction vs. mention ─────────────────────────────────────────────────────────
288
+ // A delegation prompt can MENTION a forbidden command without ordering it: "confirm no
289
+ // script ever calls a hard reset", "do NOT run git push", "the user will push". A gate that
290
+ // greps the raw command regex over prose denies all of those. This looks at the words
291
+ // governing each mention: a reporting verb, a negation, or a third-party actor before it
292
+ // (within LOOK_BACK characters, same sentence) makes it a description, not an order.
293
+ const MENTION_LOOK_BACK = 80;
294
+ // Words that, in the same sentence before a mention, make it a description or a prohibition
295
+ // rather than an order. Each is a small boundary-wrapped pattern; tested one by one.
296
+ const DESCRIPTION_GOVERNOR_TERMS = [
297
+ 'describe|explain|summar\\p{L}*|mention\\p{L}*|document\\p{L}*|report|changelog',
298
+ 'confirm|verify|check|ensure|audit|grep|search|find|look for',
299
+ "never|not|don'?t|do not|avoid|forbid|prohibit|instead of|without",
300
+ 'the user|user will|yourself|themselves',
301
+ 'no|nunca|jam[aá]s|evit\\p{L}*|prohib\\p{L}*|sin|en vez de|en lugar de|el usuario',
302
+ 'documenta|explica|resume|menciona|confirma|verifica|revisa|busca|audita',
303
+ ];
304
+ const DESCRIPTION_GOVERNOR_PATTERNS = DESCRIPTION_GOVERNOR_TERMS.map((terms) =>
305
+ withUnicodeWordBoundary(terms),
306
+ );
307
+
308
+ /** The part of `before` that belongs to the same sentence as the mention that follows it. */
309
+ function sameSentenceTail(before) {
310
+ return before.split(/[.\n]/).at(-1) ?? '';
311
+ }
312
+
313
+ function isGovernedByDescription(before) {
314
+ const tail = sameSentenceTail(before);
315
+ return DESCRIPTION_GOVERNOR_PATTERNS.some((pattern) => pattern.test(tail));
316
+ }
317
+
318
+ /**
319
+ * Whether `pattern` matches the text as a real instruction: a non-quoted occurrence that is
320
+ * not governed by a reporting verb, a negation or a third-party actor in the same sentence.
321
+ */
322
+ export function hasRealCommandIntent(text, pattern) {
323
+ const raw = String(text ?? '');
324
+ if (!pattern.test(raw)) return false;
325
+ const cleaned = stripQuoted(raw);
326
+ return allMatches(pattern, cleaned).some((match) => {
327
+ const from = Math.max(0, match.index - MENTION_LOOK_BACK);
328
+ return !isGovernedByDescription(cleaned.slice(from, match.index));
329
+ });
330
+ }
331
+
332
+ // ── Excerpts for messages ───────────────────────────────────────────────────────────
333
+ const PROMPT_EXCERPT_LENGTH = 80;
334
+
335
+ /** The start of a prompt, for quoting back in a deny message. */
336
+ export function promptExcerpt(prompt) {
337
+ const text = String(prompt ?? '').trim();
338
+ return text.length > PROMPT_EXCERPT_LENGTH
339
+ ? `${text.slice(0, PROMPT_EXCERPT_LENGTH)}…`
340
+ : text;
341
+ }
342
+
343
+ // ── Feature citations (spec-driven gates) ───────────────────────────────────────────
344
+ const FEATURE_CITATION_PATTERN = /\.(?:ai)[\\/]features[\\/]([\w.@-]+)/gi;
345
+
346
+ /** Feature names cited as `.ai/features/<name>` in a prompt, de-duplicated. */
347
+ export function featureNamesCitedIn(prompt) {
348
+ const names = new Set();
349
+ for (const match of String(prompt ?? '').matchAll(FEATURE_CITATION_PATTERN))
350
+ names.add(match[1]);
351
+ return [...names];
352
+ }
@@ -0,0 +1,141 @@
1
+ // gate-log — the per-project decision log. Every deny, warn and Stop-block a gate emits is
2
+ // appended as one JSON line to `<project root>/.ai/gates-log.jsonl`, so a user can later
3
+ // reconstruct WHICH gate blocked WHAT action, WHEN, and WHY — the conversation transcript
4
+ // is not a reliable record (it is compacted, and a denied tool call shows only the reason).
5
+ //
6
+ // Self-contained: Node built-ins only. Never throws: a logging failure must never change a
7
+ // gate's decision, so every filesystem call is wrapped and any error is swallowed.
8
+ //
9
+ // ── Line shape ──────────────────────────────────────────────────────────────────────
10
+ // { "ts": ISO-8601, "decision": "deny"|"warn"|"block"|"config",
11
+ // "gate": id, "configKey": key, "tool": toolName, "session": sessionId|null,
12
+ // "summary": short description of the action judged, "reason": the message }
13
+ //
14
+ // ── Rotation ────────────────────────────────────────────────────────────────────────
15
+ // When the file passes MAX_LOG_BYTES it is renamed to `gates-log.1.jsonl` (replacing any
16
+ // previous rotation) and a fresh file starts — one bounded generation kept, never unbounded
17
+ // growth in a project directory.
18
+ //
19
+ // ── Opt-out ─────────────────────────────────────────────────────────────────────────
20
+ // CLAUDE_GATES_LOG=0 disables writing (tests that assert a gate's output in a scratch
21
+ // project do not need a log file appearing next to it).
22
+
23
+ import {
24
+ appendFileSync,
25
+ existsSync,
26
+ mkdirSync,
27
+ renameSync,
28
+ statSync,
29
+ } from 'node:fs';
30
+ import { join } from 'node:path';
31
+ import { projectRootOf } from './config.mjs';
32
+
33
+ export const LOG_DIRECTORY = '.ai';
34
+ export const LOG_FILE = 'gates-log.jsonl';
35
+ export const ROTATED_LOG_FILE = 'gates-log.1.jsonl';
36
+ const DISABLE_ENV = 'CLAUDE_GATES_LOG';
37
+ const BYTES_PER_KILOBYTE = 1024;
38
+ const BYTES_PER_MEGABYTE = BYTES_PER_KILOBYTE * BYTES_PER_KILOBYTE;
39
+ const MAX_LOG_MEGABYTES = 5;
40
+ const MAX_LOG_BYTES = MAX_LOG_MEGABYTES * BYTES_PER_MEGABYTE;
41
+ const SUMMARY_MAX_LENGTH = 200;
42
+ const REASON_MAX_LENGTH = 800;
43
+
44
+ export const DECISIONS = Object.freeze({
45
+ DENY: 'deny',
46
+ WARN: 'warn',
47
+ BLOCK: 'block',
48
+ CONFIG: 'config',
49
+ });
50
+
51
+ /** The log path for a project root (the file need not exist yet). */
52
+ export function logPathFor(projectRoot) {
53
+ return join(projectRoot, LOG_DIRECTORY, LOG_FILE);
54
+ }
55
+
56
+ function truncate(text, max = SUMMARY_MAX_LENGTH) {
57
+ const single = String(text ?? '')
58
+ .replace(/\s+/g, ' ')
59
+ .trim();
60
+ return single.length > max ? `${single.slice(0, max - 1)}…` : single;
61
+ }
62
+
63
+ /**
64
+ * A one-line description of the action a tool call was about to perform, across the tool
65
+ * shapes the gates know: a shell command, a written path, a delegation prompt (with its
66
+ * subagent type), or a question. Falls back to the tool name alone.
67
+ */
68
+ function firstText(input, keys) {
69
+ for (const key of keys) {
70
+ const value = input[key];
71
+ if (typeof value === 'string' && value.trim()) return value;
72
+ }
73
+ return '';
74
+ }
75
+
76
+ export function summarizeToolInput(toolName, toolInput) {
77
+ const input = toolInput && typeof toolInput === 'object' ? toolInput : {};
78
+ const command = firstText(input, ['command', 'CommandLine']);
79
+ if (command) return truncate(command);
80
+ const path = firstText(input, [
81
+ 'file_path',
82
+ 'path',
83
+ 'target_file',
84
+ 'notebook_path',
85
+ ]);
86
+ if (path) return truncate(path);
87
+ const prompt = firstText(input, ['prompt', 'description', 'task']);
88
+ if (prompt) {
89
+ const type = firstText(input, ['subagent_type', 'subagentType']);
90
+ return truncate(type ? `[${type}] ${prompt}` : prompt);
91
+ }
92
+ const question = input.questions?.[0]?.question;
93
+ return truncate(typeof question === 'string' ? question : (toolName ?? ''));
94
+ }
95
+
96
+ function rotateIfLarge(path, directory) {
97
+ try {
98
+ if (!existsSync(path)) return;
99
+ if (statSync(path).size < MAX_LOG_BYTES) return;
100
+ renameSync(path, join(directory, ROTATED_LOG_FILE));
101
+ } catch {
102
+ // Rotation is best-effort; a failure just means the file keeps growing this once.
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Appends one decision line. `entry.cwd` (default process.cwd()) locates the project; when
108
+ * no project root is found (no .git / .ai marker) nothing is written — there is no project
109
+ * to attribute the decision to.
110
+ *
111
+ * @param {{ decision: string, gate: string, configKey: string, toolName?: string,
112
+ * toolInput?: object, summary?: string, reason: string, sessionId?: string|null,
113
+ * cwd?: string }} entry
114
+ * @returns {boolean} whether a line was written
115
+ */
116
+ export function logDecision(entry) {
117
+ if (process.env[DISABLE_ENV] === '0') return false;
118
+ try {
119
+ const root = projectRootOf(entry.cwd ?? process.cwd());
120
+ if (!root) return false;
121
+ const directory = join(root, LOG_DIRECTORY);
122
+ const path = join(directory, LOG_FILE);
123
+ mkdirSync(directory, { recursive: true });
124
+ rotateIfLarge(path, directory);
125
+ const line = {
126
+ ts: new Date().toISOString(),
127
+ decision: entry.decision,
128
+ gate: entry.gate,
129
+ configKey: entry.configKey,
130
+ tool: entry.toolName ?? null,
131
+ session: entry.sessionId ?? null,
132
+ summary:
133
+ entry.summary ?? summarizeToolInput(entry.toolName, entry.toolInput),
134
+ reason: truncate(entry.reason, REASON_MAX_LENGTH),
135
+ };
136
+ appendFileSync(path, `${JSON.stringify(line)}\n`, 'utf8');
137
+ return true;
138
+ } catch {
139
+ return false;
140
+ }
141
+ }