@devrik-tools/claude-gates 0.8.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +3 -3
- package/README.es.md +102 -9
- package/README.md +93 -9
- package/cli/artifacts.mjs +213 -0
- package/cli/constants.mjs +14 -0
- package/cli/doctor.mjs +2 -1
- package/cli/index.mjs +54 -2
- package/cli/init.mjs +95 -9
- package/cli/install.mjs +53 -1
- package/cli/registry.mjs +2 -0
- package/cli/selection.mjs +23 -1
- package/cli/smoke-fixtures.json +53 -3
- package/cli/task.mjs +69 -4
- package/package.json +5 -4
- package/plugins/gates/.claude-plugin/plugin.json +1 -1
- package/plugins/gates/hooks/gates/capability-map/index.mjs +37 -208
- package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +4 -11
- package/plugins/gates/hooks/gates/circuit-breaker/track.mjs +285 -0
- package/plugins/gates/hooks/gates/force-parallel/index.mjs +11 -12
- package/plugins/gates/hooks/gates/library-docs/index.mjs +107 -31
- package/plugins/gates/hooks/gates/no-trivial-scripts/index.mjs +114 -0
- package/plugins/gates/hooks/gates/require-monitor/index.mjs +126 -0
- package/plugins/gates/hooks/gates/require-task-split/index.mjs +88 -0
- package/plugins/gates/hooks/gates/skill-first/index.mjs +138 -0
- package/plugins/gates/hooks/gates/skill-first/track.mjs +66 -0
- package/plugins/gates/hooks/hooks.json +61 -1
- package/plugins/gates/hooks/lib/capabilities.mjs +401 -0
- package/plugins/gates/hooks/lib/hook-io.mjs +9 -2
- package/plugins/gates/hooks/lib/signals.mjs +91 -0
- package/plugins/gates/hooks/lib/testing.mjs +15 -4
- package/plugins/tasks/.claude-plugin/plugin.json +1 -1
- package/plugins/tasks/hooks/lib/task-store.mjs +6 -0
- package/plugins/tasks/hooks/register-requests.mjs +37 -10
- package/registry.json +106 -5
package/cli/smoke-fixtures.json
CHANGED
|
@@ -136,8 +136,8 @@
|
|
|
136
136
|
{
|
|
137
137
|
"id": "force-parallel",
|
|
138
138
|
"configKey": "warnSequentialDelegations",
|
|
139
|
-
"enabledByDefault":
|
|
140
|
-
"type": "
|
|
139
|
+
"enabledByDefault": true,
|
|
140
|
+
"type": "deny",
|
|
141
141
|
"payload": {
|
|
142
142
|
"tool_name": "Task",
|
|
143
143
|
"tool_input": {
|
|
@@ -145,7 +145,7 @@
|
|
|
145
145
|
}
|
|
146
146
|
},
|
|
147
147
|
"needsState": true,
|
|
148
|
-
"note": "stateful:
|
|
148
|
+
"note": "stateful: denies only on the 3rd consecutive delegation under one session."
|
|
149
149
|
},
|
|
150
150
|
{
|
|
151
151
|
"id": "feature-catalog",
|
|
@@ -413,6 +413,14 @@
|
|
|
413
413
|
"needsState": true,
|
|
414
414
|
"note": "side-effect only: records to .ai/tool-map.json, never denies/warns."
|
|
415
415
|
},
|
|
416
|
+
{
|
|
417
|
+
"id": "skill-first",
|
|
418
|
+
"configKey": "requireSkillCheckBeforeActing",
|
|
419
|
+
"enabledByDefault": false,
|
|
420
|
+
"type": "deny",
|
|
421
|
+
"payload": null,
|
|
422
|
+
"needsState": true
|
|
423
|
+
},
|
|
416
424
|
{
|
|
417
425
|
"id": "forge-flow",
|
|
418
426
|
"configKey": "requireForgeRunToEdit",
|
|
@@ -542,6 +550,48 @@
|
|
|
542
550
|
}
|
|
543
551
|
},
|
|
544
552
|
"needsState": false
|
|
553
|
+
},
|
|
554
|
+
{
|
|
555
|
+
"id": "require-task-split",
|
|
556
|
+
"configKey": "requireTaskSplitBeforeImplementing",
|
|
557
|
+
"enabledByDefault": true,
|
|
558
|
+
"type": "deny",
|
|
559
|
+
"payload": {
|
|
560
|
+
"tool_name": "Write",
|
|
561
|
+
"tool_input": {
|
|
562
|
+
"file_path": "src/x.js",
|
|
563
|
+
"content": "x"
|
|
564
|
+
}
|
|
565
|
+
},
|
|
566
|
+
"needsState": true,
|
|
567
|
+
"note": "needs an .ai/tasks/active.json with a medium+ task that has no children."
|
|
568
|
+
},
|
|
569
|
+
{
|
|
570
|
+
"id": "require-monitor",
|
|
571
|
+
"configKey": "requireMonitorForBackground",
|
|
572
|
+
"enabledByDefault": true,
|
|
573
|
+
"type": "deny",
|
|
574
|
+
"payload": {
|
|
575
|
+
"tool_name": "Bash",
|
|
576
|
+
"tool_input": {
|
|
577
|
+
"command": "npm run build",
|
|
578
|
+
"run_in_background": true
|
|
579
|
+
}
|
|
580
|
+
},
|
|
581
|
+
"needsState": false
|
|
582
|
+
},
|
|
583
|
+
{
|
|
584
|
+
"id": "no-trivial-scripts",
|
|
585
|
+
"configKey": "blockTrivialInlineScripts",
|
|
586
|
+
"enabledByDefault": true,
|
|
587
|
+
"type": "deny",
|
|
588
|
+
"payload": {
|
|
589
|
+
"tool_name": "Bash",
|
|
590
|
+
"tool_input": {
|
|
591
|
+
"command": "sed -i 's/old/new/' src/a.mjs"
|
|
592
|
+
}
|
|
593
|
+
},
|
|
594
|
+
"needsState": false
|
|
545
595
|
}
|
|
546
596
|
]
|
|
547
597
|
}
|
package/cli/task.mjs
CHANGED
|
@@ -16,6 +16,12 @@ import { verifyCommand, verifyPath } from './evidence.mjs';
|
|
|
16
16
|
|
|
17
17
|
const DEFAULT_SIZE = 'unspecified';
|
|
18
18
|
|
|
19
|
+
const VERIFY_USAGE =
|
|
20
|
+
'task add requires a deterministic verification criterion: ' +
|
|
21
|
+
'--verify-command "<command>" [--verify-expect <text>] (command that must exit 0 when done) ' +
|
|
22
|
+
'or --verify-path <path> [--verify-contains <text>] (file/dir that must exist when done). ' +
|
|
23
|
+
'This defines HOW the task will be verified as complete — free text is not enough.';
|
|
24
|
+
|
|
19
25
|
function fail(message) {
|
|
20
26
|
process.stderr.write(`${message}\n`);
|
|
21
27
|
process.exit(EXIT_CODE.FAILURE);
|
|
@@ -31,18 +37,41 @@ function openStoreOrFail(cwd) {
|
|
|
31
37
|
return store;
|
|
32
38
|
}
|
|
33
39
|
|
|
40
|
+
function buildVerifyCriteria(options) {
|
|
41
|
+
if (options.verifyCommand) {
|
|
42
|
+
return {
|
|
43
|
+
kind: 'command',
|
|
44
|
+
command: options.verifyCommand,
|
|
45
|
+
expect: options.verifyExpect ?? null,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
if (options.verifyPath) {
|
|
49
|
+
return {
|
|
50
|
+
kind: 'path',
|
|
51
|
+
path: options.verifyPath,
|
|
52
|
+
contains: options.verifyContains ?? null,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
34
58
|
function taskAdd(title, options, { cwd = process.cwd() } = {}) {
|
|
35
59
|
if (!title || !title.trim()) fail('task add requires a non-empty title.');
|
|
60
|
+
const verify = buildVerifyCriteria(options);
|
|
61
|
+
if (!verify) fail(VERIFY_USAGE);
|
|
36
62
|
const store = openStoreOrFail(cwd);
|
|
37
|
-
const task =
|
|
63
|
+
const task = {
|
|
38
64
|
id: options.id || randomUUID(),
|
|
39
65
|
title: title.trim(),
|
|
40
66
|
description: options.description ?? '',
|
|
41
67
|
status: STATUS.OPEN,
|
|
42
68
|
size: options.size ?? DEFAULT_SIZE,
|
|
69
|
+
verify,
|
|
43
70
|
createdAt: new Date().toISOString(),
|
|
44
71
|
messages: [],
|
|
45
|
-
}
|
|
72
|
+
};
|
|
73
|
+
if (options.parent) task.parentId = options.parent;
|
|
74
|
+
store.add(task);
|
|
46
75
|
process.stdout.write(`${JSON.stringify(task, null, 2)}\n`);
|
|
47
76
|
}
|
|
48
77
|
|
|
@@ -80,9 +109,26 @@ function collectEvidence(options, cwd) {
|
|
|
80
109
|
return null;
|
|
81
110
|
}
|
|
82
111
|
|
|
112
|
+
function autoVerifyFromTask(task, cwd) {
|
|
113
|
+
if (!task?.verify) return null;
|
|
114
|
+
const { kind, command, expect, path, contains } = task.verify;
|
|
115
|
+
if (kind === 'command' && command) {
|
|
116
|
+
return verifyCommand(command, { cwd, expect: expect ?? undefined });
|
|
117
|
+
}
|
|
118
|
+
if (kind === 'path' && path) {
|
|
119
|
+
return verifyPath(path, { cwd, contains: contains ?? undefined });
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
83
124
|
function taskClose(id, options, { cwd = process.cwd() } = {}) {
|
|
84
125
|
const store = openStoreOrFail(cwd);
|
|
85
|
-
|
|
126
|
+
let evidence = collectEvidence(options, store.root);
|
|
127
|
+
if (!evidence && options.evidence !== undefined) fail(EVIDENCE_USAGE);
|
|
128
|
+
if (!evidence) {
|
|
129
|
+
const task = store.active().find((entry) => entry.id === id);
|
|
130
|
+
evidence = autoVerifyFromTask(task, store.root);
|
|
131
|
+
}
|
|
86
132
|
if (!evidence) fail(EVIDENCE_USAGE);
|
|
87
133
|
if (!evidence.verified) {
|
|
88
134
|
const output = evidence.outputTail ? `\n${evidence.outputTail}` : '';
|
|
@@ -121,10 +167,29 @@ export function registerTaskCommand(program) {
|
|
|
121
167
|
|
|
122
168
|
task
|
|
123
169
|
.command('add <title>')
|
|
124
|
-
.description(
|
|
170
|
+
.description(
|
|
171
|
+
'Register a new open task (requires a verification criterion).',
|
|
172
|
+
)
|
|
125
173
|
.option('--id <id>', 'explicit task id (default: a generated uuid)')
|
|
126
174
|
.option('--description <text>', 'longer description of the task')
|
|
127
175
|
.option('--size <size>', 'rough size estimate (e.g. trivial, small, large)')
|
|
176
|
+
.option(
|
|
177
|
+
'--verify-command <command>',
|
|
178
|
+
'shell command that must exit 0 when the task is done',
|
|
179
|
+
)
|
|
180
|
+
.option(
|
|
181
|
+
'--verify-expect <text>',
|
|
182
|
+
'text the --verify-command output must contain',
|
|
183
|
+
)
|
|
184
|
+
.option(
|
|
185
|
+
'--verify-path <path>',
|
|
186
|
+
'file or directory that must exist when the task is done',
|
|
187
|
+
)
|
|
188
|
+
.option(
|
|
189
|
+
'--verify-contains <text>',
|
|
190
|
+
'text the --verify-path file must contain',
|
|
191
|
+
)
|
|
192
|
+
.option('--parent <id>', 'parent task id (makes this a sub-task)')
|
|
128
193
|
.action((title, options) => taskAdd(title, options));
|
|
129
194
|
|
|
130
195
|
task
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@devrik-tools/claude-gates",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "49 installable, deterministic gates (hooks) for Claude Code: block destructive commands and protected paths, enforce delegation/spec/quality/research rules, and track tasks that only close with verified evidence. Configurable per project.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|
|
7
7
|
"hooks",
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
"guardrails",
|
|
10
10
|
"pretooluse",
|
|
11
11
|
"policy-enforcement",
|
|
12
|
-
"ai-agent"
|
|
12
|
+
"ai-agent",
|
|
13
|
+
"task-tracking"
|
|
13
14
|
],
|
|
14
15
|
"type": "module",
|
|
15
16
|
"bin": {
|
|
@@ -74,4 +75,4 @@
|
|
|
74
75
|
"globals": "^17.11.0",
|
|
75
76
|
"prettier": "^3.9.6"
|
|
76
77
|
}
|
|
77
|
-
}
|
|
78
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gates",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Deterministic gates for Claude Code: destructive-command blocks, protected paths, delegation briefs, spec-driven flow and session-start validation. Selection lives in config, not in code.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Devrik"
|
|
@@ -9,17 +9,21 @@
|
|
|
9
9
|
// ~/.claude/blurb-overrides.json and <project>/<blurbOverridesFile> (project wins per key).
|
|
10
10
|
// `.agents/skills` and `.ai/skills` (home and project) are scanned as skill-only roots:
|
|
11
11
|
// other installers write there. Never blocks; any failure injects nothing.
|
|
12
|
+
//
|
|
13
|
+
// Discovery itself lives in lib/capabilities.mjs, shared with skill-first (the PreToolUse
|
|
14
|
+
// half that judges whether an action has a skill covering it): one catalog definition, so
|
|
15
|
+
// what the model is TOLD it has and what a gate CHECKS it has can never disagree.
|
|
12
16
|
|
|
13
17
|
import { createHash } from 'node:crypto';
|
|
14
|
-
import {
|
|
15
|
-
mkdirSync,
|
|
16
|
-
readFileSync,
|
|
17
|
-
readdirSync,
|
|
18
|
-
statSync,
|
|
19
|
-
writeFileSync,
|
|
20
|
-
} from 'node:fs';
|
|
18
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
21
19
|
import { homedir } from 'node:os';
|
|
22
|
-
import {
|
|
20
|
+
import { dirname, join } from 'node:path';
|
|
21
|
+
import {
|
|
22
|
+
buildRawCatalog,
|
|
23
|
+
firstClause,
|
|
24
|
+
mtimeMsOf,
|
|
25
|
+
truncateAtWordBoundary,
|
|
26
|
+
} from '../../lib/capabilities.mjs';
|
|
23
27
|
import {
|
|
24
28
|
loadGateConfig,
|
|
25
29
|
projectRootOf,
|
|
@@ -30,6 +34,7 @@ import {
|
|
|
30
34
|
readSessionState,
|
|
31
35
|
writeSessionState,
|
|
32
36
|
} from '../../lib/session-state.mjs';
|
|
37
|
+
import { workNatureOf } from '../../lib/signals.mjs';
|
|
33
38
|
|
|
34
39
|
const STDIN_FILE_DESCRIPTOR = 0;
|
|
35
40
|
const GATE_ID = 'capability-map';
|
|
@@ -48,13 +53,9 @@ const DEFAULT_PARAMS = Object.freeze({
|
|
|
48
53
|
mapFile: join('.ai', 'capability-map.json'),
|
|
49
54
|
blurbOverridesFile: join('.ai', 'blurb-overrides.json'),
|
|
50
55
|
injectEveryMessages: 10,
|
|
56
|
+
reinjectOnWorkNatureChange: true,
|
|
51
57
|
});
|
|
52
58
|
|
|
53
|
-
const KIND_EXTENSIONS = {
|
|
54
|
-
agents: ['.md'],
|
|
55
|
-
commands: ['.md', '.toml'],
|
|
56
|
-
};
|
|
57
|
-
|
|
58
59
|
function readPayload() {
|
|
59
60
|
try {
|
|
60
61
|
return JSON.parse(readFileSync(STDIN_FILE_DESCRIPTOR, 'utf8'));
|
|
@@ -63,193 +64,6 @@ function readPayload() {
|
|
|
63
64
|
}
|
|
64
65
|
}
|
|
65
66
|
|
|
66
|
-
function isDirectory(path) {
|
|
67
|
-
try {
|
|
68
|
-
return statSync(path).isDirectory();
|
|
69
|
-
} catch {
|
|
70
|
-
return false;
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function mtimeMsOf(path) {
|
|
75
|
-
try {
|
|
76
|
-
return statSync(path).mtimeMs;
|
|
77
|
-
} catch {
|
|
78
|
-
return null;
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
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 ">".
|
|
85
|
-
const BLOCK_SCALAR_INDICATOR_PATTERN = /^[|>][+-]?\d*$/;
|
|
86
|
-
|
|
87
|
-
function readBlockScalarValue(lines, startIndex) {
|
|
88
|
-
const parts = [];
|
|
89
|
-
for (let index = startIndex; index < lines.length; index += 1) {
|
|
90
|
-
const line = lines[index];
|
|
91
|
-
if (line.trim() === '---') break;
|
|
92
|
-
if (!/^[ \t]+\S/.test(line)) break;
|
|
93
|
-
parts.push(line.trim());
|
|
94
|
-
}
|
|
95
|
-
return parts.join(' ');
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// Parsed line by line (no multi-line regex) so a large body can never backtrack.
|
|
99
|
-
function parseFrontMatter(fileText) {
|
|
100
|
-
const lines = fileText.split(/\r?\n/);
|
|
101
|
-
if (lines[0]?.trim() !== '---') return { name: '', description: '' };
|
|
102
|
-
let name = '';
|
|
103
|
-
let description = '';
|
|
104
|
-
for (let index = 1; index < lines.length; index += 1) {
|
|
105
|
-
const line = lines[index];
|
|
106
|
-
if (line.trim() === '---') break;
|
|
107
|
-
const separator = line.indexOf(':');
|
|
108
|
-
if (separator < 0) continue;
|
|
109
|
-
const key = line.slice(0, separator).trim();
|
|
110
|
-
let value = line
|
|
111
|
-
.slice(separator + 1)
|
|
112
|
-
.trim()
|
|
113
|
-
.replace(/^["']|["']$/g, '');
|
|
114
|
-
if (BLOCK_SCALAR_INDICATOR_PATTERN.test(value)) {
|
|
115
|
-
value = readBlockScalarValue(lines, index + 1);
|
|
116
|
-
}
|
|
117
|
-
if (key === 'name') name = value;
|
|
118
|
-
else if (key === 'description') description = value;
|
|
119
|
-
}
|
|
120
|
-
return { name, description };
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function truncateAtWordBoundary(text, maxChars) {
|
|
124
|
-
if (text.length <= maxChars) return text;
|
|
125
|
-
const budget = text.slice(0, maxChars - 1);
|
|
126
|
-
const lastSpace = budget.lastIndexOf(' ');
|
|
127
|
-
const cut = lastSpace > 0 ? budget.slice(0, lastSpace) : budget;
|
|
128
|
-
return `${cut.trimEnd()}…`;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
function firstClause(description, maxClauseChars) {
|
|
132
|
-
if (!description) return '';
|
|
133
|
-
const sentenceEnd = description.indexOf('. ');
|
|
134
|
-
const clause =
|
|
135
|
-
sentenceEnd > 0 ? description.slice(0, sentenceEnd) : description;
|
|
136
|
-
return truncateAtWordBoundary(clause, maxClauseChars);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// ── Discovery ───────────────────────────────────────────────────────────────────────
|
|
140
|
-
function filesUnder(directory, extensions) {
|
|
141
|
-
let names;
|
|
142
|
-
try {
|
|
143
|
-
names = readdirSync(directory);
|
|
144
|
-
} catch {
|
|
145
|
-
return [];
|
|
146
|
-
}
|
|
147
|
-
const files = [];
|
|
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);
|
|
152
|
-
}
|
|
153
|
-
return files;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
function entryFor(file, fallbackName) {
|
|
157
|
-
const mtimeMs = mtimeMsOf(file);
|
|
158
|
-
if (mtimeMs === null) return null;
|
|
159
|
-
let content;
|
|
160
|
-
try {
|
|
161
|
-
content = readFileSync(file, 'utf8');
|
|
162
|
-
} catch {
|
|
163
|
-
return null;
|
|
164
|
-
}
|
|
165
|
-
const { name, description } = parseFrontMatter(content);
|
|
166
|
-
return {
|
|
167
|
-
name: name || fallbackName,
|
|
168
|
-
description,
|
|
169
|
-
stamp: `${file}:${mtimeMs}`,
|
|
170
|
-
};
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
function skillEntriesUnder(skillsRoot) {
|
|
174
|
-
let names;
|
|
175
|
-
try {
|
|
176
|
-
names = readdirSync(skillsRoot);
|
|
177
|
-
} catch {
|
|
178
|
-
return [];
|
|
179
|
-
}
|
|
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) {
|
|
197
|
-
return [
|
|
198
|
-
join(root, '.claude', 'skills'),
|
|
199
|
-
join(root, '.agents', 'skills'),
|
|
200
|
-
join(root, '.ai', 'skills'),
|
|
201
|
-
join(homedir(), '.claude', 'skills'),
|
|
202
|
-
join(homedir(), '.agents', 'skills'),
|
|
203
|
-
join(homedir(), '.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)),
|
|
213
|
-
];
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
function collectKind(kind, root, settings) {
|
|
217
|
-
if (kind === 'skills') {
|
|
218
|
-
return skillRootsFor(root, settings.extraSkillsDirs).flatMap(
|
|
219
|
-
skillEntriesUnder,
|
|
220
|
-
);
|
|
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
|
-
}
|
|
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) {
|
|
234
|
-
const seen = new Set();
|
|
235
|
-
const unique = [];
|
|
236
|
-
for (const entry of collectKind(kind, root, settings)) {
|
|
237
|
-
if (seen.has(entry.name)) continue;
|
|
238
|
-
seen.add(entry.name);
|
|
239
|
-
unique.push(entry);
|
|
240
|
-
}
|
|
241
|
-
return unique.sort((a, b) => a.name.localeCompare(b.name));
|
|
242
|
-
}
|
|
243
|
-
|
|
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
67
|
// ── Blurbs, overrides and the fingerprint ───────────────────────────────────────────
|
|
254
68
|
function blurbOverridePathsFor(root, blurbOverridesFile) {
|
|
255
69
|
return [
|
|
@@ -355,12 +169,20 @@ function renderCatalog(catalog, kinds) {
|
|
|
355
169
|
return `[capabilities] available (check before improvising something one of these covers):\n${sections.join('\n')}\n`;
|
|
356
170
|
}
|
|
357
171
|
|
|
358
|
-
//
|
|
359
|
-
//
|
|
360
|
-
|
|
172
|
+
// Three reasons to inject, then the throttle. The catalog changing on disk was the only
|
|
173
|
+
// content-driven trigger the gate had, which left the case the reminder is actually for:
|
|
174
|
+
// the session PIVOTING to a different kind of work (debugging → designing → releasing)
|
|
175
|
+
// with a catalog that never moved, so the model kept whatever the throttle last emitted
|
|
176
|
+
// and the skills that matter for the new nature were never re-surfaced. The nature is a
|
|
177
|
+
// coarse lexical read of the prompt (lib/signals.mjs), and being wrong costs one extra
|
|
178
|
+
// injection of a never-blocking catalog — cheap enough to prefer over staying silent.
|
|
179
|
+
function injectionDecision(session, fingerprint, nature, settings) {
|
|
361
180
|
const changed = session.fingerprint !== fingerprint;
|
|
181
|
+
const pivoted =
|
|
182
|
+
settings.reinjectOnWorkNatureChange && session.workNature !== nature;
|
|
362
183
|
const nextCount = (Number(session.messageCount) || 0) + 1;
|
|
363
|
-
const shouldInject =
|
|
184
|
+
const shouldInject =
|
|
185
|
+
changed || pivoted || nextCount >= settings.injectEveryMessages;
|
|
364
186
|
return { shouldInject, messageCount: shouldInject ? 0 : nextCount };
|
|
365
187
|
}
|
|
366
188
|
|
|
@@ -417,22 +239,28 @@ function syncMap(mapPath, rawCatalog, fingerprint, root, settings) {
|
|
|
417
239
|
return catalog;
|
|
418
240
|
}
|
|
419
241
|
|
|
420
|
-
function shouldInjectNow(sessionId, root, fingerprint,
|
|
242
|
+
function shouldInjectNow(sessionId, root, fingerprint, nature, settings) {
|
|
421
243
|
const session = readSessionState(GATE_ID, sessionId, {}, { cwd: root });
|
|
422
244
|
const { shouldInject, messageCount } = injectionDecision(
|
|
423
245
|
session,
|
|
424
246
|
fingerprint,
|
|
425
|
-
|
|
247
|
+
nature,
|
|
248
|
+
settings,
|
|
426
249
|
);
|
|
427
250
|
writeSessionState(
|
|
428
251
|
GATE_ID,
|
|
429
252
|
sessionId,
|
|
430
|
-
{ fingerprint, messageCount },
|
|
253
|
+
{ fingerprint, messageCount, workNature: nature },
|
|
431
254
|
{ cwd: root },
|
|
432
255
|
);
|
|
433
256
|
return shouldInject;
|
|
434
257
|
}
|
|
435
258
|
|
|
259
|
+
function promptOf(payload) {
|
|
260
|
+
const prompt = payload?.prompt ?? payload?.user_prompt ?? payload?.message;
|
|
261
|
+
return typeof prompt === 'string' ? prompt : '';
|
|
262
|
+
}
|
|
263
|
+
|
|
436
264
|
function run() {
|
|
437
265
|
const payload = readPayload();
|
|
438
266
|
const cwd = cwdOf(payload);
|
|
@@ -455,7 +283,8 @@ function run() {
|
|
|
455
283
|
payload.session_id ?? null,
|
|
456
284
|
root,
|
|
457
285
|
fingerprint,
|
|
458
|
-
|
|
286
|
+
workNatureOf(promptOf(payload)),
|
|
287
|
+
settings,
|
|
459
288
|
);
|
|
460
289
|
if (inject) process.stdout.write(renderCatalog(catalog, settings.kinds));
|
|
461
290
|
}
|
|
@@ -30,7 +30,6 @@ const CONFIG_KEY = 'requireCircuitBreakerOnDelegation';
|
|
|
30
30
|
const DEFAULT_RETRY_THRESHOLD = 2;
|
|
31
31
|
const MIN_RETRY_THRESHOLD = 2;
|
|
32
32
|
const DEFAULT_SIMILARITY_THRESHOLD = 0.6;
|
|
33
|
-
const MAX_ENTRIES_PER_KEY = 12;
|
|
34
33
|
const MAX_OVERRIDE_SENTENCE_WORDS = 8;
|
|
35
34
|
const MIN_TOKEN_LENGTH = 2;
|
|
36
35
|
|
|
@@ -391,18 +390,12 @@ runGate(
|
|
|
391
390
|
return;
|
|
392
391
|
}
|
|
393
392
|
|
|
393
|
+
// Count includes the current attempt (+1) against stored occurrences.
|
|
394
|
+
// Recording happens in the PostToolUse tracker (track.mjs), not here: a
|
|
395
|
+
// delegation rejected by ANOTHER gate (running in parallel) must not inflate
|
|
396
|
+
// the counter — only delegations that actually launched count as attempts.
|
|
394
397
|
const count =
|
|
395
398
|
1 + similarOccurrenceCount(state, signature, similarityThreshold);
|
|
396
|
-
const occurrences = [
|
|
397
|
-
...occurrencesFor(state, key),
|
|
398
|
-
{ signature, seenAt: Date.now() },
|
|
399
|
-
].slice(-MAX_ENTRIES_PER_KEY);
|
|
400
|
-
writeSessionState(
|
|
401
|
-
GATE_ID,
|
|
402
|
-
sessionId,
|
|
403
|
-
{ ...state, [key]: occurrences },
|
|
404
|
-
stateOptions,
|
|
405
|
-
);
|
|
406
399
|
|
|
407
400
|
const retryThreshold = Math.max(
|
|
408
401
|
MIN_RETRY_THRESHOLD,
|