@devrik-tools/claude-gates 0.8.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.
Files changed (34) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/README.es.md +102 -9
  3. package/README.md +93 -9
  4. package/cli/artifacts.mjs +213 -0
  5. package/cli/constants.mjs +14 -0
  6. package/cli/doctor.mjs +2 -1
  7. package/cli/index.mjs +54 -2
  8. package/cli/init.mjs +95 -9
  9. package/cli/install.mjs +53 -1
  10. package/cli/registry.mjs +2 -0
  11. package/cli/selection.mjs +23 -1
  12. package/cli/smoke-fixtures.json +53 -3
  13. package/cli/task.mjs +69 -4
  14. package/package.json +5 -4
  15. package/plugins/gates/.claude-plugin/plugin.json +1 -1
  16. package/plugins/gates/hooks/gates/capability-map/index.mjs +37 -208
  17. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +4 -11
  18. package/plugins/gates/hooks/gates/circuit-breaker/track.mjs +285 -0
  19. package/plugins/gates/hooks/gates/force-parallel/index.mjs +11 -12
  20. package/plugins/gates/hooks/gates/library-docs/index.mjs +107 -31
  21. package/plugins/gates/hooks/gates/no-trivial-scripts/index.mjs +114 -0
  22. package/plugins/gates/hooks/gates/require-monitor/index.mjs +126 -0
  23. package/plugins/gates/hooks/gates/require-task-split/index.mjs +88 -0
  24. package/plugins/gates/hooks/gates/skill-first/index.mjs +138 -0
  25. package/plugins/gates/hooks/gates/skill-first/track.mjs +66 -0
  26. package/plugins/gates/hooks/hooks.json +61 -1
  27. package/plugins/gates/hooks/lib/capabilities.mjs +401 -0
  28. package/plugins/gates/hooks/lib/hook-io.mjs +9 -2
  29. package/plugins/gates/hooks/lib/signals.mjs +91 -0
  30. package/plugins/gates/hooks/lib/testing.mjs +15 -4
  31. package/plugins/tasks/.claude-plugin/plugin.json +1 -1
  32. package/plugins/tasks/hooks/lib/task-store.mjs +6 -0
  33. package/plugins/tasks/hooks/register-requests.mjs +37 -10
  34. package/registry.json +106 -5
@@ -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);
package/cli/install.mjs CHANGED
@@ -5,11 +5,19 @@
5
5
 
6
6
  import { execFileSync } from 'node:child_process';
7
7
  import { readFileSync } from 'node:fs';
8
+ import { join } from 'node:path';
8
9
  import { SCOPES } from './config.mjs';
9
10
  import { MARKETPLACE_PATH, REPOSITORY_ROOT } from './constants.mjs';
11
+ import { compareVersions, parsePluginList } from './doctor.mjs';
10
12
 
11
13
  const CLAUDE_BIN = 'claude';
12
14
 
15
+ /** The version of THIS package — what an install is expected to leave behind. */
16
+ function packageVersion() {
17
+ return JSON.parse(readFileSync(join(REPOSITORY_ROOT, 'package.json'), 'utf8'))
18
+ .version;
19
+ }
20
+
13
21
  // Config scope decides where the plugin is installed: a project selection stays local to
14
22
  // this project (its .claude/settings.json); a global selection installs for every project.
15
23
  const PLUGIN_SCOPE = Object.freeze({
@@ -198,6 +206,47 @@ function removePreviousInstalls(targets, scope, runClaude) {
198
206
  }
199
207
  }
200
208
 
209
+ /**
210
+ * Whether the install ACTUALLY took, described as a problem string (null when it did).
211
+ *
212
+ * `claude plugin install` exiting 0 is not evidence that anything changed: a marketplace
213
+ * still serving a stale path, a plugin that resolves but is never enabled, or an update
214
+ * that silently no-ops all exit 0 too. Reporting success on the exit code alone is what
215
+ * produced the "it says it worked but nothing updated" failure — the installer claimed a
216
+ * result it had not checked, and only `doctor`, run separately and later, ever noticed.
217
+ *
218
+ * So the post-condition is read back from Claude Code itself: the plugin must now appear
219
+ * in `plugin list`, at a version not older than this package's. Reuses doctor's parser
220
+ * rather than a second one, so what install verifies and what doctor reports can never
221
+ * disagree. A verification that cannot run (no `claude` on PATH, unparseable output) is
222
+ * NOT treated as failure — that would turn a working install into a false alarm; only a
223
+ * definite mismatch is reported.
224
+ */
225
+ function installationProblem(runClaude, plugin) {
226
+ let listed;
227
+ try {
228
+ listed = parsePluginList(runClaude(['plugin', 'list']));
229
+ } catch {
230
+ return null;
231
+ }
232
+ if (listed.length === 0) return null;
233
+
234
+ const found = listed.filter((entry) => entry.plugin === plugin);
235
+ if (found.length === 0)
236
+ return 'the install reported success but the plugin is not in `claude plugin list`';
237
+
238
+ const expected = packageVersion();
239
+ const stale = found.filter(
240
+ (entry) => entry.version && compareVersions(entry.version, expected) < 0,
241
+ );
242
+ if (stale.length === found.length)
243
+ return (
244
+ `still running ${stale[0].version} after installing ${expected} — the marketplace is ` +
245
+ 'serving a stale copy. Run `claude plugin marketplace remove`, then re-run init from this package.'
246
+ );
247
+ return null;
248
+ }
249
+
201
250
  /**
202
251
  * Registers the marketplace (idempotent: a second add just reports it already exists, which
203
252
  * is not fatal) once, then installs EVERY plugin the manifest declares at the scope matching
@@ -259,10 +308,13 @@ export function installPlugin(
259
308
  '--scope',
260
309
  scope,
261
310
  ]);
262
- return { plugin, installed: true };
263
311
  } catch (error) {
264
312
  return { plugin, installed: false, reason: reasonFor(error) };
265
313
  }
314
+ const problem = installationProblem(runClaude, plugin);
315
+ return problem
316
+ ? { plugin, installed: false, reason: problem }
317
+ : { plugin, installed: true };
266
318
  });
267
319
 
268
320
  const installed = results.every((result) => result.installed);
package/cli/registry.mjs CHANGED
@@ -21,6 +21,8 @@ export const TOOL_GROUPS = [
21
21
  'execution',
22
22
  'question',
23
23
  'research',
24
+ 'monitor',
25
+ 'skill',
24
26
  ];
25
27
 
26
28
  /** The kinds of value a gate param can take, so the CLI can describe and validate it. */
package/cli/selection.mjs CHANGED
@@ -10,8 +10,25 @@ export const MODES = Object.freeze({
10
10
  GRANULAR: 'granular',
11
11
  DEFAULTS: 'defaults',
12
12
  NONE: 'none',
13
+ /**
14
+ * Only what this config has never decided about. Resolves exactly like GRANULAR — by the
15
+ * time picks arrive the user has already chosen from a list narrowed to the new gates —
16
+ * but the caller builds that list with `newGatesFor`, so an upgrade can adopt what a
17
+ * release added without re-answering, or silently flipping, anything already in the file.
18
+ */
19
+ NEW: 'new',
13
20
  });
14
21
 
22
+ /**
23
+ * The gates a config has never decided about: no entry under `gates` for their configKey.
24
+ * An entry set to `false` counts as DECIDED — the user turned it off on purpose, and
25
+ * offering it again as "new" would be how a deliberate opt-out gets undone by an upgrade.
26
+ */
27
+ export function newGatesFor(registry, existingGates = {}) {
28
+ const decided = new Set(Object.keys(existingGates ?? {}));
29
+ return allGates(registry).filter((gate) => !decided.has(gate.configKey));
30
+ }
31
+
15
32
  function assertKnown(chosen, known, kind) {
16
33
  const unknown = [...chosen].filter((id) => !known.has(id));
17
34
  if (unknown.length > 0)
@@ -35,6 +52,11 @@ const STRATEGIES = {
35
52
  gates.filter((gate) => chosen.has(gate.family)).map((gate) => gate.id),
36
53
  );
37
54
  },
55
+ [MODES.NEW]: (gates, _registry, picks) => {
56
+ const chosen = new Set(picks.gates ?? []);
57
+ assertKnown(chosen, new Set(gates.map((gate) => gate.id)), 'gate');
58
+ return chosen;
59
+ },
38
60
  [MODES.GRANULAR]: (gates, _registry, picks) => {
39
61
  const chosen = new Set(picks.gates ?? []);
40
62
  assertKnown(chosen, new Set(gates.map((gate) => gate.id)), 'gate');
@@ -93,7 +115,7 @@ export function namedGatesFor(registry, mode, picks = {}) {
93
115
  const chosen = new Set(picks.families ?? []);
94
116
  return keysOf((gate) => chosen.has(gate.family));
95
117
  }
96
- if (mode === MODES.GRANULAR) {
118
+ if (mode === MODES.GRANULAR || mode === MODES.NEW) {
97
119
  const chosen = new Set(picks.gates ?? []);
98
120
  return keysOf((gate) => chosen.has(gate.id));
99
121
  }