@devrik-tools/claude-gates 0.6.0 → 0.7.1
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 +25 -5
- package/README.md +23 -5
- package/cli/smoke-fixtures.json +14 -0
- package/package.json +1 -1
- package/plugins/gates/.claude-plugin/plugin.json +1 -1
- package/plugins/gates/hooks/gates/atomic-commit/index.mjs +2 -2
- package/plugins/gates/hooks/gates/audit-before-build/index.mjs +2 -2
- package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +1 -1
- package/plugins/gates/hooks/gates/bash-commands/index.mjs +3 -3
- package/plugins/gates/hooks/gates/block-remote-publish/index.mjs +2 -2
- package/plugins/gates/hooks/gates/brief-approved/index.mjs +216 -0
- package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +3 -3
- package/plugins/gates/hooks/gates/capability-map/index.mjs +366 -42
- package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +1 -1
- package/plugins/gates/hooks/gates/dependency-skills/index.mjs +1 -1
- package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +1 -1
- package/plugins/gates/hooks/gates/feature-catalog/index.mjs +2 -2
- package/plugins/gates/hooks/gates/force-parallel/index.mjs +1 -1
- package/plugins/gates/hooks/gates/forge-flow/index.mjs +2 -2
- package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +1 -1
- package/plugins/gates/hooks/gates/intent-flow/index.mjs +3 -3
- package/plugins/gates/hooks/gates/lint-commit/index.mjs +2 -2
- package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +3 -3
- package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +1 -1
- package/plugins/gates/hooks/gates/never-assume/index.mjs +1 -1
- package/plugins/gates/hooks/gates/no-blocking/index.mjs +1 -1
- package/plugins/gates/hooks/gates/no-coauthor/index.mjs +1 -1
- package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +1 -1
- package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +1 -1
- package/plugins/gates/hooks/gates/no-reconfirm/index.mjs +1 -1
- package/plugins/gates/hooks/gates/protected-paths/index.mjs +2 -2
- package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +1 -1
- package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +2 -2
- package/plugins/gates/hooks/gates/risk-level/index.mjs +3 -3
- package/plugins/gates/hooks/gates/root-cause-first/index.mjs +1 -1
- package/plugins/gates/hooks/gates/root-whitelist/index.mjs +1 -1
- package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +2 -2
- package/plugins/gates/hooks/gates/sdd-specs/index.mjs +3 -3
- package/plugins/gates/hooks/gates/staged-lint/index.mjs +2 -2
- package/plugins/gates/hooks/gates/stop-pending/index.mjs +1 -2
- package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +1 -1
- package/plugins/gates/hooks/gates/test-matrix/index.mjs +1 -1
- package/plugins/gates/hooks/gates/tool-map/index.mjs +1 -1
- package/plugins/gates/hooks/hooks.json +10 -0
- package/plugins/gates/hooks/lib/hook-io.mjs +1 -1
- package/plugins/tasks/.claude-plugin/plugin.json +1 -1
- package/registry.json +22 -1
|
@@ -11,13 +11,13 @@
|
|
|
11
11
|
"name": "gates",
|
|
12
12
|
"source": "./plugins/gates",
|
|
13
13
|
"description": "PreToolUse gates: destructive-command blocks, protected paths, delegation/spec/quality rules, tool-discovery and forge-pipeline enforcement. Which gates run is decided by .ai/config.json (project) or ~/.claude/claude-gates/config.json (global).",
|
|
14
|
-
"version": "0.
|
|
14
|
+
"version": "0.7.1"
|
|
15
15
|
},
|
|
16
16
|
{
|
|
17
17
|
"name": "tasks",
|
|
18
18
|
"source": "./plugins/tasks",
|
|
19
19
|
"description": "Deterministic task tracking: the model registers tasks via the CLI, a UserPromptSubmit hook reminds of open tasks every few messages, and a SessionStart hook lists active tasks when a session opens.",
|
|
20
|
-
"version": "0.
|
|
20
|
+
"version": "0.7.1"
|
|
21
21
|
}
|
|
22
22
|
]
|
|
23
23
|
}
|
package/README.es.md
CHANGED
|
@@ -30,7 +30,7 @@ npx @devrik-tools/claude-gates init
|
|
|
30
30
|
|
|
31
31
|
Reinicia la sesión de Claude Code (o ejecuta `/plugin`) para que los hooks carguen.
|
|
32
32
|
|
|
33
|
-
> **¿Por qué dos cosas?** El plugin **siempre trae los
|
|
33
|
+
> **¿Por qué dos cosas?** El plugin **siempre trae los 41 gates**; la configuración decide
|
|
34
34
|
> **cuáles se ejecutan**. Así puedes prender uno sin reinstalar: es una línea en un JSON.
|
|
35
35
|
|
|
36
36
|
---
|
|
@@ -56,6 +56,12 @@ Si algún gate bloquea, la herramienta no se ejecuta.
|
|
|
56
56
|
- **WARN**: la acción necesita criterio → el gate inyecta un aviso y deja seguir.
|
|
57
57
|
- **Silencio**: el caso común. Un gate no molesta si no hay nada que objetar.
|
|
58
58
|
|
|
59
|
+
Todo mensaje DENY/WARN lleva el prefijo `[configKey]` — el nombre exacto de la clave para
|
|
60
|
+
buscar (o apagar/ajustar) bajo `"gates"` en `.ai/config.json`, por ejemplo
|
|
61
|
+
`[blockPathsOutsideRootWhitelist] '...' no está en la whitelist.` significa que la
|
|
62
|
+
configuración es `blockPathsOutsideRootWhitelist` en la tabla de abajo, no otro nombre que
|
|
63
|
+
haya que adivinar o traducir desde un id corto de gate.
|
|
64
|
+
|
|
59
65
|
Cada gate es **autocontenido** (solo Node built-ins, sin dependencias en tiempo de
|
|
60
66
|
ejecución), así que funciona aunque instales uno suelto por fuera.
|
|
61
67
|
|
|
@@ -88,6 +94,7 @@ ejecución), así que funciona aunque instales uno suelto por fuera.
|
|
|
88
94
|
|---|---|---|
|
|
89
95
|
| `feature-catalog` | on | Una sola feature en progreso; cerrar exige asserts y revisión. |
|
|
90
96
|
| `sdd-specs` | off | Exige requirements/design/tasks no vacíos antes de implementar. |
|
|
97
|
+
| `brief-approved` | on | Exige una aprobación del usuario registrada (`status: approved` + cita) en el brief de la feature citada antes de implementar. |
|
|
91
98
|
| `implementation-pipeline` | off | Exige declarar definición → escritura → validación → QA → cierre. |
|
|
92
99
|
| `mandatory-flow` | off | Exige una tarea activa con contrato antes de implementar. |
|
|
93
100
|
| `test-matrix` | off | Exige una matriz de tests (los tipos que el requerimiento vuelve obligatorios). |
|
|
@@ -128,7 +135,7 @@ ejecución), así que funciona aunque instales uno suelto por fuera.
|
|
|
128
135
|
| `doctor` | on | Al iniciar la sesión, corre el validador de entorno y solo habla si algo falla. |
|
|
129
136
|
| `ask-adoption` | on | En un proyecto que nunca respondió, hace que el asistente pregunte qué adoptar. |
|
|
130
137
|
| `wiring-check` | on | Avisa cuando un hook registrado falta o un script quedó huérfano. |
|
|
131
|
-
| `capability-map` |
|
|
138
|
+
| `capability-map` | on | Cada `injectEveryMessages` mensajes (default 10; siempre en la primera corrida y cuando se agrega/borra una capacidad), inyecta el catálogo de capacidades del proyecto — skills, agents/subagents, comandos — como dato compacto, y lo persiste en `.ai/capability-map.json` (como el mapa de herramientas). Autosincronizado desde el disco. Nunca bloquea. |
|
|
132
139
|
|
|
133
140
|
---
|
|
134
141
|
|
|
@@ -170,10 +177,23 @@ ves y editas cada perilla:
|
|
|
170
177
|
(saltea el chequeo de staged-lint por un commit), `[wip]` (permite un commit
|
|
171
178
|
deliberadamente amplio, no atómico). `dependency-skills` se exime vía su lista
|
|
172
179
|
`depsWithoutOwnApi`.
|
|
173
|
-
- **Inyección de capacidades:** `capability-map` (
|
|
180
|
+
- **Inyección de capacidades:** `capability-map` (on por defecto) es totalmente ajustable —
|
|
174
181
|
elegí qué tipos exponer (`"kinds": ["skills", "agents", "commands"]`), limitá cada blurb
|
|
175
|
-
(`maxClauseChars
|
|
176
|
-
(`
|
|
182
|
+
(`maxClauseChars`, default 120), agregá raíces extra por tipo, regulá cada cuánto se
|
|
183
|
+
re-inyecta el catálogo completo (`injectEveryMessages`, default 10 — el archivo persistido
|
|
184
|
+
se refresca igual en cada mensaje), o apagá la persistencia (`"persist": false`) y apuntá
|
|
185
|
+
el mapa a otro archivo (`mapFile`). Las skills también se escanean por defecto en
|
|
186
|
+
`~/.agents/skills`, `<proyecto>/.agents/skills`, `~/.ai/skills` y `<proyecto>/.ai/skills`
|
|
187
|
+
(raíces exclusivas de skills que usan otros instaladores además de `.claude/skills` — sin
|
|
188
|
+
necesidad de configurar nada), sumadas a cualquier `extraSkillsDirs` que el proyecto
|
|
189
|
+
declare. Una descripción que no entra en `maxClauseChars` cae al truncado mecánico por
|
|
190
|
+
palabra completa, pero podés escribir a mano un resumen mejor por capacidad en
|
|
191
|
+
`~/.claude/blurb-overrides.json` (global) o `<proyecto>/<blurbOverridesFile>` (default
|
|
192
|
+
`.ai/blurb-overrides.json`, el proyecto gana por clave) — un mapa
|
|
193
|
+
`{ "nombre-skill": "resumen corto" }`, usado tal cual en vez del corte mecánico. El
|
|
194
|
+
re-escaneo de una capacidad se saltea (se reusa su blurb tal cual) cuando el disco no
|
|
195
|
+
cambió desde el último escaneo (mismos archivos fuente, mismos mtimes); borrar una
|
|
196
|
+
skill/agente/comando saca su entrada del mapa en la corrida siguiente.
|
|
177
197
|
|
|
178
198
|
---
|
|
179
199
|
|
package/README.md
CHANGED
|
@@ -29,7 +29,7 @@ npx @devrik-tools/claude-gates init
|
|
|
29
29
|
|
|
30
30
|
Restart the Claude Code session (or run `/plugin`) so the hooks load.
|
|
31
31
|
|
|
32
|
-
> **Why two things?** The plugin **always ships all
|
|
32
|
+
> **Why two things?** The plugin **always ships all 41 gates**; the config decides **which
|
|
33
33
|
> ones run**. So you can turn one on without reinstalling — it is one line in a JSON file.
|
|
34
34
|
|
|
35
35
|
---
|
|
@@ -55,6 +55,11 @@ If any gate blocks, the tool does not run.
|
|
|
55
55
|
- **WARN**: the action needs judgment → the gate injects a note and lets it proceed.
|
|
56
56
|
- **Silence**: the common path. A gate never nags when there is nothing to object to.
|
|
57
57
|
|
|
58
|
+
Every DENY/WARN message is prefixed `[configKey]` — the exact key name to look up (or turn
|
|
59
|
+
off/tune) under `"gates"` in `.ai/config.json`, e.g. `[blockPathsOutsideRootWhitelist] '...'
|
|
60
|
+
is not on the whitelist.` means the setting is `blockPathsOutsideRootWhitelist` in the table
|
|
61
|
+
below, not some other name you'd have to guess or map from a short gate id.
|
|
62
|
+
|
|
58
63
|
Every gate is **self-contained** (Node built-ins only, no runtime dependencies), so it
|
|
59
64
|
works even if you install one on its own.
|
|
60
65
|
|
|
@@ -90,6 +95,7 @@ works even if you install one on its own.
|
|
|
90
95
|
| ------------------------- | --- | -------------------------------------------------------------------- |
|
|
91
96
|
| `feature-catalog` | on | A single feature in progress; closing requires asserts and review. |
|
|
92
97
|
| `sdd-specs` | off | Requires non-empty requirements/design/tasks before implementing. |
|
|
98
|
+
| `brief-approved` | on | Requires a recorded user approval (`status: approved` + quote) on the cited feature's brief before implementing. |
|
|
93
99
|
| `implementation-pipeline` | off | Requires declaring definition → writing → validation → QA → closure. |
|
|
94
100
|
| `mandatory-flow` | off | Requires an active task with a contract before implementing. |
|
|
95
101
|
| `test-matrix` | off | Requires a test matrix (the types the requirement makes mandatory). |
|
|
@@ -134,7 +140,7 @@ works even if you install one on its own.
|
|
|
134
140
|
| `doctor` | on | On session start, runs the environment validator and only speaks on failure. |
|
|
135
141
|
| `ask-adoption` | on | In a project that never answered, makes the assistant ask what to adopt. |
|
|
136
142
|
| `wiring-check` | on | Warns when a registered hook is missing or a script is orphaned. |
|
|
137
|
-
| `capability-map` |
|
|
143
|
+
| `capability-map` | on | Every `injectEveryMessages` messages (default 10; always on the first run and whenever a capability is added/removed), injects the project's capability catalog — skills, agents/subagents, commands — as compact data, and persists it to `.ai/capability-map.json` (like the tool map). Autosynced from disk. Never blocks. |
|
|
138
144
|
|
|
139
145
|
---
|
|
140
146
|
|
|
@@ -183,10 +189,22 @@ see and edit every knob:
|
|
|
183
189
|
`lint-ok: <reason>` (a documented linter false positive), `[skip-lint]` (skip the
|
|
184
190
|
staged-lint check for one commit), `[wip]` (allow one deliberately broad,
|
|
185
191
|
non-atomic commit). `dependency-skills` opts out via its `depsWithoutOwnApi` list.
|
|
186
|
-
- **Capability injection:** `capability-map` (
|
|
192
|
+
- **Capability injection:** `capability-map` (on by default) is fully tunable — pick which
|
|
187
193
|
kinds to surface (`"kinds": ["skills", "agents", "commands"]`), cap each blurb
|
|
188
|
-
(`maxClauseChars
|
|
189
|
-
|
|
194
|
+
(`maxClauseChars`, default 120), add extra roots per kind, throttle how often the full
|
|
195
|
+
catalog is re-injected (`injectEveryMessages`, default 10 — the persisted map file itself
|
|
196
|
+
still refreshes every message), or turn off persistence (`"persist": false`) and point the
|
|
197
|
+
map file elsewhere (`mapFile`). Skills are also scanned by default under `~/.agents/skills`,
|
|
198
|
+
`<project>/.agents/skills`, `~/.ai/skills` and `<project>/.ai/skills` (skill-only roots some
|
|
199
|
+
installers use besides `.claude/skills` — no config needed), in addition to any
|
|
200
|
+
`extraSkillsDirs` the project declares. A description too long to fit `maxClauseChars`
|
|
201
|
+
falls back to mechanical word-boundary truncation, but you can hand-write a better one-line
|
|
202
|
+
summary per capability in `~/.claude/blurb-overrides.json` (global) or
|
|
203
|
+
`<project>/<blurbOverridesFile>` (default `.ai/blurb-overrides.json`, project wins per
|
|
204
|
+
key) — a `{ "skill-name": "short summary" }` map, used verbatim instead of the mechanical
|
|
205
|
+
cut. A capability's re-scan is skipped (its blurb reused verbatim) whenever disk is
|
|
206
|
+
unchanged since the last scan (same set of source files, same mtimes); removing a
|
|
207
|
+
skill/agent/command drops its entry from the map on the very next run.
|
|
190
208
|
|
|
191
209
|
---
|
|
192
210
|
|
package/cli/smoke-fixtures.json
CHANGED
|
@@ -157,6 +157,20 @@
|
|
|
157
157
|
"needsState": true,
|
|
158
158
|
"note": "auto-off unless a features tree exists; needs seeded .ai/feature_list.json + features dir."
|
|
159
159
|
},
|
|
160
|
+
{
|
|
161
|
+
"id": "brief-approved",
|
|
162
|
+
"configKey": "requireApprovedBriefBeforeImplementing",
|
|
163
|
+
"enabledByDefault": false,
|
|
164
|
+
"type": "deny",
|
|
165
|
+
"payload": {
|
|
166
|
+
"tool_name": "Agent",
|
|
167
|
+
"tool_input": {
|
|
168
|
+
"prompt": "Nivel: STANDARD\nImplementa .ai/features/checkout/brief.md."
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
"needsState": true,
|
|
172
|
+
"note": "auto-off unless a features tree exists; needs a seeded .ai/features/<name>/brief.md with no approved frontmatter."
|
|
173
|
+
},
|
|
160
174
|
{
|
|
161
175
|
"id": "implementation-pipeline",
|
|
162
176
|
"configKey": "requireImplementationPipeline",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@devrik-tools/claude-gates",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "Installable, deterministic gates (hooks) for Claude Code: block destructive commands, protected paths, and enforce delegation/spec/quality rules. Configurable per project.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gates",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "Deterministic gates for Claude Code: destructive-command blocks, protected paths, delegation briefs, spec-driven flow and session-start validation. Selection lives in config, not in code.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Devrik"
|
|
@@ -208,7 +208,7 @@ runGate(
|
|
|
208
208
|
|
|
209
209
|
if (countedNatures.size > maxNatures) {
|
|
210
210
|
deny(
|
|
211
|
-
|
|
211
|
+
CONFIG_KEY,
|
|
212
212
|
`This commit mixes ${countedNatures.size} kinds of change ` +
|
|
213
213
|
`(${[...countedNatures].join(', ')}) — a commit should be one cohesive change ` +
|
|
214
214
|
`(max ${maxNatures}). Split it: stage and commit one nature at a time (e.g. the ` +
|
|
@@ -219,7 +219,7 @@ runGate(
|
|
|
219
219
|
|
|
220
220
|
if (counted.length > maxFiles) {
|
|
221
221
|
deny(
|
|
222
|
-
|
|
222
|
+
CONFIG_KEY,
|
|
223
223
|
`This commit stages ${counted.length} reviewable files (max ${maxFiles}) — too large ` +
|
|
224
224
|
`to review as one unit. Split it into smaller, cohesive commits (docs/assets/` +
|
|
225
225
|
`generated files are not counted). Add "${escapeHatch}" for one deliberately broad commit.`,
|
|
@@ -53,7 +53,7 @@ function checkDelegation(toolInput) {
|
|
|
53
53
|
if (AUDIT_EVIDENCE_PATTERN.test(prompt)) return;
|
|
54
54
|
|
|
55
55
|
deny(
|
|
56
|
-
|
|
56
|
+
CONFIG_KEY,
|
|
57
57
|
'Delegating creation of a new script/checker/gate/hook/linter/tool without evidence of a prior audit. State what you searched and why no existing tool covers this (e.g. "audited and no existing tool...").',
|
|
58
58
|
);
|
|
59
59
|
}
|
|
@@ -84,7 +84,7 @@ function checkWrite(toolInput, parameters) {
|
|
|
84
84
|
if (INLINE_JUSTIFICATION_PATTERN.test(content)) return;
|
|
85
85
|
|
|
86
86
|
deny(
|
|
87
|
-
|
|
87
|
+
CONFIG_KEY,
|
|
88
88
|
`Creating a new executable tool at ${filePath} without a justification comment (e.g. "justification: ..."). Document why no existing tool covers this before building a new one.`,
|
|
89
89
|
);
|
|
90
90
|
}
|
|
@@ -173,7 +173,7 @@ function checkDestructive(command, parameters) {
|
|
|
173
173
|
: [entry, 'Destructive command is not allowed.'],
|
|
174
174
|
);
|
|
175
175
|
for (const [source, reason] of denyPairs) {
|
|
176
|
-
if (compile(source).test(normalized)) deny(
|
|
176
|
+
if (compile(source).test(normalized)) deny(CONFIG_KEY, reason);
|
|
177
177
|
}
|
|
178
178
|
|
|
179
179
|
// rm -rf over a protected area: areas read from config at runtime, so editing
|
|
@@ -184,12 +184,12 @@ function checkDestructive(command, parameters) {
|
|
|
184
184
|
rmRfAreas.length > 0 &&
|
|
185
185
|
compile(rmRfSourceFrom(rmRfAreas)).test(command)
|
|
186
186
|
) {
|
|
187
|
-
deny(
|
|
187
|
+
deny(CONFIG_KEY, "'rm -rf' over a protected area is not allowed.");
|
|
188
188
|
}
|
|
189
189
|
|
|
190
190
|
if (parameters.embeddedInterpreterEnabled) {
|
|
191
191
|
const reason = checkEmbeddedInterpreter(command);
|
|
192
|
-
if (reason) deny(
|
|
192
|
+
if (reason) deny(CONFIG_KEY, reason);
|
|
193
193
|
}
|
|
194
194
|
}
|
|
195
195
|
|
|
@@ -131,9 +131,9 @@ runGate(
|
|
|
131
131
|
for (const [source, reason] of parameters.publishRules) {
|
|
132
132
|
const pattern = compile(source);
|
|
133
133
|
if (isShell) {
|
|
134
|
-
if (pattern.test(shellCommand)) deny(
|
|
134
|
+
if (pattern.test(shellCommand)) deny(CONFIG_KEY, reason);
|
|
135
135
|
} else if (hasRealPublishIntent(command, pattern)) {
|
|
136
|
-
deny(
|
|
136
|
+
deny(CONFIG_KEY, reason);
|
|
137
137
|
}
|
|
138
138
|
}
|
|
139
139
|
},
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// brief-approved — denies an implementation delegation (STANDARD/HIGH-RISK, an
|
|
2
|
+
// implementation verb) that targets a feature whose brief/spec has no recorded human
|
|
3
|
+
// approval. Migrated from a real incident: the assistant took an ambiguous user reply
|
|
4
|
+
// ("hagamos el brief") as approval of a spec it had already written, delegated
|
|
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.
|
|
9
|
+
//
|
|
10
|
+
// ── What "approved" means here (deliberately narrow) ────────────────────────────────
|
|
11
|
+
// A hook cannot read the chat, so it cannot verify the assistant actually pasted the
|
|
12
|
+
// full brief and that the user actually read it. What IS checkable: the brief file
|
|
13
|
+
// itself carries a frontmatter block written ONLY once that confirmation happened —
|
|
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.
|
|
33
|
+
|
|
34
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
35
|
+
import { dirname, join } from 'node:path';
|
|
36
|
+
import {
|
|
37
|
+
runGate,
|
|
38
|
+
deny,
|
|
39
|
+
toolInGroups,
|
|
40
|
+
delegationPromptOf,
|
|
41
|
+
} from '../../lib/hook-io.mjs';
|
|
42
|
+
|
|
43
|
+
const GATE_ID = 'brief-approved';
|
|
44
|
+
const CONFIG_KEY = 'requireApprovedBriefBeforeImplementing';
|
|
45
|
+
|
|
46
|
+
const CATALOG_FILE_NAME = 'feature_list.json';
|
|
47
|
+
const DEFAULT_CATALOG_LOCATIONS = [
|
|
48
|
+
join('.ai', CATALOG_FILE_NAME),
|
|
49
|
+
CATALOG_FILE_NAME,
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
const DEFAULT_EXEMPT_SUBAGENTS = [
|
|
53
|
+
'explore',
|
|
54
|
+
'plan',
|
|
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;
|
|
77
|
+
|
|
78
|
+
function withWordBoundary(alternation) {
|
|
79
|
+
return new RegExp(
|
|
80
|
+
`(?<![\\p{L}\\p{N}_])(${alternation})(?![\\p{L}\\p{N}_])`,
|
|
81
|
+
'iu',
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const IMPLEMENTATION_VERBS = withWordBoundary(
|
|
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) {
|
|
100
|
+
for (const relative of catalogLocations) {
|
|
101
|
+
const path = join(process.cwd(), relative);
|
|
102
|
+
if (existsSync(path)) return path;
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function contractTreeRootFor(catalogPath) {
|
|
108
|
+
const candidates = catalogPath
|
|
109
|
+
? [join(dirname(catalogPath), 'features')]
|
|
110
|
+
: [];
|
|
111
|
+
candidates.push(join(process.cwd(), '.ai', 'features'));
|
|
112
|
+
candidates.push(join(process.cwd(), 'features'));
|
|
113
|
+
for (const candidate of candidates) {
|
|
114
|
+
if (existsSync(candidate)) return candidate;
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The first existing contract file's path for a feature, or null if none exists. */
|
|
120
|
+
function contractFileFor(treeRoot, featureName) {
|
|
121
|
+
if (!treeRoot) return null;
|
|
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;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Whether a contract file's frontmatter records a real approval: `status: approved`
|
|
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) {
|
|
135
|
+
let content;
|
|
136
|
+
try {
|
|
137
|
+
content = readFileSync(contractPath, 'utf8');
|
|
138
|
+
} catch {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
const frontmatter = APPROVED_FRONTMATTER_PATTERN.exec(content)?.groups?.body;
|
|
142
|
+
if (!frontmatter) return false;
|
|
143
|
+
if (!APPROVED_STATUS_PATTERN.test(frontmatter)) return false;
|
|
144
|
+
const quoteMatch = APPROVAL_QUOTE_PATTERN.exec(frontmatter);
|
|
145
|
+
const quote = quoteMatch?.[1]?.replace(/^["']|["']$/g, '').trim();
|
|
146
|
+
return Boolean(quote);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function isExemptDelegation(toolInput, prompt, exemptSubagents) {
|
|
150
|
+
const subagentType = String(
|
|
151
|
+
toolInput.subagent_type ?? toolInput.subagentType ?? '',
|
|
152
|
+
).toLowerCase();
|
|
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;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
runGate(
|
|
169
|
+
{
|
|
170
|
+
id: GATE_ID,
|
|
171
|
+
configKey: CONFIG_KEY,
|
|
172
|
+
enabledByDefault: true,
|
|
173
|
+
defaultParams: {
|
|
174
|
+
catalogLocations: DEFAULT_CATALOG_LOCATIONS,
|
|
175
|
+
exemptSubagents: DEFAULT_EXEMPT_SUBAGENTS,
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
({ toolName, toolInput, parameters }) => {
|
|
179
|
+
if (!toolInGroups(toolName, ['delegation'])) return;
|
|
180
|
+
|
|
181
|
+
const prompt = delegationPromptOf(toolInput);
|
|
182
|
+
if (!prompt.trim()) return;
|
|
183
|
+
|
|
184
|
+
const exemptSubagents =
|
|
185
|
+
parameters.exemptSubagents ?? DEFAULT_EXEMPT_SUBAGENTS;
|
|
186
|
+
if (isExemptDelegation(toolInput, prompt, exemptSubagents)) return;
|
|
187
|
+
|
|
188
|
+
const citedFeatures = featureNamesCitedIn(prompt);
|
|
189
|
+
if (citedFeatures.length === 0) return; // no citation: nothing this gate can check
|
|
190
|
+
|
|
191
|
+
const catalogLocations =
|
|
192
|
+
parameters.catalogLocations ?? DEFAULT_CATALOG_LOCATIONS;
|
|
193
|
+
const catalogPath = findCatalog(catalogLocations);
|
|
194
|
+
const treeRoot = contractTreeRootFor(catalogPath);
|
|
195
|
+
if (!treeRoot) return; // no SDD harness adopted: stay silent
|
|
196
|
+
|
|
197
|
+
const unapproved = citedFeatures.filter((feature) => {
|
|
198
|
+
const contractPath = contractFileFor(treeRoot, feature);
|
|
199
|
+
if (!contractPath) return false; // sdd-specs already denies this case
|
|
200
|
+
return !isApproved(contractPath);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
if (unapproved.length === 0) return;
|
|
204
|
+
|
|
205
|
+
deny(
|
|
206
|
+
CONFIG_KEY,
|
|
207
|
+
`This implementation delegation cites feature(s) [${unapproved.join(', ')}] ` +
|
|
208
|
+
'whose brief/contract has no recorded approval. Paste the FULL brief into the ' +
|
|
209
|
+
'chat, get an explicit confirmation from the user (not a vague "dale"/"sigamos" ' +
|
|
210
|
+
'— an actual sentence confirming they read it), then add this frontmatter to ' +
|
|
211
|
+
'the top of the contract file before relaunching:\n' +
|
|
212
|
+
'---\nstatus: approved\napproved_at: <ISO timestamp>\n' +
|
|
213
|
+
'approval_quote: "<the user\'s own confirming words>"\n---',
|
|
214
|
+
);
|
|
215
|
+
},
|
|
216
|
+
);
|
|
@@ -209,7 +209,7 @@ function denyTooShort(prompt) {
|
|
|
209
209
|
const excerpt = prompt.slice(0, PROMPT_EXCERPT_LENGTH);
|
|
210
210
|
const ellipsis = prompt.length > PROMPT_EXCERPT_LENGTH ? '…' : '';
|
|
211
211
|
deny(
|
|
212
|
-
|
|
212
|
+
CONFIG_KEY,
|
|
213
213
|
`This delegation asks for implementation ("${excerpt}${ellipsis}") in a ${prompt.length}-character ` +
|
|
214
214
|
'prompt — too short to carry a goal, steps and a done-when criterion. State what this aims to ' +
|
|
215
215
|
'achieve, what concretely needs doing (as a list or steps), and how completion is verified, then ' +
|
|
@@ -222,7 +222,7 @@ function reportMissingSignals(missing) {
|
|
|
222
222
|
|
|
223
223
|
if (missing.length < TOTAL_REQUIRED_SIGNALS) {
|
|
224
224
|
warn(
|
|
225
|
-
|
|
225
|
+
CONFIG_KEY,
|
|
226
226
|
`This implementation delegation does not recognizably state: ${missing.join('; ')}. ` +
|
|
227
227
|
'If it is already there under different wording, proceed — this is only a warning. ' +
|
|
228
228
|
'Otherwise add it before the subagent starts blind.',
|
|
@@ -231,7 +231,7 @@ function reportMissingSignals(missing) {
|
|
|
231
231
|
}
|
|
232
232
|
|
|
233
233
|
deny(
|
|
234
|
-
|
|
234
|
+
CONFIG_KEY,
|
|
235
235
|
'This delegation asks for implementation but states neither as a list nor recognizable prose: ' +
|
|
236
236
|
`${missing.join('; ')}. Add to the prompt: (1) the GOAL — what this aims to achieve; ` +
|
|
237
237
|
'(2) the STEPS — concrete files/actions, as a list; (3) the CRITERION — how completion is ' +
|