@devrik-tools/claude-gates 0.7.2 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/README.es.md +69 -9
- package/README.md +68 -7
- package/cli/doctor.mjs +132 -0
- package/cli/evidence.mjs +78 -0
- package/cli/hooks-manifest.mjs +89 -0
- package/cli/index.mjs +124 -6
- package/cli/init.mjs +97 -3
- package/cli/log.mjs +70 -0
- package/cli/materialize.mjs +36 -2
- package/cli/registry.mjs +11 -1
- package/cli/selection.mjs +18 -0
- package/cli/smoke-fixtures.json +114 -22
- package/cli/task.mjs +36 -8
- package/cli/toggle.mjs +125 -0
- package/package.json +1 -1
- package/plugins/gates/.claude-plugin/plugin.json +1 -1
- package/plugins/gates/hooks/doctor.mjs +47 -1
- package/plugins/gates/hooks/gates/atomic-commit/index.mjs +92 -119
- package/plugins/gates/hooks/gates/audit-before-build/index.mjs +101 -66
- package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +8 -8
- package/plugins/gates/hooks/gates/autonomous-mode/stop.mjs +13 -64
- package/plugins/gates/hooks/gates/bash-commands/index.mjs +186 -163
- package/plugins/gates/hooks/gates/block-remote-publish/index.mjs +100 -101
- package/plugins/gates/hooks/gates/brief-approved/index.mjs +71 -140
- package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +56 -137
- package/plugins/gates/hooks/gates/capability-map/index.mjs +280 -506
- package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +98 -212
- package/plugins/gates/hooks/gates/dependency-skills/index.mjs +127 -46
- package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +81 -19
- package/plugins/gates/hooks/gates/engram-first/index.mjs +41 -0
- package/plugins/gates/hooks/gates/engram-first/session-start.mjs +64 -0
- package/plugins/gates/hooks/gates/engram-first/shared.mjs +90 -0
- package/plugins/gates/hooks/gates/engram-first/stop.mjs +23 -0
- package/plugins/gates/hooks/gates/engram-first/track.mjs +97 -0
- package/plugins/gates/hooks/gates/feature-catalog/index.mjs +156 -50
- package/plugins/gates/hooks/gates/force-parallel/index.mjs +51 -92
- package/plugins/gates/hooks/gates/forge-flow/index.mjs +51 -83
- package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +40 -80
- package/plugins/gates/hooks/gates/intent-flow/index.mjs +23 -149
- package/plugins/gates/hooks/gates/library-docs/index.mjs +262 -0
- package/plugins/gates/hooks/gates/library-docs/track.mjs +95 -0
- package/plugins/gates/hooks/gates/lint-commit/index.mjs +88 -97
- package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +48 -95
- package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +69 -31
- package/plugins/gates/hooks/gates/never-assume/index.mjs +23 -18
- package/plugins/gates/hooks/gates/no-blocking/index.mjs +127 -86
- package/plugins/gates/hooks/gates/no-coauthor/index.mjs +108 -88
- package/plugins/gates/hooks/gates/no-explanatory-comments/index.mjs +281 -0
- package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +170 -110
- package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +46 -77
- package/plugins/gates/hooks/gates/no-reconfirm/index.mjs +85 -47
- package/plugins/gates/hooks/gates/protected-paths/index.mjs +137 -90
- package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +95 -51
- package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +72 -216
- package/plugins/gates/hooks/gates/risk-level/index.mjs +51 -204
- package/plugins/gates/hooks/gates/root-cause-first/index.mjs +34 -17
- package/plugins/gates/hooks/gates/root-whitelist/index.mjs +160 -95
- package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +196 -97
- package/plugins/gates/hooks/gates/sdd-specs/index.mjs +90 -143
- package/plugins/gates/hooks/gates/staged-lint/index.mjs +98 -106
- package/plugins/gates/hooks/gates/stop-pending/index.mjs +47 -148
- package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +78 -68
- package/plugins/gates/hooks/gates/test-matrix/index.mjs +53 -88
- package/plugins/gates/hooks/gates/tool-map/index.mjs +80 -110
- package/plugins/gates/hooks/hooks.json +112 -42
- package/plugins/gates/hooks/lib/config.mjs +6 -3
- package/plugins/gates/hooks/lib/delegation.mjs +352 -0
- package/plugins/gates/hooks/lib/gate-log.mjs +141 -0
- package/plugins/gates/hooks/lib/git.mjs +262 -0
- package/plugins/gates/hooks/lib/hook-io.mjs +448 -83
- package/plugins/gates/hooks/lib/session-state.mjs +150 -0
- package/plugins/gates/hooks/lib/signals.mjs +18 -13
- package/plugins/gates/hooks/lib/testing.mjs +137 -0
- package/plugins/gates/hooks/lib/tools.mjs +144 -0
- package/plugins/tasks/.claude-plugin/plugin.json +1 -1
- package/plugins/tasks/hooks/lib/task-store.mjs +6 -6
- package/registry.json +204 -36
|
@@ -1,34 +1,34 @@
|
|
|
1
|
-
// implementation-pipeline — denies a
|
|
2
|
-
//
|
|
3
|
-
// definition
|
|
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
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|
|
41
|
-
|
|
42
|
-
'
|
|
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
|
-
|
|
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) =>
|
|
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) =>
|
|
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,
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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 =
|
|
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
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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 {
|
|
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 ("${
|
|
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
|
+
);
|