@devrik-tools/claude-gates 0.9.0 → 1.0.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/README.es.md +39 -4
- package/README.md +39 -4
- package/cli/artifacts.mjs +213 -0
- package/cli/constants.mjs +14 -0
- package/cli/doctor.mjs +2 -1
- package/cli/index.mjs +54 -2
- package/cli/init.mjs +95 -9
- package/cli/install.mjs +53 -1
- package/cli/registry.mjs +1 -0
- package/cli/selection.mjs +23 -1
- package/cli/smoke-fixtures.json +8 -0
- package/package.json +2 -2
- package/plugins/gates/hooks/gates/capability-map/index.mjs +37 -208
- package/plugins/gates/hooks/gates/skill-first/index.mjs +138 -0
- package/plugins/gates/hooks/gates/skill-first/track.mjs +66 -0
- package/plugins/gates/hooks/hooks.json +20 -0
- package/plugins/gates/hooks/lib/capabilities.mjs +401 -0
- package/plugins/gates/hooks/lib/hook-io.mjs +4 -0
- package/plugins/gates/hooks/lib/signals.mjs +91 -0
- package/registry.json +58 -0
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 50 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
|
---
|
|
@@ -67,7 +67,7 @@ ejecución), así que funciona aunque instales uno suelto por fuera.
|
|
|
67
67
|
|
|
68
68
|
---
|
|
69
69
|
|
|
70
|
-
## Los gates (
|
|
70
|
+
## Los gates (50, en 11 familias)
|
|
71
71
|
|
|
72
72
|
`[on]` = encendidos por defecto; `[off]` = los prendes si los quieres.
|
|
73
73
|
|
|
@@ -127,6 +127,7 @@ ejecución), así que funciona aunque instales uno suelto por fuera.
|
|
|
127
127
|
|---|---|---|
|
|
128
128
|
| `reuse-before-build` | off | Antes de construir una herramienta, consulta el mapa de herramientas del proyecto; bloquea si no auditaste (local → Context7 → web). |
|
|
129
129
|
| `tool-map` | off | Registra las herramientas descubiertas en `.ai/tool-map.json` para no volver a explorar. |
|
|
130
|
+
| `skill-first` | off | Bloquea una escritura/comando/delegación que una **skill** disponible cubre de forma plausible hasta que la pregunta se haya hecho: cargar la skill, o decir por qué no aplica (`no skill covers this` / `using the <name> skill`). Lee el mismo catálogo que inyecta `capability-map`. |
|
|
130
131
|
|
|
131
132
|
### 🧠 Research flow — la memoria primero, nunca adivinar una librería
|
|
132
133
|
|
|
@@ -219,12 +220,15 @@ ves y editas cada perilla:
|
|
|
219
220
|
deliberadamente amplio, no atómico), `comment-ok: <razón>` (un comentario explicativo que
|
|
220
221
|
debe quedarse), `SEQUENTIAL-JUSTIFIED` (una delegación que sí depende de la anterior),
|
|
221
222
|
`MONITOR-PLANNED:` (el comando en background declara cómo se va a monitorear).
|
|
222
|
-
`dependency-skills` se exime vía su lista `depsWithoutOwnApi`.
|
|
223
|
+
`dependency-skills` se exime vía su lista `depsWithoutOwnApi`. `skill-first` se despeja
|
|
224
|
+
con una frase en el contenido/prompt: `no skill covers this`, o `using the <name> skill`.
|
|
223
225
|
- **Inyección de capacidades:** `capability-map` (on por defecto) es totalmente ajustable —
|
|
224
226
|
elegí qué tipos exponer (`"kinds": ["skills", "agents", "commands"]`), limitá cada blurb
|
|
225
227
|
(`maxClauseChars`, default 120), agregá raíces extra por tipo, regulá cada cuánto se
|
|
226
228
|
re-inyecta el catálogo completo (`injectEveryMessages`, default 10 — el archivo persistido
|
|
227
|
-
se refresca igual en cada mensaje
|
|
229
|
+
se refresca igual en cada mensaje; el catálogo TAMBIÉN se re-inyecta a mitad del throttle
|
|
230
|
+
cuando cambia el tipo de trabajo del prompt, por ejemplo depurar → publicar, lo que podés
|
|
231
|
+
apagar con `"reinjectOnWorkNatureChange": false`), o apagá la persistencia (`"persist": false`) y apuntá
|
|
228
232
|
el mapa a otro archivo (`mapFile`). Las skills también se escanean por defecto en
|
|
229
233
|
`~/.agents/skills`, `<proyecto>/.agents/skills`, `~/.ai/skills` y `<proyecto>/.ai/skills`
|
|
230
234
|
(raíces exclusivas de skills que usan otros instaladores además de `.claude/skills` — sin
|
|
@@ -294,6 +298,8 @@ npx @devrik-tools/claude-gates init
|
|
|
294
298
|
|
|
295
299
|
# Sin menú (para CI o scripts):
|
|
296
300
|
claude-gates init --project|--global --defaults|--all|--none|--families a,b|--gates x,y --yes --dry-run
|
|
301
|
+
claude-gates init --new # SOLO los gates sobre los que esta config nunca decidió:
|
|
302
|
+
# actualizás sin volver a contestar ni tocar lo ya definido
|
|
297
303
|
claude-gates init --no-install # escribe la configuración pero no instala el plugin
|
|
298
304
|
claude-gates init --force # aplica cambios a gates ya presentes sin preguntar
|
|
299
305
|
|
|
@@ -328,6 +334,35 @@ instalar, o en CI, para detectar un gate enganchado que en silencio deja pasar t
|
|
|
328
334
|
|
|
329
335
|
---
|
|
330
336
|
|
|
337
|
+
## Artefactos generados
|
|
338
|
+
|
|
339
|
+
Todo lo que un agente produce que **no es código fuente** — un chequeo determinista que
|
|
340
|
+
escribió para que veas si algo funciona, una auditoría de lo que ya existe, una nota
|
|
341
|
+
explicando qué hizo — tiene una sola forma y un solo lugar, así se encuentra por convención
|
|
342
|
+
y se valida en CI en vez de caer donde al modelo se le ocurra.
|
|
343
|
+
|
|
344
|
+
```bash
|
|
345
|
+
claude-gates new check mi-afirmacion --title "..." --source "qué pidió esto"
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
| Tipo | Vive en | Para |
|
|
349
|
+
| ------- | ------------- | ---------------------------------------------------------------------- |
|
|
350
|
+
| `check` | `.ai/checks/` | Una afirmación verificable: el comando, qué debe imprimir, y la evidencia que realmente imprimió. |
|
|
351
|
+
| `audit` | `.ai/audits/` | Mirar antes de construir: buscado / existe / falta / decisión. |
|
|
352
|
+
| `note` | `.ai/notes/` | Razonamiento que si no quedaría solo en el chat. |
|
|
353
|
+
|
|
354
|
+
Cuando la misma cosa sale mal dos veces, el registro es dato y no documento: se anota la
|
|
355
|
+
**clase** de defecto en `.ai/reincidencias.json`, que `recurrence-lock` lee para bloquear
|
|
356
|
+
trabajo mutante hasta que la clase se cierre de raíz.
|
|
357
|
+
|
|
358
|
+
El contrato es `cli/artifacts.mjs`; las reglas completas están en
|
|
359
|
+
[`.ai/README.md`](./.ai/README.md). Dos tests lo sostienen — uno cubre el módulo, el otro
|
|
360
|
+
recorre el árbol `.ai/` real de este repo y falla ante cualquier artefacto desviado (sin
|
|
361
|
+
front matter, en el directorio equivocado, sin una sección obligatoria, o un check marcado
|
|
362
|
+
`passed` cuya evidencia sigue siendo el placeholder generado).
|
|
363
|
+
|
|
364
|
+
---
|
|
365
|
+
|
|
331
366
|
## Estructura del repositorio
|
|
332
367
|
|
|
333
368
|
```
|
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 50 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
|
---
|
|
@@ -65,7 +65,7 @@ works even if you install one on its own.
|
|
|
65
65
|
|
|
66
66
|
---
|
|
67
67
|
|
|
68
|
-
## The gates (
|
|
68
|
+
## The gates (50, in 11 families)
|
|
69
69
|
|
|
70
70
|
`[on]` = enabled by default; `[off]` = enable it if you want it.
|
|
71
71
|
|
|
@@ -130,6 +130,7 @@ works even if you install one on its own.
|
|
|
130
130
|
| -------------------- | --- | ------------------------------------------------------------------------------------------------------------ |
|
|
131
131
|
| `reuse-before-build` | off | Before building a tool, consults the project tool map; blocks if you did not audit (local → Context7 → web). |
|
|
132
132
|
| `tool-map` | off | Records discovered tools in `.ai/tool-map.json` so exploration is not repeated. |
|
|
133
|
+
| `skill-first` | off | Blocks a write/command/delegation an available **skill** plausibly covers until the question was asked: load the skill, or say why it does not fit (`no skill covers this` / `using the <name> skill`). Reads the same catalog `capability-map` injects. |
|
|
133
134
|
|
|
134
135
|
### 🧠 Research flow — memory first, never guess a library
|
|
135
136
|
|
|
@@ -232,12 +233,15 @@ see and edit every knob:
|
|
|
232
233
|
non-atomic commit), `comment-ok: <reason>` (one explanatory comment that must stay),
|
|
233
234
|
`SEQUENTIAL-JUSTIFIED` (a delegation that genuinely depends on the previous one),
|
|
234
235
|
`MONITOR-PLANNED:` (the background command declares how it will be monitored).
|
|
235
|
-
`dependency-skills` opts out via its `depsWithoutOwnApi` list.
|
|
236
|
+
`dependency-skills` opts out via its `depsWithoutOwnApi` list. `skill-first` clears on
|
|
237
|
+
one sentence in the content/prompt: `no skill covers this`, or `using the <name> skill`.
|
|
236
238
|
- **Capability injection:** `capability-map` (on by default) is fully tunable — pick which
|
|
237
239
|
kinds to surface (`"kinds": ["skills", "agents", "commands"]`), cap each blurb
|
|
238
240
|
(`maxClauseChars`, default 120), add extra roots per kind, throttle how often the full
|
|
239
241
|
catalog is re-injected (`injectEveryMessages`, default 10 — the persisted map file itself
|
|
240
|
-
still refreshes every message
|
|
242
|
+
still refreshes every message; the catalog is ALSO re-injected mid-throttle whenever the
|
|
243
|
+
prompt's kind of work changes, e.g. debugging → releasing, which you can turn off with
|
|
244
|
+
`"reinjectOnWorkNatureChange": false`), or turn off persistence (`"persist": false`) and point the
|
|
241
245
|
map file elsewhere (`mapFile`). Skills are also scanned by default under `~/.agents/skills`,
|
|
242
246
|
`<project>/.agents/skills`, `~/.ai/skills` and `<project>/.ai/skills` (skill-only roots some
|
|
243
247
|
installers use besides `.claude/skills` — no config needed), in addition to any
|
|
@@ -252,6 +256,35 @@ see and edit every knob:
|
|
|
252
256
|
|
|
253
257
|
---
|
|
254
258
|
|
|
259
|
+
## Generated artifacts
|
|
260
|
+
|
|
261
|
+
Everything an agent produces that is **not source code** — a deterministic check it wrote
|
|
262
|
+
so you can see whether something works, an audit of what already exists, a note explaining
|
|
263
|
+
what it did — has one shape and one home, so it can be found by convention and validated
|
|
264
|
+
in CI instead of landing wherever the model felt like.
|
|
265
|
+
|
|
266
|
+
```bash
|
|
267
|
+
claude-gates new check my-claim --title "..." --source "what asked for this"
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
| Kind | Lives in | For |
|
|
271
|
+
| ------- | ------------- | ------------------------------------------------------------------ |
|
|
272
|
+
| `check` | `.ai/checks/` | One verifiable claim: the command, what it must print, the evidence it actually printed. |
|
|
273
|
+
| `audit` | `.ai/audits/` | Looking before building: searched / exists / missing / decision. |
|
|
274
|
+
| `note` | `.ai/notes/` | Reasoning that would otherwise live only in a chat log. |
|
|
275
|
+
|
|
276
|
+
When the same thing goes wrong twice, the record is data rather than a document: register
|
|
277
|
+
the defect **class** in `.ai/reincidencias.json`, which `recurrence-lock` reads to block
|
|
278
|
+
mutating work until the class is closed at the root.
|
|
279
|
+
|
|
280
|
+
The contract is `cli/artifacts.mjs`; the full rules are in
|
|
281
|
+
[`.ai/README.md`](./.ai/README.md). Two tests keep it honest — one covers the module, the
|
|
282
|
+
other walks this repo's own `.ai/` tree and fails on any artifact that drifted (missing
|
|
283
|
+
front matter, wrong directory, a missing section, or a check marked `passed` whose
|
|
284
|
+
evidence is still the generated placeholder).
|
|
285
|
+
|
|
286
|
+
---
|
|
287
|
+
|
|
255
288
|
## Decision log
|
|
256
289
|
|
|
257
290
|
Every deny, warn and Stop-block is appended as one JSON line to
|
|
@@ -306,6 +339,8 @@ npx @devrik-tools/claude-gates init
|
|
|
306
339
|
|
|
307
340
|
# Non-interactive (for CI or scripts):
|
|
308
341
|
claude-gates init --project|--global --defaults|--all|--none|--families a,b|--gates x,y --yes --dry-run
|
|
342
|
+
claude-gates init --new # ONLY gates this config never decided about — upgrade
|
|
343
|
+
# without re-answering or changing anything already set
|
|
309
344
|
claude-gates init --no-install # write the config but do not install the plugin
|
|
310
345
|
claude-gates init --force # apply changes to gates already in the file without asking
|
|
311
346
|
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// artifacts.mjs — the one definition of what a GENERATED artifact looks like and where it
|
|
2
|
+
// goes. Everything an agent produces that is not source code lands in one of a few shapes:
|
|
3
|
+
// a deterministic check it wrote so we can see whether something works, an audit result, a
|
|
4
|
+
// note explaining what it did, or an entry in the recurrence registry when it tripped on
|
|
5
|
+
// the same thing twice. Before this module each of those existed exactly once, in its own
|
|
6
|
+
// ad-hoc shape and its own ad-hoc place — GATES.md at the repo root, `.ai/tasks/
|
|
7
|
+
// .audit-reuse.md` hidden inside the task store, `impl.md` buried under a feature's task
|
|
8
|
+
// directory — so nothing could be found by convention and nothing could be checked.
|
|
9
|
+
//
|
|
10
|
+
// The module is deliberately shaped like registry.mjs, the pattern this repo already uses
|
|
11
|
+
// for "declare it once, validate it with zod": the KINDS table below is the contract, the
|
|
12
|
+
// generator renders from it, and the conformance test validates against it. A new required
|
|
13
|
+
// section is added in one place and both halves follow.
|
|
14
|
+
//
|
|
15
|
+
// Front matter is the machine-readable half and is identical across kinds, so a tool can
|
|
16
|
+
// answer "what is this file, who asked for it, is it still open" without parsing prose.
|
|
17
|
+
// The required SECTIONS are the human half, and they differ per kind because what makes a
|
|
18
|
+
// check trustworthy (a command, an expectation, the evidence it actually produced) is not
|
|
19
|
+
// what makes an audit trustworthy (what was searched, what exists, what was decided).
|
|
20
|
+
|
|
21
|
+
import { z } from 'zod';
|
|
22
|
+
import {
|
|
23
|
+
ARTIFACT_DIRECTORIES,
|
|
24
|
+
ARTIFACT_EXTENSION,
|
|
25
|
+
PROJECT_STATE_DIRECTORY,
|
|
26
|
+
} from './constants.mjs';
|
|
27
|
+
|
|
28
|
+
const SLUG_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
29
|
+
const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
30
|
+
|
|
31
|
+
/** Statuses an artifact can carry. `open` is the only one a generator ever writes. */
|
|
32
|
+
export const ARTIFACT_STATUSES = ['open', 'passed', 'failed', 'closed'];
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The contract, per kind. `sections` are the level-2 headings the body must contain, in
|
|
36
|
+
* any order; `summary` is what the kind is for, shown by `new --help` and in the docs.
|
|
37
|
+
*/
|
|
38
|
+
export const KINDS = Object.freeze({
|
|
39
|
+
check: {
|
|
40
|
+
directory: ARTIFACT_DIRECTORIES.check,
|
|
41
|
+
summary:
|
|
42
|
+
'A deterministic verification: the command to run, what it must print, and the ' +
|
|
43
|
+
'evidence it actually printed. Written so a claim can be re-checked by anyone.',
|
|
44
|
+
sections: ['Check', 'Expect', 'Evidence'],
|
|
45
|
+
},
|
|
46
|
+
audit: {
|
|
47
|
+
directory: ARTIFACT_DIRECTORIES.audit,
|
|
48
|
+
summary:
|
|
49
|
+
'The result of looking before building: what was searched, what already exists, ' +
|
|
50
|
+
'what is genuinely missing, and the decision that followed.',
|
|
51
|
+
sections: ['Searched', 'Exists', 'Missing', 'Decision'],
|
|
52
|
+
},
|
|
53
|
+
note: {
|
|
54
|
+
directory: ARTIFACT_DIRECTORIES.note,
|
|
55
|
+
summary:
|
|
56
|
+
'What was done and why, for work whose reasoning would otherwise live only in a ' +
|
|
57
|
+
'chat log: the situation, what changed, and what it cost or left open.',
|
|
58
|
+
sections: ['Context', 'Change', 'Outcome'],
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
export const ARTIFACT_KINDS = Object.keys(KINDS);
|
|
63
|
+
|
|
64
|
+
export const frontMatterSchema = z.object({
|
|
65
|
+
kind: z.enum(ARTIFACT_KINDS),
|
|
66
|
+
slug: z.string().regex(SLUG_PATTERN, 'slug must be kebab-case'),
|
|
67
|
+
title: z.string().min(1, 'title must not be empty'),
|
|
68
|
+
created: z.string().regex(ISO_DATE_PATTERN, 'created must be YYYY-MM-DD'),
|
|
69
|
+
status: z.enum(ARTIFACT_STATUSES),
|
|
70
|
+
source: z.string().min(1, 'source must say what asked for this artifact'),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// The recurrence registry is not a document but a record, so it is a JSON schema rather
|
|
74
|
+
// than front matter. The shape is READ FROM the recurrence-lock gate, not invented here:
|
|
75
|
+
// the gate counts `classes[].occurrences[]` and reopens on anything whose `status` is not
|
|
76
|
+
// closed, so those three fields are what a generated entry must carry.
|
|
77
|
+
export const recurrenceSchema = z.object({
|
|
78
|
+
class: z.string().min(1, 'class names the defect class, not one instance'),
|
|
79
|
+
occurrences: z
|
|
80
|
+
.array(z.object({ id: z.string().min(1), note: z.string().optional() }))
|
|
81
|
+
.min(1),
|
|
82
|
+
status: z.enum(['open', 'closed', 'cerrada']),
|
|
83
|
+
block: z
|
|
84
|
+
.string()
|
|
85
|
+
.min(1, 'block names the deterministic guard that closes the class')
|
|
86
|
+
.optional(),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
export const recurrenceRegistrySchema = z.object({
|
|
90
|
+
classes: z.array(recurrenceSchema),
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
/** Where an artifact of this kind and slug belongs, relative to the project root. */
|
|
94
|
+
export function artifactPathFor(kind, slug) {
|
|
95
|
+
const definition = KINDS[kind];
|
|
96
|
+
if (!definition) throw new Error(`unknown artifact kind: ${kind}`);
|
|
97
|
+
if (!SLUG_PATTERN.test(slug))
|
|
98
|
+
throw new Error(`slug must be kebab-case: ${slug}`);
|
|
99
|
+
return [
|
|
100
|
+
PROJECT_STATE_DIRECTORY,
|
|
101
|
+
definition.directory,
|
|
102
|
+
`${slug}${ARTIFACT_EXTENSION}`,
|
|
103
|
+
].join('/');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── Parsing ─────────────────────────────────────────────────────────────────────────
|
|
107
|
+
// Line by line, no multi-line regex, so a long body can never backtrack — the same
|
|
108
|
+
// approach lib/capabilities.mjs uses on skill front matter.
|
|
109
|
+
function splitFrontMatter(text) {
|
|
110
|
+
const lines = String(text ?? '').split(/\r?\n/);
|
|
111
|
+
if (lines[0]?.trim() !== '---') return { fields: null, body: text ?? '' };
|
|
112
|
+
const fields = {};
|
|
113
|
+
let index = 1;
|
|
114
|
+
for (; index < lines.length; index += 1) {
|
|
115
|
+
if (lines[index].trim() === '---') break;
|
|
116
|
+
const separator = lines[index].indexOf(':');
|
|
117
|
+
if (separator < 0) continue;
|
|
118
|
+
const key = lines[index].slice(0, separator).trim();
|
|
119
|
+
fields[key] = lines[index]
|
|
120
|
+
.slice(separator + 1)
|
|
121
|
+
.trim()
|
|
122
|
+
.replace(/^["']|["']$/g, '');
|
|
123
|
+
}
|
|
124
|
+
return { fields, body: lines.slice(index + 1).join('\n') };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const SECTION_PREFIX = '## ';
|
|
128
|
+
|
|
129
|
+
function headingsIn(body) {
|
|
130
|
+
return body
|
|
131
|
+
.split(/\r?\n/)
|
|
132
|
+
.filter((line) => line.startsWith(SECTION_PREFIX))
|
|
133
|
+
.map((line) => line.slice(SECTION_PREFIX.length).trim());
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Every way an artifact file breaks the contract, as human-readable strings. Empty means
|
|
138
|
+
* it conforms. Returning a list (never throwing) is what lets the conformance test report
|
|
139
|
+
* every drifted file in one run instead of stopping at the first.
|
|
140
|
+
*/
|
|
141
|
+
export function artifactProblems(text, { kind, slug } = {}) {
|
|
142
|
+
const { fields, body } = splitFrontMatter(text);
|
|
143
|
+
if (fields === null)
|
|
144
|
+
return ['missing front matter (the file must open with `---`)'];
|
|
145
|
+
|
|
146
|
+
const parsed = frontMatterSchema.safeParse(fields);
|
|
147
|
+
const problems = parsed.success
|
|
148
|
+
? []
|
|
149
|
+
: parsed.error.issues.map(
|
|
150
|
+
(issue) =>
|
|
151
|
+
`front matter ${issue.path.join('.') || '(root)'}: ${issue.message}`,
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
if (kind && fields.kind !== kind)
|
|
155
|
+
problems.push(
|
|
156
|
+
`kind is "${fields.kind}" but the file sits in the ${kind} directory`,
|
|
157
|
+
);
|
|
158
|
+
if (slug && fields.slug !== slug)
|
|
159
|
+
problems.push(`slug is "${fields.slug}" but the file is named "${slug}"`);
|
|
160
|
+
|
|
161
|
+
const definition = KINDS[fields.kind];
|
|
162
|
+
if (definition) {
|
|
163
|
+
const present = new Set(headingsIn(body));
|
|
164
|
+
const missing = definition.sections.filter(
|
|
165
|
+
(section) => !present.has(section),
|
|
166
|
+
);
|
|
167
|
+
if (missing.length > 0)
|
|
168
|
+
problems.push(`missing required section(s): ${missing.join(', ')}`);
|
|
169
|
+
}
|
|
170
|
+
return problems;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── Generation ──────────────────────────────────────────────────────────────────────
|
|
174
|
+
const PLACEHOLDERS = {
|
|
175
|
+
Check: 'The exact command, copy-pasteable, that decides this.',
|
|
176
|
+
Expect:
|
|
177
|
+
'What that command must print or exit with for this to count as passing.',
|
|
178
|
+
Evidence:
|
|
179
|
+
'What it ACTUALLY printed when run. Never fill this in before running it.',
|
|
180
|
+
Searched:
|
|
181
|
+
'Where you looked: this repo, installed deps, the registry, the web.',
|
|
182
|
+
Exists: 'What you found that already covers part of this.',
|
|
183
|
+
Missing:
|
|
184
|
+
'What genuinely does not exist yet, and is therefore worth building.',
|
|
185
|
+
Decision: 'What was decided and why, in one or two sentences.',
|
|
186
|
+
Context: 'The situation this work started from.',
|
|
187
|
+
Change: 'What actually changed, concretely.',
|
|
188
|
+
Outcome: 'The result, including what it cost or left open.',
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
function todayIso(now = new Date()) {
|
|
192
|
+
return now.toISOString().slice(0, 'YYYY-MM-DD'.length);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The skeleton for a new artifact: valid front matter plus every required section. */
|
|
196
|
+
export function renderArtifact({ kind, slug, title, source, now }) {
|
|
197
|
+
const definition = KINDS[kind];
|
|
198
|
+
if (!definition) throw new Error(`unknown artifact kind: ${kind}`);
|
|
199
|
+
const frontMatter = [
|
|
200
|
+
'---',
|
|
201
|
+
`kind: ${kind}`,
|
|
202
|
+
`slug: ${slug}`,
|
|
203
|
+
`title: ${title}`,
|
|
204
|
+
`created: ${todayIso(now)}`,
|
|
205
|
+
'status: open',
|
|
206
|
+
`source: ${source}`,
|
|
207
|
+
'---',
|
|
208
|
+
];
|
|
209
|
+
const sections = definition.sections.map(
|
|
210
|
+
(section) => `## ${section}\n\n${PLACEHOLDERS[section] ?? 'TODO'}\n`,
|
|
211
|
+
);
|
|
212
|
+
return `${frontMatter.join('\n')}\n\n# ${title}\n\n${sections.join('\n')}`;
|
|
213
|
+
}
|
package/cli/constants.mjs
CHANGED
|
@@ -23,6 +23,20 @@ export const CONFIG_FILE = 'config.json';
|
|
|
23
23
|
/** Markers that identify a project root while climbing from the cwd. */
|
|
24
24
|
export const PROJECT_ROOT_MARKERS = ['.git', PROJECT_STATE_DIRECTORY];
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Where each kind of GENERATED artifact lives, relative to the project's `.ai/` root.
|
|
28
|
+
* Declared here (not inside artifacts.mjs) for the same reason every other path is: one
|
|
29
|
+
* place to read, one place to change. These four directories are the whole answer to
|
|
30
|
+
* "where does this go" — an artifact that fits no kind does not get invented a home.
|
|
31
|
+
*/
|
|
32
|
+
export const ARTIFACT_DIRECTORIES = Object.freeze({
|
|
33
|
+
check: 'checks',
|
|
34
|
+
audit: 'audits',
|
|
35
|
+
note: 'notes',
|
|
36
|
+
});
|
|
37
|
+
export const RECURRENCES_FILE = 'reincidencias.json';
|
|
38
|
+
export const ARTIFACT_EXTENSION = '.md';
|
|
39
|
+
|
|
26
40
|
/** Global-scope config lives under Claude Code's own user directory. */
|
|
27
41
|
export const CLAUDE_USER_DIRECTORY = '.claude';
|
|
28
42
|
export const GLOBAL_STATE_DIRECTORY = 'claude-gates';
|
package/cli/doctor.mjs
CHANGED
|
@@ -53,7 +53,8 @@ function withoutTrailingSlashes(text) {
|
|
|
53
53
|
return text.slice(0, end).toLowerCase();
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
/** Semver-ish numeric compare, shared with install.mjs so both judge staleness alike. */
|
|
57
|
+
export function compareVersions(a, b) {
|
|
57
58
|
const left = String(a).split('.').map(Number);
|
|
58
59
|
const right = String(b).split('.').map(Number);
|
|
59
60
|
for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
|
package/cli/index.mjs
CHANGED
|
@@ -2,10 +2,17 @@
|
|
|
2
2
|
// Entry point (commander). Commands:
|
|
3
3
|
// init interactive (or flag-driven) selection of gates, per project or globally
|
|
4
4
|
// registry --check validates registry.json; --list prints the catalog
|
|
5
|
+
// new scaffold a generated artifact (check/audit/note) in the standard shape
|
|
5
6
|
|
|
6
|
-
import { readFileSync } from 'node:fs';
|
|
7
|
-
import { join } from 'node:path';
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { dirname, join } from 'node:path';
|
|
8
9
|
import { Command } from 'commander';
|
|
10
|
+
import {
|
|
11
|
+
ARTIFACT_KINDS,
|
|
12
|
+
KINDS,
|
|
13
|
+
artifactPathFor,
|
|
14
|
+
renderArtifact,
|
|
15
|
+
} from './artifacts.mjs';
|
|
9
16
|
import { SCOPES, configPathFor } from './config.mjs';
|
|
10
17
|
import {
|
|
11
18
|
EXIT_CODE,
|
|
@@ -174,6 +181,10 @@ program
|
|
|
174
181
|
.option('--defaults', 'enable the recommended defaults')
|
|
175
182
|
.option('--all', 'enable every gate')
|
|
176
183
|
.option('--none', "record a 'no' so you are not asked again")
|
|
184
|
+
.option(
|
|
185
|
+
'--new',
|
|
186
|
+
'only gates this config has never decided about; nothing already in the file is shown, asked about or changed',
|
|
187
|
+
)
|
|
177
188
|
.option(
|
|
178
189
|
'--families <ids>',
|
|
179
190
|
'enable whole families, comma-separated',
|
|
@@ -267,6 +278,47 @@ program
|
|
|
267
278
|
)
|
|
268
279
|
.action(() => smokeGates());
|
|
269
280
|
|
|
281
|
+
// Width the kind name is padded to in `new --help`, so the summaries line up.
|
|
282
|
+
const ARTIFACT_KIND_COLUMN_WIDTH = 6;
|
|
283
|
+
|
|
284
|
+
function kindHelp() {
|
|
285
|
+
return ARTIFACT_KINDS.map(
|
|
286
|
+
(kind) =>
|
|
287
|
+
` ${kind.padEnd(ARTIFACT_KIND_COLUMN_WIDTH)} ${KINDS[kind].summary}`,
|
|
288
|
+
).join('\n');
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function newArtifact(kind, slug, options) {
|
|
292
|
+
if (!ARTIFACT_KINDS.includes(kind))
|
|
293
|
+
return fail(`unknown kind "${kind}". Known kinds:\n${kindHelp()}`);
|
|
294
|
+
const title = options.title ?? slug.replaceAll('-', ' ');
|
|
295
|
+
const source = options.source ?? 'unspecified';
|
|
296
|
+
|
|
297
|
+
let relativePath;
|
|
298
|
+
try {
|
|
299
|
+
relativePath = artifactPathFor(kind, slug);
|
|
300
|
+
} catch (error) {
|
|
301
|
+
return fail(error.message);
|
|
302
|
+
}
|
|
303
|
+
const path = join(process.cwd(), relativePath);
|
|
304
|
+
if (existsSync(path) && !options.force)
|
|
305
|
+
return fail(`${relativePath} already exists. Pass --force to overwrite.`);
|
|
306
|
+
|
|
307
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
308
|
+
writeFileSync(path, renderArtifact({ kind, slug, title, source }), 'utf8');
|
|
309
|
+
process.stdout.write(`Wrote ${relativePath}\n`);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
program
|
|
313
|
+
.command('new <kind> <slug>')
|
|
314
|
+
.description(
|
|
315
|
+
`Create a generated artifact with the standard shape. Kinds:\n${kindHelp()}`,
|
|
316
|
+
)
|
|
317
|
+
.option('--title <text>', 'one-line title (default: the slug, spaced)')
|
|
318
|
+
.option('--source <text>', 'what asked for this artifact')
|
|
319
|
+
.option('--force', 'overwrite an existing file')
|
|
320
|
+
.action((kind, slug, options) => newArtifact(kind, slug, options));
|
|
321
|
+
|
|
270
322
|
registerTaskCommand(program);
|
|
271
323
|
|
|
272
324
|
program.parseAsync(process.argv).catch((error) => fail(error.message));
|
package/cli/init.mjs
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
import { loadRegistry, allGates } from './registry.mjs';
|
|
20
20
|
import {
|
|
21
21
|
MODES,
|
|
22
|
+
newGatesFor,
|
|
22
23
|
resolveSelection,
|
|
23
24
|
adoptionOf,
|
|
24
25
|
namedGatesFor,
|
|
@@ -32,6 +33,11 @@ const MODE_OPTIONS = [
|
|
|
32
33
|
hint: 'gates marked default in the registry',
|
|
33
34
|
},
|
|
34
35
|
{ value: MODES.ALL, label: 'Everything', hint: 'every gate in every family' },
|
|
36
|
+
{
|
|
37
|
+
value: MODES.NEW,
|
|
38
|
+
label: 'Only what is new',
|
|
39
|
+
hint: 'lists just the gates this config has never decided about',
|
|
40
|
+
},
|
|
35
41
|
{ value: MODES.FAMILIES, label: 'By family', hint: 'pick whole families' },
|
|
36
42
|
{ value: MODES.GRANULAR, label: 'Granular', hint: 'pick individual gates' },
|
|
37
43
|
{
|
|
@@ -46,6 +52,7 @@ const MODE_BY_OPTION = {
|
|
|
46
52
|
defaults: MODES.DEFAULTS,
|
|
47
53
|
all: MODES.ALL,
|
|
48
54
|
none: MODES.NONE,
|
|
55
|
+
new: MODES.NEW,
|
|
49
56
|
families: MODES.FAMILIES,
|
|
50
57
|
gates: MODES.GRANULAR,
|
|
51
58
|
};
|
|
@@ -153,21 +160,89 @@ async function askGates(registry) {
|
|
|
153
160
|
);
|
|
154
161
|
}
|
|
155
162
|
|
|
163
|
+
/**
|
|
164
|
+
* The "only what is new" prompt: the same grouped picker as `askGates`, but built from
|
|
165
|
+
* `newGatesFor` so nothing already decided in this config is even shown — the point is to
|
|
166
|
+
* adopt what a release added without re-answering, or accidentally flipping, the rest.
|
|
167
|
+
* Everything starts checked when it is a recommended default, matching the other pickers.
|
|
168
|
+
*/
|
|
169
|
+
async function askNewGates(io, registry, existingGates) {
|
|
170
|
+
const fresh = newGatesFor(registry, existingGates);
|
|
171
|
+
if (fresh.length === 0) return { picks: [], none: true };
|
|
172
|
+
|
|
173
|
+
const options = {};
|
|
174
|
+
for (const gate of fresh) {
|
|
175
|
+
const family = registry.families.find((entry) => entry.id === gate.family);
|
|
176
|
+
const label = family?.name ?? gate.family;
|
|
177
|
+
options[label] ??= [];
|
|
178
|
+
options[label].push({
|
|
179
|
+
value: gate.id,
|
|
180
|
+
label: gate.id,
|
|
181
|
+
hint: gate.description,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
const picks = bail(
|
|
185
|
+
await io.groupMultiselect({
|
|
186
|
+
message: `${fresh.length} gate(s) this config has never decided about (space to toggle, enter to confirm)`,
|
|
187
|
+
options,
|
|
188
|
+
initialValues: fresh
|
|
189
|
+
.filter((gate) => gate.default)
|
|
190
|
+
.map((gate) => gate.id),
|
|
191
|
+
required: false,
|
|
192
|
+
}),
|
|
193
|
+
);
|
|
194
|
+
return { picks, none: false };
|
|
195
|
+
}
|
|
196
|
+
|
|
156
197
|
/** `claude plugin install <plugin>@<marketplace>`, read from the marketplace manifest, never hard-coded. */
|
|
157
198
|
|
|
158
199
|
/** Fills in whatever the flags left undecided, asking only when there is a TTY. */
|
|
159
|
-
|
|
200
|
+
/**
|
|
201
|
+
* Fills `picks.gates` for the "only what is new" mode. Needs the config that is about to be
|
|
202
|
+
* written, so it is resolved here rather than in the generic prompt step: the list of new
|
|
203
|
+
* gates is a function of what that file already decided. Scripted runs (`--new --yes`) take
|
|
204
|
+
* the new gates that are recommended defaults — "adopt what the release added" is the only
|
|
205
|
+
* sensible unattended reading of the mode.
|
|
206
|
+
*/
|
|
207
|
+
async function decideNewPicks(io, registry, path, interactive) {
|
|
208
|
+
const existingGates = readConfig(path).data.gates ?? {};
|
|
209
|
+
if (!interactive)
|
|
210
|
+
return {
|
|
211
|
+
picks: newGatesFor(registry, existingGates)
|
|
212
|
+
.filter((gate) => gate.default)
|
|
213
|
+
.map((gate) => gate.id),
|
|
214
|
+
none: newGatesFor(registry, existingGates).length === 0,
|
|
215
|
+
};
|
|
216
|
+
return askNewGates(io, registry, existingGates);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Fills the picks the FAMILIES/GRANULAR modes need, when a flag did not already supply them. */
|
|
220
|
+
async function askPicksFor(mode, registry, picks, interactive) {
|
|
221
|
+
if (!interactive) return;
|
|
222
|
+
if (mode === MODES.FAMILIES && picks.families.length === 0)
|
|
223
|
+
picks.families = await askFamilies(registry);
|
|
224
|
+
if (mode === MODES.GRANULAR && picks.gates.length === 0)
|
|
225
|
+
picks.gates = await askGates(registry);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function decide(flags, registry, cwd, interactive, io) {
|
|
160
229
|
const scope =
|
|
161
230
|
flags.scope ?? (interactive ? await askScope(cwd) : SCOPES.PROJECT);
|
|
162
231
|
const mode = flags.mode ?? (interactive ? await askMode() : MODES.DEFAULTS);
|
|
163
232
|
const picks = { families: flags.families, gates: flags.gates };
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
233
|
+
await askPicksFor(mode, registry, picks, interactive);
|
|
234
|
+
|
|
235
|
+
if (mode !== MODES.NEW || picks.gates.length > 0)
|
|
236
|
+
return { scope, mode, picks, nothingNew: false };
|
|
237
|
+
|
|
238
|
+
const fresh = await decideNewPicks(
|
|
239
|
+
io,
|
|
240
|
+
registry,
|
|
241
|
+
configPathFor(scope, { cwd }),
|
|
242
|
+
interactive,
|
|
243
|
+
);
|
|
244
|
+
picks.gates = fresh.picks;
|
|
245
|
+
return { scope, mode, picks, nothingNew: fresh.none };
|
|
171
246
|
}
|
|
172
247
|
|
|
173
248
|
function renderSummary(registry, gatesMap) {
|
|
@@ -330,12 +405,23 @@ export async function runInit(
|
|
|
330
405
|
|
|
331
406
|
if (interactive) io.intro('claude-gates');
|
|
332
407
|
|
|
333
|
-
const { scope, mode, picks } = await decide(
|
|
408
|
+
const { scope, mode, picks, nothingNew } = await decide(
|
|
334
409
|
flags,
|
|
335
410
|
registry,
|
|
336
411
|
cwd,
|
|
337
412
|
interactive,
|
|
413
|
+
io,
|
|
338
414
|
);
|
|
415
|
+
if (nothingNew) {
|
|
416
|
+
io.outro(
|
|
417
|
+
'Nothing new: this config already decides about every gate in the registry.',
|
|
418
|
+
);
|
|
419
|
+
return {
|
|
420
|
+
path: configPathFor(scope, { cwd }),
|
|
421
|
+
config: null,
|
|
422
|
+
written: false,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
339
425
|
const gates = resolveSelection(registry, mode, picks);
|
|
340
426
|
const path = configPathFor(scope, { cwd });
|
|
341
427
|
const existing = readConfig(path);
|