@devrik-tools/claude-gates 0.7.2 → 0.9.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 (83) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/README.es.md +129 -11
  3. package/README.md +120 -10
  4. package/cli/doctor.mjs +132 -0
  5. package/cli/evidence.mjs +78 -0
  6. package/cli/hooks-manifest.mjs +89 -0
  7. package/cli/index.mjs +124 -6
  8. package/cli/init.mjs +97 -3
  9. package/cli/log.mjs +70 -0
  10. package/cli/materialize.mjs +36 -2
  11. package/cli/registry.mjs +12 -1
  12. package/cli/selection.mjs +18 -0
  13. package/cli/smoke-fixtures.json +159 -25
  14. package/cli/task.mjs +104 -11
  15. package/cli/toggle.mjs +125 -0
  16. package/package.json +4 -3
  17. package/plugins/gates/.claude-plugin/plugin.json +1 -1
  18. package/plugins/gates/hooks/doctor.mjs +47 -1
  19. package/plugins/gates/hooks/gates/atomic-commit/index.mjs +92 -119
  20. package/plugins/gates/hooks/gates/audit-before-build/index.mjs +101 -66
  21. package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +8 -8
  22. package/plugins/gates/hooks/gates/autonomous-mode/stop.mjs +13 -64
  23. package/plugins/gates/hooks/gates/bash-commands/index.mjs +186 -163
  24. package/plugins/gates/hooks/gates/block-remote-publish/index.mjs +100 -101
  25. package/plugins/gates/hooks/gates/brief-approved/index.mjs +71 -140
  26. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +56 -137
  27. package/plugins/gates/hooks/gates/capability-map/index.mjs +280 -506
  28. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +94 -215
  29. package/plugins/gates/hooks/gates/circuit-breaker/track.mjs +285 -0
  30. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +127 -46
  31. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +81 -19
  32. package/plugins/gates/hooks/gates/engram-first/index.mjs +41 -0
  33. package/plugins/gates/hooks/gates/engram-first/session-start.mjs +64 -0
  34. package/plugins/gates/hooks/gates/engram-first/shared.mjs +90 -0
  35. package/plugins/gates/hooks/gates/engram-first/stop.mjs +23 -0
  36. package/plugins/gates/hooks/gates/engram-first/track.mjs +97 -0
  37. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +156 -50
  38. package/plugins/gates/hooks/gates/force-parallel/index.mjs +53 -95
  39. package/plugins/gates/hooks/gates/forge-flow/index.mjs +51 -83
  40. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +40 -80
  41. package/plugins/gates/hooks/gates/intent-flow/index.mjs +23 -149
  42. package/plugins/gates/hooks/gates/library-docs/index.mjs +338 -0
  43. package/plugins/gates/hooks/gates/library-docs/track.mjs +95 -0
  44. package/plugins/gates/hooks/gates/lint-commit/index.mjs +88 -97
  45. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +48 -95
  46. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +69 -31
  47. package/plugins/gates/hooks/gates/never-assume/index.mjs +23 -18
  48. package/plugins/gates/hooks/gates/no-blocking/index.mjs +127 -86
  49. package/plugins/gates/hooks/gates/no-coauthor/index.mjs +108 -88
  50. package/plugins/gates/hooks/gates/no-explanatory-comments/index.mjs +281 -0
  51. package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +170 -110
  52. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +46 -77
  53. package/plugins/gates/hooks/gates/no-reconfirm/index.mjs +85 -47
  54. package/plugins/gates/hooks/gates/no-trivial-scripts/index.mjs +114 -0
  55. package/plugins/gates/hooks/gates/protected-paths/index.mjs +137 -90
  56. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +95 -51
  57. package/plugins/gates/hooks/gates/require-monitor/index.mjs +126 -0
  58. package/plugins/gates/hooks/gates/require-task-split/index.mjs +88 -0
  59. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +72 -216
  60. package/plugins/gates/hooks/gates/risk-level/index.mjs +51 -204
  61. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +34 -17
  62. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +160 -95
  63. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +196 -97
  64. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +90 -143
  65. package/plugins/gates/hooks/gates/staged-lint/index.mjs +98 -106
  66. package/plugins/gates/hooks/gates/stop-pending/index.mjs +47 -148
  67. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +78 -68
  68. package/plugins/gates/hooks/gates/test-matrix/index.mjs +53 -88
  69. package/plugins/gates/hooks/gates/tool-map/index.mjs +80 -110
  70. package/plugins/gates/hooks/hooks.json +152 -42
  71. package/plugins/gates/hooks/lib/config.mjs +6 -3
  72. package/plugins/gates/hooks/lib/delegation.mjs +352 -0
  73. package/plugins/gates/hooks/lib/gate-log.mjs +141 -0
  74. package/plugins/gates/hooks/lib/git.mjs +262 -0
  75. package/plugins/gates/hooks/lib/hook-io.mjs +451 -83
  76. package/plugins/gates/hooks/lib/session-state.mjs +150 -0
  77. package/plugins/gates/hooks/lib/signals.mjs +18 -13
  78. package/plugins/gates/hooks/lib/testing.mjs +148 -0
  79. package/plugins/gates/hooks/lib/tools.mjs +144 -0
  80. package/plugins/tasks/.claude-plugin/plugin.json +1 -1
  81. package/plugins/tasks/hooks/lib/task-store.mjs +12 -6
  82. package/plugins/tasks/hooks/register-requests.mjs +37 -10
  83. package/registry.json +249 -38
@@ -1,86 +1,17 @@
1
- // capability-map — UserPromptSubmit hook. Surfaces the REAL, current catalog of the project's
2
- // AI capabilities skills, agents/subagents, and commands as compact data ("caveman"
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.
11
- //
12
- // It does NOT tell the model to obey a reminder — that would be prose the model may ignore,
13
- // the exact antipattern guard-no-model-reliance forbids. It injects a fact (which capabilities
14
- // exist) and lets the assistant decide to use them.
15
- //
16
- // justification: no existing gate covers this. rule-skill-autodiscovery EXECUTES sub-gate
17
- // scripts found under skills; dependency-skills cross-checks package.json deps against skill
18
- // dirs; tool-map records built TOOLS (scripts) into .ai/tool-map.json. None enumerate the
19
- // skill/agent/command catalog or inject it as context. This is the read+inject counterpart to
20
- // tool-map for the capability catalog.
21
- //
22
- // A UserPromptSubmit hook's stdout is appended to the assistant's context as plain text, so
23
- // the catalog is simply written to stdout (no PreToolUse JSON shape). Self-contained: Node
24
- // built-ins only.
25
- //
26
- // ── What it discovers (every root: ~/.claude and <project>/.claude, plus config extras) ──
27
- // skills <root>/skills/<name>/SKILL.md front matter name + description
28
- // agents <root>/agents/*.md front matter name (falls back to file basename)
29
- // + description; subagents nested deeper too
30
- // commands <root>/commands/*.{md,toml} front matter description; name is the basename
31
- // Each injected line is `name — first clause` (up to the first '. ' or a hard char cap), so
32
- // the whole catalog stays cheap even at dozens of entries.
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
- //
61
- // ── What a project can configure (params) — everything is customizable ───────────────
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).
65
- // extraSkillsDirs / extraAgentsDirs / extraCommandsDirs additional roots per kind
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.
74
- //
75
- // ── Fail-safe shape ──────────────────────────────────────────────────────────────────
76
- // Nothing found anywhere, gate disabled, or unreadable input: inject nothing (silent, no
77
- // wasted context) — and never write an empty map over a good one. Persistence failure is
78
- // swallowed: the injection is the job, the file is a convenience. Never blocks —
79
- // UserPromptSubmit cannot deny; it only adds context.
1
+ // capability-map — UserPromptSubmit hook. Injects the REAL, current catalog of the project's
2
+ // AI capabilities (skills, agents, commands) as compact data — `name — first clause`, one
3
+ // line each, grouped by kind and persists it to .ai/capability-map.json. The catalog IS
4
+ // the directory listing, so it is autosynced by construction. Injection is throttled
5
+ // (every Nth message, or immediately when the catalog changed), with the counter kept in
6
+ // session state so the map file is rewritten only when the catalog itself changed.
7
+ // Project roots are scanned BEFORE ~/.claude so a project skill shadows a global one.
8
+ // A description whose first clause runs long is replaced by a hand-written override from
9
+ // ~/.claude/blurb-overrides.json and <project>/<blurbOverridesFile> (project wins per key).
10
+ // `.agents/skills` and `.ai/skills` (home and project) are scanned as skill-only roots:
11
+ // other installers write there. Never blocks; any failure injects nothing.
80
12
 
81
13
  import { createHash } from 'node:crypto';
82
14
  import {
83
- existsSync,
84
15
  mkdirSync,
85
16
  readFileSync,
86
17
  readdirSync,
@@ -89,124 +20,97 @@ import {
89
20
  } from 'node:fs';
90
21
  import { homedir } from 'node:os';
91
22
  import { basename, dirname, extname, isAbsolute, join } from 'node:path';
23
+ import {
24
+ loadGateConfig,
25
+ projectRootOf,
26
+ readJsonOrNull,
27
+ } from '../../lib/config.mjs';
28
+ import { coerceParameters } from '../../lib/hook-io.mjs';
29
+ import {
30
+ readSessionState,
31
+ writeSessionState,
32
+ } from '../../lib/session-state.mjs';
92
33
 
93
34
  const STDIN_FILE_DESCRIPTOR = 0;
35
+ const GATE_ID = 'capability-map';
94
36
  const CONFIG_KEY = 'injectCapabilityMap';
95
37
  const DUMP_ENV = 'CLAUDE_GATES_DUMP_DEFAULTS';
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
38
  const ENABLED_BY_DEFAULT = true;
102
- const DEFAULT_MAX_CLAUSE_CHARS = 120;
103
- const DEFAULT_KINDS = ['skills', 'agents', 'commands'];
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;
107
39
  const JSON_INDENT = 2;
108
40
 
109
- // Config lookup mirrors config.mjs (project → global), kept local so a gate stays runnable on
110
- // its own alongside the other event-scripts (doctor, wiring-check).
111
- const PROJECT_ROOT_MARKERS = ['.git', '.ai'];
112
- const PROJECT_CONFIG = join('.ai', 'config.json');
113
- const GLOBAL_CONFIG = join('.claude', 'claude-gates', 'config.json');
41
+ const DEFAULT_PARAMS = Object.freeze({
42
+ kinds: ['skills', 'agents', 'commands'],
43
+ maxClauseChars: 120,
44
+ extraSkillsDirs: [],
45
+ extraAgentsDirs: [],
46
+ extraCommandsDirs: [],
47
+ persist: true,
48
+ mapFile: join('.ai', 'capability-map.json'),
49
+ blurbOverridesFile: join('.ai', 'blurb-overrides.json'),
50
+ injectEveryMessages: 10,
51
+ });
52
+
53
+ const KIND_EXTENSIONS = {
54
+ agents: ['.md'],
55
+ commands: ['.md', '.toml'],
56
+ };
114
57
 
115
- function readJson(path) {
58
+ function readPayload() {
116
59
  try {
117
- return JSON.parse(readFileSync(path, 'utf8').replace(/^\uFEFF/, ''));
60
+ return JSON.parse(readFileSync(STDIN_FILE_DESCRIPTOR, 'utf8'));
118
61
  } catch {
119
- return null;
62
+ return {};
120
63
  }
121
64
  }
122
65
 
123
- function projectRootOf(startDirectory) {
124
- let current = startDirectory;
125
- while (true) {
126
- if (
127
- PROJECT_ROOT_MARKERS.some((marker) => existsSync(join(current, marker)))
128
- ) {
129
- return current;
130
- }
131
- const parent = dirname(current);
132
- if (parent === current) return null;
133
- current = parent;
66
+ function isDirectory(path) {
67
+ try {
68
+ return statSync(path).isDirectory();
69
+ } catch {
70
+ return false;
134
71
  }
135
72
  }
136
73
 
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. */
144
- function gateConfig(startDirectory) {
145
- const root = projectRootOf(startDirectory);
146
- const projectData = root ? readJson(join(root, PROJECT_CONFIG)) : null;
147
- const globalData = readJson(join(homedir(), GLOBAL_CONFIG));
148
- const layer = projectData?.gates ?? globalData?.gates ?? {};
149
- const entry = layer[CONFIG_KEY];
150
- if (typeof entry === 'boolean') return { enabled: entry };
151
- if (entry && typeof entry === 'object') return entry;
152
- return {};
153
- }
154
-
155
- function readPayload() {
74
+ function mtimeMsOf(path) {
156
75
  try {
157
- return JSON.parse(readFileSync(STDIN_FILE_DESCRIPTOR, 'utf8'));
76
+ return statSync(path).mtimeMs;
158
77
  } catch {
159
- return {};
78
+ return null;
160
79
  }
161
80
  }
162
81
 
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 `">-"`.
82
+ // ── Front matter ────────────────────────────────────────────────────────────────────
83
+ // A bare block-scalar indicator (`>`, `>-`, `|`, `|-`) means the value is on the following
84
+ // indented lines; without this the blurb rendered as ">".
168
85
  const BLOCK_SCALAR_INDICATOR_PATTERN = /^[|>][+-]?\d*$/;
169
86
 
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
87
  function readBlockScalarValue(lines, startIndex) {
176
88
  const parts = [];
177
89
  for (let index = startIndex; index < lines.length; index += 1) {
178
90
  const line = lines[index];
179
91
  if (line.trim() === '---') break;
180
- if (!/^[ \t]+\S/.test(line)) break; // not indented: block scalar ended
92
+ if (!/^[ \t]+\S/.test(line)) break;
181
93
  parts.push(line.trim());
182
94
  }
183
95
  return parts.join(' ');
184
96
  }
185
97
 
186
- /**
187
- * The `name` and `description` from a markdown-style front matter block (skills, agents,
188
- * commands all use `---`-fenced YAML-ish front matter). Parsed line-by-line (no multi-line
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 ''.
193
- */
98
+ // Parsed line by line (no multi-line regex) so a large body can never backtrack.
194
99
  function parseFrontMatter(fileText) {
195
100
  const lines = fileText.split(/\r?\n/);
196
101
  if (lines[0]?.trim() !== '---') return { name: '', description: '' };
197
-
198
102
  let name = '';
199
103
  let description = '';
200
104
  for (let index = 1; index < lines.length; index += 1) {
201
105
  const line = lines[index];
202
- if (line.trim() === '---') break; // end of front matter
106
+ if (line.trim() === '---') break;
203
107
  const separator = line.indexOf(':');
204
108
  if (separator < 0) continue;
205
109
  const key = line.slice(0, separator).trim();
206
110
  let value = line
207
111
  .slice(separator + 1)
208
112
  .trim()
209
- .replace(/^["']|["']$/g, ''); // toml/yaml quoting around the value
113
+ .replace(/^["']|["']$/g, '');
210
114
  if (BLOCK_SCALAR_INDICATOR_PATTERN.test(value)) {
211
115
  value = readBlockScalarValue(lines, index + 1);
212
116
  }
@@ -216,10 +120,6 @@ function parseFrontMatter(fileText) {
216
120
  return { name, description };
217
121
  }
218
122
 
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
123
  function truncateAtWordBoundary(text, maxChars) {
224
124
  if (text.length <= maxChars) return text;
225
125
  const budget = text.slice(0, maxChars - 1);
@@ -228,7 +128,6 @@ function truncateAtWordBoundary(text, maxChars) {
228
128
  return `${cut.trimEnd()}…`;
229
129
  }
230
130
 
231
- /** The description's first clause, capped — the caveman blurb. */
232
131
  function firstClause(description, maxClauseChars) {
233
132
  if (!description) return '';
234
133
  const sentenceEnd = description.indexOf('. ');
@@ -237,294 +136,199 @@ function firstClause(description, maxClauseChars) {
237
136
  return truncateAtWordBoundary(clause, maxClauseChars);
238
137
  }
239
138
 
240
- /** Recursively lists files under a directory whose extension is in `extensions`. */
139
+ // ── Discovery ───────────────────────────────────────────────────────────────────────
241
140
  function filesUnder(directory, extensions) {
242
- if (!existsSync(directory)) return [];
243
- let entries;
141
+ let names;
244
142
  try {
245
- entries = readdirSync(directory, { withFileTypes: true });
143
+ names = readdirSync(directory);
246
144
  } catch {
247
145
  return [];
248
146
  }
249
147
  const files = [];
250
- for (const entry of entries) {
251
- const full = join(directory, entry.name);
252
- if (entry.isDirectory()) {
253
- files.push(...filesUnder(full, extensions));
254
- } else if (extensions.includes(extname(entry.name).toLowerCase())) {
255
- files.push(full);
256
- }
148
+ for (const name of names) {
149
+ const full = join(directory, name);
150
+ if (isDirectory(full)) files.push(...filesUnder(full, extensions));
151
+ else if (extensions.includes(extname(name).toLowerCase())) files.push(full);
257
152
  }
258
153
  return files;
259
154
  }
260
155
 
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) {
156
+ function entryFor(file, fallbackName) {
157
+ const mtimeMs = mtimeMsOf(file);
158
+ if (mtimeMs === null) return null;
159
+ let content;
265
160
  try {
266
- return statSync(path).mtimeMs;
161
+ content = readFileSync(file, 'utf8');
267
162
  } catch {
268
163
  return null;
269
164
  }
165
+ const { name, description } = parseFrontMatter(content);
166
+ return {
167
+ name: name || fallbackName,
168
+ description,
169
+ stamp: `${file}:${mtimeMs}`,
170
+ };
270
171
  }
271
172
 
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. */
276
- function skillEntriesUnder(skillsRoot, maxClauseChars) {
277
- if (!existsSync(skillsRoot)) return [];
278
- let skillDirectories;
173
+ function skillEntriesUnder(skillsRoot) {
174
+ let names;
279
175
  try {
280
- skillDirectories = readdirSync(skillsRoot, { withFileTypes: true });
176
+ names = readdirSync(skillsRoot);
281
177
  } catch {
282
178
  return [];
283
179
  }
284
- const entries = [];
285
- for (const skillDirectory of skillDirectories) {
286
- if (!skillDirectory.isDirectory()) continue;
287
- const skillFile = join(skillsRoot, skillDirectory.name, 'SKILL.md');
288
- const mtimeMs = mtimeMsOf(skillFile);
289
- if (mtimeMs === null) continue; // missing or a dangling link: not a real source
290
- let content;
291
- try {
292
- content = readFileSync(skillFile, 'utf8');
293
- } catch {
294
- continue;
295
- }
296
- const { name, description } = parseFrontMatter(content);
297
- entries.push({
298
- name: name || skillDirectory.name,
299
- description,
300
- maxClauseChars,
301
- stamp: `${skillFile}:${mtimeMs}`,
302
- });
303
- }
304
- return entries;
305
- }
306
-
307
- /** Agent/command capabilities: flat or nested files whose front matter carries a description.
308
- * Same stamp shape as skillEntriesUnder, for the same reason. */
309
- function fileEntriesUnder(directory, extensions, maxClauseChars) {
310
- const entries = [];
311
- for (const file of filesUnder(directory, extensions)) {
312
- const mtimeMs = mtimeMsOf(file);
313
- if (mtimeMs === null) continue;
314
- let content;
315
- try {
316
- content = readFileSync(file, 'utf8');
317
- } catch {
318
- continue;
319
- }
320
- const { name, description } = parseFrontMatter(content);
321
- entries.push({
322
- name: name || basename(file, extname(file)),
323
- description,
324
- maxClauseChars,
325
- stamp: `${file}:${mtimeMs}`,
326
- });
327
- }
328
- return entries;
329
- }
330
-
331
- function baseRoots(cwd) {
332
- return [join(homedir(), '.claude'), join(cwd, '.claude')];
333
- }
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) {
180
+ return names
181
+ .filter((name) => isDirectory(join(skillsRoot, name)))
182
+ .map((name) => entryFor(join(skillsRoot, name, 'SKILL.md'), name))
183
+ .filter(Boolean);
184
+ }
185
+
186
+ function fileEntriesUnder(directory, extensions) {
187
+ return filesUnder(directory, extensions)
188
+ .map((file) => entryFor(file, basename(file, extname(file))))
189
+ .filter(Boolean);
190
+ }
191
+
192
+ function resolveExtra(root, directory) {
193
+ return isAbsolute(directory) ? directory : join(root, directory);
194
+ }
195
+
196
+ function skillRootsFor(root, extraDirectories) {
348
197
  return [
198
+ join(root, '.claude', 'skills'),
199
+ join(root, '.agents', 'skills'),
200
+ join(root, '.ai', 'skills'),
201
+ join(homedir(), '.claude', 'skills'),
349
202
  join(homedir(), '.agents', 'skills'),
350
- join(cwd, '.agents', 'skills'),
351
203
  join(homedir(), '.ai', 'skills'),
352
- join(cwd, '.ai', 'skills'),
204
+ ...extraDirectories.map((directory) => resolveExtra(root, directory)),
205
+ ];
206
+ }
207
+
208
+ function fileRootsFor(root, kind, extraDirectories) {
209
+ return [
210
+ join(root, '.claude', kind),
211
+ join(homedir(), '.claude', kind),
212
+ ...extraDirectories.map((directory) => resolveExtra(root, directory)),
353
213
  ];
354
214
  }
355
215
 
356
- function resolveExtra(cwd, directory) {
357
- return isAbsolute(directory) ? directory : join(cwd, directory);
358
- }
359
-
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). */
363
- function entriesForKind(kind, cwd, config, maxClauseChars) {
364
- const perKind = {
365
- skills: {
366
- subdir: 'skills',
367
- extra: config.extraSkillsDirs,
368
- collect: (root) =>
369
- skillEntriesUnder(join(root, 'skills'), maxClauseChars),
370
- collectExtra: (directory) => skillEntriesUnder(directory, maxClauseChars),
371
- },
372
- agents: {
373
- extra: config.extraAgentsDirs,
374
- collect: (root) =>
375
- fileEntriesUnder(join(root, 'agents'), ['.md'], maxClauseChars),
376
- collectExtra: (directory) =>
377
- fileEntriesUnder(directory, ['.md'], maxClauseChars),
378
- },
379
- commands: {
380
- extra: config.extraCommandsDirs,
381
- collect: (root) =>
382
- fileEntriesUnder(
383
- join(root, 'commands'),
384
- ['.md', '.toml'],
385
- maxClauseChars,
386
- ),
387
- collectExtra: (directory) =>
388
- fileEntriesUnder(directory, ['.md', '.toml'], maxClauseChars),
389
- },
390
- }[kind];
391
- if (!perKind) return [];
392
-
393
- const collected = [];
394
- for (const root of baseRoots(cwd)) collected.push(...perKind.collect(root));
216
+ function collectKind(kind, root, settings) {
395
217
  if (kind === 'skills') {
396
- for (const root of defaultSkillOnlyRoots(cwd)) {
397
- collected.push(...skillEntriesUnder(root, maxClauseChars));
398
- }
399
- }
400
- const extra = Array.isArray(perKind.extra) ? perKind.extra : [];
401
- for (const directory of extra) {
402
- collected.push(...perKind.collectExtra(resolveExtra(cwd, directory)));
218
+ return skillRootsFor(root, settings.extraSkillsDirs).flatMap(
219
+ skillEntriesUnder,
220
+ );
403
221
  }
222
+ const extensions = KIND_EXTENSIONS[kind];
223
+ if (!extensions) return [];
224
+ const extra =
225
+ kind === 'agents' ? settings.extraAgentsDirs : settings.extraCommandsDirs;
226
+ return fileRootsFor(root, kind, extra).flatMap((directory) =>
227
+ fileEntriesUnder(directory, extensions),
228
+ );
229
+ }
404
230
 
231
+ // First occurrence wins, and project roots come first: a project capability shadows a
232
+ // global one of the same name.
233
+ function entriesForKind(kind, root, settings) {
405
234
  const seen = new Set();
406
235
  const unique = [];
407
- for (const entry of collected) {
408
- if (seen.has(entry.name)) continue; // a capability present in two roots is listed once
236
+ for (const entry of collectKind(kind, root, settings)) {
237
+ if (seen.has(entry.name)) continue;
409
238
  seen.add(entry.name);
410
239
  unique.push(entry);
411
240
  }
412
- unique.sort((a, b) => a.name.localeCompare(b.name));
413
- return unique;
241
+ return unique.sort((a, b) => a.name.localeCompare(b.name));
414
242
  }
415
243
 
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];
244
+ function buildRawCatalog(root, settings) {
245
+ const catalog = {};
246
+ for (const kind of settings.kinds) {
247
+ const entries = entriesForKind(kind, root, settings);
248
+ if (entries.length > 0) catalog[kind] = entries;
249
+ }
250
+ return catalog;
251
+ }
252
+
253
+ // ── Blurbs, overrides and the fingerprint ───────────────────────────────────────────
254
+ function blurbOverridePathsFor(root, blurbOverridesFile) {
255
+ return [
256
+ join(homedir(), '.claude', 'blurb-overrides.json'),
257
+ join(root, blurbOverridesFile),
258
+ ];
425
259
  }
426
260
 
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) {
261
+ function blurbOverridesFor(root, blurbOverridesFile) {
431
262
  const [globalPath, projectPath] = blurbOverridePathsFor(
432
- cwd,
263
+ root,
433
264
  blurbOverridesFile,
434
265
  );
266
+ const asObject = (value) =>
267
+ value && typeof value === 'object' && !Array.isArray(value) ? value : {};
435
268
  return {
436
- ...(readJson(globalPath) ?? {}),
437
- ...(projectPath ? (readJson(projectPath) ?? {}) : {}),
269
+ ...asObject(readJsonOrNull(globalPath)),
270
+ ...asObject(readJsonOrNull(projectPath)),
438
271
  };
439
272
  }
440
273
 
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) {
274
+ function applyBlurbs(entries, overrides, maxClauseChars) {
462
275
  return entries.map((entry) => {
463
276
  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 };
277
+ const blurb =
278
+ typeof override === 'string' && override
279
+ ? truncateAtWordBoundary(override, maxClauseChars)
280
+ : firstClause(entry.description, maxClauseChars);
281
+ return { name: entry.name, blurb };
468
282
  });
469
283
  }
470
284
 
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)
285
+ // Everything the rendered blurbs depend on: every source file's path+mtime, the override
286
+ // files (absent counts as a stable stamp), and the settings that shape a blurb.
287
+ function fingerprintOf(rawCatalog, root, settings) {
288
+ const overrideStamps = blurbOverridePathsFor(
289
+ root,
290
+ settings.blurbOverridesFile,
291
+ ).map((path) => `${path}:${mtimeMsOf(path) ?? 'absent'}`);
292
+ const settingStamps = [
293
+ `maxClauseChars=${settings.maxClauseChars}`,
294
+ `kinds=${settings.kinds.join(',')}`,
295
+ `blurbOverridesFile=${settings.blurbOverridesFile}`,
296
+ ];
297
+ const stamps = Object.values(rawCatalog)
483
298
  .flat()
484
299
  .map((entry) => entry.stamp)
485
- .concat(extraStamps)
300
+ .concat(overrideStamps, settingStamps)
486
301
  .sort();
487
302
  return createHash('sha256').update(stamps.join('\n')).digest('hex');
488
303
  }
489
304
 
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;
305
+ function renderedCatalogOf(rawCatalog, root, settings) {
306
+ const overrides = blurbOverridesFor(root, settings.blurbOverridesFile);
307
+ return Object.fromEntries(
308
+ Object.entries(rawCatalog).map(([kind, entries]) => [
309
+ kind,
310
+ applyBlurbs(entries, overrides, settings.maxClauseChars),
311
+ ]),
312
+ );
498
313
  }
499
314
 
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
- };
315
+ // ── Persistence ─────────────────────────────────────────────────────────────────────
316
+ function readPersistedMap(mapPath) {
317
+ const data = readJsonOrNull(mapPath);
318
+ const catalog =
319
+ data?.capabilities && typeof data.capabilities === 'object'
320
+ ? data.capabilities
321
+ : null;
322
+ const fingerprint =
323
+ typeof data?.fingerprint === 'string' ? data.fingerprint : null;
324
+ return { catalog, fingerprint };
516
325
  }
517
326
 
518
- /** Persists the map to .ai/capability-map.json, best-effort — never throws, never blocks. */
519
- function persistMap(cwd, mapFile, catalog, fingerprint, messageCount) {
520
- const root = projectRootOf(cwd);
521
- if (!root) return;
522
- const mapPath = join(root, mapFile);
327
+ function persistMap(mapPath, catalog, fingerprint) {
523
328
  const payload = {
524
329
  generatedAt: new Date().toISOString(),
525
330
  fingerprint,
526
- messageCount,
527
- capabilities: withoutStamps(catalog),
331
+ capabilities: catalog,
528
332
  };
529
333
  try {
530
334
  mkdirSync(dirname(mapPath), { recursive: true });
@@ -534,167 +338,137 @@ function persistMap(cwd, mapFile, catalog, fingerprint, messageCount) {
534
338
  'utf8',
535
339
  );
536
340
  } catch {
537
- // The injection is the job; the persisted file is a convenience. A write failure must
538
- // not break the turn.
341
+ // The injection is the job; the file is a convenience.
539
342
  }
540
343
  }
541
344
 
542
- const KIND_LABELS = {
543
- skills: 'skills',
544
- agents: 'agents',
545
- commands: 'commands',
546
- };
547
-
548
- function dumpDefaults() {
549
- process.stdout.write(
550
- JSON.stringify({
551
- id: 'capability-map',
552
- configKey: CONFIG_KEY,
553
- enabledByDefault: ENABLED_BY_DEFAULT,
554
- defaultParams: {
555
- kinds: DEFAULT_KINDS,
556
- maxClauseChars: DEFAULT_MAX_CLAUSE_CHARS,
557
- extraSkillsDirs: [],
558
- extraAgentsDirs: [],
559
- extraCommandsDirs: [],
560
- persist: true,
561
- mapFile: DEFAULT_MAP_FILE,
562
- blurbOverridesFile: DEFAULT_BLURB_OVERRIDES_FILE,
563
- injectEveryMessages: DEFAULT_INJECT_EVERY_MESSAGES,
564
- },
565
- }),
566
- );
567
- }
568
-
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) {
572
- const catalog = {};
573
- for (const kind of kinds) {
574
- const entries = entriesForKind(kind, cwd, config, maxClauseChars);
575
- if (entries.length > 0) catalog[kind] = entries;
576
- }
577
- return catalog;
578
- }
579
-
580
- /** The injected text for a catalog, grouped and captioned per kind. */
581
345
  function renderCatalog(catalog, kinds) {
582
346
  const sections = [];
583
347
  for (const kind of kinds) {
584
348
  const entries = catalog[kind];
585
349
  if (!entries) continue;
586
- const label = KIND_LABELS[kind] || kind;
587
350
  const lines = entries.map((entry) =>
588
351
  entry.blurb ? ` ${entry.name} — ${entry.blurb}` : ` ${entry.name}`,
589
352
  );
590
- sections.push(`${label}:\n${lines.join('\n')}`);
353
+ sections.push(`${kind}:\n${lines.join('\n')}`);
591
354
  }
592
355
  return `[capabilities] available (check before improvising something one of these covers):\n${sections.join('\n')}\n`;
593
356
  }
594
357
 
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) {
358
+ // A changed catalog (or the first message of a session) injects immediately; otherwise
359
+ // every Nth message.
360
+ function injectionDecision(session, fingerprint, injectEveryMessages) {
361
+ const changed = session.fingerprint !== fingerprint;
362
+ const nextCount = (Number(session.messageCount) || 0) + 1;
363
+ const shouldInject = changed || nextCount >= injectEveryMessages;
364
+ return { shouldInject, messageCount: shouldInject ? 0 : nextCount };
365
+ }
366
+
367
+ function settingsFor(cwd) {
368
+ const config = loadGateConfig(cwd);
369
+ if (!config.isEnabled(CONFIG_KEY, ENABLED_BY_DEFAULT)) return null;
370
+ const { parameters } = coerceParameters(
371
+ DEFAULT_PARAMS,
372
+ config.paramsFor(CONFIG_KEY),
373
+ );
598
374
  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,
375
+ ...parameters,
376
+ kinds: parameters.kinds.map(String),
377
+ maxClauseChars:
378
+ parameters.maxClauseChars > 0
379
+ ? parameters.maxClauseChars
380
+ : DEFAULT_PARAMS.maxClauseChars,
604
381
  injectEveryMessages:
605
- Number(config.injectEveryMessages) || DEFAULT_INJECT_EVERY_MESSAGES,
606
- persist: config.persist !== false,
382
+ parameters.injectEveryMessages > 0
383
+ ? parameters.injectEveryMessages
384
+ : DEFAULT_PARAMS.injectEveryMessages,
607
385
  };
608
386
  }
609
387
 
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
- ]),
388
+ function dumpDefaults() {
389
+ process.stdout.write(
390
+ JSON.stringify({
391
+ id: GATE_ID,
392
+ configKey: CONFIG_KEY,
393
+ enabledByDefault: ENABLED_BY_DEFAULT,
394
+ defaultParams: DEFAULT_PARAMS,
395
+ }),
625
396
  );
626
- return withoutStamps(rendered);
627
397
  }
628
398
 
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 };
399
+ function cwdOf(payload) {
400
+ return typeof payload.cwd === 'string' && payload.cwd
401
+ ? payload.cwd
402
+ : process.cwd();
642
403
  }
643
404
 
644
- function main() {
645
- if (process.env[DUMP_ENV]) {
646
- dumpDefaults();
647
- return;
648
- }
405
+ // The persisted rendering is reused only when its fingerprint matches; the map file is
406
+ // rewritten only when the rendered catalog actually differs from what is on disk.
407
+ function syncMap(mapPath, rawCatalog, fingerprint, root, settings) {
408
+ const persisted = readPersistedMap(mapPath);
409
+ const catalog =
410
+ persisted.fingerprint === fingerprint && persisted.catalog
411
+ ? persisted.catalog
412
+ : renderedCatalogOf(rawCatalog, root, settings);
413
+ const changed =
414
+ persisted.fingerprint !== fingerprint ||
415
+ JSON.stringify(persisted.catalog) !== JSON.stringify(catalog);
416
+ if (settings.persist && changed) persistMap(mapPath, catalog, fingerprint);
417
+ return catalog;
418
+ }
649
419
 
650
- const payload = readPayload();
651
- const cwd = payload.cwd || process.cwd();
652
-
653
- const config = gateConfig(cwd);
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,
420
+ function shouldInjectNow(sessionId, root, fingerprint, injectEveryMessages) {
421
+ const session = readSessionState(GATE_ID, sessionId, {}, { cwd: root });
422
+ const { shouldInject, messageCount } = injectionDecision(
423
+ session,
424
+ fingerprint,
425
+ injectEveryMessages,
664
426
  );
665
- if (Object.keys(rawCatalog).length === 0) return; // nothing to surface: never overwrite a good map
666
-
667
- const overrideStamps = blurbOverrideStampsFor(
668
- cwd,
669
- settings.blurbOverridesFile,
427
+ writeSessionState(
428
+ GATE_ID,
429
+ sessionId,
430
+ { fingerprint, messageCount },
431
+ { cwd: root },
670
432
  );
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 };
677
-
678
- const catalog = resolveCatalog(
433
+ return shouldInject;
434
+ }
435
+
436
+ function run() {
437
+ const payload = readPayload();
438
+ const cwd = cwdOf(payload);
439
+ const settings = settingsFor(cwd);
440
+ if (!settings) return;
441
+ const root = projectRootOf(cwd) ?? cwd;
442
+
443
+ const rawCatalog = buildRawCatalog(root, settings);
444
+ if (Object.keys(rawCatalog).length === 0) return;
445
+
446
+ const fingerprint = fingerprintOf(rawCatalog, root, settings);
447
+ const catalog = syncMap(
448
+ join(root, settings.mapFile),
679
449
  rawCatalog,
680
- persisted,
681
450
  fingerprint,
682
- cwd,
451
+ root,
683
452
  settings,
684
453
  );
685
-
686
- const { shouldInject, messageCount } = injectionDecision(
687
- persisted,
454
+ const inject = shouldInjectNow(
455
+ payload.session_id ?? null,
456
+ root,
688
457
  fingerprint,
689
458
  settings.injectEveryMessages,
690
459
  );
460
+ if (inject) process.stdout.write(renderCatalog(catalog, settings.kinds));
461
+ }
691
462
 
692
- if (settings.persist && mapPath) {
693
- persistMap(cwd, settings.mapFile, catalog, fingerprint, messageCount);
463
+ function main() {
464
+ if (process.env[DUMP_ENV]) {
465
+ dumpDefaults();
466
+ return;
694
467
  }
695
-
696
- if (shouldInject) {
697
- process.stdout.write(renderCatalog(catalog, settings.kinds));
468
+ try {
469
+ run();
470
+ } catch {
471
+ // Context injection is best-effort; an error must never reach the user.
698
472
  }
699
473
  }
700
474