@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
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import {
|
|
2
|
+
allow,
|
|
3
|
+
mcpActionSegment,
|
|
4
|
+
mcpServerSegment,
|
|
5
|
+
readHookPayload,
|
|
6
|
+
sessionIdOf,
|
|
7
|
+
toolInputOf,
|
|
8
|
+
toolNameOf,
|
|
9
|
+
toolResponseOf,
|
|
10
|
+
} from '../../lib/hook-io.mjs';
|
|
11
|
+
import { updateSessionState } from '../../lib/session-state.mjs';
|
|
12
|
+
|
|
13
|
+
const GATE_ID = 'library-docs';
|
|
14
|
+
const ENGRAM_SERVERS = new Set(['engram', 'plugin_engram_engram']);
|
|
15
|
+
const CONTEXT7_SERVERS = new Set(['context7', 'plugin_context7_context7']);
|
|
16
|
+
const SAVE_ACTIONS = new Set(['mem_save', 'mem_session_summary', 'mem_update']);
|
|
17
|
+
const NO_RESULT_PATTERN = /no memories found|no results|0 results/i;
|
|
18
|
+
const MAX_ENTRIES = 40;
|
|
19
|
+
const MAX_ENTRY_LENGTH = 300;
|
|
20
|
+
|
|
21
|
+
function textOf(value) {
|
|
22
|
+
if (typeof value === 'string') return value;
|
|
23
|
+
try {
|
|
24
|
+
return JSON.stringify(value ?? '');
|
|
25
|
+
} catch {
|
|
26
|
+
return '';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function inputText(toolInput) {
|
|
31
|
+
if (!toolInput || typeof toolInput !== 'object') return '';
|
|
32
|
+
return [
|
|
33
|
+
toolInput.query,
|
|
34
|
+
toolInput.title,
|
|
35
|
+
toolInput.content,
|
|
36
|
+
toolInput.libraryName,
|
|
37
|
+
toolInput.context7CompatibleLibraryID,
|
|
38
|
+
toolInput.topic,
|
|
39
|
+
]
|
|
40
|
+
.filter((value) => typeof value === 'string')
|
|
41
|
+
.join(' ');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function remember(list, entry) {
|
|
45
|
+
return [...(list ?? []), String(entry).slice(0, MAX_ENTRY_LENGTH)].slice(
|
|
46
|
+
-MAX_ENTRIES,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function classify(toolName) {
|
|
51
|
+
const server = mcpServerSegment(toolName).toLowerCase();
|
|
52
|
+
const action = mcpActionSegment(toolName).toLowerCase();
|
|
53
|
+
if (ENGRAM_SERVERS.has(server) && action === 'mem_search') return 'search';
|
|
54
|
+
if (ENGRAM_SERVERS.has(server) && SAVE_ACTIONS.has(action)) return 'save';
|
|
55
|
+
if (CONTEXT7_SERVERS.has(server)) return 'docs';
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function main() {
|
|
60
|
+
const rawPayload = readHookPayload();
|
|
61
|
+
if (rawPayload === null) allow();
|
|
62
|
+
const toolName = toolNameOf(rawPayload) ?? '';
|
|
63
|
+
const kind = classify(toolName);
|
|
64
|
+
if (!kind) allow();
|
|
65
|
+
const toolInput = toolInputOf(rawPayload);
|
|
66
|
+
const response = textOf(toolResponseOf(rawPayload));
|
|
67
|
+
const text = inputText(toolInput);
|
|
68
|
+
updateSessionState(GATE_ID, sessionIdOf(rawPayload), {}, (state) => {
|
|
69
|
+
if (kind === 'search') {
|
|
70
|
+
const hit = response.length > 0 && !NO_RESULT_PATTERN.test(response);
|
|
71
|
+
return hit
|
|
72
|
+
? {
|
|
73
|
+
...state,
|
|
74
|
+
memSearchHits: remember(state.memSearchHits, `${text} ${response}`),
|
|
75
|
+
}
|
|
76
|
+
: state;
|
|
77
|
+
}
|
|
78
|
+
if (kind === 'save')
|
|
79
|
+
return { ...state, memSaves: remember(state.memSaves, text) };
|
|
80
|
+
return {
|
|
81
|
+
...state,
|
|
82
|
+
context7Lookups: remember(
|
|
83
|
+
state.context7Lookups,
|
|
84
|
+
`${text} ${response.slice(0, MAX_ENTRY_LENGTH)}`,
|
|
85
|
+
),
|
|
86
|
+
};
|
|
87
|
+
});
|
|
88
|
+
allow();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
main();
|
|
93
|
+
} catch {
|
|
94
|
+
allow();
|
|
95
|
+
}
|
|
@@ -1,99 +1,85 @@
|
|
|
1
|
-
// lint-commit — denies `git commit` while the project's lint script fails.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// runs an external process as its verdict.
|
|
9
|
-
//
|
|
10
|
-
// ── What a project can configure (params) ───────────────────────────────────────────
|
|
11
|
-
// lintCommand explicit command to run instead of autodetecting `npm run lint`.
|
|
12
|
-
// null (default) means: read package.json's `scripts.lint`, and if
|
|
13
|
-
// absent, allow — this gate does not invent a lint step.
|
|
14
|
-
// lintTimeoutMs how long the lint run may take before it is treated as a failure.
|
|
15
|
-
// The defaults live here, in the source, so a project reads them and knows exactly what
|
|
16
|
-
// its override replaces.
|
|
17
|
-
//
|
|
18
|
-
// ── How it detects a commit ──────────────────────────────────────────────────────────
|
|
19
|
-
// Reuses bash-commands' git-global-option normalization idea: `git -C <path> commit` or
|
|
20
|
-
// `git -c user.name=x commit` must be caught the same as a bare `git commit`. Only a real
|
|
21
|
-
// commit (not `git commit --help`, not a mention inside a delegation prompt describing one)
|
|
22
|
-
// is gated — this runs on the shell tool only, not on delegation text, because spawning a
|
|
23
|
-
// lint run for a prompt that merely MENTIONS a commit would be wasted work and a false
|
|
24
|
-
// blocker on unrelated delegations.
|
|
25
|
-
//
|
|
26
|
-
// ── Why spawnSync + shell:true on Windows ────────────────────────────────────────────
|
|
27
|
-
// `npm` is `npm.cmd` on Windows; resolving the concrete binary name per platform is more
|
|
28
|
-
// fragile than letting the shell resolve it, so this always spawns through the shell.
|
|
29
|
-
//
|
|
30
|
-
// ── Fail-safe shape ───────────────────────────────────────────────────────────────────
|
|
31
|
-
// No package.json, or package.json with no `scripts.lint` and no `lintCommand` override:
|
|
32
|
-
// allow (nothing to enforce, never invented). Lint run exits non-zero: deny with the last
|
|
33
|
-
// ~15 lines of its combined output. Lint run exits zero: allow.
|
|
1
|
+
// lint-commit — denies a `git commit` while the project's lint script fails. It never invents
|
|
2
|
+
// a lint step: only a `lint` script the project declared in package.json (or an explicit
|
|
3
|
+
// lintCommand) runs, and only for a command segment that RUNS git commit (a `grep "git
|
|
4
|
+
// commit"` is not one; a `--dry-run` writes nothing). The lint runs in the repo the commit
|
|
5
|
+
// targets: `-C <path>` when given, else the project root of the cwd. Spawned through the
|
|
6
|
+
// shell because `npm` is `npm.cmd` on Windows. A commit that cannot be evaluated (the lint
|
|
7
|
+
// process fails to spawn) is denied: a commit gate must not silently permit.
|
|
34
8
|
|
|
35
9
|
import { spawnSync } from 'node:child_process';
|
|
36
|
-
import {
|
|
37
|
-
import {
|
|
38
|
-
import {
|
|
10
|
+
import { join, resolve } from 'node:path';
|
|
11
|
+
import { projectRootOf, readJsonOrNull } from '../../lib/config.mjs';
|
|
12
|
+
import {
|
|
13
|
+
isDryRunCommit,
|
|
14
|
+
isGitCommit,
|
|
15
|
+
normalizeGitCommand,
|
|
16
|
+
} from '../../lib/git.mjs';
|
|
17
|
+
import {
|
|
18
|
+
deny,
|
|
19
|
+
runGate,
|
|
20
|
+
shellCommandOf,
|
|
21
|
+
toolInGroups,
|
|
22
|
+
} from '../../lib/hook-io.mjs';
|
|
39
23
|
|
|
40
24
|
const GATE_ID = 'lint-commit';
|
|
41
25
|
const CONFIG_KEY = 'blockCommitWithFailingLint';
|
|
42
26
|
|
|
43
|
-
const SHELL_GROUPS = ['shell'];
|
|
44
27
|
const DEFAULT_LINT_TIMEOUT_MS = 60000;
|
|
45
28
|
const OUTPUT_TAIL_LINES = 15;
|
|
46
29
|
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
let previous;
|
|
59
|
-
let normalized = command;
|
|
60
|
-
do {
|
|
61
|
-
previous = normalized;
|
|
62
|
-
normalized = normalized.replace(GIT_GLOBAL_OPTION_PATTERN, 'git ');
|
|
63
|
-
} while (normalized !== previous);
|
|
64
|
-
return normalized;
|
|
30
|
+
// ── The commit segment ──────────────────────────────────────────────────────────────
|
|
31
|
+
const SEGMENT_SEPARATOR = /;|&&|\|\||\||\n/;
|
|
32
|
+
const ARGUMENT_PATTERN = /"([^"]*)"|'([^']*)'|(\S+)/g;
|
|
33
|
+
const GIT_BINARY_TOKEN = /(?:^|[\\/])git(?:\.exe)?$/i;
|
|
34
|
+
const WRAPPER_TOKEN = /^(?:\w+=\S*|command|sudo|env)$/i;
|
|
35
|
+
|
|
36
|
+
function tokensOf(text) {
|
|
37
|
+
const tokens = [];
|
|
38
|
+
for (const match of text.matchAll(ARGUMENT_PATTERN))
|
|
39
|
+
tokens.push(match[1] ?? match[2] ?? match[3]);
|
|
40
|
+
return tokens;
|
|
65
41
|
}
|
|
66
42
|
|
|
67
|
-
function
|
|
68
|
-
|
|
43
|
+
function runsGit(segment) {
|
|
44
|
+
const tokens = tokensOf(segment);
|
|
45
|
+
while (tokens.length > 0 && WRAPPER_TOKEN.test(tokens[0])) tokens.shift();
|
|
46
|
+
return tokens.length > 0 && GIT_BINARY_TOKEN.test(tokens[0]);
|
|
69
47
|
}
|
|
70
48
|
|
|
71
|
-
function
|
|
72
|
-
|
|
49
|
+
function commitSegmentOf(command) {
|
|
50
|
+
for (const segment of String(command).split(SEGMENT_SEPARATOR)) {
|
|
51
|
+
if (!runsGit(segment)) continue;
|
|
52
|
+
const normalized = normalizeGitCommand(segment);
|
|
53
|
+
if (isGitCommit(normalized) && !isDryRunCommit(normalized)) return segment;
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
73
56
|
}
|
|
74
57
|
|
|
75
|
-
|
|
76
|
-
function
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
try {
|
|
83
|
-
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
|
|
84
|
-
if (packageJson?.scripts?.lint) return 'npm run lint';
|
|
85
|
-
return null;
|
|
86
|
-
} catch {
|
|
87
|
-
// Corrupt package.json is not this gate's problem to diagnose; do not invent a lint step.
|
|
88
|
-
return null;
|
|
58
|
+
// `-C` BEFORE the subcommand is git's working directory; after `commit` it reuses a message.
|
|
59
|
+
function commitDirectoryOf(segment) {
|
|
60
|
+
const tokens = tokensOf(segment);
|
|
61
|
+
const gitIndex = tokens.findIndex((token) => GIT_BINARY_TOKEN.test(token));
|
|
62
|
+
for (let index = gitIndex + 1; index < tokens.length; index += 1) {
|
|
63
|
+
if (tokens[index] === 'commit') return null;
|
|
64
|
+
if (tokens[index] === '-C') return tokens[index + 1] ?? null;
|
|
89
65
|
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── Lint ────────────────────────────────────────────────────────────────────────────
|
|
70
|
+
function resolveLintCommand(lintDirectory, lintCommandOverride) {
|
|
71
|
+
if (typeof lintCommandOverride === 'string' && lintCommandOverride.trim())
|
|
72
|
+
return lintCommandOverride;
|
|
73
|
+
const packageJson = readJsonOrNull(join(lintDirectory, 'package.json'));
|
|
74
|
+
return packageJson?.scripts?.lint ? 'npm run lint' : null;
|
|
90
75
|
}
|
|
91
76
|
|
|
92
77
|
function tailLines(text, count) {
|
|
93
|
-
|
|
78
|
+
return String(text)
|
|
94
79
|
.split(/\r?\n/)
|
|
95
|
-
.filter((line) => line.length > 0)
|
|
96
|
-
|
|
80
|
+
.filter((line) => line.length > 0)
|
|
81
|
+
.slice(-count)
|
|
82
|
+
.join('\n');
|
|
97
83
|
}
|
|
98
84
|
|
|
99
85
|
function runLint(lintCommand, cwd, timeoutMs) {
|
|
@@ -115,37 +101,42 @@ runGate(
|
|
|
115
101
|
lintTimeoutMs: DEFAULT_LINT_TIMEOUT_MS,
|
|
116
102
|
},
|
|
117
103
|
},
|
|
118
|
-
({ toolName, toolInput, parameters }) => {
|
|
119
|
-
if (!toolInGroups(toolName,
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
if (
|
|
123
|
-
|
|
124
|
-
const
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
104
|
+
({ toolName, toolInput, parameters, cwd }) => {
|
|
105
|
+
if (!toolInGroups(toolName, ['shell'])) return;
|
|
106
|
+
|
|
107
|
+
const segment = commitSegmentOf(shellCommandOf(toolInput));
|
|
108
|
+
if (segment === null) return;
|
|
109
|
+
|
|
110
|
+
const explicitDirectory = commitDirectoryOf(segment);
|
|
111
|
+
const lintDirectory = explicitDirectory
|
|
112
|
+
? resolve(cwd, explicitDirectory)
|
|
113
|
+
: (projectRootOf(cwd) ?? cwd);
|
|
114
|
+
const lintCommand = resolveLintCommand(
|
|
115
|
+
lintDirectory,
|
|
116
|
+
parameters.lintCommand,
|
|
117
|
+
);
|
|
118
|
+
if (!lintCommand) return;
|
|
119
|
+
|
|
120
|
+
const result = runLint(
|
|
121
|
+
lintCommand,
|
|
122
|
+
lintDirectory,
|
|
123
|
+
parameters.lintTimeoutMs,
|
|
124
|
+
);
|
|
131
125
|
if (result.error) {
|
|
132
|
-
// The lint process itself could not be spawned (bad shell, missing interpreter, or a
|
|
133
|
-
// timeout kill). A commit gate that cannot evaluate must not silently permit — deny
|
|
134
|
-
// with the spawn error so the user can fix the environment, not guess.
|
|
135
126
|
deny(
|
|
136
127
|
CONFIG_KEY,
|
|
137
|
-
`Could not run the lint command ("${lintCommand}"
|
|
138
|
-
|
|
128
|
+
`Could not run the lint command ("${lintCommand}" in ${lintDirectory}): ` +
|
|
129
|
+
`${result.error.message}. Fix the lint setup, or set lintCommand under ` +
|
|
130
|
+
`${CONFIG_KEY} in .ai/config.json.`,
|
|
139
131
|
);
|
|
140
132
|
}
|
|
141
|
-
|
|
142
133
|
if (result.status !== 0) {
|
|
143
134
|
const combinedOutput =
|
|
144
135
|
`${result.stdout ?? ''}\n${result.stderr ?? ''}`.trim();
|
|
145
136
|
deny(
|
|
146
137
|
CONFIG_KEY,
|
|
147
|
-
`Lint failed (exit ${result.status}
|
|
148
|
-
|
|
138
|
+
`Lint failed (exit ${result.status}, "${lintCommand}" in ${lintDirectory}) — ` +
|
|
139
|
+
`commit blocked until it passes:\n${tailLines(combinedOutput, OUTPUT_TAIL_LINES)}`,
|
|
149
140
|
);
|
|
150
141
|
}
|
|
151
142
|
},
|
|
@@ -1,23 +1,24 @@
|
|
|
1
|
-
// mandatory-flow — denies
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// ~/.claude/hooks/guard-flujo-obligatorio.mjs.
|
|
1
|
+
// mandatory-flow — denies a STANDARD/HIGH-RISK implementation delegation when no live task
|
|
2
|
+
// exists on disk: a pointer file naming the active task slug, and a non-empty contract file
|
|
3
|
+
// under that task's directory. Declaring stages in prose (implementation-pipeline) is not
|
|
4
|
+
// the same as a task actually existing; this gate checks the disk fact.
|
|
6
5
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
6
|
+
// Decisions: the contract root follows the pointer — `<dir of activePointerPath>/<slug>/` —
|
|
7
|
+
// so a project that moves the pointer moves its tasks with it; an absolute pointer path is
|
|
8
|
+
// honored as-is. A slug is a directory name, never a path (traversal is rejected). An empty
|
|
9
|
+
// contract file is not a contract. Only the exempt list exempts a subagent type.
|
|
10
|
+
|
|
11
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
12
|
+
import { dirname, isAbsolute, join, normalize, sep } from 'node:path';
|
|
13
|
+
import { projectRootOf } from '../../lib/config.mjs';
|
|
14
|
+
import {
|
|
15
|
+
DEFAULT_EXEMPT_SUBAGENTS,
|
|
16
|
+
DEMANDING_LEVELS,
|
|
17
|
+
isHarnessWork,
|
|
18
|
+
isImplementationRequest,
|
|
19
|
+
isSubagentNamedIn,
|
|
20
|
+
operativeLevelOf,
|
|
21
|
+
} from '../../lib/delegation.mjs';
|
|
21
22
|
import {
|
|
22
23
|
runGate,
|
|
23
24
|
deny,
|
|
@@ -30,19 +31,6 @@ const CONFIG_KEY = 'requireLiveTaskWhenImplementing';
|
|
|
30
31
|
|
|
31
32
|
const DEFAULT_ACTIVE_POINTER_PATH = join('.ai', 'pipeline', 'ACTIVA');
|
|
32
33
|
|
|
33
|
-
const DEFAULT_EXEMPT_SUBAGENTS = [
|
|
34
|
-
'explore',
|
|
35
|
-
'plan',
|
|
36
|
-
'scout',
|
|
37
|
-
'revision',
|
|
38
|
-
'contraste',
|
|
39
|
-
'test-planner',
|
|
40
|
-
'qa',
|
|
41
|
-
'ui',
|
|
42
|
-
'ux',
|
|
43
|
-
];
|
|
44
|
-
|
|
45
|
-
/** Contract files that count as evidence a task's contract exists on disk. */
|
|
46
34
|
const TASK_CONTRACT_FILES = [
|
|
47
35
|
'asserts.md',
|
|
48
36
|
'task.json',
|
|
@@ -51,58 +39,27 @@ const TASK_CONTRACT_FILES = [
|
|
|
51
39
|
'acceptance.md',
|
|
52
40
|
];
|
|
53
41
|
|
|
54
|
-
function
|
|
55
|
-
return
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
);
|
|
42
|
+
function isExempt(toolInput, prompt, exemptSubagents) {
|
|
43
|
+
if (isSubagentNamedIn(toolInput, exemptSubagents)) return true;
|
|
44
|
+
if (!DEMANDING_LEVELS.has(operativeLevelOf(prompt))) return true;
|
|
45
|
+
if (!isImplementationRequest(prompt)) return true;
|
|
46
|
+
return isHarnessWork(prompt);
|
|
59
47
|
}
|
|
60
48
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
'add\\w*|remov\\w*|delet\\w*|modify|modifies|modifying|rewrit\\w*',
|
|
68
|
-
);
|
|
69
|
-
|
|
70
|
-
/** Declared LEVEL near the word "level"/"classification" in Spanish or English, matching
|
|
71
|
-
* the plugin-wide convention (see risk-level.mjs). */
|
|
72
|
-
const DEMANDING_LEVEL_PATTERN =
|
|
73
|
-
/(nivel|level|clasificaci[oó]n|classification)[^\n]{0,25}?\b(STANDARD|HIGH-RISK)\b/iu;
|
|
74
|
-
const EXEMPT_LEVEL_PATTERN =
|
|
75
|
-
/(nivel|level|clasificaci[oó]n|classification)[^\n]{0,25}?\b(QUESTION|MICRO)\b/iu;
|
|
76
|
-
|
|
77
|
-
const HARNESS_PATTERN =
|
|
78
|
-
/(\.claude[\\/]|hooks[\\/]|plugins[\\/]gates|settings\.json|[\\/]agents[\\/]\w|[\\/]rules[\\/]\w|[\\/]skills[\\/]\w)/i;
|
|
79
|
-
|
|
80
|
-
function isExempt(toolInput, prompt, exemptSubagents) {
|
|
81
|
-
const subagentType = String(
|
|
82
|
-
toolInput.subagent_type ?? toolInput.subagentType ?? '',
|
|
83
|
-
).toLowerCase();
|
|
84
|
-
if (exemptSubagents.includes(subagentType)) return true;
|
|
85
|
-
if (EXEMPT_LEVEL_PATTERN.test(prompt)) return true;
|
|
86
|
-
if (!DEMANDING_LEVEL_PATTERN.test(prompt)) return true;
|
|
87
|
-
if (!IMPLEMENTATION_VERBS.test(prompt)) return true;
|
|
88
|
-
if (HARNESS_PATTERN.test(prompt)) return true;
|
|
89
|
-
return false;
|
|
49
|
+
function fileExistsNonEmpty(path) {
|
|
50
|
+
try {
|
|
51
|
+
return existsSync(path) && statSync(path).size > 0;
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
90
55
|
}
|
|
91
56
|
|
|
92
57
|
function hasTaskContract(taskDirectory) {
|
|
93
|
-
return TASK_CONTRACT_FILES.some((file) =>
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
} catch {
|
|
97
|
-
return false;
|
|
98
|
-
}
|
|
99
|
-
});
|
|
58
|
+
return TASK_CONTRACT_FILES.some((file) =>
|
|
59
|
+
fileExistsNonEmpty(join(taskDirectory, file)),
|
|
60
|
+
);
|
|
100
61
|
}
|
|
101
62
|
|
|
102
|
-
/** The pointer file's trimmed content, or '' when it cannot be read or the slug
|
|
103
|
-
* attempts path traversal. A slug is a directory name, not a path: rejecting any
|
|
104
|
-
* segment separator or '..' closes off `join(cwd, '.ai', 'pipeline', slug)` escaping
|
|
105
|
-
* that directory to accept an unrelated file elsewhere on disk as the task contract. */
|
|
106
63
|
function readSlug(pointerPath) {
|
|
107
64
|
let raw;
|
|
108
65
|
try {
|
|
@@ -121,7 +78,6 @@ function readSlug(pointerPath) {
|
|
|
121
78
|
return normalized;
|
|
122
79
|
}
|
|
123
80
|
|
|
124
|
-
/** Denies for whichever of the three live-task facts is missing, or does nothing. */
|
|
125
81
|
function checkLiveTask(pointerPath) {
|
|
126
82
|
if (!existsSync(pointerPath)) {
|
|
127
83
|
deny(
|
|
@@ -136,20 +92,19 @@ function checkLiveTask(pointerPath) {
|
|
|
136
92
|
if (!slug) {
|
|
137
93
|
deny(
|
|
138
94
|
CONFIG_KEY,
|
|
139
|
-
`${pointerPath} exists but
|
|
140
|
-
'
|
|
95
|
+
`${pointerPath} exists but names no valid task slug (empty, or a path instead of a ` +
|
|
96
|
+
'directory name). Write the task slug into it before delegating.',
|
|
141
97
|
);
|
|
142
98
|
}
|
|
143
99
|
|
|
144
|
-
const taskDirectory = join(
|
|
100
|
+
const taskDirectory = join(dirname(pointerPath), slug);
|
|
145
101
|
if (!hasTaskContract(taskDirectory)) {
|
|
146
102
|
deny(
|
|
147
103
|
CONFIG_KEY,
|
|
148
|
-
`The active task '${slug}' has no contract on disk: none of ` +
|
|
149
|
-
`${TASK_CONTRACT_FILES.join(', ')} exists under ${taskDirectory}. ` +
|
|
150
|
-
|
|
151
|
-
`.ai/
|
|
152
|
-
'different gate, sdd-specs, but NOT this one; if you already wrote a brief there, ' +
|
|
104
|
+
`The active task '${slug}' has no non-empty contract on disk: none of ` +
|
|
105
|
+
`${TASK_CONTRACT_FILES.join(', ')} exists (with content) under ${taskDirectory}. ` +
|
|
106
|
+
'Write ONE of those files there before implementing (a brief.md under ' +
|
|
107
|
+
`.ai/features/${slug}/ satisfies sdd-specs, not this gate; if you already wrote one, ` +
|
|
153
108
|
`create a short asserts.md under ${taskDirectory} referencing it).`,
|
|
154
109
|
);
|
|
155
110
|
}
|
|
@@ -165,19 +120,17 @@ runGate(
|
|
|
165
120
|
exemptSubagents: DEFAULT_EXEMPT_SUBAGENTS,
|
|
166
121
|
},
|
|
167
122
|
},
|
|
168
|
-
({ toolName, toolInput, parameters }) => {
|
|
123
|
+
({ toolName, toolInput, parameters, cwd }) => {
|
|
169
124
|
if (!toolInGroups(toolName, ['delegation'])) return;
|
|
170
125
|
|
|
171
126
|
const prompt = delegationPromptOf(toolInput);
|
|
172
127
|
if (!prompt.trim()) return;
|
|
128
|
+
if (isExempt(toolInput, prompt, parameters.exemptSubagents)) return;
|
|
173
129
|
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
parameters.activePointerPath ?? DEFAULT_ACTIVE_POINTER_PATH,
|
|
180
|
-
);
|
|
181
|
-
checkLiveTask(join(process.cwd(), activePointerPath));
|
|
130
|
+
const root = projectRootOf(cwd) ?? cwd;
|
|
131
|
+
const pointer =
|
|
132
|
+
String(parameters.activePointerPath ?? '').trim() ||
|
|
133
|
+
DEFAULT_ACTIVE_POINTER_PATH;
|
|
134
|
+
checkLiveTask(isAbsolute(pointer) ? pointer : join(root, pointer));
|
|
182
135
|
},
|
|
183
136
|
);
|