@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 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 49 gates**; la configuración decide
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 (49, en 11 familias)
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), o apagá la persistencia (`"persist": false`) y apuntá
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 49 gates**; the config decides **which
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 (49, in 11 families)
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), or turn off persistence (`"persist": false`) and point the
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
- function compareVersions(a, b) {
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
- async function decide(flags, registry, cwd, interactive) {
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
- if (interactive && mode === MODES.FAMILIES && picks.families.length === 0) {
165
- picks.families = await askFamilies(registry);
166
- }
167
- if (interactive && mode === MODES.GRANULAR && picks.gates.length === 0) {
168
- picks.gates = await askGates(registry);
169
- }
170
- return { scope, mode, picks };
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);