@devrik-tools/claude-gates 0.1.2 → 0.2.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 +7 -1
- package/cli/__tests__/config.test.mjs +113 -101
- package/cli/__tests__/install.test.mjs +102 -0
- package/cli/__tests__/materialize.test.mjs +95 -0
- package/cli/__tests__/registry-gates-consistency.test.mjs +52 -44
- package/cli/__tests__/task.test.mjs +124 -0
- package/cli/__tests__/version-consistency.test.mjs +44 -0
- package/cli/config.mjs +124 -113
- package/cli/index.mjs +100 -97
- package/cli/init.mjs +259 -256
- package/cli/install.mjs +150 -80
- package/cli/materialize.mjs +102 -51
- package/cli/registry.mjs +136 -127
- package/cli/task.mjs +140 -0
- package/package.json +2 -1
- package/plugins/gates/.claude-plugin/plugin.json +8 -2
- package/plugins/gates/hooks/__tests__/ask-adoption.test.mjs +83 -0
- package/plugins/gates/hooks/__tests__/doctor.test.mjs +85 -0
- package/plugins/gates/hooks/__tests__/wiring-check.test.mjs +65 -0
- package/plugins/gates/hooks/ask-adoption.mjs +147 -0
- package/plugins/gates/hooks/doctor.mjs +207 -0
- package/plugins/gates/hooks/gates/audit-before-build/audit-before-build.edge.test.mjs +83 -0
- package/plugins/gates/hooks/gates/audit-before-build/index.mjs +88 -88
- package/plugins/gates/hooks/gates/audit-before-build/test.mjs +117 -92
- package/plugins/gates/hooks/gates/autonomous-mode/autonomous.edge.test.mjs +104 -0
- package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +48 -45
- package/plugins/gates/hooks/gates/bash-commands/bash-commands.edge.test.mjs +165 -0
- package/plugins/gates/hooks/gates/bash-commands/index.mjs +285 -268
- package/plugins/gates/hooks/gates/brief-before-delegate/brief-before-delegate.edge.test.mjs +151 -0
- package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +265 -177
- package/plugins/gates/hooks/gates/circuit-breaker/circuit-breaker.edge.test.mjs +207 -0
- package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +501 -456
- package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +161 -143
- package/plugins/gates/hooks/gates/dependency-skills/dependency-skills.edge.test.mjs +69 -0
- package/plugins/gates/hooks/gates/dependency-skills/index.mjs +126 -118
- package/plugins/gates/hooks/gates/diagnosis-before-patch/diagnosis-before-patch.edge.test.mjs +68 -0
- package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +43 -49
- package/plugins/gates/hooks/gates/feature-catalog/edge.edge.test.mjs +83 -0
- package/plugins/gates/hooks/gates/feature-catalog/index.mjs +83 -100
- package/plugins/gates/hooks/gates/force-parallel/index.mjs +119 -0
- package/plugins/gates/hooks/gates/force-parallel/test.mjs +88 -0
- package/plugins/gates/hooks/gates/forge-flow/forge-flow.edge.test.mjs +155 -0
- package/plugins/gates/hooks/gates/forge-flow/index.mjs +134 -112
- package/plugins/gates/hooks/gates/implementation-pipeline/edge.edge.test.mjs +66 -0
- package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +187 -186
- package/plugins/gates/hooks/gates/intent-flow/index.mjs +260 -238
- package/plugins/gates/hooks/gates/intent-flow/intent-flow.edge.test.mjs +96 -0
- package/plugins/gates/hooks/gates/intent-flow/test.mjs +165 -136
- package/plugins/gates/hooks/gates/lint-commit/index.mjs +149 -0
- package/plugins/gates/hooks/gates/lint-commit/test.mjs +85 -0
- package/plugins/gates/hooks/gates/mandatory-flow/edge.edge.test.mjs +100 -0
- package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +180 -166
- package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +79 -85
- package/plugins/gates/hooks/gates/neutral-spanish/neutral-spanish.edge.test.mjs +80 -0
- package/plugins/gates/hooks/gates/never-assume/index.mjs +58 -55
- package/plugins/gates/hooks/gates/never-assume/never-assume.edge.test.mjs +71 -0
- package/plugins/gates/hooks/gates/never-assume/test.mjs +95 -78
- package/plugins/gates/hooks/gates/no-blocking/index.mjs +148 -142
- package/plugins/gates/hooks/gates/no-blocking/no-blocking.edge.test.mjs +109 -0
- package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +123 -120
- package/plugins/gates/hooks/gates/no-memory-dependency/no-memory-dependency.edge.test.mjs +83 -0
- package/plugins/gates/hooks/gates/no-reconfirm/no-reconfirm.edge.test.mjs +91 -0
- package/plugins/gates/hooks/gates/protected-paths/index.mjs +144 -147
- package/plugins/gates/hooks/gates/protected-paths/protected-paths.edge.test.mjs +126 -0
- package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +89 -64
- package/plugins/gates/hooks/gates/recurrence-lock/recurrence-lock.edge.test.mjs +102 -0
- package/plugins/gates/hooks/gates/reuse-before-build/edge.edge.test.mjs +94 -0
- package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +159 -150
- package/plugins/gates/hooks/gates/risk-level/index.mjs +263 -203
- package/plugins/gates/hooks/gates/risk-level/risk-level.edge.test.mjs +116 -0
- package/plugins/gates/hooks/gates/risk-level/test.mjs +153 -125
- package/plugins/gates/hooks/gates/root-cause-first/index.mjs +56 -50
- package/plugins/gates/hooks/gates/root-cause-first/root-cause-first.edge.test.mjs +58 -0
- package/plugins/gates/hooks/gates/root-whitelist/index.mjs +116 -111
- package/plugins/gates/hooks/gates/root-whitelist/root-whitelist.edge.test.mjs +97 -0
- package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +184 -99
- package/plugins/gates/hooks/gates/rule-skill-autodiscovery/rule-skill-autodiscovery.edge.test.mjs +126 -0
- package/plugins/gates/hooks/gates/sdd-specs/edge.edge.test.mjs +130 -0
- package/plugins/gates/hooks/gates/sdd-specs/index.mjs +256 -251
- package/plugins/gates/hooks/gates/stop-pending/index.mjs +159 -0
- package/plugins/gates/hooks/gates/stop-pending/test.mjs +114 -0
- package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +101 -101
- package/plugins/gates/hooks/gates/test-after-implementation/test-after-implementation.edge.test.mjs +72 -0
- package/plugins/gates/hooks/gates/test-matrix/edge.edge.test.mjs +63 -0
- package/plugins/gates/hooks/gates/test-matrix/index.mjs +187 -181
- package/plugins/gates/hooks/gates/test-matrix/test.mjs +114 -87
- package/plugins/gates/hooks/gates/tool-map/edge.edge.test.mjs +62 -0
- package/plugins/gates/hooks/gates/tool-map/index.mjs +143 -140
- package/plugins/gates/hooks/hooks.json +346 -286
- package/plugins/gates/hooks/lib/__tests__/config.test.mjs +173 -154
- package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +210 -154
- package/plugins/gates/hooks/lib/__tests__/signals.test.mjs +114 -0
- package/plugins/gates/hooks/lib/config.mjs +172 -165
- package/plugins/gates/hooks/lib/hook-io-config.edge.test.mjs +189 -0
- package/plugins/gates/hooks/lib/hook-io.mjs +318 -208
- package/plugins/gates/hooks/lib/signals.mjs +127 -0
- package/plugins/gates/hooks/wiring-check.mjs +227 -0
- package/plugins/tasks/.claude-plugin/plugin.json +14 -0
- package/plugins/tasks/hooks/__tests__/register-requests.test.mjs +100 -0
- package/plugins/tasks/hooks/__tests__/session-tasks.test.mjs +95 -0
- package/plugins/tasks/hooks/hooks.json +26 -0
- package/plugins/tasks/hooks/lib/__tests__/task-store.test.mjs +180 -132
- package/plugins/tasks/hooks/lib/task-store.mjs +197 -159
- package/plugins/tasks/hooks/register-requests.mjs +141 -108
- package/plugins/tasks/hooks/session-tasks.mjs +108 -0
- package/registry.json +793 -686
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Edge-case audit for brief-before-delegate. See gate comment header for the rule this
|
|
2
|
+
// gate enforces. Each test below demonstrates either a confirmed BUG (asserted with a
|
|
3
|
+
// comment explaining why the result is wrong) or an OK (confirms the gate handles the
|
|
4
|
+
// case correctly). Run: node --test brief-before-delegate.edge.test.mjs
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { execFileSync } from 'node:child_process';
|
|
7
|
+
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { dirname, join } from 'node:path';
|
|
10
|
+
import { test } from 'node:test';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
|
|
13
|
+
const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
|
|
14
|
+
|
|
15
|
+
function runGate(payload, { config } = {}) {
|
|
16
|
+
const project = mkdtempSync(join(tmpdir(), 'brief-before-delegate-edge-'));
|
|
17
|
+
mkdirSync(join(project, '.git'));
|
|
18
|
+
if (config) {
|
|
19
|
+
mkdirSync(join(project, '.ai'));
|
|
20
|
+
writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
|
|
21
|
+
}
|
|
22
|
+
const out = execFileSync(process.execPath, [GATE], {
|
|
23
|
+
input: JSON.stringify(payload),
|
|
24
|
+
encoding: 'utf8',
|
|
25
|
+
cwd: project,
|
|
26
|
+
env: { ...process.env, HOME: project, USERPROFILE: project },
|
|
27
|
+
});
|
|
28
|
+
return out.trim() ? JSON.parse(out.trim()) : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isDeny(result) {
|
|
32
|
+
return result?.hookSpecificOutput?.permissionDecision === 'deny';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const ENABLED = { config: { gates: { requireBriefBeforeDelegating: true } } };
|
|
36
|
+
|
|
37
|
+
const NO_BRIEF_PROMPT = 'Fix the login bug please, thanks.';
|
|
38
|
+
|
|
39
|
+
test('FIXED: a delegation tool name outside the native list is now caught via toolInGroups (MCP delegation signal)', () => {
|
|
40
|
+
// toolInGroups matches an MCP tool name whose action segment carries a delegation
|
|
41
|
+
// signal (agent/task/delegat/spawn/dispatch/orchestrat/worker), so a renamed
|
|
42
|
+
// orchestration tool like mcp__orchestrator__spawn_agent is no longer invisible.
|
|
43
|
+
const payload = {
|
|
44
|
+
tool_name: 'mcp__orchestrator__spawn_agent',
|
|
45
|
+
tool_input: { prompt: NO_BRIEF_PROMPT },
|
|
46
|
+
};
|
|
47
|
+
const result = runGate(payload, ENABLED);
|
|
48
|
+
assert.ok(isDeny(result), 'gate now denies the brief-less prompt under the MCP tool name too');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('FIXED: brief carried in a field other than prompt/description/task is now read via delegationPromptOf', () => {
|
|
52
|
+
// delegationPromptOf also reads instructions/message/input/Prompt, so a payload that
|
|
53
|
+
// carries the delegation text under "instructions" (as some MCP subagent tools do) is
|
|
54
|
+
// no longer read as an empty prompt.
|
|
55
|
+
const payload = {
|
|
56
|
+
tool_name: 'Agent',
|
|
57
|
+
tool_input: { instructions: NO_BRIEF_PROMPT, subagent_type: 'worker-senior' },
|
|
58
|
+
};
|
|
59
|
+
const result = runGate(payload, ENABLED);
|
|
60
|
+
assert.ok(isDeny(result), 'gate now sees the brief under "instructions" and denies it for being brief-less');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('FIXED: keyword-only compliance no longer passes with content that does not carry a real brief', () => {
|
|
64
|
+
// missingSignals now requires each marker to be followed by substantive content
|
|
65
|
+
// (markerHasSubstance / stepsHaveSubstance), not just the marker's presence. A prompt
|
|
66
|
+
// that pastes GOAL/STEPS/CRITERION markers next to filler no longer clears the check.
|
|
67
|
+
const prompt = [
|
|
68
|
+
'Objetivo: cosa.',
|
|
69
|
+
'- paso',
|
|
70
|
+
'Criterio: listo cuando funcione bien y quede resuelto satisfactoriamente para todos.',
|
|
71
|
+
'Implementa el arreglo correspondiente segun corresponda en el sistema relevante.',
|
|
72
|
+
].join('\n');
|
|
73
|
+
assert.ok(prompt.length >= 180, 'sanity: prompt clears the length floor');
|
|
74
|
+
const result = runGate({ tool_name: 'Agent', tool_input: { prompt } }, ENABLED);
|
|
75
|
+
assert.ok(
|
|
76
|
+
isDeny(result),
|
|
77
|
+
'gate now denies a brief that is letter-compliant (has GOAL/STEPS/CRITERION markers) but has no real content',
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('FIXED: a real brief with substantial content past each marker is still allowed', () => {
|
|
82
|
+
const prompt = [
|
|
83
|
+
'Objetivo: fix the broken login redirect so users land on the correct dashboard page.',
|
|
84
|
+
'',
|
|
85
|
+
'Haceres:',
|
|
86
|
+
'- update src/auth/redirect.js to use the post-login route',
|
|
87
|
+
'- add a regression test for the redirect',
|
|
88
|
+
'',
|
|
89
|
+
'Criterio: se considera hecho cuando el test de regresion pasa y el login redirige correctamente.',
|
|
90
|
+
].join('\n');
|
|
91
|
+
assert.equal(runGate({ tool_name: 'Agent', tool_input: { prompt } }, ENABLED), null);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('FIXED: a mutation-risk signal in the prompt overrides a whitelisted read-only subagent name', () => {
|
|
95
|
+
// isReadOnlySubagent now voids the name-based exemption whenever the prompt itself
|
|
96
|
+
// carries a mutation-risk signal (money/auth/data/write/deploy): a subagent named
|
|
97
|
+
// "explore" cannot exempt a real payment-data mutation just by using that label.
|
|
98
|
+
const prompt =
|
|
99
|
+
'Implementa el guardado de datos de pago del usuario en la base y escribe el token de auth en el archivo de sesion.';
|
|
100
|
+
const result = runGate(
|
|
101
|
+
{ tool_name: 'Agent', tool_input: { prompt, subagent_type: 'explore' } },
|
|
102
|
+
ENABLED,
|
|
103
|
+
);
|
|
104
|
+
assert.ok(
|
|
105
|
+
isDeny(result),
|
|
106
|
+
'gate no longer exempts a mutation-risk prompt solely because subagent_type says "explore"',
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('bilingual control: an EN mutation-risk prompt voids the exemption exactly like its ES equivalent', () => {
|
|
111
|
+
const es =
|
|
112
|
+
'Implementa el guardado de datos de pago del usuario en la base y escribe el token de auth en el archivo de sesion.';
|
|
113
|
+
const en =
|
|
114
|
+
'Implement saving the user payment data to the database and write the auth token to the session file.';
|
|
115
|
+
const resultEs = runGate(
|
|
116
|
+
{ tool_name: 'Agent', tool_input: { prompt: es, subagent_type: 'explore' } },
|
|
117
|
+
ENABLED,
|
|
118
|
+
);
|
|
119
|
+
const resultEn = runGate(
|
|
120
|
+
{ tool_name: 'Agent', tool_input: { prompt: en, subagent_type: 'explore' } },
|
|
121
|
+
ENABLED,
|
|
122
|
+
);
|
|
123
|
+
assert.ok(isDeny(resultEs));
|
|
124
|
+
assert.ok(isDeny(resultEn));
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('OK: a whitelisted read-only subagent name with no mutation-risk signal is still exempt', () => {
|
|
128
|
+
assert.equal(
|
|
129
|
+
runGate(
|
|
130
|
+
{ tool_name: 'Agent', tool_input: { prompt: NO_BRIEF_PROMPT, subagent_type: 'explore' } },
|
|
131
|
+
ENABLED,
|
|
132
|
+
),
|
|
133
|
+
null,
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('OK: a genuinely brief-less implementation delegation via Agent is denied', () => {
|
|
138
|
+
const result = runGate({ tool_name: 'Agent', tool_input: { prompt: NO_BRIEF_PROMPT } }, ENABLED);
|
|
139
|
+
assert.ok(isDeny(result));
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('OK: Task and invoke_subagent tool names are both covered (in TOOL_GROUPS.delegation)', () => {
|
|
143
|
+
assert.ok(
|
|
144
|
+
isDeny(runGate({ tool_name: 'Task', tool_input: { prompt: NO_BRIEF_PROMPT } }, ENABLED)),
|
|
145
|
+
);
|
|
146
|
+
assert.ok(
|
|
147
|
+
isDeny(
|
|
148
|
+
runGate({ tool_name: 'invoke_subagent', tool_input: { prompt: NO_BRIEF_PROMPT } }, ENABLED),
|
|
149
|
+
),
|
|
150
|
+
);
|
|
151
|
+
});
|
|
@@ -1,177 +1,265 @@
|
|
|
1
|
-
// brief-before-delegate — denies an implementation delegation whose prompt does not
|
|
2
|
-
// declare, in recognizable form, a GOAL, concrete STEPS and a DONE-WHEN criterion.
|
|
3
|
-
//
|
|
4
|
-
// ── Why this is decidable, and what it deliberately does not judge ─────────────────
|
|
5
|
-
// A hook sees a tool call, not how hard the underlying task is. "Complex" is not a
|
|
6
|
-
// fact in the payload, and any heuristic that approximates it (file count, prompt
|
|
7
|
-
// length, "architecture" keywords) ends up blocking trivial work sooner or later.
|
|
8
|
-
// The decidable question is narrower: did THIS delegation's prompt state a goal, its
|
|
9
|
-
// steps and a success criterion before the subagent starts? That is a fact about the
|
|
10
|
-
// text itself, not a judgment about the task.
|
|
11
|
-
//
|
|
12
|
-
// Whether the declared goal is the RIGHT goal, or the steps are the RIGHT steps, or
|
|
13
|
-
// the criterion truly resolves the ambiguity — that needs domain understanding a
|
|
14
|
-
// script cannot supply. This gate only enforces that the brief EXISTS, never that it
|
|
15
|
-
// is good.
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
// verb
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
)
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
)
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
1
|
+
// brief-before-delegate — denies an implementation delegation whose prompt does not
|
|
2
|
+
// declare, in recognizable form, a GOAL, concrete STEPS and a DONE-WHEN criterion.
|
|
3
|
+
//
|
|
4
|
+
// ── Why this is decidable, and what it deliberately does not judge ─────────────────
|
|
5
|
+
// A hook sees a tool call, not how hard the underlying task is. "Complex" is not a
|
|
6
|
+
// fact in the payload, and any heuristic that approximates it (file count, prompt
|
|
7
|
+
// length, "architecture" keywords) ends up blocking trivial work sooner or later.
|
|
8
|
+
// The decidable question is narrower: did THIS delegation's prompt state a goal, its
|
|
9
|
+
// steps and a success criterion before the subagent starts? That is a fact about the
|
|
10
|
+
// text itself, not a judgment about the task.
|
|
11
|
+
//
|
|
12
|
+
// Whether the declared goal is the RIGHT goal, or the steps are the RIGHT steps, or
|
|
13
|
+
// the criterion truly resolves the ambiguity — that needs domain understanding a
|
|
14
|
+
// script cannot supply. This gate only enforces that the brief EXISTS, never that it
|
|
15
|
+
// is good. It DOES require each section to carry actual content past its marker —
|
|
16
|
+
// pasting the words "Objetivo:"/"Criterio:" next to filler is not a brief either.
|
|
17
|
+
//
|
|
18
|
+
// ── What is exempt ───────────────────────────────────────────────────────────────
|
|
19
|
+
// - Read-only exploration/subagents (readOnlySubagents param, or a prompt whose
|
|
20
|
+
// dominant verb is investigate/search/read/explain/audit with no implementation
|
|
21
|
+
// verb): that is a QUESTION, not an implementation order — forcing a brief onto it
|
|
22
|
+
// would make it simulate project structure it does not have.
|
|
23
|
+
// The readOnlySubagents exemption is a name the delegator declares, not a verified
|
|
24
|
+
// capability this hook can check — so it is void whenever the prompt itself carries
|
|
25
|
+
// a mutation-risk signal (money/auth/data/write/deploy): the signal in the text
|
|
26
|
+
// outranks the label on the call.
|
|
27
|
+
// - A prompt that already carries structure (a list, numbered steps, or prose that
|
|
28
|
+
// otherwise states the three signals): the form is free, only the content is
|
|
29
|
+
// required.
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
runGate,
|
|
33
|
+
deny,
|
|
34
|
+
warn,
|
|
35
|
+
toolInGroups,
|
|
36
|
+
delegationPromptOf,
|
|
37
|
+
} from '../../lib/hook-io.mjs';
|
|
38
|
+
import { MUTATION_RISK_SIGNAL } from '../../lib/signals.mjs';
|
|
39
|
+
|
|
40
|
+
const GATE_ID = 'brief-before-delegate';
|
|
41
|
+
const CONFIG_KEY = 'requireBriefBeforeDelegating';
|
|
42
|
+
|
|
43
|
+
const DEFAULT_MIN_BRIEF_LENGTH = 180;
|
|
44
|
+
const DEFAULT_READ_ONLY_SUBAGENTS = ['explore', 'claude-code-guide', 'plan'];
|
|
45
|
+
|
|
46
|
+
// How much of an over-length prompt to quote back in a denial message.
|
|
47
|
+
const PROMPT_EXCERPT_LENGTH = 80;
|
|
48
|
+
// Total signals this gate checks for (goal, steps, criterion): when all are missing at
|
|
49
|
+
// once it is indistinguishable from "never thought through", so that combination denies
|
|
50
|
+
// instead of only warning.
|
|
51
|
+
const TOTAL_REQUIRED_SIGNALS = 3;
|
|
52
|
+
// Minimum substantial (non-stopword) characters a section needs past its own marker to
|
|
53
|
+
// count as "stated" instead of merely name-dropped. Chosen so a single short filler
|
|
54
|
+
// word ("cosa.", "listo.") does not clear it, but any real sentence does.
|
|
55
|
+
const MIN_SECTION_SUBSTANCE_LENGTH = 12;
|
|
56
|
+
|
|
57
|
+
/** Unicode-aware word boundary: JS's `\b` does not treat accented letters as word
|
|
58
|
+
* chars, so a plain `\bcorregi\b`-style pattern silently misses an accented
|
|
59
|
+
* imperative. Lookarounds over `\p{L}|\p{N}|_` cover the full alphabet instead. */
|
|
60
|
+
function withUnicodeWordBoundary(alternatives) {
|
|
61
|
+
return new RegExp(
|
|
62
|
+
`(?<![\\p{L}\\p{N}_])(${alternatives})(?![\\p{L}\\p{N}_])`,
|
|
63
|
+
'iu',
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const IMPLEMENTATION_VERBS = withUnicodeWordBoundary(
|
|
68
|
+
'implementa|implementar|escrib(e|í)|escribir|cre(a|á)|crear|corrige|correg(í|ir)|' +
|
|
69
|
+
'arregl(a|á)|arreglar|constru(ye|í)|construir|refactoriz(a|á)|refactorizar|' +
|
|
70
|
+
'migr(a|á)|migrar|agreg(a|á)|agregar|añad(e|í)|añadir|elimin(a|á)|eliminar|' +
|
|
71
|
+
'modific(a|á)|modificar|reescrib(e|í)|reescribir|desplieg(a|á)|desplegar|' +
|
|
72
|
+
'hac(e|er|é)|resuelve|resolv(é|er)|soluciona|solucion(á|ar)|encárgate|encargate|' +
|
|
73
|
+
'ocúpate|ocupate|cambi(a|á)|cambiar|actualiz(a|á)|actualizar|' +
|
|
74
|
+
'implement|write|fix|build|refactor|migrate|add|remove|modify|update',
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const READ_ONLY_VERBS = withUnicodeWordBoundary(
|
|
78
|
+
'investiga|investigar|busca|buscar|explora|explorar|lee|leer|explica|explicar|' +
|
|
79
|
+
'audita|auditar|analiza|analizar|compara|comparar|diagnostica|diagnosticar|' +
|
|
80
|
+
'revisa|revisar|averigua|averiguar|' +
|
|
81
|
+
'investigate|search|explore|read|explain|audit|analyze|compare|diagnose|review',
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
// A prompt-level signal that a whitelisted read-only subagent name should NOT be
|
|
85
|
+
// trusted to exempt this call: real mutation risk in the text outranks a self-declared
|
|
86
|
+
// label. Deliberately broad (over-includes) — a false positive here only means the
|
|
87
|
+
// brief check still runs, which is cheap; a false negative would let a mutator hide.
|
|
88
|
+
// Centralized in lib/signals.mjs (ES+EN) — see its header for the class this covers.
|
|
89
|
+
const MUTATION_RISK_SIGNAL_PATTERN = MUTATION_RISK_SIGNAL;
|
|
90
|
+
|
|
91
|
+
/** Evidence of a stated GOAL, captured so its trailing content can be measured. */
|
|
92
|
+
const GOAL_PATTERN = withUnicodeWordBoundary(
|
|
93
|
+
'objetivo|meta|el fin es|se busca|para lograr|para que|goal|objective',
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
/** Evidence of structured STEPS: a list/bullet/numbered form. Anchored per-line (`m`
|
|
97
|
+
* flag) with a single bounded `\s*` after the line start, so there is no nested
|
|
98
|
+
* quantifier for a backtracking engine to explode on. Global so every item's substance
|
|
99
|
+
* can be checked, not just the first. */
|
|
100
|
+
const STRUCTURED_STEPS_PATTERN = /^[ \t]*(?:[-*•]|\d+[.)])[ \t]+(\S.*)$/gm;
|
|
101
|
+
|
|
102
|
+
/** Evidence of a DONE-WHEN / acceptance criterion. */
|
|
103
|
+
const CRITERION_PATTERN = withUnicodeWordBoundary(
|
|
104
|
+
'criterio|acceptance|asserts?|se considera (hecho|terminado|listo)|' +
|
|
105
|
+
'debe (verificarse|cumplir|pasar)|hasta que|done.when|' +
|
|
106
|
+
'cuando (esto|el) (pase|funcione)|dado.{0,20}cuando.{0,20}entonces|' +
|
|
107
|
+
'given.{0,20}when.{0,20}then',
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
// Filler words that do not count toward a section's substance even though they are
|
|
111
|
+
// real words — otherwise "listo cuando funcione bien y quede resuelto satisfactoriamente
|
|
112
|
+
// para todos" reads as substantial despite saying nothing concrete.
|
|
113
|
+
const FILLER_WORDS = new Set(
|
|
114
|
+
(
|
|
115
|
+
'cosa cosas cualquier corresponda correspondiente relevante sistema bien listo ' +
|
|
116
|
+
'cuando funcione quede resuelto satisfactoriamente para todos segun paso arreglo ' +
|
|
117
|
+
'anything something whatever appropriate accordingly relevant properly done ' +
|
|
118
|
+
'the a an and or of to in on for with is are'
|
|
119
|
+
).split(' '),
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
function stripDiacritics(text) {
|
|
123
|
+
return text.normalize('NFD').replace(/[̀-ͯ]/g, '');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Non-filler word count in a chunk of text: what is left after dropping stopwords and
|
|
127
|
+
* pure-filler vocabulary, so a marker followed only by empty phrasing does not count as
|
|
128
|
+
* substance. */
|
|
129
|
+
function substantiveWordCount(text) {
|
|
130
|
+
const words = stripDiacritics(String(text).toLowerCase())
|
|
131
|
+
.split(/[^\p{L}\p{N}]+/u)
|
|
132
|
+
.filter(Boolean);
|
|
133
|
+
return words.filter((word) => word.length > 2 && !FILLER_WORDS.has(word))
|
|
134
|
+
.length;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Text following a marker match, cut at end-of-line, so a following unrelated section
|
|
138
|
+
* on the next line is not counted as this section's content. */
|
|
139
|
+
function contentAfterMarker(prompt, markerMatch) {
|
|
140
|
+
const from = markerMatch.index + markerMatch[0].length;
|
|
141
|
+
const restOfLine = prompt.slice(from).split(/\r?\n/, 1)[0] ?? '';
|
|
142
|
+
return restOfLine.replace(/^[\s:.\-–—]+/, '');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Whether the GOAL or CRITERION marker is followed by real substance: enough
|
|
146
|
+
* substantive words, not just the marker itself or generic filler around it. */
|
|
147
|
+
function markerHasSubstance(prompt, pattern) {
|
|
148
|
+
const withGlobal = new RegExp(
|
|
149
|
+
pattern.source,
|
|
150
|
+
pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`,
|
|
151
|
+
);
|
|
152
|
+
const match = withGlobal.exec(prompt);
|
|
153
|
+
if (!match) return false;
|
|
154
|
+
const content = contentAfterMarker(prompt, match);
|
|
155
|
+
return (
|
|
156
|
+
content.trim().length >= MIN_SECTION_SUBSTANCE_LENGTH &&
|
|
157
|
+
substantiveWordCount(content) >= 2
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Whether at least one structured list item carries real substance (not just a single
|
|
162
|
+
* filler word like "paso"). */
|
|
163
|
+
function stepsHaveSubstance(prompt) {
|
|
164
|
+
const matches = [...prompt.matchAll(STRUCTURED_STEPS_PATTERN)];
|
|
165
|
+
return matches.some((match) => substantiveWordCount(match[1]) >= 2);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function isReadOnlySubagentName(toolInput, readOnlySubagents) {
|
|
169
|
+
const type = String(
|
|
170
|
+
toolInput.subagent_type ?? toolInput.subagentType ?? '',
|
|
171
|
+
).toLowerCase();
|
|
172
|
+
return new Set(readOnlySubagents.map((name) => name.toLowerCase())).has(type);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** A whitelisted subagent name exempts a call ONLY when the prompt carries no
|
|
176
|
+
* mutation-risk signal. The name is a declared label, never a verified capability this
|
|
177
|
+
* hook can check — documented here so a future reader does not mistake it for one — and
|
|
178
|
+
* a real risk signal in the text must win over it. */
|
|
179
|
+
function isReadOnlySubagent(toolInput, prompt, readOnlySubagents) {
|
|
180
|
+
if (!isReadOnlySubagentName(toolInput, readOnlySubagents)) return false;
|
|
181
|
+
return !MUTATION_RISK_SIGNAL_PATTERN.test(prompt);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function isReadOnlyRequest(prompt) {
|
|
185
|
+
if (IMPLEMENTATION_VERBS.test(prompt)) return false;
|
|
186
|
+
return READ_ONLY_VERBS.test(prompt);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function missingSignals(prompt) {
|
|
190
|
+
const missing = [];
|
|
191
|
+
if (!markerHasSubstance(prompt, GOAL_PATTERN))
|
|
192
|
+
missing.push('a stated GOAL with real content (what this aims to achieve, not just the word "goal")');
|
|
193
|
+
if (!stepsHaveSubstance(prompt)) {
|
|
194
|
+
missing.push('STEPS as a list or numbered form with concrete files/actions, not a placeholder item');
|
|
195
|
+
}
|
|
196
|
+
if (!markerHasSubstance(prompt, CRITERION_PATTERN)) {
|
|
197
|
+
missing.push(
|
|
198
|
+
'a DONE-WHEN / acceptance criterion with real content (how completion is verified, not just the word "criterion")',
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
return missing;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function denyTooShort(prompt) {
|
|
205
|
+
const excerpt = prompt.slice(0, PROMPT_EXCERPT_LENGTH);
|
|
206
|
+
const ellipsis = prompt.length > PROMPT_EXCERPT_LENGTH ? '…' : '';
|
|
207
|
+
deny(
|
|
208
|
+
GATE_ID,
|
|
209
|
+
`This delegation asks for implementation ("${excerpt}${ellipsis}") in a ${prompt.length}-character ` +
|
|
210
|
+
'prompt — too short to carry a goal, steps and a done-when criterion. State what this aims to ' +
|
|
211
|
+
'achieve, what concretely needs doing (as a list or steps), and how completion is verified, then ' +
|
|
212
|
+
'relaunch.',
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function reportMissingSignals(missing) {
|
|
217
|
+
if (missing.length === 0) return;
|
|
218
|
+
|
|
219
|
+
if (missing.length < TOTAL_REQUIRED_SIGNALS) {
|
|
220
|
+
warn(
|
|
221
|
+
GATE_ID,
|
|
222
|
+
`This implementation delegation does not recognizably state: ${missing.join('; ')}. ` +
|
|
223
|
+
'If it is already there under different wording, proceed — this is only a warning. ' +
|
|
224
|
+
'Otherwise add it before the subagent starts blind.',
|
|
225
|
+
);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
deny(
|
|
230
|
+
GATE_ID,
|
|
231
|
+
'This delegation asks for implementation but states neither as a list nor recognizable prose: ' +
|
|
232
|
+
`${missing.join('; ')}. Add to the prompt: (1) the GOAL — what this aims to achieve; ` +
|
|
233
|
+
'(2) the STEPS — concrete files/actions, as a list; (3) the CRITERION — how completion is ' +
|
|
234
|
+
'verified. The form is free; the content is not optional.',
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
runGate(
|
|
239
|
+
{
|
|
240
|
+
id: GATE_ID,
|
|
241
|
+
configKey: CONFIG_KEY,
|
|
242
|
+
enabledByDefault: false,
|
|
243
|
+
defaultParams: {
|
|
244
|
+
minBriefLength: DEFAULT_MIN_BRIEF_LENGTH,
|
|
245
|
+
readOnlySubagents: DEFAULT_READ_ONLY_SUBAGENTS,
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
({ toolName, toolInput, parameters }) => {
|
|
249
|
+
if (!toolInGroups(toolName, ['delegation'])) return;
|
|
250
|
+
|
|
251
|
+
const prompt = delegationPromptOf(toolInput);
|
|
252
|
+
if (!prompt.trim()) return;
|
|
253
|
+
if (isReadOnlySubagent(toolInput, prompt, parameters.readOnlySubagents))
|
|
254
|
+
return;
|
|
255
|
+
if (isReadOnlyRequest(prompt)) return;
|
|
256
|
+
if (!IMPLEMENTATION_VERBS.test(prompt)) return; // neither implementation nor read-only: do not guess
|
|
257
|
+
|
|
258
|
+
const trimmed = prompt.trim();
|
|
259
|
+
if (trimmed.length < parameters.minBriefLength) {
|
|
260
|
+
denyTooShort(trimmed);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
reportMissingSignals(missingSignals(prompt));
|
|
264
|
+
},
|
|
265
|
+
);
|