@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,136 @@
|
|
|
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(), 'intent-flow-'));
|
|
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, extra = {}) {
|
|
30
|
+
return { tool_name: 'Agent', tool_input: { prompt, ...extra } };
|
|
31
|
+
}
|
|
32
|
+
function isDeny(result) {
|
|
33
|
+
return result?.hookSpecificOutput?.permissionDecision === 'deny';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const ENABLED = {
|
|
37
|
+
config: { gates: { requireScopeListBeforeDelegating: true } },
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
test('denies implementing a payment change with real intent and no scope list', () => {
|
|
41
|
+
const result = runGate(
|
|
42
|
+
delegate(
|
|
43
|
+
'Implementa el cobro del pago con la nueva pasarela de dinero para el checkout.',
|
|
44
|
+
),
|
|
45
|
+
ENABLED,
|
|
46
|
+
);
|
|
47
|
+
assert.ok(isDeny(result));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('allows the same request once IN SCOPE / OUT OF SCOPE / EDGE CASES are declared', () => {
|
|
51
|
+
const prompt = [
|
|
52
|
+
'Implementa el cobro del pago con la nueva pasarela de dinero para el checkout.',
|
|
53
|
+
'',
|
|
54
|
+
'QUE SI (alcance): integrar el nuevo proveedor de pago en el checkout.',
|
|
55
|
+
'QUE NO (fuera de alcance): no tocar el flujo de reembolsos.',
|
|
56
|
+
'EDGE CASES: no aplican casos borde adicionales.',
|
|
57
|
+
].join('\n');
|
|
58
|
+
assert.equal(runGate(delegate(prompt), ENABLED), null);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('denies hard when a high-impact request declares an unresolved unknown', () => {
|
|
62
|
+
const prompt = [
|
|
63
|
+
'Implementa el cobro del pago con la nueva pasarela; todavia no esta claro que hacer con reembolsos parciales, unknown.',
|
|
64
|
+
'QUE SI: cobro basico. QUE NO: reembolsos. EDGE CASES: ninguno.',
|
|
65
|
+
].join('\n');
|
|
66
|
+
assert.ok(isDeny(runGate(delegate(prompt), ENABLED)));
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('allows a request mentioning payment only as documentation topic', () => {
|
|
70
|
+
assert.equal(
|
|
71
|
+
runGate(
|
|
72
|
+
delegate(
|
|
73
|
+
'Crea un documento README.md que explique como funciona el pago y la autenticacion del sistema para el equipo nuevo.',
|
|
74
|
+
),
|
|
75
|
+
ENABLED,
|
|
76
|
+
),
|
|
77
|
+
null,
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('allows a normal implementation request with no high-impact signal', () => {
|
|
82
|
+
assert.equal(
|
|
83
|
+
runGate(
|
|
84
|
+
delegate(
|
|
85
|
+
'Arregla el boton que no cambia de color al pasar el mouse en la pagina de inicio.',
|
|
86
|
+
),
|
|
87
|
+
ENABLED,
|
|
88
|
+
),
|
|
89
|
+
null,
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('allows a read-only subagent', () => {
|
|
94
|
+
assert.equal(
|
|
95
|
+
runGate(
|
|
96
|
+
delegate(
|
|
97
|
+
'Implementa el cobro del pago con la nueva pasarela de dinero.',
|
|
98
|
+
{
|
|
99
|
+
subagent_type: 'plan',
|
|
100
|
+
},
|
|
101
|
+
),
|
|
102
|
+
ENABLED,
|
|
103
|
+
),
|
|
104
|
+
null,
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('disabled by config: the gate does not run', () => {
|
|
109
|
+
assert.equal(
|
|
110
|
+
runGate(
|
|
111
|
+
delegate('Implementa el cobro del pago con la nueva pasarela de dinero.'),
|
|
112
|
+
{
|
|
113
|
+
config: { gates: { requireScopeListBeforeDelegating: false } },
|
|
114
|
+
},
|
|
115
|
+
),
|
|
116
|
+
null,
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('project highImpactPatterns override narrows what counts as high-impact', () => {
|
|
121
|
+
const config = {
|
|
122
|
+
gates: {
|
|
123
|
+
requireScopeListBeforeDelegating: {
|
|
124
|
+
enabled: true,
|
|
125
|
+
highImpactPatterns: ['pagin[ao]ci[oó]n'],
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
const prompt =
|
|
130
|
+
'Implementa el cobro del pago con la nueva pasarela de dinero.';
|
|
131
|
+
// With the default patterns (money/payment), this same prompt is denied.
|
|
132
|
+
assert.ok(isDeny(runGate(delegate(prompt), ENABLED)));
|
|
133
|
+
// No longer matches "dinero/pago" as high-impact once overridden, so no scope list
|
|
134
|
+
// is required and the gate allows.
|
|
135
|
+
assert.equal(runGate(delegate(prompt), { config }), null);
|
|
136
|
+
});
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// mandatory-flow — denies an implementation delegation when there is no live task
|
|
2
|
+
// pointer on disk backed by a contract file. Declaring pipeline stages in prose (what
|
|
3
|
+
// implementation-pipeline.mjs checks) is not the same as a live task actually existing
|
|
4
|
+
// on disk; this gate checks the disk fact, not the prompt's wording. Migrated from
|
|
5
|
+
// ~/.claude/hooks/guard-flujo-obligatorio.mjs.
|
|
6
|
+
//
|
|
7
|
+
// ── What a project can configure (params) ───────────────────────────────────────────
|
|
8
|
+
// activePointerPath path (relative to the project root) to the file naming the
|
|
9
|
+
// active task/pipeline slug.
|
|
10
|
+
// exemptSubagents subagent types exempt outright (read-only pipeline stages).
|
|
11
|
+
// Replaces the built-in list wholesale.
|
|
12
|
+
// The defaults live here, in the source, so a project reads them and knows exactly what
|
|
13
|
+
// its override replaces.
|
|
14
|
+
//
|
|
15
|
+
// ── Off by default, and quiet outside its narrow trigger ───────────────────────────
|
|
16
|
+
// Only STANDARD/HIGH-RISK implementation delegations, on non-exempt subagents, not
|
|
17
|
+
// about the harness itself, reach the disk check.
|
|
18
|
+
|
|
19
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { runGate, deny, TOOL_GROUPS } from '../../lib/hook-io.mjs';
|
|
22
|
+
|
|
23
|
+
const GATE_ID = 'mandatory-flow';
|
|
24
|
+
const CONFIG_KEY = 'requireLiveTaskWhenImplementing';
|
|
25
|
+
|
|
26
|
+
const DELEGATION_TOOLS = new Set(TOOL_GROUPS.delegation);
|
|
27
|
+
|
|
28
|
+
const DEFAULT_ACTIVE_POINTER_PATH = join('.ai', 'pipeline', 'ACTIVA');
|
|
29
|
+
|
|
30
|
+
const DEFAULT_EXEMPT_SUBAGENTS = [
|
|
31
|
+
'explore',
|
|
32
|
+
'plan',
|
|
33
|
+
'scout',
|
|
34
|
+
'revision',
|
|
35
|
+
'contraste',
|
|
36
|
+
'test-planner',
|
|
37
|
+
'qa',
|
|
38
|
+
'ui',
|
|
39
|
+
'ux',
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
/** Contract files that count as evidence a task's contract exists on disk. */
|
|
43
|
+
const TASK_CONTRACT_FILES = [
|
|
44
|
+
'asserts.md',
|
|
45
|
+
'task.json',
|
|
46
|
+
'spec.md',
|
|
47
|
+
'requirements.md',
|
|
48
|
+
'acceptance.md',
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
function withWordBoundary(alternation) {
|
|
52
|
+
return new RegExp(
|
|
53
|
+
`(?:^|[^\\p{L}\\p{N}_])(?:${alternation})(?:[^\\p{L}\\p{N}_]|$)`,
|
|
54
|
+
'iu',
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const IMPLEMENTATION_VERBS = withWordBoundary(
|
|
59
|
+
'implementa|implementar|implement(á|é)|agreg(a|á)|agregar|añad(e|í)|añadir|cre(a|á)|crear|' +
|
|
60
|
+
'arregl(a|á)|arreglar|cambi(a|á)|cambiar|migr(a|á)|migrar|' +
|
|
61
|
+
'corrige|corregir|correg(í|ir)|constru(ye|í)|construir|modific(a|á)|modificar|' +
|
|
62
|
+
'refactoriz(a|á)|refactorizar|elimin(a|á)|eliminar|reescrib(e|í)|reescribir|desplieg(a|á)|desplegar|' +
|
|
63
|
+
'escrib(í|e)|escribir|implement\\w*|writ(?:e|ing)|creat\\w*|fix\\w*|build\\w*|refactor\\w*|migrat\\w*|' +
|
|
64
|
+
'add\\w*|remov\\w*|delet\\w*|modify|modifies|modifying|rewrit\\w*',
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
/** Declared LEVEL near the word "level"/"classification" in Spanish or English, matching
|
|
68
|
+
* the plugin-wide convention (see risk-level.mjs). */
|
|
69
|
+
const DEMANDING_LEVEL_PATTERN =
|
|
70
|
+
/(nivel|level|clasificaci[oó]n|classification)[^\n]{0,25}?\b(STANDARD|HIGH-RISK)\b/iu;
|
|
71
|
+
const EXEMPT_LEVEL_PATTERN =
|
|
72
|
+
/(nivel|level|clasificaci[oó]n|classification)[^\n]{0,25}?\b(QUESTION|MICRO)\b/iu;
|
|
73
|
+
|
|
74
|
+
const HARNESS_PATTERN =
|
|
75
|
+
/(\.claude[\\/]|hooks[\\/]|plugins[\\/]gates|settings\.json|[\\/]agents[\\/]\w|[\\/]rules[\\/]\w|[\\/]skills[\\/]\w)/i;
|
|
76
|
+
|
|
77
|
+
function isExempt(toolInput, prompt, exemptSubagents) {
|
|
78
|
+
const subagentType = String(
|
|
79
|
+
toolInput.subagent_type ?? toolInput.subagentType ?? '',
|
|
80
|
+
).toLowerCase();
|
|
81
|
+
if (exemptSubagents.includes(subagentType)) return true;
|
|
82
|
+
if (EXEMPT_LEVEL_PATTERN.test(prompt)) return true;
|
|
83
|
+
if (!DEMANDING_LEVEL_PATTERN.test(prompt)) return true;
|
|
84
|
+
if (!IMPLEMENTATION_VERBS.test(prompt)) return true;
|
|
85
|
+
if (HARNESS_PATTERN.test(prompt)) return true;
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function hasTaskContract(taskDirectory) {
|
|
90
|
+
return TASK_CONTRACT_FILES.some((file) => {
|
|
91
|
+
try {
|
|
92
|
+
return existsSync(join(taskDirectory, file));
|
|
93
|
+
} catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The pointer file's trimmed content, or '' when it cannot be read. */
|
|
100
|
+
function readSlug(pointerPath) {
|
|
101
|
+
try {
|
|
102
|
+
return readFileSync(pointerPath, 'utf8').trim();
|
|
103
|
+
} catch {
|
|
104
|
+
return '';
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Denies for whichever of the three live-task facts is missing, or does nothing. */
|
|
109
|
+
function checkLiveTask(pointerPath) {
|
|
110
|
+
if (!existsSync(pointerPath)) {
|
|
111
|
+
deny(
|
|
112
|
+
GATE_ID,
|
|
113
|
+
`This delegation is going to implement, but there is no live task: ${pointerPath} ` +
|
|
114
|
+
'does not exist. Start a task pointing to it before delegating, or declare ' +
|
|
115
|
+
'LEVEL: QUESTION/MICRO if this is not implementation.',
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const slug = readSlug(pointerPath);
|
|
120
|
+
if (!slug) {
|
|
121
|
+
deny(
|
|
122
|
+
GATE_ID,
|
|
123
|
+
`${pointerPath} exists but is empty. An active-task pointer with no slug is not ` +
|
|
124
|
+
'a live task. Write the task slug into it before delegating.',
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const taskDirectory = join(process.cwd(), '.ai', 'pipeline', slug);
|
|
129
|
+
if (!hasTaskContract(taskDirectory)) {
|
|
130
|
+
deny(
|
|
131
|
+
GATE_ID,
|
|
132
|
+
`The active task '${slug}' has no contract on disk: none of ` +
|
|
133
|
+
`${TASK_CONTRACT_FILES.join(', ')} exists under ${taskDirectory}. ` +
|
|
134
|
+
'Write the contract before implementing.',
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
runGate(
|
|
140
|
+
{
|
|
141
|
+
id: GATE_ID,
|
|
142
|
+
configKey: CONFIG_KEY,
|
|
143
|
+
enabledByDefault: false,
|
|
144
|
+
defaultParams: {
|
|
145
|
+
activePointerPath: DEFAULT_ACTIVE_POINTER_PATH,
|
|
146
|
+
exemptSubagents: DEFAULT_EXEMPT_SUBAGENTS,
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
({ toolName, toolInput, parameters }) => {
|
|
150
|
+
if (!DELEGATION_TOOLS.has(toolName)) return;
|
|
151
|
+
|
|
152
|
+
const prompt = String(
|
|
153
|
+
toolInput.prompt ?? toolInput.Prompt ?? toolInput.task ?? '',
|
|
154
|
+
);
|
|
155
|
+
if (!prompt.trim()) return;
|
|
156
|
+
|
|
157
|
+
const exemptSubagents =
|
|
158
|
+
parameters.exemptSubagents ?? DEFAULT_EXEMPT_SUBAGENTS;
|
|
159
|
+
if (isExempt(toolInput, prompt, exemptSubagents)) return;
|
|
160
|
+
|
|
161
|
+
const activePointerPath = String(
|
|
162
|
+
parameters.activePointerPath ?? DEFAULT_ACTIVE_POINTER_PATH,
|
|
163
|
+
);
|
|
164
|
+
checkLiveTask(join(process.cwd(), activePointerPath));
|
|
165
|
+
},
|
|
166
|
+
);
|
|
@@ -0,0 +1,119 @@
|
|
|
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 makeProject() {
|
|
12
|
+
const project = mkdtempSync(join(tmpdir(), 'mandatory-flow-'));
|
|
13
|
+
mkdirSync(join(project, '.git'));
|
|
14
|
+
return project;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function writeProjectConfig(project, config) {
|
|
18
|
+
mkdirSync(join(project, '.ai'), { recursive: true });
|
|
19
|
+
writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function runGate(project, payload) {
|
|
23
|
+
const out = execFileSync(process.execPath, [GATE], {
|
|
24
|
+
input: JSON.stringify(payload),
|
|
25
|
+
encoding: 'utf8',
|
|
26
|
+
cwd: project,
|
|
27
|
+
// Isolate from the user's real global config: point homedir() at the temp
|
|
28
|
+
// project so the global-config fallback finds nothing (registry default).
|
|
29
|
+
env: { ...process.env, HOME: project, USERPROFILE: project },
|
|
30
|
+
});
|
|
31
|
+
return out.trim() ? JSON.parse(out.trim()) : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function delegate(prompt, subagentType = 'backend') {
|
|
35
|
+
return {
|
|
36
|
+
tool_name: 'Agent',
|
|
37
|
+
tool_input: { prompt, subagent_type: subagentType },
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isDeny(result) {
|
|
42
|
+
return result?.hookSpecificOutput?.permissionDecision === 'deny';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function enableGate(project) {
|
|
46
|
+
writeProjectConfig(project, {
|
|
47
|
+
gates: { requireLiveTaskWhenImplementing: true },
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
test('disabled by default: no pointer file needed', () => {
|
|
52
|
+
const project = makeProject();
|
|
53
|
+
const prompt = 'Nivel: STANDARD\nImplementá el checkout.';
|
|
54
|
+
assert.equal(runGate(project, delegate(prompt)), null);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('denies implementing when no active-task pointer exists on disk', () => {
|
|
58
|
+
const project = makeProject();
|
|
59
|
+
enableGate(project);
|
|
60
|
+
const prompt = 'Nivel: STANDARD\nImplementá el checkout.';
|
|
61
|
+
assert.ok(isDeny(runGate(project, delegate(prompt))));
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('allows implementing when the active task has a contract on disk', () => {
|
|
65
|
+
const project = makeProject();
|
|
66
|
+
enableGate(project);
|
|
67
|
+
mkdirSync(join(project, '.ai', 'pipeline', 'checkout-flow'), {
|
|
68
|
+
recursive: true,
|
|
69
|
+
});
|
|
70
|
+
writeFileSync(join(project, '.ai', 'pipeline', 'ACTIVA'), 'checkout-flow');
|
|
71
|
+
writeFileSync(
|
|
72
|
+
join(project, '.ai', 'pipeline', 'checkout-flow', 'asserts.md'),
|
|
73
|
+
'# Asserts\n\nCriterio: cobrar bien.',
|
|
74
|
+
);
|
|
75
|
+
const prompt = 'Nivel: STANDARD\nImplementá el checkout.';
|
|
76
|
+
assert.equal(runGate(project, delegate(prompt)), null);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('denies when the pointer names a task with no contract file', () => {
|
|
80
|
+
const project = makeProject();
|
|
81
|
+
enableGate(project);
|
|
82
|
+
mkdirSync(join(project, '.ai', 'pipeline', 'checkout-flow'), {
|
|
83
|
+
recursive: true,
|
|
84
|
+
});
|
|
85
|
+
writeFileSync(join(project, '.ai', 'pipeline', 'ACTIVA'), 'checkout-flow');
|
|
86
|
+
const prompt = 'Nivel: STANDARD\nImplementá el checkout.';
|
|
87
|
+
assert.ok(isDeny(runGate(project, delegate(prompt))));
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('exempt subagent type (qa) is never held to the live-task requirement', () => {
|
|
91
|
+
const project = makeProject();
|
|
92
|
+
enableGate(project);
|
|
93
|
+
const prompt = 'Nivel: STANDARD\nImplementá el checkout.';
|
|
94
|
+
assert.equal(runGate(project, delegate(prompt, 'qa')), null);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('activePointerPath param override points the gate at a custom location', () => {
|
|
98
|
+
const project = makeProject();
|
|
99
|
+
writeProjectConfig(project, {
|
|
100
|
+
gates: {
|
|
101
|
+
requireLiveTaskWhenImplementing: {
|
|
102
|
+
enabled: true,
|
|
103
|
+
activePointerPath: join('.custom', 'ACTIVE'),
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
mkdirSync(join(project, '.ai', 'pipeline', 'checkout-flow'), {
|
|
108
|
+
recursive: true,
|
|
109
|
+
});
|
|
110
|
+
writeFileSync(join(project, '.ai', 'pipeline', 'ACTIVA'), 'checkout-flow');
|
|
111
|
+
writeFileSync(
|
|
112
|
+
join(project, '.ai', 'pipeline', 'checkout-flow', 'asserts.md'),
|
|
113
|
+
'Criterio: cobrar bien.',
|
|
114
|
+
);
|
|
115
|
+
const prompt = 'Nivel: STANDARD\nImplementá el checkout.';
|
|
116
|
+
// the default pointer exists and is well-formed, but the override points elsewhere,
|
|
117
|
+
// which does not exist: the gate must deny, proving the param actually took effect.
|
|
118
|
+
assert.ok(isDeny(runGate(project, delegate(prompt))));
|
|
119
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { runGate, warn } from '../../lib/hook-io.mjs';
|
|
2
|
+
|
|
3
|
+
const GATE_ID = 'neutral-spanish';
|
|
4
|
+
const CONFIG_KEY = 'warnNonNeutralSpanish';
|
|
5
|
+
|
|
6
|
+
const MAX_REPORTED_MARKERS = 8;
|
|
7
|
+
|
|
8
|
+
// Data strings, not identifiers — never add these to a cSpell dictionary.
|
|
9
|
+
const DEFAULT_REGIONAL_MARKERS = [
|
|
10
|
+
'tenés',
|
|
11
|
+
'podés',
|
|
12
|
+
'querés',
|
|
13
|
+
'sabés',
|
|
14
|
+
'hacés',
|
|
15
|
+
'decís',
|
|
16
|
+
'venís',
|
|
17
|
+
'sos',
|
|
18
|
+
'fijate',
|
|
19
|
+
'mirá',
|
|
20
|
+
'pará',
|
|
21
|
+
'esperá',
|
|
22
|
+
'dale',
|
|
23
|
+
'mandale',
|
|
24
|
+
'contame',
|
|
25
|
+
'fíjate vos',
|
|
26
|
+
'acá',
|
|
27
|
+
'allá',
|
|
28
|
+
'laburo',
|
|
29
|
+
'laburar',
|
|
30
|
+
'quilombo',
|
|
31
|
+
'posta',
|
|
32
|
+
'che',
|
|
33
|
+
'boludo',
|
|
34
|
+
'pibe',
|
|
35
|
+
'guita',
|
|
36
|
+
'al pedo',
|
|
37
|
+
'un toque',
|
|
38
|
+
'capaz que',
|
|
39
|
+
'de una',
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
function extractText(toolName, toolInput) {
|
|
43
|
+
if (toolName === 'Write') return String(toolInput?.content ?? '');
|
|
44
|
+
if (toolName === 'Edit') return String(toolInput?.new_string ?? '');
|
|
45
|
+
if (toolName === 'MultiEdit' && Array.isArray(toolInput?.edits)) {
|
|
46
|
+
return toolInput.edits
|
|
47
|
+
.map((edit) => String(edit?.new_string ?? ''))
|
|
48
|
+
.join('\n');
|
|
49
|
+
}
|
|
50
|
+
return '';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
runGate(
|
|
54
|
+
{
|
|
55
|
+
id: GATE_ID,
|
|
56
|
+
configKey: CONFIG_KEY,
|
|
57
|
+
enabledByDefault: true,
|
|
58
|
+
defaultParams: {
|
|
59
|
+
regionalMarkers: DEFAULT_REGIONAL_MARKERS,
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
({ toolName, toolInput, parameters }) => {
|
|
63
|
+
const text = extractText(toolName, toolInput).toLowerCase();
|
|
64
|
+
if (!text) return;
|
|
65
|
+
|
|
66
|
+
const hits = [];
|
|
67
|
+
for (const marker of parameters.regionalMarkers) {
|
|
68
|
+
const escaped = marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
69
|
+
// \p{L} (any Unicode letter) as the non-boundary class, instead of a hand-written
|
|
70
|
+
// accented-character set, so accented word boundaries are handled correctly without
|
|
71
|
+
// spelling out literal accented characters in the source (the project keeps its
|
|
72
|
+
// spell-check dictionary untouched, so no word list may lean on this being a string).
|
|
73
|
+
const pattern = new RegExp(`(^|[^\\p{L}])${escaped}([^\\p{L}]|$)`, 'iu');
|
|
74
|
+
if (pattern.test(text)) hits.push(marker);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (hits.length === 0) return;
|
|
78
|
+
|
|
79
|
+
const unique = [...new Set(hits)].slice(0, MAX_REPORTED_MARKERS);
|
|
80
|
+
warn(
|
|
81
|
+
GATE_ID,
|
|
82
|
+
`Text being written contains regional Spanish markers: ${unique.join(', ')}. Prefer neutral Spanish unless this is a literal quote or data.`,
|
|
83
|
+
);
|
|
84
|
+
},
|
|
85
|
+
);
|
|
@@ -0,0 +1,65 @@
|
|
|
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(), 'neutral-spanish-'));
|
|
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 write(content) {
|
|
30
|
+
return {
|
|
31
|
+
tool_name: 'Write',
|
|
32
|
+
tool_input: { file_path: '/repo/notes.md', content },
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function isWarn(result) {
|
|
36
|
+
return result?.hookSpecificOutput?.additionalContext !== undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
test('warns on Rioplatense voseo and lexicon', () => {
|
|
40
|
+
assert.ok(isWarn(runGate(write('vos tenés que revisar esto, dale'))));
|
|
41
|
+
assert.ok(isWarn(runGate(write('che, mirá el laburo que hicimos acá'))));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('allows neutral Spanish', () => {
|
|
45
|
+
assert.equal(runGate(write('tienes que revisar esto, de acuerdo')), null);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('disabled by config: the gate does not run', () => {
|
|
49
|
+
assert.equal(
|
|
50
|
+
runGate(write('vos tenés que revisar esto'), {
|
|
51
|
+
config: { gates: { warnNonNeutralSpanish: false } },
|
|
52
|
+
}),
|
|
53
|
+
null,
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('project regionalMarkers override replaces the built-in list', () => {
|
|
58
|
+
const config = {
|
|
59
|
+
gates: {
|
|
60
|
+
warnNonNeutralSpanish: { enabled: true, regionalMarkers: ['bacano'] },
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
assert.equal(runGate(write('vos tenés que revisar esto'), { config }), null);
|
|
64
|
+
assert.ok(isWarn(runGate(write('que bacano quedo esto'), { config })));
|
|
65
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { runGate, warn, TOOL_GROUPS } from '../../lib/hook-io.mjs';
|
|
2
|
+
|
|
3
|
+
const GATE_ID = 'never-assume';
|
|
4
|
+
const CONFIG_KEY = 'requireVerificationBeforeAssuming';
|
|
5
|
+
|
|
6
|
+
const DEFAULT_CONJECTURE_PATTERNS = [
|
|
7
|
+
'i assume',
|
|
8
|
+
'assuming that',
|
|
9
|
+
'probably',
|
|
10
|
+
'i guess',
|
|
11
|
+
'should be',
|
|
12
|
+
"i'll default to",
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
function extractContent(toolName, toolInput) {
|
|
16
|
+
if (TOOL_GROUPS.delegation.includes(toolName)) return toolInput?.prompt;
|
|
17
|
+
return toolInput?.content ?? toolInput?.new_string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
runGate(
|
|
21
|
+
{
|
|
22
|
+
id: GATE_ID,
|
|
23
|
+
configKey: CONFIG_KEY,
|
|
24
|
+
enabledByDefault: false,
|
|
25
|
+
defaultParams: {
|
|
26
|
+
conjecturePatterns: DEFAULT_CONJECTURE_PATTERNS,
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
({ toolName, toolInput, parameters }) => {
|
|
30
|
+
const isWrite = TOOL_GROUPS.write.includes(toolName);
|
|
31
|
+
const isDelegation = TOOL_GROUPS.delegation.includes(toolName);
|
|
32
|
+
if (!isWrite && !isDelegation) return;
|
|
33
|
+
|
|
34
|
+
const content = extractContent(toolName, toolInput);
|
|
35
|
+
if (typeof content !== 'string') return;
|
|
36
|
+
|
|
37
|
+
const patterns = parameters.conjecturePatterns.map(
|
|
38
|
+
(source) => new RegExp(source, 'i'),
|
|
39
|
+
);
|
|
40
|
+
const hits = [];
|
|
41
|
+
for (const pattern of patterns) {
|
|
42
|
+
const match = content.match(pattern);
|
|
43
|
+
if (match) hits.push(match[0]);
|
|
44
|
+
}
|
|
45
|
+
if (hits.length === 0) return;
|
|
46
|
+
|
|
47
|
+
warn(
|
|
48
|
+
GATE_ID,
|
|
49
|
+
`Content contains conjecture phrasing without stated verification: ${hits.join(', ')}. Verify before asserting instead of assuming.`,
|
|
50
|
+
);
|
|
51
|
+
},
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
// The source guard (guard-never-assume.mjs) is explicit: this never denies,
|
|
55
|
+
// only warns — conjecture language is a prompt to verify, not a blocker.
|