@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,34 +1,34 @@
1
- // implementation-pipeline — denies a delegation that sends a builder subagent straight
2
- // to writing code without the prompt declaring the pipeline stages around it:
3
- // definition (where the context came from), writing's own verification plan, and a
4
- // separate reviewer for validation/QA/closure. Migrated from
5
- // ~/.claude/hooks/guard-pipeline-de-implementacion.mjs.
1
+ // implementation-pipeline — denies a STANDARD/HIGH-RISK implementation delegation whose
2
+ // prompt does not declare the pipeline around the build: where the context came from
3
+ // (definition), what is verified and how (writing), and who reviews the result (reviewer).
6
4
  //
7
- // ── What a project can configure (params) ───────────────────────────────────────────
8
- // builderSubagents subagent types that count as builders the ones this gate
9
- // holds to the pipeline. Replaces the built-in list wholesale.
10
- // exemptSubagents subagent types exempt outright: read-only pipeline stages that
11
- // cannot be required to declare a pipeline around themselves.
12
- // Replaces the built-in list wholesale.
13
- // The defaults live here, in the source, so a project reads them and knows exactly what
14
- // its override replaces.
15
- //
16
- // ── Off by default, and quiet outside its narrow trigger ───────────────────────────
17
- // This gate only evaluates a delegation whose prompt declares a STANDARD/HIGH-RISK
18
- // level, uses an implementation verb, targets a builder subagent, and is not about the
19
- // harness itself. A prompt that only describes work, or that is read-only/exploratory,
20
- // never reaches the check.
5
+ // Decisions: every non-exempt subagent is a builder an absent or unknown subagent_type is
6
+ // not a reason to skip the check, only the exempt list is. A negated mention ("do not write
7
+ // tests", "no brief.md yet") does not satisfy a stage. Work on the agent harness itself
8
+ // (.claude/, .ai/, CLAUDE.md...) is exempt; ordinary app paths such as src/hooks/ are not.
21
9
 
10
+ import {
11
+ DEFAULT_EXEMPT_SUBAGENTS,
12
+ DEMANDING_LEVELS,
13
+ LEVELS,
14
+ allMatches,
15
+ isHarnessWork,
16
+ isImplementationRequest,
17
+ isSubagentNamedIn,
18
+ operativeLevelOf,
19
+ } from '../../lib/delegation.mjs';
22
20
  import {
23
21
  runGate,
24
22
  deny,
25
23
  toolInGroups,
26
24
  delegationPromptOf,
27
25
  } from '../../lib/hook-io.mjs';
26
+ import { withUnicodeWordBoundary } from '../../lib/signals.mjs';
28
27
 
29
28
  const GATE_ID = 'implementation-pipeline';
30
29
  const CONFIG_KEY = 'requireImplementationPipeline';
31
30
 
31
+ // Kept for config compatibility: every non-exempt type is held to the pipeline.
32
32
  const DEFAULT_BUILDER_SUBAGENTS = [
33
33
  'frontend',
34
34
  'backend',
@@ -37,46 +37,11 @@ const DEFAULT_BUILDER_SUBAGENTS = [
37
37
  'general-purpose',
38
38
  ];
39
39
 
40
- const DEFAULT_EXEMPT_SUBAGENTS = [
41
- 'scout',
42
- 'explore',
43
- 'plan',
44
- 'revision',
45
- 'contraste',
46
- 'test-planner',
47
- 'qa',
48
- 'ui',
49
- 'ux',
50
- ];
51
-
52
- function withWordBoundary(alternation) {
53
- return new RegExp(
54
- `(?:^|[^\\p{L}\\p{N}_])(?:${alternation})(?:[^\\p{L}\\p{N}_]|$)`,
55
- 'iu',
56
- );
57
- }
58
-
59
- const IMPLEMENTATION_VERBS = withWordBoundary(
60
- 'implementa|implementar|implement(á|é)|agreg(a|á)|agregar|añad(e|í)|añadir|cre(a|á)|crear|' +
61
- 'arregl(a|á)|arreglar|cambi(a|á)|cambiar|migr(a|á)|migrar|' +
62
- 'corrige|corregir|correg(í|ir)|constru(ye|í)|construir|modific(a|á)|modificar|' +
63
- 'refactoriz(a|á)|refactorizar|elimin(a|á)|eliminar|reescrib(e|í)|reescribir|desplieg(a|á)|desplegar|' +
64
- 'escrib(í|e)|escribir|implement\\w*|writ(?:e|ing)|creat\\w*|fix\\w*|build\\w*|refactor\\w*|migrat\\w*|' +
65
- 'add\\w*|remov\\w*|delet\\w*|modify|modifies|modifying|rewrit\\w*',
40
+ const NEGATION_LOOK_BACK = 15;
41
+ const NEGATION_PATTERN = withUnicodeWordBoundary(
42
+ "no|not|don'?t|do not|sin|never",
66
43
  );
67
44
 
68
- /** Declared LEVEL near the word "level"/"classification" in Spanish or English, matching
69
- * the plugin-wide convention (see risk-level.mjs). */
70
- const DEMANDING_LEVEL_PATTERN =
71
- /(nivel|level|clasificaci[oó]n|classification)[^\n]{0,25}?\b(STANDARD|HIGH-RISK)\b/iu;
72
- const EXEMPT_LEVEL_PATTERN =
73
- /(nivel|level|clasificaci[oó]n|classification)[^\n]{0,25}?\b(QUESTION|MICRO)\b/iu;
74
- const HIGH_RISK_PATTERN =
75
- /(nivel|level|clasificaci[oó]n|classification)[^\n]{0,25}?\bHIGH-RISK\b/iu;
76
-
77
- const HARNESS_PATTERN =
78
- /(\.claude[\\/]|hooks[\\/]|plugins[\\/]gates|settings\.json|[\\/]agents[\\/]\w|[\\/]rules[\\/]\w|[\\/]skills[\\/]\w)/i;
79
-
80
45
  // Several small patterns instead of one long alternation: a single big regex here
81
46
  // tripped the linter's regex-complexity limit. Any one matching counts as declared.
82
47
  const REVIEWER_NAME_PATTERN =
@@ -103,18 +68,26 @@ const DEFINITION_PATTERN =
103
68
  const WRITING_PATTERN =
104
69
  /\b(test|tests|prueba|pruebas|vitest|jest|playwright|typecheck|lint|verification|verificaci[oó]n|verification criteria)\b/i;
105
70
 
106
- /** The three pipeline stages this gate requires be declared, in this order. */
71
+ function isNegated(prompt, match) {
72
+ const from = Math.max(0, match.index - NEGATION_LOOK_BACK);
73
+ return NEGATION_PATTERN.test(prompt.slice(from, match.index));
74
+ }
75
+
76
+ function hasAffirmativeMention(prompt, pattern) {
77
+ return allMatches(pattern, prompt).some((match) => !isNegated(prompt, match));
78
+ }
79
+
107
80
  const REQUIRED_STAGES = [
108
81
  {
109
82
  id: 'definition',
110
- isDeclared: (prompt) => DEFINITION_PATTERN.test(prompt),
83
+ isDeclared: (prompt) => hasAffirmativeMention(prompt, DEFINITION_PATTERN),
111
84
  missing:
112
85
  'DEFINITION: does not declare where the context came from (a prior scout, read-only exploration, or an already-written asserts/brief).',
113
86
  line: 'Verified context: <path to the cited asserts/brief> — or: a prior scout ran, findings at <path>.',
114
87
  },
115
88
  {
116
89
  id: 'writing',
117
- isDeclared: (prompt) => WRITING_PATTERN.test(prompt),
90
+ isDeclared: (prompt) => hasAffirmativeMention(prompt, WRITING_PATTERN),
118
91
  missing:
119
92
  'WRITING: does not declare what is verified nor with what command. A builder does not validate its own work without a criterion written beforehand.',
120
93
  line: 'Verification: <exact command> and what is expected. Anything that cannot run is reported OMITTED, never PASS.',
@@ -128,19 +101,11 @@ const REQUIRED_STAGES = [
128
101
  },
129
102
  ];
130
103
 
131
- function isExempt(toolInput, prompt, builderSubagents, exemptSubagents) {
132
- const subagentType = String(
133
- toolInput.subagent_type ?? toolInput.subagentType ?? '',
134
- ).toLowerCase();
135
- if (exemptSubagents.includes(subagentType)) return true;
136
- // An unknown type is not assumed to be a builder: the safe side here is not to
137
- // invent requirements for agents this gate cannot classify.
138
- if (subagentType && !builderSubagents.includes(subagentType)) return true;
139
- if (EXEMPT_LEVEL_PATTERN.test(prompt)) return true;
140
- if (!DEMANDING_LEVEL_PATTERN.test(prompt)) return true;
141
- if (!IMPLEMENTATION_VERBS.test(prompt)) return true;
142
- if (HARNESS_PATTERN.test(prompt)) return true;
143
- return false;
104
+ function isExempt(toolInput, prompt, exemptSubagents) {
105
+ if (isSubagentNamedIn(toolInput, exemptSubagents)) return true;
106
+ if (!DEMANDING_LEVELS.has(operativeLevelOf(prompt))) return true;
107
+ if (!isImplementationRequest(prompt)) return true;
108
+ return isHarnessWork(prompt);
144
109
  }
145
110
 
146
111
  runGate(
@@ -158,19 +123,14 @@ runGate(
158
123
 
159
124
  const prompt = delegationPromptOf(toolInput);
160
125
  if (!prompt.trim()) return;
161
-
162
- const builderSubagents =
163
- parameters.builderSubagents ?? DEFAULT_BUILDER_SUBAGENTS;
164
- const exemptSubagents =
165
- parameters.exemptSubagents ?? DEFAULT_EXEMPT_SUBAGENTS;
166
- if (isExempt(toolInput, prompt, builderSubagents, exemptSubagents)) return;
126
+ if (isExempt(toolInput, prompt, parameters.exemptSubagents)) return;
167
127
 
168
128
  const missingStages = REQUIRED_STAGES.filter(
169
129
  (stage) => !stage.isDeclared(prompt),
170
130
  );
171
131
  if (missingStages.length === 0) return;
172
132
 
173
- const isHighRisk = HIGH_RISK_PATTERN.test(prompt);
133
+ const isHighRisk = operativeLevelOf(prompt) === LEVELS.HIGH_RISK;
174
134
  const details = missingStages.map((stage) => stage.missing).join(' ');
175
135
  const lines = missingStages.map((stage) => ` ${stage.line}`).join('\n');
176
136
 
@@ -1,81 +1,38 @@
1
- // intent-flow — denies an implementation delegation that has real intent to mutate a
2
- // high-impact area (money, auth, persisted data, public contract, irreversible...)
3
- // without declaring IN SCOPE / OUT OF SCOPE / EDGE CASES, and hard-denies whenever it
4
- // also leaves a high-impact UNKNOWN unresolved.
1
+ // intent-flow — denies an implementation delegation with real intent to mutate a high-impact
2
+ // area (money, auth, persisted data, public contract, irreversible...) unless it declares
3
+ // IN SCOPE / OUT OF SCOPE / EDGE CASES, and hard-denies when it also leaves a high-impact
4
+ // UNKNOWN unresolved.
5
5
  //
6
- // ── How this differs from brief-before-delegate ─────────────────────────────────────
7
- // brief-before-delegate asks "was the task thought through" (goal+steps+criterion).
8
- // This gate asks a different, complementary question: "was the SCOPE declared what
9
- // is in, what is explicitly out, and what edge cases were considered for work that
10
- // really intends to touch a sensitive area?" Both run on the same delegation prompt
11
- // independently; neither depends on the other's verdict.
12
- //
13
- // ── Two-stage intent check (not lexical match) ──────────────────────────────────────
14
- // Stage 1 (cheap prefilter): does the raw prompt contain BOTH an implementation verb
15
- // and a high-impact signal anywhere? If not, there is no candidate — allow immediately.
16
- // Stage 2 (confirmation, only on candidates): strip quoted/templated text, then require
17
- // near co-occurrence between an implementation verb and a high-impact signal, and
18
- // discard a verb whose immediate object is a documentary deliverable (a doc/report/
19
- // README/.md/.html describing the area is not mutating it). Only surviving (a)+(b)+(c)
20
- // counts as real intent.
21
- //
22
- // ── readOnlySubagents is a declared label, not a verified capability ────────────────
23
- // This hook cannot check what tools a named subagent actually has — the whitelist
24
- // exemption is void whenever the prompt itself carries a mutation-risk signal (money/
25
- // auth/data/write/deploy): the signal in the text outranks the label on the call.
6
+ // Decisions: brief-before-delegate asks "was the task thought through"; this gate asks the
7
+ // complementary "was the SCOPE declared" and the two never depend on each other. Intent is
8
+ // judged by the lib's two-stage check (verb + signal co-occurrence outside quoted text, with a
9
+ // documentary object discarded), not by a bare keyword hit. A read-only subagent name is a
10
+ // declared label, void whenever the prompt itself carries a mutation-risk signal. An empty
11
+ // `highImpactPatterns` means nothing is high-impact, so the gate stays silent.
26
12
 
13
+ import {
14
+ DEFAULT_HIGH_IMPACT_PATTERNS,
15
+ DEFAULT_READ_ONLY_SUBAGENTS,
16
+ buildHighImpactPattern,
17
+ hasRealSensitiveMutation,
18
+ isExemptQuery,
19
+ isImplementationRequest,
20
+ isReadOnlySubagent,
21
+ promptExcerpt,
22
+ stripQuoted,
23
+ } from '../../lib/delegation.mjs';
27
24
  import {
28
25
  runGate,
29
26
  deny,
30
27
  toolInGroups,
31
28
  delegationPromptOf,
32
29
  } from '../../lib/hook-io.mjs';
33
- import { MUTATION_RISK_SIGNAL } from '../../lib/signals.mjs';
30
+ import { withUnicodeWordBoundary } from '../../lib/signals.mjs';
34
31
 
35
32
  const GATE_ID = 'intent-flow';
36
33
  const CONFIG_KEY = 'requireScopeListBeforeDelegating';
37
34
 
38
- const DEFAULT_READ_ONLY_SUBAGENTS = ['explore', 'claude-code-guide', 'plan'];
39
- const DEFAULT_HIGH_IMPACT_PATTERNS = [
40
- 'dinero|pago|cobro|money|payment',
41
- 'auth|autenticaci[oó]n|authentication|permiso|permission|token|sesi[oó]n|session|seguridad|security|PII',
42
- 'borrar|delete|drop|migraci[oó]n|migration|schema',
43
- 'contrato|contract|irreversible|producci[oó]n|production|API p[uú]blica|public api',
44
- ];
45
-
46
35
  const MIN_REQUEST_LENGTH = 40;
47
- const CO_OCCURRENCE_WINDOW = 100;
48
- const VERB_OBJECT_WINDOW = 60;
49
- // How much of an over-length prompt to quote back in a denial message.
50
- const PROMPT_EXCERPT_LENGTH = 80;
51
-
52
- function withUnicodeWordBoundary(alternatives) {
53
- return new RegExp(
54
- `(?<![\\p{L}\\p{N}_])(${alternatives})(?![\\p{L}\\p{N}_])`,
55
- 'iu',
56
- );
57
- }
58
-
59
- // A prompt-level signal that a whitelisted read-only subagent name should NOT be
60
- // trusted to exempt this call: the name is a declared label, never a verified
61
- // capability this hook can check, and real mutation risk in the text must win over it.
62
- // Deliberately broad — over-including only means this gate's check still runs.
63
- // Centralized in lib/signals.mjs (ES+EN) — see its header for the class this covers.
64
- const MUTATION_RISK_SIGNAL_PATTERN = MUTATION_RISK_SIGNAL;
65
-
66
- const IMPLEMENTATION_VERBS = withUnicodeWordBoundary(
67
- 'implementa|implementar|implement(á|é)|agreg(a|á)|agregar|añad(e|í)|añadir|cre(a|á)|crear|' +
68
- 'arregl(a|á)|arreglar|cambi(a|á)|cambiar|migr(a|á)|migrar|escrib(í|e) c[oó]digo|escribir c[oó]digo|' +
69
- 'corrige|corregir|correg(í|ir)|constru(ye|í)|construir|modific(a|á)|modificar|' +
70
- 'refactoriz(a|á)|refactorizar|elimin(a|á)|eliminar|reescrib(e|í)|reescribir|desplieg(a|á)|desplegar|' +
71
- 'implement|build|fix|migrate|modify|refactor|remove|rewrite|deploy',
72
- );
73
-
74
- const QUERY_VERBS = withUnicodeWordBoundary(
75
- 'explica|explic(á|ar)|qu[eé]|c[oó]mo|muestra|mostr(á|ar)|analiza|analiz(á|ar)|investiga|' +
76
- 'investig(á|ar)|revisa|revis(á|ar)|audita|audit(á|ar)|diagnostica|diagnostic(á|ar)|' +
77
- 'explain|what|how|show|analyze|investigate|review|audit|diagnose',
78
- );
79
36
 
80
37
  const IN_SCOPE_PATTERN = withUnicodeWordBoundary(
81
38
  'qu[eé] s[ií]|qu[eé] s[ií] entra|alcance|in scope|lo pedido|se va a hacer|incluye',
@@ -92,78 +49,6 @@ const UNRESOLVED_UNKNOWN_PATTERN = withUnicodeWordBoundary(
92
49
  'unknown|no se sabe|no sabemos|sin resolver|no est[aá] claro|no est[aá] definido|' +
93
50
  'a definir|por definir|desconocido|no est[aá] decidido',
94
51
  );
95
- const DOCUMENTARY_DELIVERABLE_PATTERN = withUnicodeWordBoundary(
96
- 'documento|documentaci[oó]n|reporte|informe|diagrama|readme|wiki|changelog|' +
97
- 'p[aá]gina de documentaci[oó]n|archivo html|p[aá]gina html|markdown|document|documentation|report',
98
- );
99
- const DOCUMENTARY_EXTENSION_PATTERN = /\.(md|html?|adoc)\b/iu;
100
-
101
- function isReadOnlySubagentName(toolInput, readOnlySubagents) {
102
- const type = String(
103
- toolInput.subagent_type ?? toolInput.subagentType ?? '',
104
- ).toLowerCase();
105
- return new Set(readOnlySubagents.map((name) => name.toLowerCase())).has(type);
106
- }
107
-
108
- /** A whitelisted subagent name exempts a call ONLY when the prompt carries no
109
- * mutation-risk signal. The name is a declared label, never a verified capability this
110
- * hook can check — a real risk signal in the text must win over it. */
111
- function isReadOnlySubagent(toolInput, prompt, readOnlySubagents) {
112
- if (!isReadOnlySubagentName(toolInput, readOnlySubagents)) return false;
113
- return !MUTATION_RISK_SIGNAL_PATTERN.test(prompt);
114
- }
115
-
116
- function isImplementationRequest(prompt) {
117
- return IMPLEMENTATION_VERBS.test(prompt);
118
- }
119
-
120
- function isExemptQuery(prompt) {
121
- if (isImplementationRequest(prompt)) return false; // implementation verb takes priority
122
- return QUERY_VERBS.test(prompt);
123
- }
124
-
125
- function stripQuoted(text) {
126
- return text
127
- .replace(/```[\s\S]*?```/g, ' ')
128
- .replace(/"[^"\n]{0,300}"/g, ' ')
129
- .replace(/'[^'\n]{0,300}'/g, ' ');
130
- }
131
-
132
- function allMatches(pattern, text) {
133
- const flags = pattern.flags.includes('g')
134
- ? pattern.flags
135
- : `${pattern.flags}g`;
136
- return [...text.matchAll(new RegExp(pattern.source, flags))];
137
- }
138
-
139
- function verbProducesDocument(text, verbMatch) {
140
- const from = verbMatch.index;
141
- const to = from + verbMatch[0].length + VERB_OBJECT_WINDOW;
142
- const objectOfVerb = text.slice(from, to);
143
- return (
144
- DOCUMENTARY_DELIVERABLE_PATTERN.test(objectOfVerb) ||
145
- DOCUMENTARY_EXTENSION_PATTERN.test(objectOfVerb)
146
- );
147
- }
148
-
149
- function hasRealSensitiveMutation(prompt, highImpactPattern) {
150
- const isCandidate =
151
- IMPLEMENTATION_VERBS.test(prompt) && highImpactPattern.test(prompt);
152
- if (!isCandidate) return false; // stage 1: cheap, no stage 2 spent
153
-
154
- const text = stripQuoted(prompt); // stage 2(a)
155
- const verbs = allMatches(IMPLEMENTATION_VERBS, text);
156
- const signals = allMatches(highImpactPattern, text);
157
-
158
- for (const verb of verbs) {
159
- if (verbProducesDocument(text, verb)) continue; // stage 2(c)
160
- for (const signal of signals) {
161
- if (Math.abs(signal.index - verb.index) <= CO_OCCURRENCE_WINDOW)
162
- return true; // stage 2(b)
163
- }
164
- }
165
- return false;
166
- }
167
52
 
168
53
  function missingSignals(prompt) {
169
54
  const missing = [];
@@ -189,11 +74,9 @@ function denyUnresolvedUnknown() {
189
74
  }
190
75
 
191
76
  function denyTooShortForScopeList(prompt) {
192
- const excerpt = prompt.slice(0, PROMPT_EXCERPT_LENGTH);
193
- const ellipsis = prompt.length > PROMPT_EXCERPT_LENGTH ? '…' : '';
194
77
  deny(
195
78
  CONFIG_KEY,
196
- `This implementation request ("${excerpt}${ellipsis}") does not carry a scope list: missing IN ` +
79
+ `This implementation request ("${promptExcerpt(prompt)}") does not carry a scope list: missing IN ` +
197
80
  'SCOPE, OUT OF SCOPE and EDGE CASES. Add it to the prompt before relaunching.',
198
81
  );
199
82
  }
@@ -206,15 +89,6 @@ function denyMissingSignals(missing) {
206
89
  );
207
90
  }
208
91
 
209
- function buildHighImpactPattern(highImpactPatterns) {
210
- return new RegExp(
211
- (highImpactPatterns ?? []).map((source) => `(?:${source})`).join('|'),
212
- 'iu',
213
- );
214
- }
215
-
216
- /** True when this delegation is exempt from the whole check: not a recognizable
217
- * implementation request, a read-only subagent, or an exempt query. */
218
92
  function isExempt(toolInput, prompt, readOnlySubagents) {
219
93
  if (!prompt.trim()) return true;
220
94
  if (isReadOnlySubagent(toolInput, prompt, readOnlySubagents)) return true;
@@ -0,0 +1,262 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
+ import { builtinModules } from 'node:module';
3
+ import { extname, isAbsolute, join, relative } from 'node:path';
4
+ import { projectRootOf } from '../../lib/config.mjs';
5
+ import {
6
+ deny,
7
+ runGate,
8
+ toolInGroups,
9
+ writtenContentOf,
10
+ writtenPathOf,
11
+ } from '../../lib/hook-io.mjs';
12
+ import { readSessionState } from '../../lib/session-state.mjs';
13
+
14
+ export const GATE_ID = 'library-docs';
15
+ export const CONFIG_KEY = 'requireDocsBeforeUsingNewLibrary';
16
+
17
+ const DEFAULT_CODE_EXTENSIONS = [
18
+ '.js',
19
+ '.mjs',
20
+ '.cjs',
21
+ '.ts',
22
+ '.tsx',
23
+ '.jsx',
24
+ '.vue',
25
+ '.svelte',
26
+ '.py',
27
+ ];
28
+ const DEFAULT_IGNORED_PACKAGES = [];
29
+ const DEFAULT_MAX_SCAN_FILES = 500;
30
+ const SKIPPED_DIRECTORIES = new Set([
31
+ 'node_modules',
32
+ '.git',
33
+ 'dist',
34
+ 'build',
35
+ 'coverage',
36
+ '.next',
37
+ 'out',
38
+ 'vendor',
39
+ '.ai',
40
+ ]);
41
+ const NODE_BUILTINS = new Set(
42
+ builtinModules.map((name) => name.replace(/^node:/, '')),
43
+ );
44
+ const PYTHON_STDLIB = new Set(
45
+ (
46
+ 'os sys re json pathlib typing datetime time math random subprocess collections itertools ' +
47
+ 'functools logging unittest dataclasses enum io shutil tempfile argparse asyncio threading ' +
48
+ 'socket http urllib csv hashlib base64 uuid copy abc contextlib glob string textwrap ' +
49
+ 'statistics decimal fractions pprint traceback warnings inspect importlib pickle sqlite3 ' +
50
+ 'xml html email struct queue signal platform getpass secrets zipfile tarfile gzip ' +
51
+ 'configparser operator heapq bisect array weakref types numbers cmath ast dis gc atexit ' +
52
+ 'select ssl ftplib smtplib mimetypes unicodedata locale gettext calendar zoneinfo ' +
53
+ 'concurrent multiprocessing builtins __future__ dataclasses'
54
+ ).split(' '),
55
+ );
56
+
57
+ const JS_IMPORT_PATTERNS = [
58
+ /\bimport\s+['"]([^'"\n]+)['"]/g,
59
+ /\bfrom\s+['"]([^'"\n]+)['"]/g,
60
+ /\brequire\(\s*['"]([^'"\n]+)['"]\s*\)/g,
61
+ /\bimport\(\s*['"]([^'"\n]+)['"]\s*\)/g,
62
+ ];
63
+ const PY_IMPORT_LINE = /^import\s+([\w.]+)/;
64
+ const PY_FROM_LINE = /^from\s+([\w.]+)\s+import\b/;
65
+
66
+ function pythonSpecifiers(content) {
67
+ const found = [];
68
+ for (const rawLine of String(content ?? '').split(/\r?\n/)) {
69
+ const line = rawLine.trim();
70
+ const match = PY_IMPORT_LINE.exec(line) ?? PY_FROM_LINE.exec(line);
71
+ if (match) found.push(match[1]);
72
+ }
73
+ return found;
74
+ }
75
+
76
+ function javascriptSpecifiers(content) {
77
+ const found = [];
78
+ for (const pattern of JS_IMPORT_PATTERNS) {
79
+ for (const match of String(content ?? '').matchAll(pattern))
80
+ found.push(match[1]);
81
+ }
82
+ return found;
83
+ }
84
+
85
+ function packageOf(specifier, isPython) {
86
+ if (isPython) return specifier.split('.')[0];
87
+ if (specifier.startsWith('@'))
88
+ return specifier.split('/').slice(0, 2).join('/');
89
+ return specifier.split('/')[0];
90
+ }
91
+
92
+ function isExternal(specifier, isPython) {
93
+ if (!specifier || specifier.startsWith('.') || specifier.startsWith('/'))
94
+ return false;
95
+ if (specifier.startsWith('node:') || specifier.startsWith('#')) return false;
96
+ if (isPython) return !PYTHON_STDLIB.has(packageOf(specifier, true));
97
+ if (NODE_BUILTINS.has(packageOf(specifier, false))) return false;
98
+ if (/^[a-z]:[\\/]/i.test(specifier)) return false;
99
+ return true;
100
+ }
101
+
102
+ export function importedPackagesOf(content, filePath) {
103
+ const isPython = extname(filePath).toLowerCase() === '.py';
104
+ const specifiers = isPython
105
+ ? pythonSpecifiers(content)
106
+ : javascriptSpecifiers(content);
107
+ const packages = new Set();
108
+ for (const raw of specifiers) {
109
+ const specifier = raw.trim();
110
+ if (isExternal(specifier, isPython))
111
+ packages.add(packageOf(specifier, isPython));
112
+ }
113
+ return [...packages];
114
+ }
115
+
116
+ function* codeFiles(directory, extensions, budget) {
117
+ const stack = [directory];
118
+ while (stack.length > 0 && budget.left > 0) {
119
+ const current = stack.pop();
120
+ let entries;
121
+ try {
122
+ entries = readdirSync(current, { withFileTypes: true });
123
+ } catch {
124
+ continue;
125
+ }
126
+ for (const entry of entries) {
127
+ const path = join(current, entry.name);
128
+ if (entry.isDirectory()) {
129
+ if (!SKIPPED_DIRECTORIES.has(entry.name) && !entry.name.startsWith('.'))
130
+ stack.push(path);
131
+ } else if (extensions.includes(extname(entry.name).toLowerCase())) {
132
+ budget.left -= 1;
133
+ yield path;
134
+ if (budget.left === 0) return;
135
+ }
136
+ }
137
+ }
138
+ }
139
+
140
+ function packagesUsedElsewhere(root, writtenPath, extensions, maxFiles) {
141
+ const used = new Set();
142
+ const budget = { left: maxFiles };
143
+ for (const file of codeFiles(root, extensions, budget)) {
144
+ if (relative(file, writtenPath) === '') continue;
145
+ let content;
146
+ try {
147
+ content = readFileSync(file, 'utf8');
148
+ } catch {
149
+ continue;
150
+ }
151
+ for (const name of importedPackagesOf(content, file)) used.add(name);
152
+ }
153
+ return used;
154
+ }
155
+
156
+ function existingImports(absolutePath) {
157
+ if (!existsSync(absolutePath)) return new Set();
158
+ try {
159
+ if (!statSync(absolutePath).isFile()) return new Set();
160
+ return new Set(
161
+ importedPackagesOf(readFileSync(absolutePath, 'utf8'), absolutePath),
162
+ );
163
+ } catch {
164
+ return new Set();
165
+ }
166
+ }
167
+
168
+ function tokensOf(name) {
169
+ const lowered = name.toLowerCase();
170
+ const short = lowered.startsWith('@') ? lowered.split('/')[1] : lowered;
171
+ return [lowered, short, short.replace(/[-_.]/g, '')].filter(Boolean);
172
+ }
173
+
174
+ function mentions(list, name) {
175
+ const haystack = list.map((entry) => String(entry).toLowerCase());
176
+ return tokensOf(name).some((token) =>
177
+ haystack.some((entry) => entry.includes(token)),
178
+ );
179
+ }
180
+
181
+ export function knowledgeStatus(state, name) {
182
+ const searched = mentions(state.memSearchHits ?? [], name);
183
+ const documentation = mentions(state.context7Lookups ?? [], name);
184
+ const saved = mentions(state.memSaves ?? [], name);
185
+ return {
186
+ searched,
187
+ documentation,
188
+ saved,
189
+ known: searched || (documentation && saved),
190
+ };
191
+ }
192
+
193
+ function remedyFor(name, status) {
194
+ const steps = [];
195
+ if (!status.searched && !status.documentation)
196
+ steps.push(`1) mem_search "${name} usage" (engram is the first source)`);
197
+ if (!status.documentation)
198
+ steps.push(
199
+ `2) if engram has nothing: context7 resolve-library-id "${name}" then get-library-docs for the API you need`,
200
+ );
201
+ if (status.documentation && !status.saved)
202
+ steps.push(
203
+ `3) mem_save what you learned about ${name} (title mentioning "${name}") so the next session reads it from engram`,
204
+ );
205
+ return steps.join('; ');
206
+ }
207
+
208
+ runGate(
209
+ {
210
+ id: GATE_ID,
211
+ configKey: CONFIG_KEY,
212
+ enabledByDefault: true,
213
+ defaultParams: {
214
+ codeExtensions: DEFAULT_CODE_EXTENSIONS,
215
+ ignoredPackages: DEFAULT_IGNORED_PACKAGES,
216
+ maxScanFiles: DEFAULT_MAX_SCAN_FILES,
217
+ },
218
+ },
219
+ ({ toolName, toolInput, sessionId, parameters, cwd }) => {
220
+ if (!toolInGroups(toolName, ['write'])) return;
221
+ const writtenPath = writtenPathOf(toolInput);
222
+ if (
223
+ !writtenPath ||
224
+ !parameters.codeExtensions.includes(extname(writtenPath).toLowerCase())
225
+ )
226
+ return;
227
+ const root = projectRootOf(cwd) ?? cwd;
228
+ const absolutePath = isAbsolute(writtenPath)
229
+ ? writtenPath
230
+ : join(root, writtenPath);
231
+ const ignored = new Set(
232
+ parameters.ignoredPackages.map((name) => String(name).toLowerCase()),
233
+ );
234
+ const already = existingImports(absolutePath);
235
+ const candidates = importedPackagesOf(
236
+ writtenContentOf(toolInput),
237
+ writtenPath,
238
+ ).filter((name) => !already.has(name) && !ignored.has(name.toLowerCase()));
239
+ if (candidates.length === 0) return;
240
+ const usedElsewhere = packagesUsedElsewhere(
241
+ root,
242
+ absolutePath,
243
+ parameters.codeExtensions,
244
+ parameters.maxScanFiles,
245
+ );
246
+ const unknown = candidates.filter((name) => !usedElsewhere.has(name));
247
+ if (unknown.length === 0) return;
248
+ const state = readSessionState(GATE_ID, sessionId, {}, { cwd });
249
+ const blocked = unknown
250
+ .map((name) => ({ name, status: knowledgeStatus(state, name) }))
251
+ .filter((entry) => !entry.status.known);
252
+ if (blocked.length === 0) return;
253
+ const lines = blocked.map(
254
+ (entry) => `${entry.name}: ${remedyFor(entry.name, entry.status)}`,
255
+ );
256
+ deny(
257
+ CONFIG_KEY,
258
+ `This write introduces ${blocked.length} package(s) this project does not use anywhere yet, and nothing ` +
259
+ `in this session shows how to use them. Do not guess an API. ${lines.join(' | ')}. Then retry the write.`,
260
+ );
261
+ },
262
+ );