@devrik-tools/claude-gates 0.4.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/README.es.md +39 -4
- package/README.md +34 -5
- package/cli/config.mjs +126 -124
- package/cli/init.mjs +303 -276
- package/cli/install.mjs +281 -175
- package/cli/materialize.mjs +103 -102
- package/cli/registry.mjs +139 -136
- package/cli/smoke-fixtures.json +65 -0
- package/cli/task.mjs +140 -140
- package/package.json +1 -1
- package/plugins/gates/.claude-plugin/plugin.json +1 -1
- package/plugins/gates/hooks/ask-adoption.mjs +147 -147
- package/plugins/gates/hooks/doctor.mjs +207 -207
- package/plugins/gates/hooks/gates/atomic-commit/index.mjs +229 -0
- package/plugins/gates/hooks/gates/audit-before-build/index.mjs +110 -88
- package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +50 -50
- package/plugins/gates/hooks/gates/bash-commands/index.mjs +215 -215
- package/plugins/gates/hooks/gates/brief-approved/index.mjs +216 -0
- package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +269 -265
- package/plugins/gates/hooks/gates/capability-map/index.mjs +701 -0
- package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +527 -501
- package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +48 -43
- package/plugins/gates/hooks/gates/feature-catalog/index.mjs +83 -83
- package/plugins/gates/hooks/gates/force-parallel/index.mjs +134 -119
- package/plugins/gates/hooks/gates/forge-flow/index.mjs +134 -134
- package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +187 -187
- package/plugins/gates/hooks/gates/intent-flow/index.mjs +260 -260
- package/plugins/gates/hooks/gates/lint-commit/index.mjs +152 -149
- package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +180 -180
- package/plugins/gates/hooks/gates/never-assume/index.mjs +59 -58
- package/plugins/gates/hooks/gates/no-blocking/index.mjs +163 -148
- package/plugins/gates/hooks/gates/no-coauthor/index.mjs +127 -0
- package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +183 -0
- package/plugins/gates/hooks/gates/protected-paths/index.mjs +149 -144
- package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +91 -89
- package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +263 -159
- package/plugins/gates/hooks/gates/risk-level/index.mjs +265 -263
- package/plugins/gates/hooks/gates/root-cause-first/index.mjs +57 -56
- package/plugins/gates/hooks/gates/root-whitelist/index.mjs +211 -131
- package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +181 -184
- package/plugins/gates/hooks/gates/sdd-specs/index.mjs +256 -256
- package/plugins/gates/hooks/gates/staged-lint/index.mjs +187 -0
- package/plugins/gates/hooks/gates/stop-pending/index.mjs +169 -164
- package/plugins/gates/hooks/gates/test-matrix/index.mjs +187 -187
- package/plugins/gates/hooks/gates/tool-map/index.mjs +168 -143
- package/plugins/gates/hooks/hooks.json +61 -0
- package/plugins/gates/hooks/lib/config.mjs +179 -172
- package/plugins/gates/hooks/lib/hook-io.mjs +367 -357
- package/plugins/gates/hooks/lib/signals.mjs +172 -127
- package/plugins/gates/hooks/wiring-check.mjs +227 -227
- package/plugins/tasks/.claude-plugin/plugin.json +1 -1
- package/plugins/tasks/hooks/hooks.json +26 -26
- package/plugins/tasks/hooks/lib/task-store.mjs +217 -197
- package/plugins/tasks/hooks/register-requests.mjs +145 -145
- package/plugins/tasks/hooks/session-tasks.mjs +108 -108
- package/registry.json +192 -1
|
@@ -0,0 +1,701 @@
|
|
|
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.
|
|
80
|
+
|
|
81
|
+
import { createHash } from 'node:crypto';
|
|
82
|
+
import {
|
|
83
|
+
existsSync,
|
|
84
|
+
mkdirSync,
|
|
85
|
+
readFileSync,
|
|
86
|
+
readdirSync,
|
|
87
|
+
statSync,
|
|
88
|
+
writeFileSync,
|
|
89
|
+
} from 'node:fs';
|
|
90
|
+
import { homedir } from 'node:os';
|
|
91
|
+
import { basename, dirname, extname, isAbsolute, join } from 'node:path';
|
|
92
|
+
|
|
93
|
+
const STDIN_FILE_DESCRIPTOR = 0;
|
|
94
|
+
const CONFIG_KEY = 'injectCapabilityMap';
|
|
95
|
+
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
|
+
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
|
+
const JSON_INDENT = 2;
|
|
108
|
+
|
|
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');
|
|
114
|
+
|
|
115
|
+
function readJson(path) {
|
|
116
|
+
try {
|
|
117
|
+
return JSON.parse(readFileSync(path, 'utf8').replace(/^\uFEFF/, ''));
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
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;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
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() {
|
|
156
|
+
try {
|
|
157
|
+
return JSON.parse(readFileSync(STDIN_FILE_DESCRIPTOR, 'utf8'));
|
|
158
|
+
} catch {
|
|
159
|
+
return {};
|
|
160
|
+
}
|
|
161
|
+
}
|
|
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
|
+
|
|
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
|
+
*/
|
|
194
|
+
function parseFrontMatter(fileText) {
|
|
195
|
+
const lines = fileText.split(/\r?\n/);
|
|
196
|
+
if (lines[0]?.trim() !== '---') return { name: '', description: '' };
|
|
197
|
+
|
|
198
|
+
let name = '';
|
|
199
|
+
let description = '';
|
|
200
|
+
for (let index = 1; index < lines.length; index += 1) {
|
|
201
|
+
const line = lines[index];
|
|
202
|
+
if (line.trim() === '---') break; // end of front matter
|
|
203
|
+
const separator = line.indexOf(':');
|
|
204
|
+
if (separator < 0) continue;
|
|
205
|
+
const key = line.slice(0, separator).trim();
|
|
206
|
+
let value = line
|
|
207
|
+
.slice(separator + 1)
|
|
208
|
+
.trim()
|
|
209
|
+
.replace(/^["']|["']$/g, ''); // toml/yaml quoting around the value
|
|
210
|
+
if (BLOCK_SCALAR_INDICATOR_PATTERN.test(value)) {
|
|
211
|
+
value = readBlockScalarValue(lines, index + 1);
|
|
212
|
+
}
|
|
213
|
+
if (key === 'name') name = value;
|
|
214
|
+
else if (key === 'description') description = value;
|
|
215
|
+
}
|
|
216
|
+
return { name, description };
|
|
217
|
+
}
|
|
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
|
+
|
|
231
|
+
/** The description's first clause, capped — the caveman blurb. */
|
|
232
|
+
function firstClause(description, maxClauseChars) {
|
|
233
|
+
if (!description) return '';
|
|
234
|
+
const sentenceEnd = description.indexOf('. ');
|
|
235
|
+
const clause =
|
|
236
|
+
sentenceEnd > 0 ? description.slice(0, sentenceEnd) : description;
|
|
237
|
+
return truncateAtWordBoundary(clause, maxClauseChars);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Recursively lists files under a directory whose extension is in `extensions`. */
|
|
241
|
+
function filesUnder(directory, extensions) {
|
|
242
|
+
if (!existsSync(directory)) return [];
|
|
243
|
+
let entries;
|
|
244
|
+
try {
|
|
245
|
+
entries = readdirSync(directory, { withFileTypes: true });
|
|
246
|
+
} catch {
|
|
247
|
+
return [];
|
|
248
|
+
}
|
|
249
|
+
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
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return files;
|
|
259
|
+
}
|
|
260
|
+
|
|
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. */
|
|
276
|
+
function skillEntriesUnder(skillsRoot, maxClauseChars) {
|
|
277
|
+
if (!existsSync(skillsRoot)) return [];
|
|
278
|
+
let skillDirectories;
|
|
279
|
+
try {
|
|
280
|
+
skillDirectories = readdirSync(skillsRoot, { withFileTypes: true });
|
|
281
|
+
} catch {
|
|
282
|
+
return [];
|
|
283
|
+
}
|
|
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) {
|
|
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
|
+
|
|
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));
|
|
395
|
+
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)));
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const seen = new Set();
|
|
406
|
+
const unique = [];
|
|
407
|
+
for (const entry of collected) {
|
|
408
|
+
if (seen.has(entry.name)) continue; // a capability present in two roots is listed once
|
|
409
|
+
seen.add(entry.name);
|
|
410
|
+
unique.push(entry);
|
|
411
|
+
}
|
|
412
|
+
unique.sort((a, b) => a.name.localeCompare(b.name));
|
|
413
|
+
return unique;
|
|
414
|
+
}
|
|
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
|
+
|
|
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);
|
|
523
|
+
const payload = {
|
|
524
|
+
generatedAt: new Date().toISOString(),
|
|
525
|
+
fingerprint,
|
|
526
|
+
messageCount,
|
|
527
|
+
capabilities: withoutStamps(catalog),
|
|
528
|
+
};
|
|
529
|
+
try {
|
|
530
|
+
mkdirSync(dirname(mapPath), { recursive: true });
|
|
531
|
+
writeFileSync(
|
|
532
|
+
mapPath,
|
|
533
|
+
`${JSON.stringify(payload, null, JSON_INDENT)}\n`,
|
|
534
|
+
'utf8',
|
|
535
|
+
);
|
|
536
|
+
} catch {
|
|
537
|
+
// The injection is the job; the persisted file is a convenience. A write failure must
|
|
538
|
+
// not break the turn.
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
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
|
+
function renderCatalog(catalog, kinds) {
|
|
582
|
+
const sections = [];
|
|
583
|
+
for (const kind of kinds) {
|
|
584
|
+
const entries = catalog[kind];
|
|
585
|
+
if (!entries) continue;
|
|
586
|
+
const label = KIND_LABELS[kind] || kind;
|
|
587
|
+
const lines = entries.map((entry) =>
|
|
588
|
+
entry.blurb ? ` ${entry.name} — ${entry.blurb}` : ` ${entry.name}`,
|
|
589
|
+
);
|
|
590
|
+
sections.push(`${label}:\n${lines.join('\n')}`);
|
|
591
|
+
}
|
|
592
|
+
return `[capabilities] available (check before improvising something one of these covers):\n${sections.join('\n')}\n`;
|
|
593
|
+
}
|
|
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
|
+
|
|
644
|
+
function main() {
|
|
645
|
+
if (process.env[DUMP_ENV]) {
|
|
646
|
+
dumpDefaults();
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
|
|
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,
|
|
664
|
+
);
|
|
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,
|
|
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 };
|
|
677
|
+
|
|
678
|
+
const catalog = resolveCatalog(
|
|
679
|
+
rawCatalog,
|
|
680
|
+
persisted,
|
|
681
|
+
fingerprint,
|
|
682
|
+
cwd,
|
|
683
|
+
settings,
|
|
684
|
+
);
|
|
685
|
+
|
|
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);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
if (shouldInject) {
|
|
697
|
+
process.stdout.write(renderCatalog(catalog, settings.kinds));
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
main();
|