@devrik-tools/claude-gates 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/README.es.md +25 -5
  3. package/README.md +23 -5
  4. package/cli/smoke-fixtures.json +14 -0
  5. package/package.json +1 -1
  6. package/plugins/gates/.claude-plugin/plugin.json +1 -1
  7. package/plugins/gates/hooks/gates/atomic-commit/index.mjs +2 -2
  8. package/plugins/gates/hooks/gates/audit-before-build/index.mjs +2 -2
  9. package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +1 -1
  10. package/plugins/gates/hooks/gates/bash-commands/index.mjs +3 -3
  11. package/plugins/gates/hooks/gates/block-remote-publish/index.mjs +2 -2
  12. package/plugins/gates/hooks/gates/brief-approved/index.mjs +216 -0
  13. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +3 -3
  14. package/plugins/gates/hooks/gates/capability-map/index.mjs +366 -42
  15. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +1 -1
  16. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +1 -1
  17. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +1 -1
  18. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +2 -2
  19. package/plugins/gates/hooks/gates/force-parallel/index.mjs +1 -1
  20. package/plugins/gates/hooks/gates/forge-flow/index.mjs +2 -2
  21. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +1 -1
  22. package/plugins/gates/hooks/gates/intent-flow/index.mjs +3 -3
  23. package/plugins/gates/hooks/gates/lint-commit/index.mjs +2 -2
  24. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +3 -3
  25. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +1 -1
  26. package/plugins/gates/hooks/gates/never-assume/index.mjs +1 -1
  27. package/plugins/gates/hooks/gates/no-blocking/index.mjs +1 -1
  28. package/plugins/gates/hooks/gates/no-coauthor/index.mjs +1 -1
  29. package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +1 -1
  30. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +1 -1
  31. package/plugins/gates/hooks/gates/no-reconfirm/index.mjs +1 -1
  32. package/plugins/gates/hooks/gates/protected-paths/index.mjs +2 -2
  33. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +1 -1
  34. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +2 -2
  35. package/plugins/gates/hooks/gates/risk-level/index.mjs +3 -3
  36. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +1 -1
  37. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +1 -1
  38. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +2 -2
  39. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +3 -3
  40. package/plugins/gates/hooks/gates/staged-lint/index.mjs +2 -2
  41. package/plugins/gates/hooks/gates/stop-pending/index.mjs +1 -2
  42. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +1 -1
  43. package/plugins/gates/hooks/gates/test-matrix/index.mjs +1 -1
  44. package/plugins/gates/hooks/gates/tool-map/index.mjs +1 -1
  45. package/plugins/gates/hooks/hooks.json +10 -0
  46. package/plugins/gates/hooks/lib/hook-io.mjs +1 -1
  47. package/plugins/tasks/.claude-plugin/plugin.json +1 -1
  48. package/registry.json +22 -1
@@ -1,11 +1,13 @@
1
1
  // capability-map — UserPromptSubmit hook. Surfaces the REAL, current catalog of the project's
2
2
  // AI capabilities — skills, agents/subagents, and commands — as compact data ("caveman"
3
- // format: `name — first clause`, one line each, grouped by kind), read fresh from disk on
4
- // every message. It also PERSISTS that catalog to .ai/capability-map.json so the map is easy
5
- // to follow and read the same way the tool map (.ai/tool-map.json) is, and so another tool or
6
- // a human can consult it without re-scanning. Autosynced by construction: add or remove a
7
- // skill/agent/command file and the next message reflects it, because the map IS the directory
8
- // listing, never a hardcoded copy.
3
+ // format: `name — first clause`, one line each, grouped by kind). It also PERSISTS that
4
+ // catalog to .ai/capability-map.json so the map is easy to follow and read the same way the
5
+ // tool map (.ai/tool-map.json) is, and so another tool or a human can consult it without
6
+ // re-scanning. Autosynced by construction: add or remove a skill/agent/command file and the
7
+ // next message reflects it (a removed capability's entry disappears with it — the catalog is
8
+ // derived from what is on disk NOW, never a stale copy of what used to be there), because the
9
+ // catalog IS the directory listing. Re-deriving blurbs (the only non-trivial per-entry work)
10
+ // is skipped when disk is provably unchanged since the last scan — see fingerprintOf below.
9
11
  //
10
12
  // It does NOT tell the model to obey a reminder — that would be prose the model may ignore,
11
13
  // the exact antipattern guard-no-model-reliance forbids. It injects a fact (which capabilities
@@ -29,15 +31,46 @@
29
31
  // Each injected line is `name — first clause` (up to the first '. ' or a hard char cap), so
30
32
  // the whole catalog stays cheap even at dozens of entries.
31
33
  //
34
+ // ── Blurb overrides for descriptions that don't fit ─────────────────────────────────────
35
+ // When a description's first clause is longer than `maxClauseChars`, mechanical truncation
36
+ // (even at a word boundary) loses the point of a dense one-liner (e.g. "Referencia normativa
37
+ // completa de accesibilidad web (WCAG 2.2, ARIA, teclado, lectores de…" tells you nothing).
38
+ // Writing an actual summary needs judgment a Node hook does not have — so instead this gate
39
+ // reads a human/assistant-authored override by capability name from `~/.claude/blurb-
40
+ // overrides.json` and `<project>/<blurbOverridesFile>` (project wins per-key) and uses it
41
+ // verbatim (still capped at maxClauseChars, in case an override itself runs long). A
42
+ // capability with no override falls back to the mechanical truncation — never blocked on
43
+ // someone writing every override up front.
44
+ //
45
+ // ── Throttled injection, not every message ──────────────────────────────────────────────
46
+ // Injecting the full catalog on every UserPromptSubmit burns context on every turn for a
47
+ // catalog that rarely changes turn-to-turn. `injectEveryMessages` (default 10, same shape as
48
+ // tasks' remindEveryMessages) counts messages via a small persisted counter next to the map
49
+ // file and only injects on the Nth. The persisted .ai/capability-map.json is still refreshed
50
+ // from disk on EVERY message regardless of the counter — persistence is cheap (a file write,
51
+ // not context) and staying accurate matters even between injections.
52
+ //
53
+ // ── Skills also scanned outside the .claude layout, unconditionally ────────────────────
54
+ // `~/.agents/skills`, `<project>/.agents/skills`, `~/.ai/skills`, `<project>/.ai/skills` are
55
+ // scanned as skill roots by default (no config needed) alongside `.claude/skills` — these
56
+ // are skill directories other installers are known to use (`.agents/skills` is what the
57
+ // `skills` CLI several installers shell out to writes when run without its `-g` flag: a
58
+ // project-local skill tree, not `~/.claude`). Agents/commands are NOT looked for under these
59
+ // roots — only `.claude` is known to lay those out as siblings of `skills`.
60
+ //
32
61
  // ── What a project can configure (params) — everything is customizable ───────────────
33
- // kinds which capability kinds to include, e.g. ["skills","agents","commands"].
34
- // Drop one to stop scanning it entirely.
35
- // maxClauseChars hard cap on each entry's one-line blurb (default 90).
62
+ // kinds which capability kinds to include, e.g. ["skills","agents","commands"].
63
+ // Drop one to stop scanning it entirely.
64
+ // maxClauseChars hard cap on each entry's one-line blurb (default 120).
36
65
  // extraSkillsDirs / extraAgentsDirs / extraCommandsDirs additional roots per kind
37
- // (relative to project or absolute), added on top of the built-in ones.
38
- // persist whether to write .ai/capability-map.json (default true).
39
- // mapFile path to the persisted map, relative to project root. Default
40
- // .ai/capability-map.json.
66
+ // (relative to project or absolute), added on top of the built-in ones.
67
+ // persist whether to write .ai/capability-map.json (default true).
68
+ // mapFile path to the persisted map, relative to project root. Default
69
+ // .ai/capability-map.json.
70
+ // injectEveryMessages inject the rendered catalog only every Nth message (default 10);
71
+ // the persisted map file still refreshes every message regardless.
72
+ // blurbOverridesFile path to the overrides JSON, relative to project root. Default
73
+ // .ai/blurb-overrides.json.
41
74
  //
42
75
  // ── Fail-safe shape ──────────────────────────────────────────────────────────────────
43
76
  // Nothing found anywhere, gate disabled, or unreadable input: inject nothing (silent, no
@@ -45,11 +78,13 @@
45
78
  // swallowed: the injection is the job, the file is a convenience. Never blocks —
46
79
  // UserPromptSubmit cannot deny; it only adds context.
47
80
 
81
+ import { createHash } from 'node:crypto';
48
82
  import {
49
83
  existsSync,
50
84
  mkdirSync,
51
85
  readFileSync,
52
86
  readdirSync,
87
+ statSync,
53
88
  writeFileSync,
54
89
  } from 'node:fs';
55
90
  import { homedir } from 'node:os';
@@ -58,9 +93,17 @@ import { basename, dirname, extname, isAbsolute, join } from 'node:path';
58
93
  const STDIN_FILE_DESCRIPTOR = 0;
59
94
  const CONFIG_KEY = 'injectCapabilityMap';
60
95
  const DUMP_ENV = 'CLAUDE_GATES_DUMP_DEFAULTS';
61
- const DEFAULT_MAX_CLAUSE_CHARS = 90;
96
+ // The registry default this gate is shipped with. Kept here (not only in dumpDefaults) so
97
+ // main() and dumpDefaults agree by construction — a value duplicated in two places is
98
+ // exactly the desync that let a gate silently miss its own registry default before (see
99
+ // the project's default-desync incident: registry.json and the gate's own hardcoded
100
+ // enabledByDefault drifting apart across a version bump).
101
+ const ENABLED_BY_DEFAULT = true;
102
+ const DEFAULT_MAX_CLAUSE_CHARS = 120;
62
103
  const DEFAULT_KINDS = ['skills', 'agents', 'commands'];
63
104
  const DEFAULT_MAP_FILE = join('.ai', 'capability-map.json');
105
+ const DEFAULT_BLURB_OVERRIDES_FILE = join('.ai', 'blurb-overrides.json');
106
+ const DEFAULT_INJECT_EVERY_MESSAGES = 10;
64
107
  const JSON_INDENT = 2;
65
108
 
66
109
  // Config lookup mirrors config.mjs (project → global), kept local so a gate stays runnable on
@@ -91,7 +134,13 @@ function projectRootOf(startDirectory) {
91
134
  }
92
135
  }
93
136
 
94
- /** The capability-map gate config entry (project overrides global), or null. */
137
+ /** The capability-map gate config entry (project overrides global). `enabled` is `true`/
138
+ * `false` only when a project or global config explicitly declared it — absent (undefined)
139
+ * means "nothing was declared, fall back to the registry default", the same three-state
140
+ * shape config.mjs's isGateEnabled uses for every other gate. Distinguishing "never
141
+ * declared" from "explicitly false" matters once ENABLED_BY_DEFAULT is true: without it, a
142
+ * project that never touched this gate's config would look identical to one that turned it
143
+ * off, and the registry default could never take effect. */
95
144
  function gateConfig(startDirectory) {
96
145
  const root = projectRootOf(startDirectory);
97
146
  const projectData = root ? readJson(join(root, PROJECT_CONFIG)) : null;
@@ -100,7 +149,7 @@ function gateConfig(startDirectory) {
100
149
  const entry = layer[CONFIG_KEY];
101
150
  if (typeof entry === 'boolean') return { enabled: entry };
102
151
  if (entry && typeof entry === 'object') return entry;
103
- return null;
152
+ return {};
104
153
  }
105
154
 
106
155
  function readPayload() {
@@ -111,11 +160,36 @@ function readPayload() {
111
160
  }
112
161
  }
113
162
 
163
+ // A YAML folded/literal block scalar indicator with nothing else on the line
164
+ // (`description: >`, `description: >-`, `description: |`, `description: |-`): the real
165
+ // value is every following indented line, not this one. Seen across `.agents/skills`
166
+ // SKILL.md files (e.g. api-architect, babysit, branch-pr) — without this, the parser took
167
+ // the bare indicator itself as the description, rendering blurbs like `">"` or `">-"`.
168
+ const BLOCK_SCALAR_INDICATOR_PATTERN = /^[|>][+-]?\d*$/;
169
+
170
+ /** Every following line indented relative to the block's own indentation, joined with a
171
+ * single space (folded-scalar semantics — good enough for a one-line blurb; literal `|`
172
+ * blocks are folded too, which only affects a display detail this gate strips anyway via
173
+ * firstClause). Stops at the first line that is blank or not indented (front matter end,
174
+ * or a sibling key). */
175
+ function readBlockScalarValue(lines, startIndex) {
176
+ const parts = [];
177
+ for (let index = startIndex; index < lines.length; index += 1) {
178
+ const line = lines[index];
179
+ if (line.trim() === '---') break;
180
+ if (!/^[ \t]+\S/.test(line)) break; // not indented: block scalar ended
181
+ parts.push(line.trim());
182
+ }
183
+ return parts.join(' ');
184
+ }
185
+
114
186
  /**
115
187
  * The `name` and `description` from a markdown-style front matter block (skills, agents,
116
188
  * commands all use `---`-fenced YAML-ish front matter). Parsed line-by-line (no multi-line
117
- * regex) so a large file body can never trigger catastrophic backtracking. Returns
118
- * { name, description } with either possibly ''.
189
+ * regex) so a large file body can never trigger catastrophic backtracking. Handles a plain
190
+ * scalar value on the `key:` line itself, and a YAML folded/literal block scalar (`>`, `>-`,
191
+ * `|`, `|-`) whose value lives on the following indented lines. Returns { name, description }
192
+ * with either possibly ''.
119
193
  */
120
194
  function parseFrontMatter(fileText) {
121
195
  const lines = fileText.split(/\r?\n/);
@@ -129,25 +203,38 @@ function parseFrontMatter(fileText) {
129
203
  const separator = line.indexOf(':');
130
204
  if (separator < 0) continue;
131
205
  const key = line.slice(0, separator).trim();
132
- const value = line
206
+ let value = line
133
207
  .slice(separator + 1)
134
208
  .trim()
135
209
  .replace(/^["']|["']$/g, ''); // toml/yaml quoting around the value
210
+ if (BLOCK_SCALAR_INDICATOR_PATTERN.test(value)) {
211
+ value = readBlockScalarValue(lines, index + 1);
212
+ }
136
213
  if (key === 'name') name = value;
137
214
  else if (key === 'description') description = value;
138
215
  }
139
216
  return { name, description };
140
217
  }
141
218
 
219
+ /** Truncates text to at most maxChars, breaking at the last whitespace boundary before
220
+ * the limit rather than mid-word — a hard char-index cut turns "lectores de pantalla"
221
+ * into "lectores de…", losing the word instead of just the tail of the sentence. Falls
222
+ * back to a hard cut only when there is no whitespace to break on (one very long word). */
223
+ function truncateAtWordBoundary(text, maxChars) {
224
+ if (text.length <= maxChars) return text;
225
+ const budget = text.slice(0, maxChars - 1);
226
+ const lastSpace = budget.lastIndexOf(' ');
227
+ const cut = lastSpace > 0 ? budget.slice(0, lastSpace) : budget;
228
+ return `${cut.trimEnd()}…`;
229
+ }
230
+
142
231
  /** The description's first clause, capped — the caveman blurb. */
143
232
  function firstClause(description, maxClauseChars) {
144
233
  if (!description) return '';
145
234
  const sentenceEnd = description.indexOf('. ');
146
235
  const clause =
147
236
  sentenceEnd > 0 ? description.slice(0, sentenceEnd) : description;
148
- return clause.length > maxClauseChars
149
- ? `${clause.slice(0, maxClauseChars - 1).trimEnd()}…`
150
- : clause;
237
+ return truncateAtWordBoundary(clause, maxClauseChars);
151
238
  }
152
239
 
153
240
  /** Recursively lists files under a directory whose extension is in `extensions`. */
@@ -171,7 +258,21 @@ function filesUnder(directory, extensions) {
171
258
  return files;
172
259
  }
173
260
 
174
- /** Skill capabilities under one root: <root>/skills/<name>/SKILL.md. */
261
+ /** The file's mtime in ms, or null when it cannot be stat'd (broken junction, race). A
262
+ * source this gate cannot stat contributes nothing stable to the fingerprint — treated as
263
+ * absent so a dangling link does not poison every future comparison with a NaN/undefined. */
264
+ function mtimeMsOf(path) {
265
+ try {
266
+ return statSync(path).mtimeMs;
267
+ } catch {
268
+ return null;
269
+ }
270
+ }
271
+
272
+ /** Skill capabilities under one root: <root>/skills/<name>/SKILL.md. Each entry carries
273
+ * `stamp: "<path>:<mtimeMs>"`, the unit the disk fingerprint is built from (see
274
+ * fingerprintOf) — cheap because it reuses the stat already needed to read the file, no
275
+ * second filesystem pass. */
175
276
  function skillEntriesUnder(skillsRoot, maxClauseChars) {
176
277
  if (!existsSync(skillsRoot)) return [];
177
278
  let skillDirectories;
@@ -184,7 +285,8 @@ function skillEntriesUnder(skillsRoot, maxClauseChars) {
184
285
  for (const skillDirectory of skillDirectories) {
185
286
  if (!skillDirectory.isDirectory()) continue;
186
287
  const skillFile = join(skillsRoot, skillDirectory.name, 'SKILL.md');
187
- if (!existsSync(skillFile)) continue;
288
+ const mtimeMs = mtimeMsOf(skillFile);
289
+ if (mtimeMs === null) continue; // missing or a dangling link: not a real source
188
290
  let content;
189
291
  try {
190
292
  content = readFileSync(skillFile, 'utf8');
@@ -194,16 +296,21 @@ function skillEntriesUnder(skillsRoot, maxClauseChars) {
194
296
  const { name, description } = parseFrontMatter(content);
195
297
  entries.push({
196
298
  name: name || skillDirectory.name,
197
- blurb: firstClause(description, maxClauseChars),
299
+ description,
300
+ maxClauseChars,
301
+ stamp: `${skillFile}:${mtimeMs}`,
198
302
  });
199
303
  }
200
304
  return entries;
201
305
  }
202
306
 
203
- /** Agent/command capabilities: flat or nested files whose front matter carries a description. */
307
+ /** Agent/command capabilities: flat or nested files whose front matter carries a description.
308
+ * Same stamp shape as skillEntriesUnder, for the same reason. */
204
309
  function fileEntriesUnder(directory, extensions, maxClauseChars) {
205
310
  const entries = [];
206
311
  for (const file of filesUnder(directory, extensions)) {
312
+ const mtimeMs = mtimeMsOf(file);
313
+ if (mtimeMs === null) continue;
207
314
  let content;
208
315
  try {
209
316
  content = readFileSync(file, 'utf8');
@@ -213,7 +320,9 @@ function fileEntriesUnder(directory, extensions, maxClauseChars) {
213
320
  const { name, description } = parseFrontMatter(content);
214
321
  entries.push({
215
322
  name: name || basename(file, extname(file)),
216
- blurb: firstClause(description, maxClauseChars),
323
+ description,
324
+ maxClauseChars,
325
+ stamp: `${file}:${mtimeMs}`,
217
326
  });
218
327
  }
219
328
  return entries;
@@ -223,11 +332,34 @@ function baseRoots(cwd) {
223
332
  return [join(homedir(), '.claude'), join(cwd, '.claude')];
224
333
  }
225
334
 
335
+ // Skill directories seen in the wild outside the `.claude/skills` layout: the `skills`
336
+ // upstream CLI (invoked by installers like caveman) writes to a project-local
337
+ // `./.agents/skills` when run without its `-g` flag (see caveman/bin/install.js's own
338
+ // comment on issue #836 — the exact bug that produced 65 dangling junctions under
339
+ // `~/.claude/skills` in one real incident: they pointed at a `.agents/skills` that only
340
+ // ever existed relative to the project the installer ran from). `.ai/skills` is included
341
+ // per explicit user instruction, without independent verification of a specific installer
342
+ // using it — kept here rather than as a project-declared default so every project gets it
343
+ // without having to know the installer's quirk. These are SKILL roots only (the directory
344
+ // IS the skills folder, unlike `.claude` where `skills/agents/commands` are siblings under
345
+ // one root) — they never gain agents/commands lookup, which would be inventing a layout
346
+ // this repo has no evidence for.
347
+ function defaultSkillOnlyRoots(cwd) {
348
+ return [
349
+ join(homedir(), '.agents', 'skills'),
350
+ join(cwd, '.agents', 'skills'),
351
+ join(homedir(), '.ai', 'skills'),
352
+ join(cwd, '.ai', 'skills'),
353
+ ];
354
+ }
355
+
226
356
  function resolveExtra(cwd, directory) {
227
357
  return isAbsolute(directory) ? directory : join(cwd, directory);
228
358
  }
229
359
 
230
- /** Every entry for one kind across all roots, de-duplicated by name. */
360
+ /** Every entry for one kind across all roots, de-duplicated by name (each entry still
361
+ * carries description/maxClauseChars/stamp — blurb overrides and truncation are applied
362
+ * later, once, in applyBlurbs, not per-root). */
231
363
  function entriesForKind(kind, cwd, config, maxClauseChars) {
232
364
  const perKind = {
233
365
  skills: {
@@ -260,6 +392,11 @@ function entriesForKind(kind, cwd, config, maxClauseChars) {
260
392
 
261
393
  const collected = [];
262
394
  for (const root of baseRoots(cwd)) collected.push(...perKind.collect(root));
395
+ if (kind === 'skills') {
396
+ for (const root of defaultSkillOnlyRoots(cwd)) {
397
+ collected.push(...skillEntriesUnder(root, maxClauseChars));
398
+ }
399
+ }
263
400
  const extra = Array.isArray(perKind.extra) ? perKind.extra : [];
264
401
  for (const directory of extra) {
265
402
  collected.push(...perKind.collectExtra(resolveExtra(cwd, directory)));
@@ -276,14 +413,118 @@ function entriesForKind(kind, cwd, config, maxClauseChars) {
276
413
  return unique;
277
414
  }
278
415
 
416
+ /** The two override file paths in effect for this cwd (global always; project only when a
417
+ * project root is found), regardless of whether either currently exists. Shared by
418
+ * blurbOverridesFor (reads them) and blurbOverrideStampsFor (fingerprints them) so the two
419
+ * can never drift to different paths. */
420
+ function blurbOverridePathsFor(cwd, blurbOverridesFile) {
421
+ const globalPath = join(homedir(), '.claude', 'blurb-overrides.json');
422
+ const root = projectRootOf(cwd);
423
+ const projectPath = root ? join(root, blurbOverridesFile) : null;
424
+ return projectPath ? [globalPath, projectPath] : [globalPath];
425
+ }
426
+
427
+ /** The overrides map (capability name -> hand-written blurb). Project file wins over the
428
+ * global one entry-by-entry (spread, global first) so a project can override a single
429
+ * global entry without having to repeat the rest. */
430
+ function blurbOverridesFor(cwd, blurbOverridesFile) {
431
+ const [globalPath, projectPath] = blurbOverridePathsFor(
432
+ cwd,
433
+ blurbOverridesFile,
434
+ );
435
+ return {
436
+ ...(readJson(globalPath) ?? {}),
437
+ ...(projectPath ? (readJson(projectPath) ?? {}) : {}),
438
+ };
439
+ }
440
+
441
+ /** Stamps for the override file(s) themselves, in the same "<path>:<mtimeMs>" shape as a
442
+ * capability's stamp — folded into the fingerprint so EDITING AN OVERRIDE (with no skill
443
+ * file touched at all) still invalidates the cached catalog. Without this, a fresh or
444
+ * changed blurb-overrides.json would sit unused: the persisted catalog's fingerprint would
445
+ * still match (no skill/agent/command changed) and resolveCatalog would keep serving the
446
+ * stale, un-overridden blurb forever — the exact bug a real run surfaced (clean-architecture
447
+ * got an override added to blurb-overrides.json after the map had already been generated
448
+ * once, and kept showing the mechanically-truncated text on every later run because nothing
449
+ * ever invalidated the cache). A missing override file contributes a stable "absent" stamp
450
+ * (not skipped) so going from absent -> present is itself a fingerprint change. */
451
+ function blurbOverrideStampsFor(cwd, blurbOverridesFile) {
452
+ return blurbOverridePathsFor(cwd, blurbOverridesFile).map((path) => {
453
+ const mtimeMs = mtimeMsOf(path);
454
+ return `${path}:${mtimeMs === null ? 'absent' : mtimeMs}`;
455
+ });
456
+ }
457
+
458
+ /** Turns a raw entry (description/maxClauseChars/stamp) into the rendered shape
459
+ * (name/blurb/stamp) — an override is used verbatim (still capped, in case it runs long
460
+ * itself); with no override, falls back to the mechanical first-clause truncation. */
461
+ function applyBlurbs(entries, overrides) {
462
+ return entries.map((entry) => {
463
+ const override = overrides[entry.name];
464
+ const blurb = override
465
+ ? truncateAtWordBoundary(override, entry.maxClauseChars)
466
+ : firstClause(entry.description, entry.maxClauseChars);
467
+ return { name: entry.name, blurb, stamp: entry.stamp };
468
+ });
469
+ }
470
+
471
+ /** A stable, order-independent fingerprint of every source file's path+mtime across the
472
+ * whole catalog, plus any `extraStamps` the caller folds in (the override file(s) — see
473
+ * blurbOverrideStampsFor; a capability's rendered blurb depends on both its own source file
474
+ * AND the overrides file, so both must be able to invalidate the cache). Two scans of an
475
+ * otherwise-unchanged disk produce the same fingerprint; adding, removing, or touching any
476
+ * skill/agent/command file, or the overrides file, changes it. Sorted before hashing so
477
+ * filesystem enumeration order (which readdirSync does not guarantee) never causes a false
478
+ * "changed" reading. sha256 not for any security property (this is a change-detection
479
+ * checksum, nothing here is adversarial) — plain sha1 just trips the linter's blanket
480
+ * weak-hash rule, and sha256 is just as cheap at this size. */
481
+ function fingerprintOf(catalog, extraStamps = []) {
482
+ const stamps = Object.values(catalog)
483
+ .flat()
484
+ .map((entry) => entry.stamp)
485
+ .concat(extraStamps)
486
+ .sort();
487
+ return createHash('sha256').update(stamps.join('\n')).digest('hex');
488
+ }
489
+
490
+ /** Strips `stamp` before persisting/rendering — it is scan-time-only plumbing for the
491
+ * fingerprint, not part of the public map shape. */
492
+ function withoutStamps(catalog) {
493
+ const stripped = {};
494
+ for (const [kind, entries] of Object.entries(catalog)) {
495
+ stripped[kind] = entries.map(({ name, blurb }) => ({ name, blurb }));
496
+ }
497
+ return stripped;
498
+ }
499
+
500
+ /** Reads the persisted map's catalog + fingerprint + injection counter, or nulls when
501
+ * absent/corrupt. Used to decide whether a rescan is needed and to track injectEveryMessages
502
+ * without a second store file. */
503
+ function readPersistedState(mapPath) {
504
+ const data = readJson(mapPath);
505
+ if (!data || typeof data !== 'object') {
506
+ return { catalog: null, fingerprint: null, messageCount: 0 };
507
+ }
508
+ return {
509
+ catalog:
510
+ data.capabilities && typeof data.capabilities === 'object'
511
+ ? data.capabilities
512
+ : null,
513
+ fingerprint: typeof data.fingerprint === 'string' ? data.fingerprint : null,
514
+ messageCount: Number.isInteger(data.messageCount) ? data.messageCount : 0,
515
+ };
516
+ }
517
+
279
518
  /** Persists the map to .ai/capability-map.json, best-effort — never throws, never blocks. */
280
- function persistMap(cwd, mapFile, catalog) {
519
+ function persistMap(cwd, mapFile, catalog, fingerprint, messageCount) {
281
520
  const root = projectRootOf(cwd);
282
521
  if (!root) return;
283
522
  const mapPath = join(root, mapFile);
284
523
  const payload = {
285
524
  generatedAt: new Date().toISOString(),
286
- capabilities: catalog,
525
+ fingerprint,
526
+ messageCount,
527
+ capabilities: withoutStamps(catalog),
287
528
  };
288
529
  try {
289
530
  mkdirSync(dirname(mapPath), { recursive: true });
@@ -309,7 +550,7 @@ function dumpDefaults() {
309
550
  JSON.stringify({
310
551
  id: 'capability-map',
311
552
  configKey: CONFIG_KEY,
312
- enabledByDefault: false,
553
+ enabledByDefault: ENABLED_BY_DEFAULT,
313
554
  defaultParams: {
314
555
  kinds: DEFAULT_KINDS,
315
556
  maxClauseChars: DEFAULT_MAX_CLAUSE_CHARS,
@@ -318,13 +559,16 @@ function dumpDefaults() {
318
559
  extraCommandsDirs: [],
319
560
  persist: true,
320
561
  mapFile: DEFAULT_MAP_FILE,
562
+ blurbOverridesFile: DEFAULT_BLURB_OVERRIDES_FILE,
563
+ injectEveryMessages: DEFAULT_INJECT_EVERY_MESSAGES,
321
564
  },
322
565
  }),
323
566
  );
324
567
  }
325
568
 
326
- /** The non-empty catalog keyed by kind, in the order `kinds` lists them. */
327
- function buildCatalog(kinds, cwd, config, maxClauseChars) {
569
+ /** The non-empty raw catalog (description/maxClauseChars/stamp per entry no blurbs yet),
570
+ * keyed by kind, in the order `kinds` lists them. */
571
+ function buildRawCatalog(kinds, cwd, config, maxClauseChars) {
328
572
  const catalog = {};
329
573
  for (const kind of kinds) {
330
574
  const entries = entriesForKind(kind, cwd, config, maxClauseChars);
@@ -348,6 +592,55 @@ function renderCatalog(catalog, kinds) {
348
592
  return `[capabilities] available (check before improvising something one of these covers):\n${sections.join('\n')}\n`;
349
593
  }
350
594
 
595
+ /** Every tunable read out of the raw config object, with its default applied — keeps main()
596
+ * a single flat read instead of five inline `config.x || DEFAULT_X` expressions. */
597
+ function resolvedConfig(config) {
598
+ return {
599
+ maxClauseChars: Number(config.maxClauseChars) || DEFAULT_MAX_CLAUSE_CHARS,
600
+ kinds: Array.isArray(config.kinds) ? config.kinds : DEFAULT_KINDS,
601
+ mapFile: config.mapFile || DEFAULT_MAP_FILE,
602
+ blurbOverridesFile:
603
+ config.blurbOverridesFile || DEFAULT_BLURB_OVERRIDES_FILE,
604
+ injectEveryMessages:
605
+ Number(config.injectEveryMessages) || DEFAULT_INJECT_EVERY_MESSAGES,
606
+ persist: config.persist !== false,
607
+ };
608
+ }
609
+
610
+ /** The rendered { name, blurb } catalog for this run: reused verbatim from the persisted
611
+ * map when disk is provably unchanged since the last scan (same fingerprint), or freshly
612
+ * derived (overrides applied, then mechanical truncation for the rest) otherwise. Re-
613
+ * deriving is the only per-entry work worth skipping — everything downstream only ever
614
+ * sees { name, blurb }, never `stamp`/`description`/`maxClauseChars`. */
615
+ function resolveCatalog(rawCatalog, persisted, fingerprint, cwd, settings) {
616
+ if (persisted.fingerprint === fingerprint && persisted.catalog) {
617
+ return persisted.catalog;
618
+ }
619
+ const overrides = blurbOverridesFor(cwd, settings.blurbOverridesFile);
620
+ const rendered = Object.fromEntries(
621
+ Object.entries(rawCatalog).map(([kind, entries]) => [
622
+ kind,
623
+ applyBlurbs(entries, overrides),
624
+ ]),
625
+ );
626
+ return withoutStamps(rendered);
627
+ }
628
+
629
+ /** Whether this run should inject the rendered catalog, and the counter value to persist
630
+ * either way. A fingerprint change forces immediate injection: either this is the very
631
+ * first run for this project (persisted.fingerprint is null — nothing has ever been
632
+ * injected, and waiting up to injectEveryMessages turns before the model learns these
633
+ * capabilities exist is the wrong default) or the disk catalog changed since the last scan
634
+ * (a capability was added/removed — worth surfacing right away, not on whatever the counter
635
+ * happens to be). Both reset the counter, same as a normal throttled trigger. */
636
+ function injectionDecision(persisted, fingerprint, injectEveryMessages) {
637
+ const catalogChanged = persisted.fingerprint !== fingerprint;
638
+ const nextMessageCount = persisted.messageCount + 1;
639
+ const shouldInject =
640
+ catalogChanged || nextMessageCount >= injectEveryMessages;
641
+ return { shouldInject, messageCount: shouldInject ? 0 : nextMessageCount };
642
+ }
643
+
351
644
  function main() {
352
645
  if (process.env[DUMP_ENV]) {
353
646
  dumpDefaults();
@@ -358,20 +651,51 @@ function main() {
358
651
  const cwd = payload.cwd || process.cwd();
359
652
 
360
653
  const config = gateConfig(cwd);
361
- if (!config || config.enabled !== true) return; // opt-in: off unless explicitly enabled
654
+ const isEnabled =
655
+ config.enabled === undefined ? ENABLED_BY_DEFAULT : config.enabled;
656
+ if (!isEnabled) return;
657
+
658
+ const settings = resolvedConfig(config);
659
+ const rawCatalog = buildRawCatalog(
660
+ settings.kinds,
661
+ cwd,
662
+ config,
663
+ settings.maxClauseChars,
664
+ );
665
+ if (Object.keys(rawCatalog).length === 0) return; // nothing to surface: never overwrite a good map
362
666
 
363
- const maxClauseChars =
364
- Number(config.maxClauseChars) || DEFAULT_MAX_CLAUSE_CHARS;
365
- const kinds = Array.isArray(config.kinds) ? config.kinds : DEFAULT_KINDS;
667
+ const overrideStamps = blurbOverrideStampsFor(
668
+ cwd,
669
+ settings.blurbOverridesFile,
670
+ );
671
+ const fingerprint = fingerprintOf(rawCatalog, overrideStamps);
672
+ const root = projectRootOf(cwd);
673
+ const mapPath = root ? join(root, settings.mapFile) : null;
674
+ const persisted = mapPath
675
+ ? readPersistedState(mapPath)
676
+ : { catalog: null, fingerprint: null, messageCount: 0 };
366
677
 
367
- const catalog = buildCatalog(kinds, cwd, config, maxClauseChars);
368
- if (Object.keys(catalog).length === 0) return; // nothing to surface: never overwrite a good map
678
+ const catalog = resolveCatalog(
679
+ rawCatalog,
680
+ persisted,
681
+ fingerprint,
682
+ cwd,
683
+ settings,
684
+ );
369
685
 
370
- if (config.persist !== false) {
371
- persistMap(cwd, config.mapFile || DEFAULT_MAP_FILE, catalog);
686
+ const { shouldInject, messageCount } = injectionDecision(
687
+ persisted,
688
+ fingerprint,
689
+ settings.injectEveryMessages,
690
+ );
691
+
692
+ if (settings.persist && mapPath) {
693
+ persistMap(cwd, settings.mapFile, catalog, fingerprint, messageCount);
372
694
  }
373
695
 
374
- process.stdout.write(renderCatalog(catalog, kinds));
696
+ if (shouldInject) {
697
+ process.stdout.write(renderCatalog(catalog, settings.kinds));
698
+ }
375
699
  }
376
700
 
377
701
  main();
@@ -448,7 +448,7 @@ function identityKey(signature) {
448
448
 
449
449
  function denyRepeatedAttempt(count) {
450
450
  deny(
451
- GATE_ID,
451
+ CONFIG_KEY,
452
452
  `This same task received ${count} consecutive attempts (same goal, same scope and the same files) ` +
453
453
  'in this session, regardless of which subagent_type carried it. STOP retrying and ASK THE USER ' +
454
454
  'for help now: state what you tried, what blocked it, and the specific decision or input you need ' +
@@ -112,7 +112,7 @@ runGate(
112
112
  if (unmatched.length === 0) return;
113
113
 
114
114
  deny(
115
- GATE_ID,
115
+ CONFIG_KEY,
116
116
  `New dependencies without a matching skill in ${parameters.projectSkillsDir}: ${unmatched.join(', ')}. ` +
117
117
  'Add a skill documenting how to use each (directory name matching or containing the ' +
118
118
  'package name), OR, if a dependency genuinely needs no skill, declare it in ' +
@@ -41,7 +41,7 @@ runGate(
41
41
  if (!touchesTimeout) return;
42
42
 
43
43
  warn(
44
- GATE_ID,
44
+ CONFIG_KEY,
45
45
  'Diagnosis before patch: you are adjusting a timeout/deadline/retry value. Before changing a value to fix a symptom ("X is slow/fails"), confirm you read the evidence that proves the cause (a log line from the failing provider/process, not a hypothesis). A timeout should measure inactivity, not total time: a process that is progressing should not be cut off.',
46
46
  );
47
47
  },
@@ -61,7 +61,7 @@ runGate(
61
61
  // Base, non-negotiable: `done` is never written directly to the catalog.
62
62
  if (DONE_STATUS_PATTERN.test(content)) {
63
63
  deny(
64
- GATE_ID,
64
+ CONFIG_KEY,
65
65
  `Writing 'status: done' directly to ${catalogFileName} is not allowed. ` +
66
66
  'Only a review/QA subagent or a validated automated process may close a feature.',
67
67
  );
@@ -74,7 +74,7 @@ runGate(
74
74
  .length;
75
75
  if (inProgressCount > maxInProgress) {
76
76
  deny(
77
- GATE_ID,
77
+ CONFIG_KEY,
78
78
  `${catalogFileName} would have ${inProgressCount} features 'in_progress'; ` +
79
79
  `the maximum allowed is ${maxInProgress}.`,
80
80
  );
@@ -122,7 +122,7 @@ runGate(
122
122
  if (nextCount < threshold) return;
123
123
 
124
124
  warn(
125
- GATE_ID,
125
+ CONFIG_KEY,
126
126
  WARN_MESSAGE.replace('{count}', String(nextCount))
127
127
  .replace(
128
128
  '{windowSeconds}',
@@ -121,11 +121,11 @@ runGate(
121
121
  const result = await forgeRunState(root, forgeDatabasePath);
122
122
 
123
123
  if (result.state === 'active') return; // pipeline is running → allow
124
- if (result.state === 'none') deny(GATE_ID, DENY_MESSAGE); // no run → block
124
+ if (result.state === 'none') deny(CONFIG_KEY, DENY_MESSAGE); // no run → block
125
125
  // state 'unknown': the DB could not be read. Allow so a broken lookup never freezes work,
126
126
  // but surface it loudly — a silent allow here would disable the enforcer without a trace.
127
127
  warn(
128
- GATE_ID,
128
+ CONFIG_KEY,
129
129
  `forge enforcement is degraded: the forge DB could not be read (${result.reason}). ` +
130
130
  'Allowing this action, but the pipeline is NOT being enforced. Check that forge is ' +
131
131
  `installed and ${forgeDatabasePath} is readable, or turn this gate off if intended.`,