@devrik-tools/claude-gates 0.1.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 +17 -0
- package/README.es.md +219 -0
- package/README.md +222 -0
- package/cli/__tests__/config.test.mjs +101 -0
- package/cli/__tests__/init-flags.test.mjs +111 -0
- package/cli/__tests__/registry-gates-consistency.test.mjs +44 -0
- package/cli/__tests__/selection.test.mjs +200 -0
- package/cli/config.mjs +113 -0
- package/cli/constants.mjs +36 -0
- package/cli/index.mjs +97 -0
- package/cli/init.mjs +256 -0
- package/cli/install.mjs +80 -0
- package/cli/materialize.mjs +51 -0
- package/cli/registry.mjs +127 -0
- package/cli/selection.mjs +83 -0
- package/package.json +73 -0
- package/plugins/gates/.claude-plugin/plugin.json +9 -0
- package/plugins/gates/hooks/gates/audit-before-build/index.mjs +88 -0
- package/plugins/gates/hooks/gates/audit-before-build/test.mjs +92 -0
- package/plugins/gates/hooks/gates/bash-commands/index.mjs +233 -0
- package/plugins/gates/hooks/gates/bash-commands/test.mjs +113 -0
- package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +177 -0
- package/plugins/gates/hooks/gates/brief-before-delegate/test.mjs +111 -0
- package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +456 -0
- package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +143 -0
- package/plugins/gates/hooks/gates/dependency-skills/index.mjs +118 -0
- package/plugins/gates/hooks/gates/dependency-skills/test.mjs +109 -0
- package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +49 -0
- package/plugins/gates/hooks/gates/diagnosis-before-patch/test.mjs +68 -0
- package/plugins/gates/hooks/gates/feature-catalog/index.mjs +100 -0
- package/plugins/gates/hooks/gates/feature-catalog/test.mjs +97 -0
- package/plugins/gates/hooks/gates/forge-flow/index.mjs +112 -0
- package/plugins/gates/hooks/gates/forge-flow/test.mjs +135 -0
- package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +186 -0
- package/plugins/gates/hooks/gates/implementation-pipeline/test.mjs +86 -0
- package/plugins/gates/hooks/gates/intent-flow/index.mjs +238 -0
- package/plugins/gates/hooks/gates/intent-flow/test.mjs +136 -0
- package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +166 -0
- package/plugins/gates/hooks/gates/mandatory-flow/test.mjs +119 -0
- package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +85 -0
- package/plugins/gates/hooks/gates/neutral-spanish/test.mjs +65 -0
- package/plugins/gates/hooks/gates/never-assume/index.mjs +55 -0
- package/plugins/gates/hooks/gates/never-assume/test.mjs +78 -0
- package/plugins/gates/hooks/gates/no-blocking/index.mjs +142 -0
- package/plugins/gates/hooks/gates/no-blocking/test.mjs +108 -0
- package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +120 -0
- package/plugins/gates/hooks/gates/no-memory-dependency/test.mjs +106 -0
- package/plugins/gates/hooks/gates/no-reconfirm/index.mjs +202 -0
- package/plugins/gates/hooks/gates/no-reconfirm/test.mjs +131 -0
- package/plugins/gates/hooks/gates/protected-paths/index.mjs +147 -0
- package/plugins/gates/hooks/gates/protected-paths/test.mjs +75 -0
- package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +64 -0
- package/plugins/gates/hooks/gates/recurrence-lock/test.mjs +99 -0
- package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +150 -0
- package/plugins/gates/hooks/gates/reuse-before-build/test.mjs +101 -0
- package/plugins/gates/hooks/gates/risk-level/index.mjs +203 -0
- package/plugins/gates/hooks/gates/risk-level/test.mjs +125 -0
- package/plugins/gates/hooks/gates/root-cause-first/index.mjs +50 -0
- package/plugins/gates/hooks/gates/root-cause-first/test.mjs +73 -0
- package/plugins/gates/hooks/gates/root-whitelist/index.mjs +111 -0
- package/plugins/gates/hooks/gates/root-whitelist/test.mjs +80 -0
- package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +99 -0
- package/plugins/gates/hooks/gates/rule-skill-autodiscovery/test.mjs +78 -0
- package/plugins/gates/hooks/gates/sdd-specs/index.mjs +251 -0
- package/plugins/gates/hooks/gates/sdd-specs/test.mjs +163 -0
- package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +101 -0
- package/plugins/gates/hooks/gates/test-after-implementation/test.mjs +81 -0
- package/plugins/gates/hooks/gates/test-matrix/index.mjs +181 -0
- package/plugins/gates/hooks/gates/test-matrix/test.mjs +87 -0
- package/plugins/gates/hooks/gates/tool-map/index.mjs +140 -0
- package/plugins/gates/hooks/gates/tool-map/test.mjs +87 -0
- package/plugins/gates/hooks/hooks.json +266 -0
- package/plugins/gates/hooks/lib/__tests__/config.test.mjs +154 -0
- package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +154 -0
- package/plugins/gates/hooks/lib/config.mjs +165 -0
- package/plugins/gates/hooks/lib/hook-io.mjs +208 -0
- package/plugins/tasks/hooks/lib/__tests__/task-store.test.mjs +132 -0
- package/plugins/tasks/hooks/lib/task-store.mjs +159 -0
- package/plugins/tasks/hooks/register-requests.mjs +108 -0
- package/registry.json +668 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
7
|
+
import { test } from 'node:test';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
|
|
11
|
+
|
|
12
|
+
// Builds a forge SQLite DB with the runs table and optional active run for a given cwd.
|
|
13
|
+
function makeForgeDatabase(directory, activeCwd) {
|
|
14
|
+
const databasePath = join(directory, 'forge.db');
|
|
15
|
+
const database = new DatabaseSync(databasePath);
|
|
16
|
+
database.exec(
|
|
17
|
+
'CREATE TABLE runs (id TEXT, cwd TEXT, current_phase TEXT, status TEXT)',
|
|
18
|
+
);
|
|
19
|
+
if (activeCwd) {
|
|
20
|
+
database
|
|
21
|
+
.prepare(
|
|
22
|
+
'INSERT INTO runs (id, cwd, current_phase, status) VALUES (?,?,?,?)',
|
|
23
|
+
)
|
|
24
|
+
.run('r1', activeCwd, 'build', 'active');
|
|
25
|
+
}
|
|
26
|
+
database.close();
|
|
27
|
+
return databasePath;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Runs the gate in a temp project. `adopted` writes the forge marker; `forgeDatabasePath` is
|
|
31
|
+
// passed as the gate's param via config. HOME is isolated so the real global config is
|
|
32
|
+
// never read. Returns the parsed deny output, or null when the gate allowed.
|
|
33
|
+
function runGate(payload, { adopted, forgeDatabasePath, enabled = true } = {}) {
|
|
34
|
+
const project = mkdtempSync(join(tmpdir(), 'forge-flow-'));
|
|
35
|
+
mkdirSync(join(project, '.git'));
|
|
36
|
+
mkdirSync(join(project, '.ai'));
|
|
37
|
+
if (adopted) writeFileSync(join(project, '.ai', 'forge.json'), '{}');
|
|
38
|
+
const gateEntry = { enabled };
|
|
39
|
+
if (forgeDatabasePath) gateEntry.forgeDatabasePath = forgeDatabasePath;
|
|
40
|
+
writeFileSync(
|
|
41
|
+
join(project, '.ai', 'config.json'),
|
|
42
|
+
JSON.stringify({ gates: { requireForgeRunToEdit: gateEntry } }),
|
|
43
|
+
);
|
|
44
|
+
const out = execFileSync(process.execPath, [GATE], {
|
|
45
|
+
input: JSON.stringify(payload),
|
|
46
|
+
encoding: 'utf8',
|
|
47
|
+
cwd: project,
|
|
48
|
+
env: { ...process.env, HOME: project, USERPROFILE: project },
|
|
49
|
+
});
|
|
50
|
+
return { output: out.trim() ? JSON.parse(out.trim()) : null, project };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function write(filePath) {
|
|
54
|
+
return {
|
|
55
|
+
tool_name: 'Write',
|
|
56
|
+
tool_input: { file_path: filePath, content: 'x' },
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function isDeny(result) {
|
|
60
|
+
return result?.hookSpecificOutput?.permissionDecision === 'deny';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
test('a project that did NOT adopt forge is never blocked', () => {
|
|
64
|
+
const { output } = runGate(write('src/x.js'), { adopted: false });
|
|
65
|
+
assert.equal(output, null);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('adopted forge, no active run: a write is denied', () => {
|
|
69
|
+
const home = mkdtempSync(join(tmpdir(), 'forge-db-'));
|
|
70
|
+
const databasePath = makeForgeDatabase(home); // no active run
|
|
71
|
+
const { output } = runGate(write('src/x.js'), {
|
|
72
|
+
adopted: true,
|
|
73
|
+
forgeDatabasePath: databasePath,
|
|
74
|
+
});
|
|
75
|
+
assert.ok(isDeny(output));
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('adopted forge, active run for this project: the write is allowed', () => {
|
|
79
|
+
// The run's cwd must equal the project root the gate resolves. Run once to learn the
|
|
80
|
+
// project path, then rebuild the DB with that cwd — the helper makes a fresh project each
|
|
81
|
+
// call, so we seed the DB against the project it actually created.
|
|
82
|
+
const project = mkdtempSync(join(tmpdir(), 'forge-flow-'));
|
|
83
|
+
mkdirSync(join(project, '.git'));
|
|
84
|
+
mkdirSync(join(project, '.ai'));
|
|
85
|
+
writeFileSync(join(project, '.ai', 'forge.json'), '{}');
|
|
86
|
+
const home = mkdtempSync(join(tmpdir(), 'forge-db-'));
|
|
87
|
+
const databasePath = makeForgeDatabase(home, project); // active run whose cwd = this project
|
|
88
|
+
writeFileSync(
|
|
89
|
+
join(project, '.ai', 'config.json'),
|
|
90
|
+
JSON.stringify({
|
|
91
|
+
gates: {
|
|
92
|
+
requireForgeRunToEdit: {
|
|
93
|
+
enabled: true,
|
|
94
|
+
forgeDatabasePath: databasePath,
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
}),
|
|
98
|
+
);
|
|
99
|
+
const out = execFileSync(process.execPath, [GATE], {
|
|
100
|
+
input: JSON.stringify(write('src/x.js')),
|
|
101
|
+
encoding: 'utf8',
|
|
102
|
+
cwd: project,
|
|
103
|
+
env: { ...process.env, HOME: project, USERPROFILE: project },
|
|
104
|
+
});
|
|
105
|
+
assert.equal(out.trim(), '', 'allowed: no output');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('adopted forge but DB missing: denied (no runs at all)', () => {
|
|
109
|
+
const { output } = runGate(write('src/x.js'), {
|
|
110
|
+
adopted: true,
|
|
111
|
+
forgeDatabasePath: join(tmpdir(), 'does-not-exist', 'forge.db'),
|
|
112
|
+
});
|
|
113
|
+
assert.ok(isDeny(output));
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('disabled by config: not blocked even in an adopted project', () => {
|
|
117
|
+
const home = mkdtempSync(join(tmpdir(), 'forge-db-'));
|
|
118
|
+
const databasePath = makeForgeDatabase(home);
|
|
119
|
+
const { output } = runGate(write('src/x.js'), {
|
|
120
|
+
adopted: true,
|
|
121
|
+
forgeDatabasePath: databasePath,
|
|
122
|
+
enabled: false,
|
|
123
|
+
});
|
|
124
|
+
assert.equal(output, null);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('a read-only tool (no write/shell) is never blocked', () => {
|
|
128
|
+
const home = mkdtempSync(join(tmpdir(), 'forge-db-'));
|
|
129
|
+
const databasePath = makeForgeDatabase(home);
|
|
130
|
+
const { output } = runGate(
|
|
131
|
+
{ tool_name: 'Read', tool_input: { file_path: 'src/x.js' } },
|
|
132
|
+
{ adopted: true, forgeDatabasePath: databasePath },
|
|
133
|
+
);
|
|
134
|
+
assert.equal(output, null);
|
|
135
|
+
});
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// implementation-pipeline — denies a delegation that sends a builder subagent straight
|
|
2
|
+
// to writing code without the prompt declaring the pipeline stages around it:
|
|
3
|
+
// definition (where the context came from), writing's own verification plan, and a
|
|
4
|
+
// separate reviewer for validation/QA/closure. Migrated from
|
|
5
|
+
// ~/.claude/hooks/guard-pipeline-de-implementacion.mjs.
|
|
6
|
+
//
|
|
7
|
+
// ── What a project can configure (params) ───────────────────────────────────────────
|
|
8
|
+
// builderSubagents subagent types that count as builders — the ones this gate
|
|
9
|
+
// holds to the pipeline. Replaces the built-in list wholesale.
|
|
10
|
+
// exemptSubagents subagent types exempt outright: read-only pipeline stages that
|
|
11
|
+
// cannot be required to declare a pipeline around themselves.
|
|
12
|
+
// Replaces the built-in list wholesale.
|
|
13
|
+
// The defaults live here, in the source, so a project reads them and knows exactly what
|
|
14
|
+
// its override replaces.
|
|
15
|
+
//
|
|
16
|
+
// ── Off by default, and quiet outside its narrow trigger ───────────────────────────
|
|
17
|
+
// This gate only evaluates a delegation whose prompt declares a STANDARD/HIGH-RISK
|
|
18
|
+
// level, uses an implementation verb, targets a builder subagent, and is not about the
|
|
19
|
+
// harness itself. A prompt that only describes work, or that is read-only/exploratory,
|
|
20
|
+
// never reaches the check.
|
|
21
|
+
|
|
22
|
+
import { runGate, deny, TOOL_GROUPS } from '../../lib/hook-io.mjs';
|
|
23
|
+
|
|
24
|
+
const GATE_ID = 'implementation-pipeline';
|
|
25
|
+
const CONFIG_KEY = 'requireImplementationPipeline';
|
|
26
|
+
|
|
27
|
+
const DELEGATION_TOOLS = new Set(TOOL_GROUPS.delegation);
|
|
28
|
+
|
|
29
|
+
const DEFAULT_BUILDER_SUBAGENTS = [
|
|
30
|
+
'frontend',
|
|
31
|
+
'backend',
|
|
32
|
+
'worker',
|
|
33
|
+
'worker-senior',
|
|
34
|
+
'general-purpose',
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
const DEFAULT_EXEMPT_SUBAGENTS = [
|
|
38
|
+
'scout',
|
|
39
|
+
'explore',
|
|
40
|
+
'plan',
|
|
41
|
+
'revision',
|
|
42
|
+
'contraste',
|
|
43
|
+
'test-planner',
|
|
44
|
+
'qa',
|
|
45
|
+
'ui',
|
|
46
|
+
'ux',
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
function withWordBoundary(alternation) {
|
|
50
|
+
return new RegExp(
|
|
51
|
+
`(?:^|[^\\p{L}\\p{N}_])(?:${alternation})(?:[^\\p{L}\\p{N}_]|$)`,
|
|
52
|
+
'iu',
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const IMPLEMENTATION_VERBS = withWordBoundary(
|
|
57
|
+
'implementa|implementar|implement(á|é)|agreg(a|á)|agregar|añad(e|í)|añadir|cre(a|á)|crear|' +
|
|
58
|
+
'arregl(a|á)|arreglar|cambi(a|á)|cambiar|migr(a|á)|migrar|' +
|
|
59
|
+
'corrige|corregir|correg(í|ir)|constru(ye|í)|construir|modific(a|á)|modificar|' +
|
|
60
|
+
'refactoriz(a|á)|refactorizar|elimin(a|á)|eliminar|reescrib(e|í)|reescribir|desplieg(a|á)|desplegar|' +
|
|
61
|
+
'escrib(í|e)|escribir|implement\\w*|writ(?:e|ing)|creat\\w*|fix\\w*|build\\w*|refactor\\w*|migrat\\w*|' +
|
|
62
|
+
'add\\w*|remov\\w*|delet\\w*|modify|modifies|modifying|rewrit\\w*',
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
/** Declared LEVEL near the word "level"/"classification" in Spanish or English, matching
|
|
66
|
+
* the plugin-wide convention (see risk-level.mjs). */
|
|
67
|
+
const DEMANDING_LEVEL_PATTERN =
|
|
68
|
+
/(nivel|level|clasificaci[oó]n|classification)[^\n]{0,25}?\b(STANDARD|HIGH-RISK)\b/iu;
|
|
69
|
+
const EXEMPT_LEVEL_PATTERN =
|
|
70
|
+
/(nivel|level|clasificaci[oó]n|classification)[^\n]{0,25}?\b(QUESTION|MICRO)\b/iu;
|
|
71
|
+
const HIGH_RISK_PATTERN =
|
|
72
|
+
/(nivel|level|clasificaci[oó]n|classification)[^\n]{0,25}?\bHIGH-RISK\b/iu;
|
|
73
|
+
|
|
74
|
+
const HARNESS_PATTERN =
|
|
75
|
+
/(\.claude[\\/]|hooks[\\/]|plugins[\\/]gates|settings\.json|[\\/]agents[\\/]\w|[\\/]rules[\\/]\w|[\\/]skills[\\/]\w)/i;
|
|
76
|
+
|
|
77
|
+
// Several small patterns instead of one long alternation: a single big regex here
|
|
78
|
+
// tripped the linter's regex-complexity limit. Any one matching counts as declared.
|
|
79
|
+
const REVIEWER_NAME_PATTERN =
|
|
80
|
+
/\b(reviewer|revisor|separate review|revisi[oó]n separada|contraste|auditor)\b/i;
|
|
81
|
+
const REVIEWER_LABEL_PATTERN = /(review|revisi[oó]n)\s*:/i;
|
|
82
|
+
const REVIEWER_ACTION_EN_PATTERN =
|
|
83
|
+
/\breview\s+(?:the\s+)?(?:diff|result|work)\b/i;
|
|
84
|
+
const REVIEWER_ACTION_ES_PATTERN =
|
|
85
|
+
/\brevisa\s+(?:el\s+)?(?:diff|resultado|trabajo)\b/i;
|
|
86
|
+
const REVIEWER_CODE_REVIEW_PATTERN = /code.review/i;
|
|
87
|
+
|
|
88
|
+
function reviewerStageDeclared(prompt) {
|
|
89
|
+
return (
|
|
90
|
+
REVIEWER_NAME_PATTERN.test(prompt) ||
|
|
91
|
+
REVIEWER_LABEL_PATTERN.test(prompt) ||
|
|
92
|
+
REVIEWER_ACTION_EN_PATTERN.test(prompt) ||
|
|
93
|
+
REVIEWER_ACTION_ES_PATTERN.test(prompt) ||
|
|
94
|
+
REVIEWER_CODE_REVIEW_PATTERN.test(prompt)
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const DEFINITION_PATTERN =
|
|
99
|
+
/\b(scout|reconnaissance|reconocimiento|prior exploration|explora(?:ci[oó]n)? previa|verified context|contexto verificado|asserts\.md|brief\.md)\b/i;
|
|
100
|
+
const WRITING_PATTERN =
|
|
101
|
+
/\b(test|tests|prueba|pruebas|vitest|jest|playwright|typecheck|lint|verification|verificaci[oó]n|verification criteria)\b/i;
|
|
102
|
+
|
|
103
|
+
/** The three pipeline stages this gate requires be declared, in this order. */
|
|
104
|
+
const REQUIRED_STAGES = [
|
|
105
|
+
{
|
|
106
|
+
id: 'definition',
|
|
107
|
+
isDeclared: (prompt) => DEFINITION_PATTERN.test(prompt),
|
|
108
|
+
missing:
|
|
109
|
+
'DEFINITION: does not declare where the context came from (a prior scout, read-only exploration, or an already-written asserts/brief).',
|
|
110
|
+
line: 'Verified context: <path to the cited asserts/brief> — or: a prior scout ran, findings at <path>.',
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
id: 'writing',
|
|
114
|
+
isDeclared: (prompt) => WRITING_PATTERN.test(prompt),
|
|
115
|
+
missing:
|
|
116
|
+
'WRITING: does not declare what is verified nor with what command. A builder does not validate its own work without a criterion written beforehand.',
|
|
117
|
+
line: 'Verification: <exact command> and what is expected. Anything that cannot run is reported OMITTED, never PASS.',
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
id: 'reviewer',
|
|
121
|
+
isDeclared: reviewerStageDeclared,
|
|
122
|
+
missing:
|
|
123
|
+
'REVIEWER: does not declare who reviews the result. Whoever implements does not validate their own work.',
|
|
124
|
+
line: 'Review: the coordinator reviews the diff and the evidence before closing — or: a read-only `revision`/`contraste` agent reviews it.',
|
|
125
|
+
},
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
function isExempt(toolInput, prompt, builderSubagents, exemptSubagents) {
|
|
129
|
+
const subagentType = String(
|
|
130
|
+
toolInput.subagent_type ?? toolInput.subagentType ?? '',
|
|
131
|
+
).toLowerCase();
|
|
132
|
+
if (exemptSubagents.includes(subagentType)) return true;
|
|
133
|
+
// An unknown type is not assumed to be a builder: the safe side here is not to
|
|
134
|
+
// invent requirements for agents this gate cannot classify.
|
|
135
|
+
if (subagentType && !builderSubagents.includes(subagentType)) return true;
|
|
136
|
+
if (EXEMPT_LEVEL_PATTERN.test(prompt)) return true;
|
|
137
|
+
if (!DEMANDING_LEVEL_PATTERN.test(prompt)) return true;
|
|
138
|
+
if (!IMPLEMENTATION_VERBS.test(prompt)) return true;
|
|
139
|
+
if (HARNESS_PATTERN.test(prompt)) return true;
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
runGate(
|
|
144
|
+
{
|
|
145
|
+
id: GATE_ID,
|
|
146
|
+
configKey: CONFIG_KEY,
|
|
147
|
+
enabledByDefault: false,
|
|
148
|
+
defaultParams: {
|
|
149
|
+
builderSubagents: DEFAULT_BUILDER_SUBAGENTS,
|
|
150
|
+
exemptSubagents: DEFAULT_EXEMPT_SUBAGENTS,
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
({ toolName, toolInput, parameters }) => {
|
|
154
|
+
if (!DELEGATION_TOOLS.has(toolName)) return;
|
|
155
|
+
|
|
156
|
+
const prompt = String(
|
|
157
|
+
toolInput.prompt ?? toolInput.Prompt ?? toolInput.task ?? '',
|
|
158
|
+
);
|
|
159
|
+
if (!prompt.trim()) return;
|
|
160
|
+
|
|
161
|
+
const builderSubagents =
|
|
162
|
+
parameters.builderSubagents ?? DEFAULT_BUILDER_SUBAGENTS;
|
|
163
|
+
const exemptSubagents =
|
|
164
|
+
parameters.exemptSubagents ?? DEFAULT_EXEMPT_SUBAGENTS;
|
|
165
|
+
if (isExempt(toolInput, prompt, builderSubagents, exemptSubagents)) return;
|
|
166
|
+
|
|
167
|
+
const missingStages = REQUIRED_STAGES.filter(
|
|
168
|
+
(stage) => !stage.isDeclared(prompt),
|
|
169
|
+
);
|
|
170
|
+
if (missingStages.length === 0) return;
|
|
171
|
+
|
|
172
|
+
const isHighRisk = HIGH_RISK_PATTERN.test(prompt);
|
|
173
|
+
const details = missingStages.map((stage) => stage.missing).join(' ');
|
|
174
|
+
const lines = missingStages.map((stage) => ` ${stage.line}`).join('\n');
|
|
175
|
+
|
|
176
|
+
deny(
|
|
177
|
+
GATE_ID,
|
|
178
|
+
`This delegation is going to build code but skips ${missingStages.length} pipeline ` +
|
|
179
|
+
`stage(s). ${details} ${
|
|
180
|
+
isHighRisk
|
|
181
|
+
? 'In HIGH-RISK a separate reviewer is not negotiable. '
|
|
182
|
+
: ''
|
|
183
|
+
}PASTE THESE LINES INTO THE PROMPT AND RELAUNCH:\n${lines}`,
|
|
184
|
+
);
|
|
185
|
+
},
|
|
186
|
+
);
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { test } from 'node:test';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
|
|
10
|
+
|
|
11
|
+
function runGate(payload, { config } = {}) {
|
|
12
|
+
const project = mkdtempSync(join(tmpdir(), 'implementation-pipeline-'));
|
|
13
|
+
mkdirSync(join(project, '.git'));
|
|
14
|
+
if (config) {
|
|
15
|
+
mkdirSync(join(project, '.ai'));
|
|
16
|
+
writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
|
|
17
|
+
}
|
|
18
|
+
const out = execFileSync(process.execPath, [GATE], {
|
|
19
|
+
input: JSON.stringify(payload),
|
|
20
|
+
encoding: 'utf8',
|
|
21
|
+
cwd: project,
|
|
22
|
+
// Isolate from the user's real global config: point homedir() at the temp
|
|
23
|
+
// project so the global-config fallback finds nothing (registry default).
|
|
24
|
+
env: { ...process.env, HOME: project, USERPROFILE: project },
|
|
25
|
+
});
|
|
26
|
+
return out.trim() ? JSON.parse(out.trim()) : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function delegate(prompt, subagentType = 'backend') {
|
|
30
|
+
return {
|
|
31
|
+
tool_name: 'Agent',
|
|
32
|
+
tool_input: { prompt, subagent_type: subagentType },
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isDeny(result) {
|
|
37
|
+
return result?.hookSpecificOutput?.permissionDecision === 'deny';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function enabledConfig(extraParameters) {
|
|
41
|
+
return {
|
|
42
|
+
gates: {
|
|
43
|
+
requireImplementationPipeline: extraParameters
|
|
44
|
+
? { enabled: true, ...extraParameters }
|
|
45
|
+
: true,
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
test('disabled by default: a builder delegation with no pipeline stages is allowed', () => {
|
|
51
|
+
const prompt = 'Nivel: STANDARD\nImplementá el checkout.';
|
|
52
|
+
assert.equal(runGate(delegate(prompt)), null);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('denies a builder delegation missing all three pipeline stages', () => {
|
|
56
|
+
const prompt = 'Nivel: STANDARD\nImplementá el checkout.';
|
|
57
|
+
assert.ok(isDeny(runGate(delegate(prompt), { config: enabledConfig() })));
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('allows a builder delegation that declares all three stages', () => {
|
|
61
|
+
const prompt =
|
|
62
|
+
'Nivel: STANDARD\nImplementá el checkout. Contexto verificado: .ai/features/checkout/brief.md. ' +
|
|
63
|
+
'Verificación: npm test debe pasar. Revisión: el principal revisa el diff antes de cerrar.';
|
|
64
|
+
assert.equal(runGate(delegate(prompt), { config: enabledConfig() }), null);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('exempt subagent type (scout) is never held to the pipeline', () => {
|
|
68
|
+
const prompt = 'Nivel: STANDARD\nImplementá el checkout.';
|
|
69
|
+
assert.equal(
|
|
70
|
+
runGate(delegate(prompt, 'scout'), { config: enabledConfig() }),
|
|
71
|
+
null,
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('a prompt that only describes, does not order, is exempt via QUESTION level', () => {
|
|
76
|
+
const prompt = 'Nivel: QUESTION\nExplicá cómo funciona el checkout hoy.';
|
|
77
|
+
assert.equal(runGate(delegate(prompt), { config: enabledConfig() }), null);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('builderSubagents param override holds a custom type to the pipeline', () => {
|
|
81
|
+
const prompt = 'Nivel: STANDARD\nImplementá el checkout.';
|
|
82
|
+
const config = enabledConfig({ builderSubagents: ['custom-builder'] });
|
|
83
|
+
assert.ok(isDeny(runGate(delegate(prompt, 'custom-builder'), { config })));
|
|
84
|
+
// a type NOT in the overridden list is not assumed to be a builder
|
|
85
|
+
assert.equal(runGate(delegate(prompt, 'backend'), { config }), null);
|
|
86
|
+
});
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// intent-flow — denies an implementation delegation that has real intent to mutate a
|
|
2
|
+
// high-impact area (money, auth, persisted data, public contract, irreversible...)
|
|
3
|
+
// without declaring IN SCOPE / OUT OF SCOPE / EDGE CASES, and hard-denies whenever it
|
|
4
|
+
// also leaves a high-impact UNKNOWN unresolved.
|
|
5
|
+
//
|
|
6
|
+
// ── How this differs from brief-before-delegate ─────────────────────────────────────
|
|
7
|
+
// brief-before-delegate asks "was the task thought through" (goal+steps+criterion).
|
|
8
|
+
// This gate asks a different, complementary question: "was the SCOPE declared — what
|
|
9
|
+
// is in, what is explicitly out, and what edge cases were considered — for work that
|
|
10
|
+
// really intends to touch a sensitive area?" Both run on the same delegation prompt
|
|
11
|
+
// independently; neither depends on the other's verdict.
|
|
12
|
+
//
|
|
13
|
+
// ── Two-stage intent check (not lexical match) ──────────────────────────────────────
|
|
14
|
+
// Stage 1 (cheap prefilter): does the raw prompt contain BOTH an implementation verb
|
|
15
|
+
// and a high-impact signal anywhere? If not, there is no candidate — allow immediately.
|
|
16
|
+
// Stage 2 (confirmation, only on candidates): strip quoted/templated text, then require
|
|
17
|
+
// near co-occurrence between an implementation verb and a high-impact signal, and
|
|
18
|
+
// discard a verb whose immediate object is a documentary deliverable (a doc/report/
|
|
19
|
+
// README/.md/.html describing the area is not mutating it). Only surviving (a)+(b)+(c)
|
|
20
|
+
// counts as real intent.
|
|
21
|
+
|
|
22
|
+
import { runGate, deny, TOOL_GROUPS } from '../../lib/hook-io.mjs';
|
|
23
|
+
|
|
24
|
+
const GATE_ID = 'intent-flow';
|
|
25
|
+
const CONFIG_KEY = 'requireScopeListBeforeDelegating';
|
|
26
|
+
|
|
27
|
+
const DELEGATION_TOOLS = new Set(TOOL_GROUPS.delegation);
|
|
28
|
+
|
|
29
|
+
const DEFAULT_READ_ONLY_SUBAGENTS = ['explore', 'claude-code-guide', 'plan'];
|
|
30
|
+
const DEFAULT_HIGH_IMPACT_PATTERNS = [
|
|
31
|
+
'dinero|pago|cobro|money|payment',
|
|
32
|
+
'auth|autenticaci[oó]n|authentication|permiso|permission|token|sesi[oó]n|session|seguridad|security|PII',
|
|
33
|
+
'borrar|delete|drop|migraci[oó]n|migration|schema',
|
|
34
|
+
'contrato|contract|irreversible|producci[oó]n|production|API p[uú]blica|public api',
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
const MIN_REQUEST_LENGTH = 40;
|
|
38
|
+
const CO_OCCURRENCE_WINDOW = 100;
|
|
39
|
+
const VERB_OBJECT_WINDOW = 60;
|
|
40
|
+
// How much of an over-length prompt to quote back in a denial message.
|
|
41
|
+
const PROMPT_EXCERPT_LENGTH = 80;
|
|
42
|
+
|
|
43
|
+
function withUnicodeWordBoundary(alternatives) {
|
|
44
|
+
return new RegExp(
|
|
45
|
+
`(?<![\\p{L}\\p{N}_])(${alternatives})(?![\\p{L}\\p{N}_])`,
|
|
46
|
+
'iu',
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const IMPLEMENTATION_VERBS = withUnicodeWordBoundary(
|
|
51
|
+
'implementa|implementar|implement(á|é)|agreg(a|á)|agregar|añad(e|í)|añadir|cre(a|á)|crear|' +
|
|
52
|
+
'arregl(a|á)|arreglar|cambi(a|á)|cambiar|migr(a|á)|migrar|escrib(í|e) c[oó]digo|escribir c[oó]digo|' +
|
|
53
|
+
'corrige|corregir|correg(í|ir)|constru(ye|í)|construir|modific(a|á)|modificar|' +
|
|
54
|
+
'refactoriz(a|á)|refactorizar|elimin(a|á)|eliminar|reescrib(e|í)|reescribir|desplieg(a|á)|desplegar|' +
|
|
55
|
+
'implement|build|fix|migrate|modify|refactor|remove|rewrite|deploy',
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const QUERY_VERBS = withUnicodeWordBoundary(
|
|
59
|
+
'explica|explic(á|ar)|qu[eé]|c[oó]mo|muestra|mostr(á|ar)|analiza|analiz(á|ar)|investiga|' +
|
|
60
|
+
'investig(á|ar)|revisa|revis(á|ar)|audita|audit(á|ar)|diagnostica|diagnostic(á|ar)|' +
|
|
61
|
+
'explain|what|how|show|analyze|investigate|review|audit|diagnose',
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const IN_SCOPE_PATTERN = withUnicodeWordBoundary(
|
|
65
|
+
'qu[eé] s[ií]|qu[eé] s[ií] entra|alcance|in scope|lo pedido|se va a hacer|incluye',
|
|
66
|
+
);
|
|
67
|
+
const OUT_OF_SCOPE_PATTERN = withUnicodeWordBoundary(
|
|
68
|
+
'qu[eé] no|fuera de alcance|out of scope|no tocar|no modificar|no debe cambiar|' +
|
|
69
|
+
'must not change|no se debe (cambiar|tocar|modificar)',
|
|
70
|
+
);
|
|
71
|
+
const EDGE_CASES_PATTERN = withUnicodeWordBoundary(
|
|
72
|
+
'edge cases?|edge-cases?|casos? borde|casos? l[ií]mite|caso l[ií]mite|sin edge cases|' +
|
|
73
|
+
'no (hay|aplican) edge cases|no hay casos borde',
|
|
74
|
+
);
|
|
75
|
+
const UNRESOLVED_UNKNOWN_PATTERN = withUnicodeWordBoundary(
|
|
76
|
+
'unknown|no se sabe|no sabemos|sin resolver|no est[aá] claro|no est[aá] definido|' +
|
|
77
|
+
'a definir|por definir|desconocido|no est[aá] decidido',
|
|
78
|
+
);
|
|
79
|
+
const DOCUMENTARY_DELIVERABLE_PATTERN = withUnicodeWordBoundary(
|
|
80
|
+
'documento|documentaci[oó]n|reporte|informe|diagrama|readme|wiki|changelog|' +
|
|
81
|
+
'p[aá]gina de documentaci[oó]n|archivo html|p[aá]gina html|markdown|document|documentation|report',
|
|
82
|
+
);
|
|
83
|
+
const DOCUMENTARY_EXTENSION_PATTERN = /\.(md|html?|adoc)\b/iu;
|
|
84
|
+
|
|
85
|
+
function isReadOnlySubagent(toolInput, readOnlySubagents) {
|
|
86
|
+
const type = String(
|
|
87
|
+
toolInput.subagent_type ?? toolInput.subagentType ?? '',
|
|
88
|
+
).toLowerCase();
|
|
89
|
+
return new Set(readOnlySubagents.map((name) => name.toLowerCase())).has(type);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isImplementationRequest(prompt) {
|
|
93
|
+
return IMPLEMENTATION_VERBS.test(prompt);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isExemptQuery(prompt) {
|
|
97
|
+
if (isImplementationRequest(prompt)) return false; // implementation verb takes priority
|
|
98
|
+
return QUERY_VERBS.test(prompt);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function stripQuoted(text) {
|
|
102
|
+
return text
|
|
103
|
+
.replace(/```[\s\S]*?```/g, ' ')
|
|
104
|
+
.replace(/"[^"\n]{0,300}"/g, ' ')
|
|
105
|
+
.replace(/'[^'\n]{0,300}'/g, ' ');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function allMatches(pattern, text) {
|
|
109
|
+
const flags = pattern.flags.includes('g')
|
|
110
|
+
? pattern.flags
|
|
111
|
+
: `${pattern.flags}g`;
|
|
112
|
+
return [...text.matchAll(new RegExp(pattern.source, flags))];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function verbProducesDocument(text, verbMatch) {
|
|
116
|
+
const from = verbMatch.index;
|
|
117
|
+
const to = from + verbMatch[0].length + VERB_OBJECT_WINDOW;
|
|
118
|
+
const objectOfVerb = text.slice(from, to);
|
|
119
|
+
return (
|
|
120
|
+
DOCUMENTARY_DELIVERABLE_PATTERN.test(objectOfVerb) ||
|
|
121
|
+
DOCUMENTARY_EXTENSION_PATTERN.test(objectOfVerb)
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function hasRealSensitiveMutation(prompt, highImpactPattern) {
|
|
126
|
+
const isCandidate =
|
|
127
|
+
IMPLEMENTATION_VERBS.test(prompt) && highImpactPattern.test(prompt);
|
|
128
|
+
if (!isCandidate) return false; // stage 1: cheap, no stage 2 spent
|
|
129
|
+
|
|
130
|
+
const text = stripQuoted(prompt); // stage 2(a)
|
|
131
|
+
const verbs = allMatches(IMPLEMENTATION_VERBS, text);
|
|
132
|
+
const signals = allMatches(highImpactPattern, text);
|
|
133
|
+
|
|
134
|
+
for (const verb of verbs) {
|
|
135
|
+
if (verbProducesDocument(text, verb)) continue; // stage 2(c)
|
|
136
|
+
for (const signal of signals) {
|
|
137
|
+
if (Math.abs(signal.index - verb.index) <= CO_OCCURRENCE_WINDOW)
|
|
138
|
+
return true; // stage 2(b)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function missingSignals(prompt) {
|
|
145
|
+
const missing = [];
|
|
146
|
+
if (!IN_SCOPE_PATTERN.test(prompt))
|
|
147
|
+
missing.push('IN SCOPE (what is requested, stated explicitly)');
|
|
148
|
+
if (!OUT_OF_SCOPE_PATTERN.test(prompt))
|
|
149
|
+
missing.push('OUT OF SCOPE (what must not be touched)');
|
|
150
|
+
if (!EDGE_CASES_PATTERN.test(prompt)) {
|
|
151
|
+
missing.push(
|
|
152
|
+
'EDGE CASES (considered, or an explicit mark that none apply)',
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
return missing;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function denyUnresolvedUnknown() {
|
|
159
|
+
deny(
|
|
160
|
+
GATE_ID,
|
|
161
|
+
'This request touches a high-impact signal (money/auth/persisted data/public contract/' +
|
|
162
|
+
'irreversible/production) AND declares an unresolved UNKNOWN. Do not relaunch assuming an ' +
|
|
163
|
+
'answer — ask the user what to decide about that UNKNOWN before delegating again.',
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function denyTooShortForScopeList(prompt) {
|
|
168
|
+
const excerpt = prompt.slice(0, PROMPT_EXCERPT_LENGTH);
|
|
169
|
+
const ellipsis = prompt.length > PROMPT_EXCERPT_LENGTH ? '…' : '';
|
|
170
|
+
deny(
|
|
171
|
+
GATE_ID,
|
|
172
|
+
`This implementation request ("${excerpt}${ellipsis}") does not carry a scope list: missing IN ` +
|
|
173
|
+
'SCOPE, OUT OF SCOPE and EDGE CASES. Add it to the prompt before relaunching.',
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function denyMissingSignals(missing) {
|
|
178
|
+
deny(
|
|
179
|
+
GATE_ID,
|
|
180
|
+
`This implementation request does not declare, in recognizable form: ${missing.join('; ')}. ` +
|
|
181
|
+
'Add what is missing to the prompt before relaunching this delegation.',
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function buildHighImpactPattern(highImpactPatterns) {
|
|
186
|
+
return new RegExp(
|
|
187
|
+
(highImpactPatterns ?? []).map((source) => `(?:${source})`).join('|'),
|
|
188
|
+
'iu',
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** True when this delegation is exempt from the whole check: not a recognizable
|
|
193
|
+
* implementation request, a read-only subagent, or an exempt query. */
|
|
194
|
+
function isExempt(toolInput, prompt, readOnlySubagents) {
|
|
195
|
+
if (!prompt.trim()) return true;
|
|
196
|
+
if (isReadOnlySubagent(toolInput, readOnlySubagents)) return true;
|
|
197
|
+
if (isExemptQuery(prompt)) return true;
|
|
198
|
+
return !isImplementationRequest(prompt);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
runGate(
|
|
202
|
+
{
|
|
203
|
+
id: GATE_ID,
|
|
204
|
+
configKey: CONFIG_KEY,
|
|
205
|
+
enabledByDefault: false,
|
|
206
|
+
defaultParams: {
|
|
207
|
+
highImpactPatterns: DEFAULT_HIGH_IMPACT_PATTERNS,
|
|
208
|
+
readOnlySubagents: DEFAULT_READ_ONLY_SUBAGENTS,
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
({ toolName, toolInput, parameters }) => {
|
|
212
|
+
if (!DELEGATION_TOOLS.has(toolName)) return;
|
|
213
|
+
|
|
214
|
+
const prompt = String(
|
|
215
|
+
toolInput.prompt ?? toolInput.description ?? toolInput.task ?? '',
|
|
216
|
+
);
|
|
217
|
+
if (isExempt(toolInput, prompt, parameters.readOnlySubagents)) return;
|
|
218
|
+
|
|
219
|
+
const highImpactPattern = buildHighImpactPattern(
|
|
220
|
+
parameters.highImpactPatterns,
|
|
221
|
+
);
|
|
222
|
+
if (!hasRealSensitiveMutation(prompt, highImpactPattern)) return;
|
|
223
|
+
|
|
224
|
+
if (UNRESOLVED_UNKNOWN_PATTERN.test(stripQuoted(prompt))) {
|
|
225
|
+
denyUnresolvedUnknown();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const trimmed = prompt.trim();
|
|
229
|
+
if (trimmed.length < MIN_REQUEST_LENGTH) {
|
|
230
|
+
denyTooShortForScopeList(trimmed);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const missing = missingSignals(prompt);
|
|
234
|
+
if (missing.length === 0) return;
|
|
235
|
+
|
|
236
|
+
denyMissingSignals(missing);
|
|
237
|
+
},
|
|
238
|
+
);
|