@devrik-tools/claude-gates 0.7.2 → 0.8.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 +69 -9
- package/README.md +68 -7
- package/cli/doctor.mjs +132 -0
- package/cli/evidence.mjs +78 -0
- package/cli/hooks-manifest.mjs +89 -0
- package/cli/index.mjs +124 -6
- package/cli/init.mjs +97 -3
- package/cli/log.mjs +70 -0
- package/cli/materialize.mjs +36 -2
- package/cli/registry.mjs +11 -1
- package/cli/selection.mjs +18 -0
- package/cli/smoke-fixtures.json +114 -22
- package/cli/task.mjs +36 -8
- package/cli/toggle.mjs +125 -0
- package/package.json +1 -1
- package/plugins/gates/.claude-plugin/plugin.json +1 -1
- package/plugins/gates/hooks/doctor.mjs +47 -1
- package/plugins/gates/hooks/gates/atomic-commit/index.mjs +92 -119
- package/plugins/gates/hooks/gates/audit-before-build/index.mjs +101 -66
- package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +8 -8
- package/plugins/gates/hooks/gates/autonomous-mode/stop.mjs +13 -64
- package/plugins/gates/hooks/gates/bash-commands/index.mjs +186 -163
- package/plugins/gates/hooks/gates/block-remote-publish/index.mjs +100 -101
- package/plugins/gates/hooks/gates/brief-approved/index.mjs +71 -140
- package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +56 -137
- package/plugins/gates/hooks/gates/capability-map/index.mjs +280 -506
- package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +98 -212
- package/plugins/gates/hooks/gates/dependency-skills/index.mjs +127 -46
- package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +81 -19
- package/plugins/gates/hooks/gates/engram-first/index.mjs +41 -0
- package/plugins/gates/hooks/gates/engram-first/session-start.mjs +64 -0
- package/plugins/gates/hooks/gates/engram-first/shared.mjs +90 -0
- package/plugins/gates/hooks/gates/engram-first/stop.mjs +23 -0
- package/plugins/gates/hooks/gates/engram-first/track.mjs +97 -0
- package/plugins/gates/hooks/gates/feature-catalog/index.mjs +156 -50
- package/plugins/gates/hooks/gates/force-parallel/index.mjs +51 -92
- package/plugins/gates/hooks/gates/forge-flow/index.mjs +51 -83
- package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +40 -80
- package/plugins/gates/hooks/gates/intent-flow/index.mjs +23 -149
- package/plugins/gates/hooks/gates/library-docs/index.mjs +262 -0
- package/plugins/gates/hooks/gates/library-docs/track.mjs +95 -0
- package/plugins/gates/hooks/gates/lint-commit/index.mjs +88 -97
- package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +48 -95
- package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +69 -31
- package/plugins/gates/hooks/gates/never-assume/index.mjs +23 -18
- package/plugins/gates/hooks/gates/no-blocking/index.mjs +127 -86
- package/plugins/gates/hooks/gates/no-coauthor/index.mjs +108 -88
- package/plugins/gates/hooks/gates/no-explanatory-comments/index.mjs +281 -0
- package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +170 -110
- package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +46 -77
- package/plugins/gates/hooks/gates/no-reconfirm/index.mjs +85 -47
- package/plugins/gates/hooks/gates/protected-paths/index.mjs +137 -90
- package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +95 -51
- package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +72 -216
- package/plugins/gates/hooks/gates/risk-level/index.mjs +51 -204
- package/plugins/gates/hooks/gates/root-cause-first/index.mjs +34 -17
- package/plugins/gates/hooks/gates/root-whitelist/index.mjs +160 -95
- package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +196 -97
- package/plugins/gates/hooks/gates/sdd-specs/index.mjs +90 -143
- package/plugins/gates/hooks/gates/staged-lint/index.mjs +98 -106
- package/plugins/gates/hooks/gates/stop-pending/index.mjs +47 -148
- package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +78 -68
- package/plugins/gates/hooks/gates/test-matrix/index.mjs +53 -88
- package/plugins/gates/hooks/gates/tool-map/index.mjs +80 -110
- package/plugins/gates/hooks/hooks.json +112 -42
- package/plugins/gates/hooks/lib/config.mjs +6 -3
- package/plugins/gates/hooks/lib/delegation.mjs +352 -0
- package/plugins/gates/hooks/lib/gate-log.mjs +141 -0
- package/plugins/gates/hooks/lib/git.mjs +262 -0
- package/plugins/gates/hooks/lib/hook-io.mjs +448 -83
- package/plugins/gates/hooks/lib/session-state.mjs +150 -0
- package/plugins/gates/hooks/lib/signals.mjs +18 -13
- package/plugins/gates/hooks/lib/testing.mjs +137 -0
- package/plugins/gates/hooks/lib/tools.mjs +144 -0
- package/plugins/tasks/.claude-plugin/plugin.json +1 -1
- package/plugins/tasks/hooks/lib/task-store.mjs +6 -6
- package/registry.json +204 -36
|
@@ -1,86 +1,17 @@
|
|
|
1
|
-
// capability-map — UserPromptSubmit hook.
|
|
2
|
-
// AI capabilities
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
58
|
+
function readPayload() {
|
|
116
59
|
try {
|
|
117
|
-
return JSON.parse(readFileSync(
|
|
60
|
+
return JSON.parse(readFileSync(STDIN_FILE_DESCRIPTOR, 'utf8'));
|
|
118
61
|
} catch {
|
|
119
|
-
return
|
|
62
|
+
return {};
|
|
120
63
|
}
|
|
121
64
|
}
|
|
122
65
|
|
|
123
|
-
function
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
|
76
|
+
return statSync(path).mtimeMs;
|
|
158
77
|
} catch {
|
|
159
|
-
return
|
|
78
|
+
return null;
|
|
160
79
|
}
|
|
161
80
|
}
|
|
162
81
|
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
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;
|
|
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;
|
|
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, '');
|
|
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
|
-
|
|
139
|
+
// ── Discovery ───────────────────────────────────────────────────────────────────────
|
|
241
140
|
function filesUnder(directory, extensions) {
|
|
242
|
-
|
|
243
|
-
let entries;
|
|
141
|
+
let names;
|
|
244
142
|
try {
|
|
245
|
-
|
|
143
|
+
names = readdirSync(directory);
|
|
246
144
|
} catch {
|
|
247
145
|
return [];
|
|
248
146
|
}
|
|
249
147
|
const files = [];
|
|
250
|
-
for (const
|
|
251
|
-
const full = join(directory,
|
|
252
|
-
if (
|
|
253
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
156
|
+
function entryFor(file, fallbackName) {
|
|
157
|
+
const mtimeMs = mtimeMsOf(file);
|
|
158
|
+
if (mtimeMs === null) return null;
|
|
159
|
+
let content;
|
|
265
160
|
try {
|
|
266
|
-
|
|
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
|
-
|
|
273
|
-
|
|
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
|
-
|
|
176
|
+
names = readdirSync(skillsRoot);
|
|
281
177
|
} catch {
|
|
282
178
|
return [];
|
|
283
179
|
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
397
|
-
|
|
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
|
|
408
|
-
if (seen.has(entry.name)) continue;
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
|
|
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
|
-
|
|
263
|
+
root,
|
|
433
264
|
blurbOverridesFile,
|
|
434
265
|
);
|
|
266
|
+
const asObject = (value) =>
|
|
267
|
+
value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
435
268
|
return {
|
|
436
|
-
...(
|
|
437
|
-
...(
|
|
269
|
+
...asObject(readJsonOrNull(globalPath)),
|
|
270
|
+
...asObject(readJsonOrNull(projectPath)),
|
|
438
271
|
};
|
|
439
272
|
}
|
|
440
273
|
|
|
441
|
-
|
|
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 =
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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(
|
|
300
|
+
.concat(overrideStamps, settingStamps)
|
|
486
301
|
.sort();
|
|
487
302
|
return createHash('sha256').update(stamps.join('\n')).digest('hex');
|
|
488
303
|
}
|
|
489
304
|
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(`${
|
|
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
|
-
|
|
596
|
-
|
|
597
|
-
function
|
|
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
|
-
|
|
600
|
-
kinds:
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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
|
-
|
|
606
|
-
|
|
382
|
+
parameters.injectEveryMessages > 0
|
|
383
|
+
? parameters.injectEveryMessages
|
|
384
|
+
: DEFAULT_PARAMS.injectEveryMessages,
|
|
607
385
|
};
|
|
608
386
|
}
|
|
609
387
|
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
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
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
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
|
-
|
|
651
|
-
const
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
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
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
427
|
+
writeSessionState(
|
|
428
|
+
GATE_ID,
|
|
429
|
+
sessionId,
|
|
430
|
+
{ fingerprint, messageCount },
|
|
431
|
+
{ cwd: root },
|
|
670
432
|
);
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
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
|
-
|
|
451
|
+
root,
|
|
683
452
|
settings,
|
|
684
453
|
);
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
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
|
-
|
|
693
|
-
|
|
463
|
+
function main() {
|
|
464
|
+
if (process.env[DUMP_ENV]) {
|
|
465
|
+
dumpDefaults();
|
|
466
|
+
return;
|
|
694
467
|
}
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
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
|
|