@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,38 +1,25 @@
|
|
|
1
|
-
// brief-approved — denies
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// implementation straight away, and never pasted the full brief into the chat for the
|
|
6
|
-
// user to confirm line by line. `sdd-specs` already blocks implementing a feature with
|
|
7
|
-
// NO contract at all; this gate blocks implementing a feature whose contract exists but
|
|
8
|
-
// was never actually approved by the user — a distinct failure this incident exposed.
|
|
1
|
+
// brief-approved — denies a STANDARD/HIGH-RISK implementation delegation that cites a
|
|
2
|
+
// feature whose brief.md carries no recorded human approval. Born from a real incident: an
|
|
3
|
+
// ambiguous reply ("hagamos el brief") was taken as approval of a spec the assistant had
|
|
4
|
+
// written itself, and implementation was delegated without the user ever reading it.
|
|
9
5
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
// status: approved
|
|
16
|
-
// approved_at: <ISO timestamp>
|
|
17
|
-
// approval_quote: "<user's own words>"
|
|
18
|
-
// ---
|
|
19
|
-
// The gate does not (cannot) verify the quote is genuine; it verifies the field exists
|
|
20
|
-
// and is non-empty, which at minimum forces the assistant to stop and produce a
|
|
21
|
-
// specific artifact instead of silently inferring consent from a vague reply.
|
|
22
|
-
//
|
|
23
|
-
// ── Auto-off when there is no brief to approve ──────────────────────────────────────
|
|
24
|
-
// Same discovery as sdd-specs: only fires once a feature contract tree exists under
|
|
25
|
-
// .ai/features/<name>/ (or the configured catalog's sibling `features` dir) AND that
|
|
26
|
-
// feature has a brief.md/asserts.md file. A project with no such file for the cited
|
|
27
|
-
// feature is out of this gate's scope (sdd-specs already denies that case).
|
|
28
|
-
//
|
|
29
|
-
// ── What is NOT configurable (base, non-negotiable) ─────────────────────────────────
|
|
30
|
-
// The frontmatter field names (status/approved_at/approval_quote) are fixed, not a
|
|
31
|
-
// project param — a configurable field name would let a delegation "approve" itself by
|
|
32
|
-
// pointing the gate at whatever key it just wrote.
|
|
6
|
+
// Decisions: a hook cannot read the chat, so "approved" means the brief's frontmatter
|
|
7
|
+
// carries `status: approved` AND a non-empty `approval_quote` — an artifact the assistant
|
|
8
|
+
// must stop and produce. The field names are fixed on purpose: a configurable key would let
|
|
9
|
+
// a delegation approve itself. The gate enforces ONLY when the feature directory contains
|
|
10
|
+
// brief.md; a feature with other contract files or none is sdd-specs' business.
|
|
33
11
|
|
|
34
12
|
import { existsSync, readFileSync } from 'node:fs';
|
|
35
|
-
import { dirname, join } from 'node:path';
|
|
13
|
+
import { dirname, isAbsolute, join } from 'node:path';
|
|
14
|
+
import { projectRootOf, stripBom } from '../../lib/config.mjs';
|
|
15
|
+
import {
|
|
16
|
+
DEFAULT_EXEMPT_SUBAGENTS,
|
|
17
|
+
DEMANDING_LEVELS,
|
|
18
|
+
featureNamesCitedIn,
|
|
19
|
+
isImplementationRequest,
|
|
20
|
+
isSubagentNamedIn,
|
|
21
|
+
operativeLevelOf,
|
|
22
|
+
} from '../../lib/delegation.mjs';
|
|
36
23
|
import {
|
|
37
24
|
runGate,
|
|
38
25
|
deny,
|
|
@@ -44,125 +31,68 @@ const GATE_ID = 'brief-approved';
|
|
|
44
31
|
const CONFIG_KEY = 'requireApprovedBriefBeforeImplementing';
|
|
45
32
|
|
|
46
33
|
const CATALOG_FILE_NAME = 'feature_list.json';
|
|
34
|
+
const FEATURES_DIRECTORY = 'features';
|
|
35
|
+
const BRIEF_FILE = 'brief.md';
|
|
47
36
|
const DEFAULT_CATALOG_LOCATIONS = [
|
|
48
37
|
join('.ai', CATALOG_FILE_NAME),
|
|
49
38
|
CATALOG_FILE_NAME,
|
|
50
39
|
];
|
|
51
40
|
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
'scout',
|
|
56
|
-
'revision',
|
|
57
|
-
'contraste',
|
|
58
|
-
'test-planner',
|
|
59
|
-
'qa',
|
|
60
|
-
'ui',
|
|
61
|
-
'ux',
|
|
62
|
-
];
|
|
63
|
-
|
|
64
|
-
/** Files that count as a feature's contract; the first one found on disk is the one
|
|
65
|
-
* checked for approval (mirrors sdd-specs' CONTRACT_FILES precedence). */
|
|
66
|
-
const CONTRACT_FILES = [
|
|
67
|
-
'brief.md',
|
|
68
|
-
'requirements.md',
|
|
69
|
-
'design.md',
|
|
70
|
-
'tasks.md',
|
|
71
|
-
'asserts.md',
|
|
72
|
-
];
|
|
73
|
-
|
|
74
|
-
const APPROVED_FRONTMATTER_PATTERN = /^---\r?\n(?<body>[\s\S]*?)\r?\n---/;
|
|
75
|
-
const APPROVED_STATUS_PATTERN = /^status:[ \t]*approved[ \t]*$/im;
|
|
76
|
-
const APPROVAL_QUOTE_PATTERN = /^approval_quote:[ \t]*(\S.*)$/im;
|
|
41
|
+
const FRONTMATTER_PATTERN = /^---\r?\n(?<body>[\s\S]*?)\r?\n---/;
|
|
42
|
+
const APPROVED_STATUS_PATTERN = /^status:[ \t]*["']?approved["']?[ \t]*$/im;
|
|
43
|
+
const APPROVAL_QUOTE_PATTERN = /^approval_quote:(.*)$/im;
|
|
77
44
|
|
|
78
|
-
function
|
|
79
|
-
return
|
|
80
|
-
`(?<![\\p{L}\\p{N}_])(${alternation})(?![\\p{L}\\p{N}_])`,
|
|
81
|
-
'iu',
|
|
82
|
-
);
|
|
45
|
+
function resolveFrom(root, path) {
|
|
46
|
+
return isAbsolute(path) ? path : join(root, path);
|
|
83
47
|
}
|
|
84
48
|
|
|
85
|
-
|
|
86
|
-
'implementa|implementar|implement(á|é)|agreg(a|á)|agregar|añad(e|í)|añadir|cre(a|á)|crear|' +
|
|
87
|
-
'arregl(a|á)|arreglar|cambi(a|á)|cambiar|migr(a|á)|migrar|' +
|
|
88
|
-
'corrige|corregir|correg(í|ir)|constru(ye|í)|construir|modific(a|á)|modificar|' +
|
|
89
|
-
'refactoriz(a|á)|refactorizar|elimin(a|á)|eliminar|reescrib(e|í)|reescribir|desplieg(a|á)|desplegar|' +
|
|
90
|
-
'escrib(í|e)|escribir|implement\\w*|writ(e|ing)|creat\\w*|fix\\w*|build\\w*|refactor\\w*|migrat\\w*|' +
|
|
91
|
-
'add\\w*|remov\\w*|delet\\w*|modify|modifies|modifying|rewrit\\w*',
|
|
92
|
-
);
|
|
93
|
-
|
|
94
|
-
const DEMANDING_LEVEL_PATTERN =
|
|
95
|
-
/(nivel|level|clasificaci[oó]n|classification)[^\n]{0,25}?\b(STANDARD|HIGH-RISK)\b/iu;
|
|
96
|
-
const EXEMPT_LEVEL_PATTERN =
|
|
97
|
-
/(nivel|level|clasificaci[oó]n|classification)[^\n]{0,25}?\b(QUESTION|MICRO)\b/iu;
|
|
98
|
-
|
|
99
|
-
function findCatalog(catalogLocations) {
|
|
49
|
+
function findCatalog(root, catalogLocations) {
|
|
100
50
|
for (const relative of catalogLocations) {
|
|
101
|
-
const path =
|
|
51
|
+
const path = resolveFrom(root, relative);
|
|
102
52
|
if (existsSync(path)) return path;
|
|
103
53
|
}
|
|
104
54
|
return null;
|
|
105
55
|
}
|
|
106
56
|
|
|
107
|
-
function contractTreeRootFor(catalogPath) {
|
|
57
|
+
function contractTreeRootFor(root, catalogPath) {
|
|
108
58
|
const candidates = catalogPath
|
|
109
|
-
? [join(dirname(catalogPath),
|
|
59
|
+
? [join(dirname(catalogPath), FEATURES_DIRECTORY)]
|
|
110
60
|
: [];
|
|
111
|
-
candidates.push(
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
return null;
|
|
61
|
+
candidates.push(
|
|
62
|
+
join(root, '.ai', FEATURES_DIRECTORY),
|
|
63
|
+
join(root, FEATURES_DIRECTORY),
|
|
64
|
+
);
|
|
65
|
+
return candidates.find((candidate) => existsSync(candidate)) ?? null;
|
|
117
66
|
}
|
|
118
67
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const featureDirectory = join(treeRoot, featureName);
|
|
123
|
-
if (!existsSync(featureDirectory)) return null;
|
|
124
|
-
for (const file of CONTRACT_FILES) {
|
|
125
|
-
const path = join(featureDirectory, file);
|
|
126
|
-
if (existsSync(path)) return path;
|
|
127
|
-
}
|
|
128
|
-
return null;
|
|
68
|
+
function briefPathFor(treeRoot, featureName) {
|
|
69
|
+
const path = join(treeRoot, featureName, BRIEF_FILE);
|
|
70
|
+
return existsSync(path) ? path : null;
|
|
129
71
|
}
|
|
130
72
|
|
|
131
|
-
|
|
132
|
-
* plus a non-empty `approval_quote`. Both must be present — status alone is a label
|
|
133
|
-
* the assistant could set on itself with no evidence a quote was ever collected. */
|
|
134
|
-
function isApproved(contractPath) {
|
|
73
|
+
function isApproved(briefPath) {
|
|
135
74
|
let content;
|
|
136
75
|
try {
|
|
137
|
-
content = readFileSync(
|
|
76
|
+
content = stripBom(readFileSync(briefPath, 'utf8'));
|
|
138
77
|
} catch {
|
|
139
78
|
return false;
|
|
140
79
|
}
|
|
141
|
-
const frontmatter =
|
|
80
|
+
const frontmatter = FRONTMATTER_PATTERN.exec(content)?.groups?.body;
|
|
142
81
|
if (!frontmatter) return false;
|
|
143
82
|
if (!APPROVED_STATUS_PATTERN.test(frontmatter)) return false;
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
83
|
+
const quote = APPROVAL_QUOTE_PATTERN.exec(frontmatter)?.[1] ?? '';
|
|
84
|
+
return (
|
|
85
|
+
quote
|
|
86
|
+
.trim()
|
|
87
|
+
.replace(/^["']|["']$/g, '')
|
|
88
|
+
.trim().length > 0
|
|
89
|
+
);
|
|
147
90
|
}
|
|
148
91
|
|
|
149
92
|
function isExemptDelegation(toolInput, prompt, exemptSubagents) {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
if (exemptSubagents.includes(subagentType)) return true;
|
|
154
|
-
if (EXEMPT_LEVEL_PATTERN.test(prompt)) return true;
|
|
155
|
-
if (!DEMANDING_LEVEL_PATTERN.test(prompt)) return true;
|
|
156
|
-
if (!IMPLEMENTATION_VERBS.test(prompt)) return true;
|
|
157
|
-
return false;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
function featureNamesCitedIn(prompt) {
|
|
161
|
-
const pattern = /\.(?:ai)[\\/]features[\\/]([\w.@-]+)/gi;
|
|
162
|
-
const names = [];
|
|
163
|
-
let match;
|
|
164
|
-
while ((match = pattern.exec(prompt)) !== null) names.push(match[1]);
|
|
165
|
-
return names;
|
|
93
|
+
if (isSubagentNamedIn(toolInput, exemptSubagents)) return true;
|
|
94
|
+
if (!DEMANDING_LEVELS.has(operativeLevelOf(prompt))) return true;
|
|
95
|
+
return !isImplementationRequest(prompt);
|
|
166
96
|
}
|
|
167
97
|
|
|
168
98
|
runGate(
|
|
@@ -175,45 +105,46 @@ runGate(
|
|
|
175
105
|
exemptSubagents: DEFAULT_EXEMPT_SUBAGENTS,
|
|
176
106
|
},
|
|
177
107
|
},
|
|
178
|
-
({ toolName, toolInput, parameters }) => {
|
|
108
|
+
({ toolName, toolInput, parameters, cwd }) => {
|
|
179
109
|
if (!toolInGroups(toolName, ['delegation'])) return;
|
|
180
110
|
|
|
181
111
|
const prompt = delegationPromptOf(toolInput);
|
|
182
112
|
if (!prompt.trim()) return;
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
parameters.exemptSubagents ?? DEFAULT_EXEMPT_SUBAGENTS;
|
|
186
|
-
if (isExemptDelegation(toolInput, prompt, exemptSubagents)) return;
|
|
113
|
+
if (isExemptDelegation(toolInput, prompt, parameters.exemptSubagents))
|
|
114
|
+
return;
|
|
187
115
|
|
|
188
116
|
const citedFeatures = featureNamesCitedIn(prompt);
|
|
189
|
-
if (citedFeatures.length === 0) return;
|
|
117
|
+
if (citedFeatures.length === 0) return;
|
|
190
118
|
|
|
119
|
+
const root = projectRootOf(cwd) ?? cwd;
|
|
191
120
|
const catalogLocations =
|
|
192
|
-
parameters.catalogLocations
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
121
|
+
parameters.catalogLocations.length > 0
|
|
122
|
+
? parameters.catalogLocations
|
|
123
|
+
: DEFAULT_CATALOG_LOCATIONS;
|
|
124
|
+
const treeRoot = contractTreeRootFor(
|
|
125
|
+
root,
|
|
126
|
+
findCatalog(root, catalogLocations),
|
|
127
|
+
);
|
|
128
|
+
if (!treeRoot) return;
|
|
196
129
|
|
|
197
130
|
const unapproved = citedFeatures
|
|
198
131
|
.map((feature) => ({
|
|
199
132
|
feature,
|
|
200
|
-
|
|
133
|
+
briefPath: briefPathFor(treeRoot, feature),
|
|
201
134
|
}))
|
|
202
|
-
.filter(({
|
|
203
|
-
.filter(({ contractPath }) => !isApproved(contractPath));
|
|
204
|
-
|
|
135
|
+
.filter(({ briefPath }) => briefPath && !isApproved(briefPath));
|
|
205
136
|
if (unapproved.length === 0) return;
|
|
206
137
|
|
|
207
138
|
const fileList = unapproved
|
|
208
|
-
.map(({ feature,
|
|
139
|
+
.map(({ feature, briefPath }) => `${feature} -> ${briefPath}`)
|
|
209
140
|
.join('\n ');
|
|
210
141
|
deny(
|
|
211
142
|
CONFIG_KEY,
|
|
212
|
-
`This implementation delegation cites
|
|
143
|
+
`This implementation delegation cites feature(s) whose brief has no recorded approval:\n ${fileList}\n` +
|
|
213
144
|
'Paste the FULL brief into the chat, get an explicit confirmation from the user ' +
|
|
214
|
-
'(not a vague "
|
|
215
|
-
'then add this frontmatter to the TOP of that exact
|
|
216
|
-
'
|
|
145
|
+
'(not a vague "ok"/"sigamos" — an actual sentence confirming they read it), ' +
|
|
146
|
+
'then add this frontmatter to the TOP of that exact brief.md before relaunching ' +
|
|
147
|
+
'(no filesystem exploration needed — the path above is the file to edit):\n' +
|
|
217
148
|
'---\nstatus: approved\napproved_at: <ISO timestamp>\n' +
|
|
218
149
|
'approval_quote: "<the user\'s own confirming words>"\n---',
|
|
219
150
|
);
|
|
@@ -1,33 +1,20 @@
|
|
|
1
|
-
// brief-before-delegate — denies an implementation delegation whose prompt does not
|
|
2
|
-
//
|
|
1
|
+
// brief-before-delegate — denies an implementation delegation whose prompt does not state a
|
|
2
|
+
// GOAL, concrete STEPS and a DONE-WHEN criterion, each with real content past its marker.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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.
|
|
4
|
+
// Decisions: the gate judges that the brief EXISTS, never that it is good — "is this the
|
|
5
|
+
// right goal" needs domain judgment a hook cannot supply. A read-only subagent name is a
|
|
6
|
+
// declared label, not a verified capability, so it exempts only while the prompt carries no
|
|
7
|
+
// mutation-risk signal. A documentary deliverable (README, report, changelog) is not an
|
|
8
|
+
// implementation brief and is not judged here.
|
|
30
9
|
|
|
10
|
+
import {
|
|
11
|
+
DEFAULT_READ_ONLY_SUBAGENTS,
|
|
12
|
+
isDocumentaryRequest,
|
|
13
|
+
isExemptQuery,
|
|
14
|
+
isImplementationRequest,
|
|
15
|
+
isReadOnlySubagent,
|
|
16
|
+
promptExcerpt,
|
|
17
|
+
} from '../../lib/delegation.mjs';
|
|
31
18
|
import {
|
|
32
19
|
runGate,
|
|
33
20
|
deny,
|
|
@@ -35,71 +22,29 @@ import {
|
|
|
35
22
|
toolInGroups,
|
|
36
23
|
delegationPromptOf,
|
|
37
24
|
} from '../../lib/hook-io.mjs';
|
|
38
|
-
import {
|
|
25
|
+
import { withUnicodeWordBoundary } from '../../lib/signals.mjs';
|
|
39
26
|
|
|
40
27
|
const GATE_ID = 'brief-before-delegate';
|
|
41
28
|
const CONFIG_KEY = 'requireBriefBeforeDelegating';
|
|
42
29
|
|
|
43
30
|
const DEFAULT_MIN_BRIEF_LENGTH = 180;
|
|
44
|
-
const DEFAULT_READ_ONLY_SUBAGENTS = ['explore', 'claude-code-guide', 'plan'];
|
|
45
31
|
|
|
46
|
-
//
|
|
47
|
-
|
|
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.
|
|
32
|
+
// All three signals missing at once is indistinguishable from "never thought through", so
|
|
33
|
+
// that combination denies where a partial brief only warns.
|
|
51
34
|
const TOTAL_REQUIRED_SIGNALS = 3;
|
|
52
|
-
//
|
|
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.
|
|
35
|
+
// A single filler word ("cosa.", "listo.") must not clear the bar; any real sentence does.
|
|
55
36
|
const MIN_SECTION_SUBSTANCE_LENGTH = 12;
|
|
37
|
+
const MIN_SUBSTANTIVE_WORDS = 2;
|
|
38
|
+
const MIN_WORD_LENGTH = 3;
|
|
56
39
|
|
|
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
40
|
const GOAL_PATTERN = withUnicodeWordBoundary(
|
|
93
41
|
'objetivo|meta|el fin es|se busca|para lograr|para que|goal|objective',
|
|
94
42
|
);
|
|
95
43
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
* quantifier for a backtracking engine to explode on. Global so every item's substance
|
|
99
|
-
* can be checked, not just the first. */
|
|
44
|
+
// Anchored per line with a single bounded `\s*` so a backtracking engine has nothing to
|
|
45
|
+
// explode on; global so every item's substance can be checked.
|
|
100
46
|
const STRUCTURED_STEPS_PATTERN = /^[ \t]*(?:[-*•]|\d+[.)])[ \t]+(\S.*)$/gm;
|
|
101
47
|
|
|
102
|
-
/** Evidence of a DONE-WHEN / acceptance criterion. */
|
|
103
48
|
const CRITERION_PATTERN = withUnicodeWordBoundary(
|
|
104
49
|
'criterio|acceptance|asserts?|se considera (hecho|terminado|listo)|' +
|
|
105
50
|
'debe (verificarse|cumplir|pasar)|hasta que|done.when|' +
|
|
@@ -107,9 +52,8 @@ const CRITERION_PATTERN = withUnicodeWordBoundary(
|
|
|
107
52
|
'given.{0,20}when.{0,20}then',
|
|
108
53
|
);
|
|
109
54
|
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
// para todos" reads as substantial despite saying nothing concrete.
|
|
55
|
+
// Real words that say nothing concrete, so "listo cuando funcione bien y quede resuelto
|
|
56
|
+
// satisfactoriamente para todos" does not read as substance.
|
|
113
57
|
const FILLER_WORDS = new Set(
|
|
114
58
|
(
|
|
115
59
|
'cosa cosas cualquier corresponda correspondiente relevante sistema bien listo ' +
|
|
@@ -120,70 +64,43 @@ const FILLER_WORDS = new Set(
|
|
|
120
64
|
);
|
|
121
65
|
|
|
122
66
|
function stripDiacritics(text) {
|
|
123
|
-
return text.normalize('NFD').replace(/
|
|
67
|
+
return text.normalize('NFD').replace(/\p{M}/gu, '');
|
|
124
68
|
}
|
|
125
69
|
|
|
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
70
|
function substantiveWordCount(text) {
|
|
130
71
|
const words = stripDiacritics(String(text).toLowerCase())
|
|
131
72
|
.split(/[^\p{L}\p{N}]+/u)
|
|
132
73
|
.filter(Boolean);
|
|
133
|
-
return words.filter(
|
|
134
|
-
.length
|
|
74
|
+
return words.filter(
|
|
75
|
+
(word) => word.length >= MIN_WORD_LENGTH && !FILLER_WORDS.has(word),
|
|
76
|
+
).length;
|
|
135
77
|
}
|
|
136
78
|
|
|
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
79
|
function contentAfterMarker(prompt, markerMatch) {
|
|
140
80
|
const from = markerMatch.index + markerMatch[0].length;
|
|
141
81
|
const restOfLine = prompt.slice(from).split(/\r?\n/, 1)[0] ?? '';
|
|
142
82
|
return restOfLine.replace(/^[\s:.\-–—]+/, '');
|
|
143
83
|
}
|
|
144
84
|
|
|
145
|
-
|
|
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);
|
|
85
|
+
function hasSubstance(content) {
|
|
155
86
|
return (
|
|
156
87
|
content.trim().length >= MIN_SECTION_SUBSTANCE_LENGTH &&
|
|
157
|
-
substantiveWordCount(content) >=
|
|
88
|
+
substantiveWordCount(content) >= MIN_SUBSTANTIVE_WORDS
|
|
158
89
|
);
|
|
159
90
|
}
|
|
160
91
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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);
|
|
92
|
+
function markerHasSubstance(prompt, pattern) {
|
|
93
|
+
const withGlobal = new RegExp(pattern.source, `${pattern.flags}g`);
|
|
94
|
+
for (const match of prompt.matchAll(withGlobal)) {
|
|
95
|
+
if (hasSubstance(contentAfterMarker(prompt, match))) return true;
|
|
96
|
+
}
|
|
97
|
+
return false;
|
|
182
98
|
}
|
|
183
99
|
|
|
184
|
-
function
|
|
185
|
-
|
|
186
|
-
|
|
100
|
+
function stepsHaveSubstance(prompt) {
|
|
101
|
+
return [...prompt.matchAll(STRUCTURED_STEPS_PATTERN)].some(
|
|
102
|
+
(match) => substantiveWordCount(match[1]) >= MIN_SUBSTANTIVE_WORDS,
|
|
103
|
+
);
|
|
187
104
|
}
|
|
188
105
|
|
|
189
106
|
function missingSignals(prompt) {
|
|
@@ -205,15 +122,13 @@ function missingSignals(prompt) {
|
|
|
205
122
|
return missing;
|
|
206
123
|
}
|
|
207
124
|
|
|
208
|
-
function denyTooShort(prompt) {
|
|
209
|
-
const excerpt = prompt.slice(0, PROMPT_EXCERPT_LENGTH);
|
|
210
|
-
const ellipsis = prompt.length > PROMPT_EXCERPT_LENGTH ? '…' : '';
|
|
125
|
+
function denyTooShort(prompt, minBriefLength) {
|
|
211
126
|
deny(
|
|
212
127
|
CONFIG_KEY,
|
|
213
|
-
`This delegation asks for implementation ("${
|
|
214
|
-
|
|
215
|
-
'achieve, what concretely needs doing (as a list or steps), and how
|
|
216
|
-
'relaunch.',
|
|
128
|
+
`This delegation asks for implementation ("${promptExcerpt(prompt)}") in a ${prompt.length}-character ` +
|
|
129
|
+
`prompt — too short to carry a goal, steps and a done-when criterion (minBriefLength: ${minBriefLength}). ` +
|
|
130
|
+
'State what this aims to achieve, what concretely needs doing (as a list or steps), and how ' +
|
|
131
|
+
'completion is verified, then relaunch.',
|
|
217
132
|
);
|
|
218
133
|
}
|
|
219
134
|
|
|
@@ -239,6 +154,14 @@ function reportMissingSignals(missing) {
|
|
|
239
154
|
);
|
|
240
155
|
}
|
|
241
156
|
|
|
157
|
+
function isExempt(toolInput, prompt, readOnlySubagents) {
|
|
158
|
+
if (!prompt.trim()) return true;
|
|
159
|
+
if (isReadOnlySubagent(toolInput, prompt, readOnlySubagents)) return true;
|
|
160
|
+
if (isExemptQuery(prompt)) return true;
|
|
161
|
+
if (!isImplementationRequest(prompt)) return true;
|
|
162
|
+
return isDocumentaryRequest(prompt);
|
|
163
|
+
}
|
|
164
|
+
|
|
242
165
|
runGate(
|
|
243
166
|
{
|
|
244
167
|
id: GATE_ID,
|
|
@@ -253,15 +176,11 @@ runGate(
|
|
|
253
176
|
if (!toolInGroups(toolName, ['delegation'])) return;
|
|
254
177
|
|
|
255
178
|
const prompt = delegationPromptOf(toolInput);
|
|
256
|
-
if (
|
|
257
|
-
if (isReadOnlySubagent(toolInput, prompt, parameters.readOnlySubagents))
|
|
258
|
-
return;
|
|
259
|
-
if (isReadOnlyRequest(prompt)) return;
|
|
260
|
-
if (!IMPLEMENTATION_VERBS.test(prompt)) return; // neither implementation nor read-only: do not guess
|
|
179
|
+
if (isExempt(toolInput, prompt, parameters.readOnlySubagents)) return;
|
|
261
180
|
|
|
262
181
|
const trimmed = prompt.trim();
|
|
263
182
|
if (trimmed.length < parameters.minBriefLength) {
|
|
264
|
-
denyTooShort(trimmed);
|
|
183
|
+
denyTooShort(trimmed, parameters.minBriefLength);
|
|
265
184
|
}
|
|
266
185
|
|
|
267
186
|
reportMissingSignals(missingSignals(prompt));
|