@devrik-tools/claude-gates 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/README.es.md +19 -5
- package/README.md +18 -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/brief-approved/index.mjs +216 -0
- package/plugins/gates/hooks/gates/capability-map/index.mjs +366 -42
- package/plugins/gates/hooks/hooks.json +10 -0
- 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.0"
|
|
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.0"
|
|
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
|
---
|
|
@@ -88,6 +88,7 @@ ejecución), así que funciona aunque instales uno suelto por fuera.
|
|
|
88
88
|
|---|---|---|
|
|
89
89
|
| `feature-catalog` | on | Una sola feature en progreso; cerrar exige asserts y revisión. |
|
|
90
90
|
| `sdd-specs` | off | Exige requirements/design/tasks no vacíos antes de implementar. |
|
|
91
|
+
| `brief-approved` | on | Exige una aprobación del usuario registrada (`status: approved` + cita) en el brief de la feature citada antes de implementar. |
|
|
91
92
|
| `implementation-pipeline` | off | Exige declarar definición → escritura → validación → QA → cierre. |
|
|
92
93
|
| `mandatory-flow` | off | Exige una tarea activa con contrato antes de implementar. |
|
|
93
94
|
| `test-matrix` | off | Exige una matriz de tests (los tipos que el requerimiento vuelve obligatorios). |
|
|
@@ -128,7 +129,7 @@ ejecución), así que funciona aunque instales uno suelto por fuera.
|
|
|
128
129
|
| `doctor` | on | Al iniciar la sesión, corre el validador de entorno y solo habla si algo falla. |
|
|
129
130
|
| `ask-adoption` | on | En un proyecto que nunca respondió, hace que el asistente pregunte qué adoptar. |
|
|
130
131
|
| `wiring-check` | on | Avisa cuando un hook registrado falta o un script quedó huérfano. |
|
|
131
|
-
| `capability-map` |
|
|
132
|
+
| `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
133
|
|
|
133
134
|
---
|
|
134
135
|
|
|
@@ -170,10 +171,23 @@ ves y editas cada perilla:
|
|
|
170
171
|
(saltea el chequeo de staged-lint por un commit), `[wip]` (permite un commit
|
|
171
172
|
deliberadamente amplio, no atómico). `dependency-skills` se exime vía su lista
|
|
172
173
|
`depsWithoutOwnApi`.
|
|
173
|
-
- **Inyección de capacidades:** `capability-map` (
|
|
174
|
+
- **Inyección de capacidades:** `capability-map` (on por defecto) es totalmente ajustable —
|
|
174
175
|
elegí qué tipos exponer (`"kinds": ["skills", "agents", "commands"]`), limitá cada blurb
|
|
175
|
-
(`maxClauseChars
|
|
176
|
-
(`
|
|
176
|
+
(`maxClauseChars`, default 120), agregá raíces extra por tipo, regulá cada cuánto se
|
|
177
|
+
re-inyecta el catálogo completo (`injectEveryMessages`, default 10 — el archivo persistido
|
|
178
|
+
se refresca igual en cada mensaje), o apagá la persistencia (`"persist": false`) y apuntá
|
|
179
|
+
el mapa a otro archivo (`mapFile`). Las skills también se escanean por defecto en
|
|
180
|
+
`~/.agents/skills`, `<proyecto>/.agents/skills`, `~/.ai/skills` y `<proyecto>/.ai/skills`
|
|
181
|
+
(raíces exclusivas de skills que usan otros instaladores además de `.claude/skills` — sin
|
|
182
|
+
necesidad de configurar nada), sumadas a cualquier `extraSkillsDirs` que el proyecto
|
|
183
|
+
declare. Una descripción que no entra en `maxClauseChars` cae al truncado mecánico por
|
|
184
|
+
palabra completa, pero podés escribir a mano un resumen mejor por capacidad en
|
|
185
|
+
`~/.claude/blurb-overrides.json` (global) o `<proyecto>/<blurbOverridesFile>` (default
|
|
186
|
+
`.ai/blurb-overrides.json`, el proyecto gana por clave) — un mapa
|
|
187
|
+
`{ "nombre-skill": "resumen corto" }`, usado tal cual en vez del corte mecánico. El
|
|
188
|
+
re-escaneo de una capacidad se saltea (se reusa su blurb tal cual) cuando el disco no
|
|
189
|
+
cambió desde el último escaneo (mismos archivos fuente, mismos mtimes); borrar una
|
|
190
|
+
skill/agente/comando saca su entrada del mapa en la corrida siguiente.
|
|
177
191
|
|
|
178
192
|
---
|
|
179
193
|
|
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
|
---
|
|
@@ -90,6 +90,7 @@ works even if you install one on its own.
|
|
|
90
90
|
| ------------------------- | --- | -------------------------------------------------------------------- |
|
|
91
91
|
| `feature-catalog` | on | A single feature in progress; closing requires asserts and review. |
|
|
92
92
|
| `sdd-specs` | off | Requires non-empty requirements/design/tasks before implementing. |
|
|
93
|
+
| `brief-approved` | on | Requires a recorded user approval (`status: approved` + quote) on the cited feature's brief before implementing. |
|
|
93
94
|
| `implementation-pipeline` | off | Requires declaring definition → writing → validation → QA → closure. |
|
|
94
95
|
| `mandatory-flow` | off | Requires an active task with a contract before implementing. |
|
|
95
96
|
| `test-matrix` | off | Requires a test matrix (the types the requirement makes mandatory). |
|
|
@@ -134,7 +135,7 @@ works even if you install one on its own.
|
|
|
134
135
|
| `doctor` | on | On session start, runs the environment validator and only speaks on failure. |
|
|
135
136
|
| `ask-adoption` | on | In a project that never answered, makes the assistant ask what to adopt. |
|
|
136
137
|
| `wiring-check` | on | Warns when a registered hook is missing or a script is orphaned. |
|
|
137
|
-
| `capability-map` |
|
|
138
|
+
| `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
139
|
|
|
139
140
|
---
|
|
140
141
|
|
|
@@ -183,10 +184,22 @@ see and edit every knob:
|
|
|
183
184
|
`lint-ok: <reason>` (a documented linter false positive), `[skip-lint]` (skip the
|
|
184
185
|
staged-lint check for one commit), `[wip]` (allow one deliberately broad,
|
|
185
186
|
non-atomic commit). `dependency-skills` opts out via its `depsWithoutOwnApi` list.
|
|
186
|
-
- **Capability injection:** `capability-map` (
|
|
187
|
+
- **Capability injection:** `capability-map` (on by default) is fully tunable — pick which
|
|
187
188
|
kinds to surface (`"kinds": ["skills", "agents", "commands"]`), cap each blurb
|
|
188
|
-
(`maxClauseChars
|
|
189
|
-
|
|
189
|
+
(`maxClauseChars`, default 120), add extra roots per kind, throttle how often the full
|
|
190
|
+
catalog is re-injected (`injectEveryMessages`, default 10 — the persisted map file itself
|
|
191
|
+
still refreshes every message), or turn off persistence (`"persist": false`) and point the
|
|
192
|
+
map file elsewhere (`mapFile`). Skills are also scanned by default under `~/.agents/skills`,
|
|
193
|
+
`<project>/.agents/skills`, `~/.ai/skills` and `<project>/.ai/skills` (skill-only roots some
|
|
194
|
+
installers use besides `.claude/skills` — no config needed), in addition to any
|
|
195
|
+
`extraSkillsDirs` the project declares. A description too long to fit `maxClauseChars`
|
|
196
|
+
falls back to mechanical word-boundary truncation, but you can hand-write a better one-line
|
|
197
|
+
summary per capability in `~/.claude/blurb-overrides.json` (global) or
|
|
198
|
+
`<project>/<blurbOverridesFile>` (default `.ai/blurb-overrides.json`, project wins per
|
|
199
|
+
key) — a `{ "skill-name": "short summary" }` map, used verbatim instead of the mechanical
|
|
200
|
+
cut. A capability's re-scan is skipped (its blurb reused verbatim) whenever disk is
|
|
201
|
+
unchanged since the last scan (same set of source files, same mtimes); removing a
|
|
202
|
+
skill/agent/command drops its entry from the map on the very next run.
|
|
190
203
|
|
|
191
204
|
---
|
|
192
205
|
|
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.0",
|
|
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.0",
|
|
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"
|
|
@@ -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
|
+
GATE_ID,
|
|
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
|
+
);
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
// capability-map — UserPromptSubmit hook. Surfaces the REAL, current catalog of the project's
|
|
2
2
|
// AI capabilities — skills, agents/subagents, and commands — as compact data ("caveman"
|
|
3
|
-
// format: `name — first clause`, one line each, grouped by kind)
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
3
|
+
// format: `name — first clause`, one line each, grouped by kind). It also PERSISTS that
|
|
4
|
+
// catalog to .ai/capability-map.json so the map is easy to follow and read the same way the
|
|
5
|
+
// tool map (.ai/tool-map.json) is, and so another tool or a human can consult it without
|
|
6
|
+
// re-scanning. Autosynced by construction: add or remove a skill/agent/command file and the
|
|
7
|
+
// next message reflects it (a removed capability's entry disappears with it — the catalog is
|
|
8
|
+
// derived from what is on disk NOW, never a stale copy of what used to be there), because the
|
|
9
|
+
// catalog IS the directory listing. Re-deriving blurbs (the only non-trivial per-entry work)
|
|
10
|
+
// is skipped when disk is provably unchanged since the last scan — see fingerprintOf below.
|
|
9
11
|
//
|
|
10
12
|
// It does NOT tell the model to obey a reminder — that would be prose the model may ignore,
|
|
11
13
|
// the exact antipattern guard-no-model-reliance forbids. It injects a fact (which capabilities
|
|
@@ -29,15 +31,46 @@
|
|
|
29
31
|
// Each injected line is `name — first clause` (up to the first '. ' or a hard char cap), so
|
|
30
32
|
// the whole catalog stays cheap even at dozens of entries.
|
|
31
33
|
//
|
|
34
|
+
// ── Blurb overrides for descriptions that don't fit ─────────────────────────────────────
|
|
35
|
+
// When a description's first clause is longer than `maxClauseChars`, mechanical truncation
|
|
36
|
+
// (even at a word boundary) loses the point of a dense one-liner (e.g. "Referencia normativa
|
|
37
|
+
// completa de accesibilidad web (WCAG 2.2, ARIA, teclado, lectores de…" tells you nothing).
|
|
38
|
+
// Writing an actual summary needs judgment a Node hook does not have — so instead this gate
|
|
39
|
+
// reads a human/assistant-authored override by capability name from `~/.claude/blurb-
|
|
40
|
+
// overrides.json` and `<project>/<blurbOverridesFile>` (project wins per-key) and uses it
|
|
41
|
+
// verbatim (still capped at maxClauseChars, in case an override itself runs long). A
|
|
42
|
+
// capability with no override falls back to the mechanical truncation — never blocked on
|
|
43
|
+
// someone writing every override up front.
|
|
44
|
+
//
|
|
45
|
+
// ── Throttled injection, not every message ──────────────────────────────────────────────
|
|
46
|
+
// Injecting the full catalog on every UserPromptSubmit burns context on every turn for a
|
|
47
|
+
// catalog that rarely changes turn-to-turn. `injectEveryMessages` (default 10, same shape as
|
|
48
|
+
// tasks' remindEveryMessages) counts messages via a small persisted counter next to the map
|
|
49
|
+
// file and only injects on the Nth. The persisted .ai/capability-map.json is still refreshed
|
|
50
|
+
// from disk on EVERY message regardless of the counter — persistence is cheap (a file write,
|
|
51
|
+
// not context) and staying accurate matters even between injections.
|
|
52
|
+
//
|
|
53
|
+
// ── Skills also scanned outside the .claude layout, unconditionally ────────────────────
|
|
54
|
+
// `~/.agents/skills`, `<project>/.agents/skills`, `~/.ai/skills`, `<project>/.ai/skills` are
|
|
55
|
+
// scanned as skill roots by default (no config needed) alongside `.claude/skills` — these
|
|
56
|
+
// are skill directories other installers are known to use (`.agents/skills` is what the
|
|
57
|
+
// `skills` CLI several installers shell out to writes when run without its `-g` flag: a
|
|
58
|
+
// project-local skill tree, not `~/.claude`). Agents/commands are NOT looked for under these
|
|
59
|
+
// roots — only `.claude` is known to lay those out as siblings of `skills`.
|
|
60
|
+
//
|
|
32
61
|
// ── What a project can configure (params) — everything is customizable ───────────────
|
|
33
|
-
// kinds
|
|
34
|
-
//
|
|
35
|
-
// maxClauseChars
|
|
62
|
+
// kinds which capability kinds to include, e.g. ["skills","agents","commands"].
|
|
63
|
+
// Drop one to stop scanning it entirely.
|
|
64
|
+
// maxClauseChars hard cap on each entry's one-line blurb (default 120).
|
|
36
65
|
// extraSkillsDirs / extraAgentsDirs / extraCommandsDirs additional roots per kind
|
|
37
|
-
//
|
|
38
|
-
// persist
|
|
39
|
-
// mapFile
|
|
40
|
-
//
|
|
66
|
+
// (relative to project or absolute), added on top of the built-in ones.
|
|
67
|
+
// persist whether to write .ai/capability-map.json (default true).
|
|
68
|
+
// mapFile path to the persisted map, relative to project root. Default
|
|
69
|
+
// .ai/capability-map.json.
|
|
70
|
+
// injectEveryMessages inject the rendered catalog only every Nth message (default 10);
|
|
71
|
+
// the persisted map file still refreshes every message regardless.
|
|
72
|
+
// blurbOverridesFile path to the overrides JSON, relative to project root. Default
|
|
73
|
+
// .ai/blurb-overrides.json.
|
|
41
74
|
//
|
|
42
75
|
// ── Fail-safe shape ──────────────────────────────────────────────────────────────────
|
|
43
76
|
// Nothing found anywhere, gate disabled, or unreadable input: inject nothing (silent, no
|
|
@@ -45,11 +78,13 @@
|
|
|
45
78
|
// swallowed: the injection is the job, the file is a convenience. Never blocks —
|
|
46
79
|
// UserPromptSubmit cannot deny; it only adds context.
|
|
47
80
|
|
|
81
|
+
import { createHash } from 'node:crypto';
|
|
48
82
|
import {
|
|
49
83
|
existsSync,
|
|
50
84
|
mkdirSync,
|
|
51
85
|
readFileSync,
|
|
52
86
|
readdirSync,
|
|
87
|
+
statSync,
|
|
53
88
|
writeFileSync,
|
|
54
89
|
} from 'node:fs';
|
|
55
90
|
import { homedir } from 'node:os';
|
|
@@ -58,9 +93,17 @@ import { basename, dirname, extname, isAbsolute, join } from 'node:path';
|
|
|
58
93
|
const STDIN_FILE_DESCRIPTOR = 0;
|
|
59
94
|
const CONFIG_KEY = 'injectCapabilityMap';
|
|
60
95
|
const DUMP_ENV = 'CLAUDE_GATES_DUMP_DEFAULTS';
|
|
61
|
-
|
|
96
|
+
// The registry default this gate is shipped with. Kept here (not only in dumpDefaults) so
|
|
97
|
+
// main() and dumpDefaults agree by construction — a value duplicated in two places is
|
|
98
|
+
// exactly the desync that let a gate silently miss its own registry default before (see
|
|
99
|
+
// the project's default-desync incident: registry.json and the gate's own hardcoded
|
|
100
|
+
// enabledByDefault drifting apart across a version bump).
|
|
101
|
+
const ENABLED_BY_DEFAULT = true;
|
|
102
|
+
const DEFAULT_MAX_CLAUSE_CHARS = 120;
|
|
62
103
|
const DEFAULT_KINDS = ['skills', 'agents', 'commands'];
|
|
63
104
|
const DEFAULT_MAP_FILE = join('.ai', 'capability-map.json');
|
|
105
|
+
const DEFAULT_BLURB_OVERRIDES_FILE = join('.ai', 'blurb-overrides.json');
|
|
106
|
+
const DEFAULT_INJECT_EVERY_MESSAGES = 10;
|
|
64
107
|
const JSON_INDENT = 2;
|
|
65
108
|
|
|
66
109
|
// Config lookup mirrors config.mjs (project → global), kept local so a gate stays runnable on
|
|
@@ -91,7 +134,13 @@ function projectRootOf(startDirectory) {
|
|
|
91
134
|
}
|
|
92
135
|
}
|
|
93
136
|
|
|
94
|
-
/** The capability-map gate config entry (project overrides global)
|
|
137
|
+
/** The capability-map gate config entry (project overrides global). `enabled` is `true`/
|
|
138
|
+
* `false` only when a project or global config explicitly declared it — absent (undefined)
|
|
139
|
+
* means "nothing was declared, fall back to the registry default", the same three-state
|
|
140
|
+
* shape config.mjs's isGateEnabled uses for every other gate. Distinguishing "never
|
|
141
|
+
* declared" from "explicitly false" matters once ENABLED_BY_DEFAULT is true: without it, a
|
|
142
|
+
* project that never touched this gate's config would look identical to one that turned it
|
|
143
|
+
* off, and the registry default could never take effect. */
|
|
95
144
|
function gateConfig(startDirectory) {
|
|
96
145
|
const root = projectRootOf(startDirectory);
|
|
97
146
|
const projectData = root ? readJson(join(root, PROJECT_CONFIG)) : null;
|
|
@@ -100,7 +149,7 @@ function gateConfig(startDirectory) {
|
|
|
100
149
|
const entry = layer[CONFIG_KEY];
|
|
101
150
|
if (typeof entry === 'boolean') return { enabled: entry };
|
|
102
151
|
if (entry && typeof entry === 'object') return entry;
|
|
103
|
-
return
|
|
152
|
+
return {};
|
|
104
153
|
}
|
|
105
154
|
|
|
106
155
|
function readPayload() {
|
|
@@ -111,11 +160,36 @@ function readPayload() {
|
|
|
111
160
|
}
|
|
112
161
|
}
|
|
113
162
|
|
|
163
|
+
// A YAML folded/literal block scalar indicator with nothing else on the line
|
|
164
|
+
// (`description: >`, `description: >-`, `description: |`, `description: |-`): the real
|
|
165
|
+
// value is every following indented line, not this one. Seen across `.agents/skills`
|
|
166
|
+
// SKILL.md files (e.g. api-architect, babysit, branch-pr) — without this, the parser took
|
|
167
|
+
// the bare indicator itself as the description, rendering blurbs like `">"` or `">-"`.
|
|
168
|
+
const BLOCK_SCALAR_INDICATOR_PATTERN = /^[|>][+-]?\d*$/;
|
|
169
|
+
|
|
170
|
+
/** Every following line indented relative to the block's own indentation, joined with a
|
|
171
|
+
* single space (folded-scalar semantics — good enough for a one-line blurb; literal `|`
|
|
172
|
+
* blocks are folded too, which only affects a display detail this gate strips anyway via
|
|
173
|
+
* firstClause). Stops at the first line that is blank or not indented (front matter end,
|
|
174
|
+
* or a sibling key). */
|
|
175
|
+
function readBlockScalarValue(lines, startIndex) {
|
|
176
|
+
const parts = [];
|
|
177
|
+
for (let index = startIndex; index < lines.length; index += 1) {
|
|
178
|
+
const line = lines[index];
|
|
179
|
+
if (line.trim() === '---') break;
|
|
180
|
+
if (!/^[ \t]+\S/.test(line)) break; // not indented: block scalar ended
|
|
181
|
+
parts.push(line.trim());
|
|
182
|
+
}
|
|
183
|
+
return parts.join(' ');
|
|
184
|
+
}
|
|
185
|
+
|
|
114
186
|
/**
|
|
115
187
|
* The `name` and `description` from a markdown-style front matter block (skills, agents,
|
|
116
188
|
* commands all use `---`-fenced YAML-ish front matter). Parsed line-by-line (no multi-line
|
|
117
|
-
* regex) so a large file body can never trigger catastrophic backtracking.
|
|
118
|
-
*
|
|
189
|
+
* regex) so a large file body can never trigger catastrophic backtracking. Handles a plain
|
|
190
|
+
* scalar value on the `key:` line itself, and a YAML folded/literal block scalar (`>`, `>-`,
|
|
191
|
+
* `|`, `|-`) whose value lives on the following indented lines. Returns { name, description }
|
|
192
|
+
* with either possibly ''.
|
|
119
193
|
*/
|
|
120
194
|
function parseFrontMatter(fileText) {
|
|
121
195
|
const lines = fileText.split(/\r?\n/);
|
|
@@ -129,25 +203,38 @@ function parseFrontMatter(fileText) {
|
|
|
129
203
|
const separator = line.indexOf(':');
|
|
130
204
|
if (separator < 0) continue;
|
|
131
205
|
const key = line.slice(0, separator).trim();
|
|
132
|
-
|
|
206
|
+
let value = line
|
|
133
207
|
.slice(separator + 1)
|
|
134
208
|
.trim()
|
|
135
209
|
.replace(/^["']|["']$/g, ''); // toml/yaml quoting around the value
|
|
210
|
+
if (BLOCK_SCALAR_INDICATOR_PATTERN.test(value)) {
|
|
211
|
+
value = readBlockScalarValue(lines, index + 1);
|
|
212
|
+
}
|
|
136
213
|
if (key === 'name') name = value;
|
|
137
214
|
else if (key === 'description') description = value;
|
|
138
215
|
}
|
|
139
216
|
return { name, description };
|
|
140
217
|
}
|
|
141
218
|
|
|
219
|
+
/** Truncates text to at most maxChars, breaking at the last whitespace boundary before
|
|
220
|
+
* the limit rather than mid-word — a hard char-index cut turns "lectores de pantalla"
|
|
221
|
+
* into "lectores de…", losing the word instead of just the tail of the sentence. Falls
|
|
222
|
+
* back to a hard cut only when there is no whitespace to break on (one very long word). */
|
|
223
|
+
function truncateAtWordBoundary(text, maxChars) {
|
|
224
|
+
if (text.length <= maxChars) return text;
|
|
225
|
+
const budget = text.slice(0, maxChars - 1);
|
|
226
|
+
const lastSpace = budget.lastIndexOf(' ');
|
|
227
|
+
const cut = lastSpace > 0 ? budget.slice(0, lastSpace) : budget;
|
|
228
|
+
return `${cut.trimEnd()}…`;
|
|
229
|
+
}
|
|
230
|
+
|
|
142
231
|
/** The description's first clause, capped — the caveman blurb. */
|
|
143
232
|
function firstClause(description, maxClauseChars) {
|
|
144
233
|
if (!description) return '';
|
|
145
234
|
const sentenceEnd = description.indexOf('. ');
|
|
146
235
|
const clause =
|
|
147
236
|
sentenceEnd > 0 ? description.slice(0, sentenceEnd) : description;
|
|
148
|
-
return clause
|
|
149
|
-
? `${clause.slice(0, maxClauseChars - 1).trimEnd()}…`
|
|
150
|
-
: clause;
|
|
237
|
+
return truncateAtWordBoundary(clause, maxClauseChars);
|
|
151
238
|
}
|
|
152
239
|
|
|
153
240
|
/** Recursively lists files under a directory whose extension is in `extensions`. */
|
|
@@ -171,7 +258,21 @@ function filesUnder(directory, extensions) {
|
|
|
171
258
|
return files;
|
|
172
259
|
}
|
|
173
260
|
|
|
174
|
-
/**
|
|
261
|
+
/** The file's mtime in ms, or null when it cannot be stat'd (broken junction, race). A
|
|
262
|
+
* source this gate cannot stat contributes nothing stable to the fingerprint — treated as
|
|
263
|
+
* absent so a dangling link does not poison every future comparison with a NaN/undefined. */
|
|
264
|
+
function mtimeMsOf(path) {
|
|
265
|
+
try {
|
|
266
|
+
return statSync(path).mtimeMs;
|
|
267
|
+
} catch {
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Skill capabilities under one root: <root>/skills/<name>/SKILL.md. Each entry carries
|
|
273
|
+
* `stamp: "<path>:<mtimeMs>"`, the unit the disk fingerprint is built from (see
|
|
274
|
+
* fingerprintOf) — cheap because it reuses the stat already needed to read the file, no
|
|
275
|
+
* second filesystem pass. */
|
|
175
276
|
function skillEntriesUnder(skillsRoot, maxClauseChars) {
|
|
176
277
|
if (!existsSync(skillsRoot)) return [];
|
|
177
278
|
let skillDirectories;
|
|
@@ -184,7 +285,8 @@ function skillEntriesUnder(skillsRoot, maxClauseChars) {
|
|
|
184
285
|
for (const skillDirectory of skillDirectories) {
|
|
185
286
|
if (!skillDirectory.isDirectory()) continue;
|
|
186
287
|
const skillFile = join(skillsRoot, skillDirectory.name, 'SKILL.md');
|
|
187
|
-
|
|
288
|
+
const mtimeMs = mtimeMsOf(skillFile);
|
|
289
|
+
if (mtimeMs === null) continue; // missing or a dangling link: not a real source
|
|
188
290
|
let content;
|
|
189
291
|
try {
|
|
190
292
|
content = readFileSync(skillFile, 'utf8');
|
|
@@ -194,16 +296,21 @@ function skillEntriesUnder(skillsRoot, maxClauseChars) {
|
|
|
194
296
|
const { name, description } = parseFrontMatter(content);
|
|
195
297
|
entries.push({
|
|
196
298
|
name: name || skillDirectory.name,
|
|
197
|
-
|
|
299
|
+
description,
|
|
300
|
+
maxClauseChars,
|
|
301
|
+
stamp: `${skillFile}:${mtimeMs}`,
|
|
198
302
|
});
|
|
199
303
|
}
|
|
200
304
|
return entries;
|
|
201
305
|
}
|
|
202
306
|
|
|
203
|
-
/** Agent/command capabilities: flat or nested files whose front matter carries a description.
|
|
307
|
+
/** Agent/command capabilities: flat or nested files whose front matter carries a description.
|
|
308
|
+
* Same stamp shape as skillEntriesUnder, for the same reason. */
|
|
204
309
|
function fileEntriesUnder(directory, extensions, maxClauseChars) {
|
|
205
310
|
const entries = [];
|
|
206
311
|
for (const file of filesUnder(directory, extensions)) {
|
|
312
|
+
const mtimeMs = mtimeMsOf(file);
|
|
313
|
+
if (mtimeMs === null) continue;
|
|
207
314
|
let content;
|
|
208
315
|
try {
|
|
209
316
|
content = readFileSync(file, 'utf8');
|
|
@@ -213,7 +320,9 @@ function fileEntriesUnder(directory, extensions, maxClauseChars) {
|
|
|
213
320
|
const { name, description } = parseFrontMatter(content);
|
|
214
321
|
entries.push({
|
|
215
322
|
name: name || basename(file, extname(file)),
|
|
216
|
-
|
|
323
|
+
description,
|
|
324
|
+
maxClauseChars,
|
|
325
|
+
stamp: `${file}:${mtimeMs}`,
|
|
217
326
|
});
|
|
218
327
|
}
|
|
219
328
|
return entries;
|
|
@@ -223,11 +332,34 @@ function baseRoots(cwd) {
|
|
|
223
332
|
return [join(homedir(), '.claude'), join(cwd, '.claude')];
|
|
224
333
|
}
|
|
225
334
|
|
|
335
|
+
// Skill directories seen in the wild outside the `.claude/skills` layout: the `skills`
|
|
336
|
+
// upstream CLI (invoked by installers like caveman) writes to a project-local
|
|
337
|
+
// `./.agents/skills` when run without its `-g` flag (see caveman/bin/install.js's own
|
|
338
|
+
// comment on issue #836 — the exact bug that produced 65 dangling junctions under
|
|
339
|
+
// `~/.claude/skills` in one real incident: they pointed at a `.agents/skills` that only
|
|
340
|
+
// ever existed relative to the project the installer ran from). `.ai/skills` is included
|
|
341
|
+
// per explicit user instruction, without independent verification of a specific installer
|
|
342
|
+
// using it — kept here rather than as a project-declared default so every project gets it
|
|
343
|
+
// without having to know the installer's quirk. These are SKILL roots only (the directory
|
|
344
|
+
// IS the skills folder, unlike `.claude` where `skills/agents/commands` are siblings under
|
|
345
|
+
// one root) — they never gain agents/commands lookup, which would be inventing a layout
|
|
346
|
+
// this repo has no evidence for.
|
|
347
|
+
function defaultSkillOnlyRoots(cwd) {
|
|
348
|
+
return [
|
|
349
|
+
join(homedir(), '.agents', 'skills'),
|
|
350
|
+
join(cwd, '.agents', 'skills'),
|
|
351
|
+
join(homedir(), '.ai', 'skills'),
|
|
352
|
+
join(cwd, '.ai', 'skills'),
|
|
353
|
+
];
|
|
354
|
+
}
|
|
355
|
+
|
|
226
356
|
function resolveExtra(cwd, directory) {
|
|
227
357
|
return isAbsolute(directory) ? directory : join(cwd, directory);
|
|
228
358
|
}
|
|
229
359
|
|
|
230
|
-
/** Every entry for one kind across all roots, de-duplicated by name
|
|
360
|
+
/** Every entry for one kind across all roots, de-duplicated by name (each entry still
|
|
361
|
+
* carries description/maxClauseChars/stamp — blurb overrides and truncation are applied
|
|
362
|
+
* later, once, in applyBlurbs, not per-root). */
|
|
231
363
|
function entriesForKind(kind, cwd, config, maxClauseChars) {
|
|
232
364
|
const perKind = {
|
|
233
365
|
skills: {
|
|
@@ -260,6 +392,11 @@ function entriesForKind(kind, cwd, config, maxClauseChars) {
|
|
|
260
392
|
|
|
261
393
|
const collected = [];
|
|
262
394
|
for (const root of baseRoots(cwd)) collected.push(...perKind.collect(root));
|
|
395
|
+
if (kind === 'skills') {
|
|
396
|
+
for (const root of defaultSkillOnlyRoots(cwd)) {
|
|
397
|
+
collected.push(...skillEntriesUnder(root, maxClauseChars));
|
|
398
|
+
}
|
|
399
|
+
}
|
|
263
400
|
const extra = Array.isArray(perKind.extra) ? perKind.extra : [];
|
|
264
401
|
for (const directory of extra) {
|
|
265
402
|
collected.push(...perKind.collectExtra(resolveExtra(cwd, directory)));
|
|
@@ -276,14 +413,118 @@ function entriesForKind(kind, cwd, config, maxClauseChars) {
|
|
|
276
413
|
return unique;
|
|
277
414
|
}
|
|
278
415
|
|
|
416
|
+
/** The two override file paths in effect for this cwd (global always; project only when a
|
|
417
|
+
* project root is found), regardless of whether either currently exists. Shared by
|
|
418
|
+
* blurbOverridesFor (reads them) and blurbOverrideStampsFor (fingerprints them) so the two
|
|
419
|
+
* can never drift to different paths. */
|
|
420
|
+
function blurbOverridePathsFor(cwd, blurbOverridesFile) {
|
|
421
|
+
const globalPath = join(homedir(), '.claude', 'blurb-overrides.json');
|
|
422
|
+
const root = projectRootOf(cwd);
|
|
423
|
+
const projectPath = root ? join(root, blurbOverridesFile) : null;
|
|
424
|
+
return projectPath ? [globalPath, projectPath] : [globalPath];
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** The overrides map (capability name -> hand-written blurb). Project file wins over the
|
|
428
|
+
* global one entry-by-entry (spread, global first) so a project can override a single
|
|
429
|
+
* global entry without having to repeat the rest. */
|
|
430
|
+
function blurbOverridesFor(cwd, blurbOverridesFile) {
|
|
431
|
+
const [globalPath, projectPath] = blurbOverridePathsFor(
|
|
432
|
+
cwd,
|
|
433
|
+
blurbOverridesFile,
|
|
434
|
+
);
|
|
435
|
+
return {
|
|
436
|
+
...(readJson(globalPath) ?? {}),
|
|
437
|
+
...(projectPath ? (readJson(projectPath) ?? {}) : {}),
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Stamps for the override file(s) themselves, in the same "<path>:<mtimeMs>" shape as a
|
|
442
|
+
* capability's stamp — folded into the fingerprint so EDITING AN OVERRIDE (with no skill
|
|
443
|
+
* file touched at all) still invalidates the cached catalog. Without this, a fresh or
|
|
444
|
+
* changed blurb-overrides.json would sit unused: the persisted catalog's fingerprint would
|
|
445
|
+
* still match (no skill/agent/command changed) and resolveCatalog would keep serving the
|
|
446
|
+
* stale, un-overridden blurb forever — the exact bug a real run surfaced (clean-architecture
|
|
447
|
+
* got an override added to blurb-overrides.json after the map had already been generated
|
|
448
|
+
* once, and kept showing the mechanically-truncated text on every later run because nothing
|
|
449
|
+
* ever invalidated the cache). A missing override file contributes a stable "absent" stamp
|
|
450
|
+
* (not skipped) so going from absent -> present is itself a fingerprint change. */
|
|
451
|
+
function blurbOverrideStampsFor(cwd, blurbOverridesFile) {
|
|
452
|
+
return blurbOverridePathsFor(cwd, blurbOverridesFile).map((path) => {
|
|
453
|
+
const mtimeMs = mtimeMsOf(path);
|
|
454
|
+
return `${path}:${mtimeMs === null ? 'absent' : mtimeMs}`;
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** Turns a raw entry (description/maxClauseChars/stamp) into the rendered shape
|
|
459
|
+
* (name/blurb/stamp) — an override is used verbatim (still capped, in case it runs long
|
|
460
|
+
* itself); with no override, falls back to the mechanical first-clause truncation. */
|
|
461
|
+
function applyBlurbs(entries, overrides) {
|
|
462
|
+
return entries.map((entry) => {
|
|
463
|
+
const override = overrides[entry.name];
|
|
464
|
+
const blurb = override
|
|
465
|
+
? truncateAtWordBoundary(override, entry.maxClauseChars)
|
|
466
|
+
: firstClause(entry.description, entry.maxClauseChars);
|
|
467
|
+
return { name: entry.name, blurb, stamp: entry.stamp };
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** A stable, order-independent fingerprint of every source file's path+mtime across the
|
|
472
|
+
* whole catalog, plus any `extraStamps` the caller folds in (the override file(s) — see
|
|
473
|
+
* blurbOverrideStampsFor; a capability's rendered blurb depends on both its own source file
|
|
474
|
+
* AND the overrides file, so both must be able to invalidate the cache). Two scans of an
|
|
475
|
+
* otherwise-unchanged disk produce the same fingerprint; adding, removing, or touching any
|
|
476
|
+
* skill/agent/command file, or the overrides file, changes it. Sorted before hashing so
|
|
477
|
+
* filesystem enumeration order (which readdirSync does not guarantee) never causes a false
|
|
478
|
+
* "changed" reading. sha256 not for any security property (this is a change-detection
|
|
479
|
+
* checksum, nothing here is adversarial) — plain sha1 just trips the linter's blanket
|
|
480
|
+
* weak-hash rule, and sha256 is just as cheap at this size. */
|
|
481
|
+
function fingerprintOf(catalog, extraStamps = []) {
|
|
482
|
+
const stamps = Object.values(catalog)
|
|
483
|
+
.flat()
|
|
484
|
+
.map((entry) => entry.stamp)
|
|
485
|
+
.concat(extraStamps)
|
|
486
|
+
.sort();
|
|
487
|
+
return createHash('sha256').update(stamps.join('\n')).digest('hex');
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Strips `stamp` before persisting/rendering — it is scan-time-only plumbing for the
|
|
491
|
+
* fingerprint, not part of the public map shape. */
|
|
492
|
+
function withoutStamps(catalog) {
|
|
493
|
+
const stripped = {};
|
|
494
|
+
for (const [kind, entries] of Object.entries(catalog)) {
|
|
495
|
+
stripped[kind] = entries.map(({ name, blurb }) => ({ name, blurb }));
|
|
496
|
+
}
|
|
497
|
+
return stripped;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** Reads the persisted map's catalog + fingerprint + injection counter, or nulls when
|
|
501
|
+
* absent/corrupt. Used to decide whether a rescan is needed and to track injectEveryMessages
|
|
502
|
+
* without a second store file. */
|
|
503
|
+
function readPersistedState(mapPath) {
|
|
504
|
+
const data = readJson(mapPath);
|
|
505
|
+
if (!data || typeof data !== 'object') {
|
|
506
|
+
return { catalog: null, fingerprint: null, messageCount: 0 };
|
|
507
|
+
}
|
|
508
|
+
return {
|
|
509
|
+
catalog:
|
|
510
|
+
data.capabilities && typeof data.capabilities === 'object'
|
|
511
|
+
? data.capabilities
|
|
512
|
+
: null,
|
|
513
|
+
fingerprint: typeof data.fingerprint === 'string' ? data.fingerprint : null,
|
|
514
|
+
messageCount: Number.isInteger(data.messageCount) ? data.messageCount : 0,
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
|
|
279
518
|
/** Persists the map to .ai/capability-map.json, best-effort — never throws, never blocks. */
|
|
280
|
-
function persistMap(cwd, mapFile, catalog) {
|
|
519
|
+
function persistMap(cwd, mapFile, catalog, fingerprint, messageCount) {
|
|
281
520
|
const root = projectRootOf(cwd);
|
|
282
521
|
if (!root) return;
|
|
283
522
|
const mapPath = join(root, mapFile);
|
|
284
523
|
const payload = {
|
|
285
524
|
generatedAt: new Date().toISOString(),
|
|
286
|
-
|
|
525
|
+
fingerprint,
|
|
526
|
+
messageCount,
|
|
527
|
+
capabilities: withoutStamps(catalog),
|
|
287
528
|
};
|
|
288
529
|
try {
|
|
289
530
|
mkdirSync(dirname(mapPath), { recursive: true });
|
|
@@ -309,7 +550,7 @@ function dumpDefaults() {
|
|
|
309
550
|
JSON.stringify({
|
|
310
551
|
id: 'capability-map',
|
|
311
552
|
configKey: CONFIG_KEY,
|
|
312
|
-
enabledByDefault:
|
|
553
|
+
enabledByDefault: ENABLED_BY_DEFAULT,
|
|
313
554
|
defaultParams: {
|
|
314
555
|
kinds: DEFAULT_KINDS,
|
|
315
556
|
maxClauseChars: DEFAULT_MAX_CLAUSE_CHARS,
|
|
@@ -318,13 +559,16 @@ function dumpDefaults() {
|
|
|
318
559
|
extraCommandsDirs: [],
|
|
319
560
|
persist: true,
|
|
320
561
|
mapFile: DEFAULT_MAP_FILE,
|
|
562
|
+
blurbOverridesFile: DEFAULT_BLURB_OVERRIDES_FILE,
|
|
563
|
+
injectEveryMessages: DEFAULT_INJECT_EVERY_MESSAGES,
|
|
321
564
|
},
|
|
322
565
|
}),
|
|
323
566
|
);
|
|
324
567
|
}
|
|
325
568
|
|
|
326
|
-
/** The non-empty catalog
|
|
327
|
-
|
|
569
|
+
/** The non-empty raw catalog (description/maxClauseChars/stamp per entry — no blurbs yet),
|
|
570
|
+
* keyed by kind, in the order `kinds` lists them. */
|
|
571
|
+
function buildRawCatalog(kinds, cwd, config, maxClauseChars) {
|
|
328
572
|
const catalog = {};
|
|
329
573
|
for (const kind of kinds) {
|
|
330
574
|
const entries = entriesForKind(kind, cwd, config, maxClauseChars);
|
|
@@ -348,6 +592,55 @@ function renderCatalog(catalog, kinds) {
|
|
|
348
592
|
return `[capabilities] available (check before improvising something one of these covers):\n${sections.join('\n')}\n`;
|
|
349
593
|
}
|
|
350
594
|
|
|
595
|
+
/** Every tunable read out of the raw config object, with its default applied — keeps main()
|
|
596
|
+
* a single flat read instead of five inline `config.x || DEFAULT_X` expressions. */
|
|
597
|
+
function resolvedConfig(config) {
|
|
598
|
+
return {
|
|
599
|
+
maxClauseChars: Number(config.maxClauseChars) || DEFAULT_MAX_CLAUSE_CHARS,
|
|
600
|
+
kinds: Array.isArray(config.kinds) ? config.kinds : DEFAULT_KINDS,
|
|
601
|
+
mapFile: config.mapFile || DEFAULT_MAP_FILE,
|
|
602
|
+
blurbOverridesFile:
|
|
603
|
+
config.blurbOverridesFile || DEFAULT_BLURB_OVERRIDES_FILE,
|
|
604
|
+
injectEveryMessages:
|
|
605
|
+
Number(config.injectEveryMessages) || DEFAULT_INJECT_EVERY_MESSAGES,
|
|
606
|
+
persist: config.persist !== false,
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** The rendered { name, blurb } catalog for this run: reused verbatim from the persisted
|
|
611
|
+
* map when disk is provably unchanged since the last scan (same fingerprint), or freshly
|
|
612
|
+
* derived (overrides applied, then mechanical truncation for the rest) otherwise. Re-
|
|
613
|
+
* deriving is the only per-entry work worth skipping — everything downstream only ever
|
|
614
|
+
* sees { name, blurb }, never `stamp`/`description`/`maxClauseChars`. */
|
|
615
|
+
function resolveCatalog(rawCatalog, persisted, fingerprint, cwd, settings) {
|
|
616
|
+
if (persisted.fingerprint === fingerprint && persisted.catalog) {
|
|
617
|
+
return persisted.catalog;
|
|
618
|
+
}
|
|
619
|
+
const overrides = blurbOverridesFor(cwd, settings.blurbOverridesFile);
|
|
620
|
+
const rendered = Object.fromEntries(
|
|
621
|
+
Object.entries(rawCatalog).map(([kind, entries]) => [
|
|
622
|
+
kind,
|
|
623
|
+
applyBlurbs(entries, overrides),
|
|
624
|
+
]),
|
|
625
|
+
);
|
|
626
|
+
return withoutStamps(rendered);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/** Whether this run should inject the rendered catalog, and the counter value to persist
|
|
630
|
+
* either way. A fingerprint change forces immediate injection: either this is the very
|
|
631
|
+
* first run for this project (persisted.fingerprint is null — nothing has ever been
|
|
632
|
+
* injected, and waiting up to injectEveryMessages turns before the model learns these
|
|
633
|
+
* capabilities exist is the wrong default) or the disk catalog changed since the last scan
|
|
634
|
+
* (a capability was added/removed — worth surfacing right away, not on whatever the counter
|
|
635
|
+
* happens to be). Both reset the counter, same as a normal throttled trigger. */
|
|
636
|
+
function injectionDecision(persisted, fingerprint, injectEveryMessages) {
|
|
637
|
+
const catalogChanged = persisted.fingerprint !== fingerprint;
|
|
638
|
+
const nextMessageCount = persisted.messageCount + 1;
|
|
639
|
+
const shouldInject =
|
|
640
|
+
catalogChanged || nextMessageCount >= injectEveryMessages;
|
|
641
|
+
return { shouldInject, messageCount: shouldInject ? 0 : nextMessageCount };
|
|
642
|
+
}
|
|
643
|
+
|
|
351
644
|
function main() {
|
|
352
645
|
if (process.env[DUMP_ENV]) {
|
|
353
646
|
dumpDefaults();
|
|
@@ -358,20 +651,51 @@ function main() {
|
|
|
358
651
|
const cwd = payload.cwd || process.cwd();
|
|
359
652
|
|
|
360
653
|
const config = gateConfig(cwd);
|
|
361
|
-
|
|
654
|
+
const isEnabled =
|
|
655
|
+
config.enabled === undefined ? ENABLED_BY_DEFAULT : config.enabled;
|
|
656
|
+
if (!isEnabled) return;
|
|
657
|
+
|
|
658
|
+
const settings = resolvedConfig(config);
|
|
659
|
+
const rawCatalog = buildRawCatalog(
|
|
660
|
+
settings.kinds,
|
|
661
|
+
cwd,
|
|
662
|
+
config,
|
|
663
|
+
settings.maxClauseChars,
|
|
664
|
+
);
|
|
665
|
+
if (Object.keys(rawCatalog).length === 0) return; // nothing to surface: never overwrite a good map
|
|
362
666
|
|
|
363
|
-
const
|
|
364
|
-
|
|
365
|
-
|
|
667
|
+
const overrideStamps = blurbOverrideStampsFor(
|
|
668
|
+
cwd,
|
|
669
|
+
settings.blurbOverridesFile,
|
|
670
|
+
);
|
|
671
|
+
const fingerprint = fingerprintOf(rawCatalog, overrideStamps);
|
|
672
|
+
const root = projectRootOf(cwd);
|
|
673
|
+
const mapPath = root ? join(root, settings.mapFile) : null;
|
|
674
|
+
const persisted = mapPath
|
|
675
|
+
? readPersistedState(mapPath)
|
|
676
|
+
: { catalog: null, fingerprint: null, messageCount: 0 };
|
|
366
677
|
|
|
367
|
-
const catalog =
|
|
368
|
-
|
|
678
|
+
const catalog = resolveCatalog(
|
|
679
|
+
rawCatalog,
|
|
680
|
+
persisted,
|
|
681
|
+
fingerprint,
|
|
682
|
+
cwd,
|
|
683
|
+
settings,
|
|
684
|
+
);
|
|
369
685
|
|
|
370
|
-
|
|
371
|
-
|
|
686
|
+
const { shouldInject, messageCount } = injectionDecision(
|
|
687
|
+
persisted,
|
|
688
|
+
fingerprint,
|
|
689
|
+
settings.injectEveryMessages,
|
|
690
|
+
);
|
|
691
|
+
|
|
692
|
+
if (settings.persist && mapPath) {
|
|
693
|
+
persistMap(cwd, settings.mapFile, catalog, fingerprint, messageCount);
|
|
372
694
|
}
|
|
373
695
|
|
|
374
|
-
|
|
696
|
+
if (shouldInject) {
|
|
697
|
+
process.stdout.write(renderCatalog(catalog, settings.kinds));
|
|
698
|
+
}
|
|
375
699
|
}
|
|
376
700
|
|
|
377
701
|
main();
|
|
@@ -121,6 +121,16 @@
|
|
|
121
121
|
}
|
|
122
122
|
]
|
|
123
123
|
},
|
|
124
|
+
{
|
|
125
|
+
"matcher": "Agent|Task|invoke_subagent|mcp__.*",
|
|
126
|
+
"hooks": [
|
|
127
|
+
{
|
|
128
|
+
"type": "command",
|
|
129
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/brief-approved/index.mjs\"",
|
|
130
|
+
"timeout": 30
|
|
131
|
+
}
|
|
132
|
+
]
|
|
133
|
+
},
|
|
124
134
|
{
|
|
125
135
|
"matcher": "Agent|Task|invoke_subagent|mcp__.*",
|
|
126
136
|
"hooks": [
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tasks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Deterministic task tracking for Claude Code: persists tasks the model registers via the CLI, reminds of open tasks on a message counter, and lists active tasks on session start.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Devrik"
|
package/registry.json
CHANGED
|
@@ -293,6 +293,27 @@
|
|
|
293
293
|
}
|
|
294
294
|
]
|
|
295
295
|
},
|
|
296
|
+
{
|
|
297
|
+
"id": "brief-approved",
|
|
298
|
+
"configKey": "requireApprovedBriefBeforeImplementing",
|
|
299
|
+
"default": true,
|
|
300
|
+
"event": "PreToolUse",
|
|
301
|
+
"tools": ["delegation"],
|
|
302
|
+
"description": "Denies implementing a cited feature whose brief has no recorded user approval (status: approved + approval_quote frontmatter).",
|
|
303
|
+
"script": "gates/brief-approved/index.mjs",
|
|
304
|
+
"params": [
|
|
305
|
+
{
|
|
306
|
+
"name": "catalogLocations",
|
|
307
|
+
"type": "string[]",
|
|
308
|
+
"description": "Paths searched for the feature catalog."
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
"name": "exemptSubagents",
|
|
312
|
+
"type": "string[]",
|
|
313
|
+
"description": "Subagent types exempt from the approval requirement."
|
|
314
|
+
}
|
|
315
|
+
]
|
|
316
|
+
},
|
|
296
317
|
{
|
|
297
318
|
"id": "implementation-pipeline",
|
|
298
319
|
"configKey": "requireImplementationPipeline",
|
|
@@ -826,7 +847,7 @@
|
|
|
826
847
|
{
|
|
827
848
|
"id": "capability-map",
|
|
828
849
|
"configKey": "injectCapabilityMap",
|
|
829
|
-
"default":
|
|
850
|
+
"default": true,
|
|
830
851
|
"event": "UserPromptSubmit",
|
|
831
852
|
"tools": [],
|
|
832
853
|
"description": "Injects the current catalog of the project's AI capabilities (skills, agents/subagents, commands) as compact context on every message, grouped by kind, read fresh from disk. Also persists it to .ai/capability-map.json like the tool map. Autosynced: it lists the directories, never a hardcoded copy. Never blocks.",
|