@devrik-tools/claude-gates 0.7.2 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +3 -3
- package/README.es.md +129 -11
- package/README.md +120 -10
- 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 +12 -1
- package/cli/selection.mjs +18 -0
- package/cli/smoke-fixtures.json +159 -25
- package/cli/task.mjs +104 -11
- package/cli/toggle.mjs +125 -0
- package/package.json +4 -3
- 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 +94 -215
- package/plugins/gates/hooks/gates/circuit-breaker/track.mjs +285 -0
- 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 +53 -95
- 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 +338 -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/no-trivial-scripts/index.mjs +114 -0
- 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/require-monitor/index.mjs +126 -0
- package/plugins/gates/hooks/gates/require-task-split/index.mjs +88 -0
- 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 +152 -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 +451 -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 +148 -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 +12 -6
- package/plugins/tasks/hooks/register-requests.mjs +37 -10
- package/registry.json +249 -38
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// Fail-safe: every check is wrapped so a doctor bug never blocks a session from
|
|
16
16
|
// starting — worst case it silently skips a check rather than throwing.
|
|
17
17
|
|
|
18
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
18
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
19
19
|
import { homedir } from 'node:os';
|
|
20
20
|
import { dirname, join } from 'node:path';
|
|
21
21
|
import { fileURLToPath } from 'node:url';
|
|
@@ -29,6 +29,14 @@ const HOOKS_DIRECTORY = dirname(fileURLToPath(import.meta.url));
|
|
|
29
29
|
const REPOSITORY_ROOT = join(HOOKS_DIRECTORY, '..', '..', '..');
|
|
30
30
|
const PACKAGE_JSON_PATH = join(REPOSITORY_ROOT, 'package.json');
|
|
31
31
|
const REGISTRY_PATH = join(REPOSITORY_ROOT, 'registry.json');
|
|
32
|
+
const PLUGIN_MANIFEST_PATH = join(
|
|
33
|
+
HOOKS_DIRECTORY,
|
|
34
|
+
'..',
|
|
35
|
+
'.claude-plugin',
|
|
36
|
+
'plugin.json',
|
|
37
|
+
);
|
|
38
|
+
const PLUGIN_CACHE = join(homedir(), '.claude', 'plugins', 'cache');
|
|
39
|
+
const PLUGIN_NAME = 'gates';
|
|
32
40
|
|
|
33
41
|
// Same config lookup as the other session hooks (see session-tasks.mjs / register-requests.mjs):
|
|
34
42
|
// kept local and dependency-free so this plugin works standalone.
|
|
@@ -126,6 +134,41 @@ function minNodeVersionRequired(configuredMin) {
|
|
|
126
134
|
return token ?? DEFAULT_MIN_NODE_VERSION;
|
|
127
135
|
}
|
|
128
136
|
|
|
137
|
+
function directoriesIn(path) {
|
|
138
|
+
try {
|
|
139
|
+
return readdirSync(path, { withFileTypes: true })
|
|
140
|
+
.filter((entry) => entry.isDirectory())
|
|
141
|
+
.map((entry) => entry.name);
|
|
142
|
+
} catch {
|
|
143
|
+
return [];
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** A newer gates version sitting in Claude Code's plugin cache means this session runs a
|
|
148
|
+
* stale install (the marketplace still points at an old package path). */
|
|
149
|
+
function staleInstallProblem() {
|
|
150
|
+
const running = readJson(PLUGIN_MANIFEST_PATH)?.version;
|
|
151
|
+
if (!parseVersion(running)) return null;
|
|
152
|
+
let newest = running;
|
|
153
|
+
let marketplace = null;
|
|
154
|
+
for (const marketplaceName of directoriesIn(PLUGIN_CACHE)) {
|
|
155
|
+
for (const version of directoriesIn(
|
|
156
|
+
join(PLUGIN_CACHE, marketplaceName, PLUGIN_NAME),
|
|
157
|
+
)) {
|
|
158
|
+
if (parseVersion(version) && !versionAtLeast(newest, version)) {
|
|
159
|
+
newest = version;
|
|
160
|
+
marketplace = marketplaceName;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (newest === running) return null;
|
|
165
|
+
return (
|
|
166
|
+
`the gates plugin running in this session is ${running} but ${newest} is installed in the ` +
|
|
167
|
+
`plugin cache (${marketplace}). Run \`claude plugin update ${PLUGIN_NAME}@${marketplace}\` or ` +
|
|
168
|
+
'`claude-gates init --global --defaults --yes` from the newer package, then restart the session.'
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
129
172
|
function checkNodeVersion(minVersion) {
|
|
130
173
|
if (versionAtLeast(process.version, minVersion)) return null;
|
|
131
174
|
return (
|
|
@@ -184,6 +227,9 @@ function main() {
|
|
|
184
227
|
const versionProblem = checkNodeVersion(minVersion);
|
|
185
228
|
if (versionProblem) problems.push(versionProblem);
|
|
186
229
|
|
|
230
|
+
const staleInstall = staleInstallProblem();
|
|
231
|
+
if (staleInstall) problems.push(staleInstall);
|
|
232
|
+
|
|
187
233
|
const missingScripts = missingFolderGateScripts();
|
|
188
234
|
if (missingScripts.length > 0) {
|
|
189
235
|
problems.push(
|
|
@@ -1,51 +1,38 @@
|
|
|
1
|
-
// atomic-commit — denies a `git commit` that is not atomic: one
|
|
2
|
-
//
|
|
3
|
-
// than
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
// escapeHatch substring in the command that allows one deliberately broad commit.
|
|
26
|
-
// Default '[wip]'.
|
|
27
|
-
// natures the classification table (name + regex source + counts flag). Replaces the
|
|
28
|
-
// built-in list wholesale.
|
|
29
|
-
//
|
|
30
|
-
// ── Fail-safe shape ──────────────────────────────────────────────────────────────────
|
|
31
|
-
// Not a commit, an --amend, or nothing staged: allow (silent). git not queryable: allow (the
|
|
32
|
-
// gate cannot judge and must not block a legitimate commit on a git error). Otherwise: deny
|
|
33
|
-
// with the offending mix/size and how to split.
|
|
34
|
-
|
|
35
|
-
import { spawnSync } from 'node:child_process';
|
|
36
|
-
import { runGate, deny, toolInGroups } from '../../lib/hook-io.mjs';
|
|
1
|
+
// atomic-commit — denies a `git commit` that is not atomic: one mixing more distinct NATURES
|
|
2
|
+
// of change (code + tests + deps + config) than `maxNatures`, or carrying more reviewable
|
|
3
|
+
// files than `maxFiles`. It judges what the commit WILL contain (the staged set, plus what a
|
|
4
|
+
// `git add …` in the same command line or `-a` stages, plus staged deletions); docs, assets
|
|
5
|
+
// and generated files count toward neither. An `--amend` reshapes an existing commit and is
|
|
6
|
+
// left alone, as is a `--dry-run`. Only a command segment that RUNS git commit counts. git
|
|
7
|
+
// not queryable: allow — the gate cannot judge and must not block on a git error.
|
|
8
|
+
|
|
9
|
+
import { projectRootOf } from '../../lib/config.mjs';
|
|
10
|
+
import {
|
|
11
|
+
effectiveCommitFiles,
|
|
12
|
+
isAmendCommit,
|
|
13
|
+
isDryRunCommit,
|
|
14
|
+
isGitCommit,
|
|
15
|
+
normalizeGitCommand,
|
|
16
|
+
stagedFiles,
|
|
17
|
+
} from '../../lib/git.mjs';
|
|
18
|
+
import {
|
|
19
|
+
compileRegex,
|
|
20
|
+
deny,
|
|
21
|
+
runGate,
|
|
22
|
+
shellCommandOf,
|
|
23
|
+
toolInGroups,
|
|
24
|
+
} from '../../lib/hook-io.mjs';
|
|
37
25
|
|
|
38
26
|
const GATE_ID = 'atomic-commit';
|
|
39
27
|
const CONFIG_KEY = 'blockNonAtomicCommits';
|
|
40
28
|
|
|
41
|
-
const SHELL_GROUPS = ['shell'];
|
|
42
29
|
const DEFAULT_MAX_FILES = 12;
|
|
43
30
|
const DEFAULT_MAX_NATURES = 2;
|
|
44
31
|
const DEFAULT_ESCAPE_HATCH = '[wip]';
|
|
45
32
|
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
33
|
+
// First match wins. `counts:false` = committed alongside anything, never reviewed line by
|
|
34
|
+
// line. A `hooks/` or `models/` folder is application code (React hooks, MVC models), not
|
|
35
|
+
// tooling or types: types are `*.d.ts` and `types/` folders only.
|
|
49
36
|
const DEFAULT_NATURES = [
|
|
50
37
|
{
|
|
51
38
|
name: 'deps',
|
|
@@ -74,7 +61,7 @@ const DEFAULT_NATURES = [
|
|
|
74
61
|
},
|
|
75
62
|
{
|
|
76
63
|
name: 'types',
|
|
77
|
-
source: String.raw`(^|/)
|
|
64
|
+
source: String.raw`(^|/)types?/|\.d\.ts$`,
|
|
78
65
|
counts: true,
|
|
79
66
|
},
|
|
80
67
|
{
|
|
@@ -84,84 +71,66 @@ const DEFAULT_NATURES = [
|
|
|
84
71
|
},
|
|
85
72
|
{
|
|
86
73
|
name: 'tooling',
|
|
87
|
-
source: String.raw`(^|/)(scripts
|
|
74
|
+
source: String.raw`(^|/)(scripts|\.ai|\.claude|\.github)/`,
|
|
88
75
|
counts: true,
|
|
89
76
|
},
|
|
90
77
|
];
|
|
91
78
|
const DEFAULT_NATURE = { name: 'code', counts: true };
|
|
92
79
|
|
|
93
|
-
//
|
|
94
|
-
const
|
|
95
|
-
const
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
)
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
let previous;
|
|
106
|
-
let normalized = command;
|
|
107
|
-
do {
|
|
108
|
-
previous = normalized;
|
|
109
|
-
normalized = normalized.replace(GIT_GLOBAL_OPTION_PATTERN, 'git ');
|
|
110
|
-
} while (normalized !== previous);
|
|
111
|
-
return normalized;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function isPlainCommit(command) {
|
|
115
|
-
const normalized = normalizeGitOptions(command);
|
|
116
|
-
return (
|
|
117
|
-
GIT_COMMIT_PATTERN.test(normalized) &&
|
|
118
|
-
!EXEMPT_COMMIT_PATTERN.test(normalized)
|
|
119
|
-
);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function commandTextFrom(toolInput) {
|
|
123
|
-
return String(toolInput.CommandLine ?? toolInput.command ?? '');
|
|
80
|
+
// ── The commit segment ──────────────────────────────────────────────────────────────
|
|
81
|
+
const SEGMENT_SEPARATOR = /;|&&|\|\||\||\n/;
|
|
82
|
+
const ARGUMENT_PATTERN = /"([^"]*)"|'([^']*)'|(\S+)/g;
|
|
83
|
+
const GIT_BINARY_TOKEN = /(?:^|[\\/])git(?:\.exe)?$/i;
|
|
84
|
+
const WRAPPER_TOKEN = /^(?:\w+=\S*|command|sudo|env)$/i;
|
|
85
|
+
|
|
86
|
+
function runsGit(segment) {
|
|
87
|
+
const tokens = [];
|
|
88
|
+
for (const match of segment.matchAll(ARGUMENT_PATTERN))
|
|
89
|
+
tokens.push(match[1] ?? match[2] ?? match[3]);
|
|
90
|
+
while (tokens.length > 0 && WRAPPER_TOKEN.test(tokens[0])) tokens.shift();
|
|
91
|
+
return tokens.length > 0 && GIT_BINARY_TOKEN.test(tokens[0]);
|
|
124
92
|
}
|
|
125
93
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
.map((line) => line.replace(/\\/g, '/'));
|
|
94
|
+
function runsJudgedCommit(command) {
|
|
95
|
+
return String(command)
|
|
96
|
+
.split(SEGMENT_SEPARATOR)
|
|
97
|
+
.some((segment) => {
|
|
98
|
+
if (!runsGit(segment)) return false;
|
|
99
|
+
const normalized = normalizeGitCommand(segment);
|
|
100
|
+
return (
|
|
101
|
+
isGitCommit(normalized) &&
|
|
102
|
+
!isDryRunCommit(normalized) &&
|
|
103
|
+
!isAmendCommit(normalized)
|
|
104
|
+
);
|
|
105
|
+
});
|
|
139
106
|
}
|
|
140
107
|
|
|
108
|
+
// ── Natures ─────────────────────────────────────────────────────────────────────────
|
|
141
109
|
function compileNatures(natures) {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
110
|
+
const compiled = [];
|
|
111
|
+
for (const nature of natures) {
|
|
112
|
+
if (!nature || typeof nature !== 'object') continue;
|
|
113
|
+
if (typeof nature.name !== 'string' || typeof nature.source !== 'string')
|
|
114
|
+
continue;
|
|
115
|
+
const pattern = compileRegex(nature.source);
|
|
116
|
+
if (!pattern) continue;
|
|
117
|
+
compiled.push({
|
|
118
|
+
name: nature.name,
|
|
119
|
+
counts: nature.counts !== false,
|
|
120
|
+
pattern,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
return compiled;
|
|
153
124
|
}
|
|
154
125
|
|
|
155
126
|
function natureOf(filePath, compiledNatures) {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
127
|
+
return (
|
|
128
|
+
compiledNatures.find((nature) => nature.pattern.test(filePath)) ??
|
|
129
|
+
DEFAULT_NATURE
|
|
130
|
+
);
|
|
160
131
|
}
|
|
161
132
|
|
|
162
|
-
|
|
163
|
-
* them. Docs/assets/generated (counts:false) are excluded from both. */
|
|
164
|
-
function classifyStaged(files, compiledNatures) {
|
|
133
|
+
function classify(files, compiledNatures) {
|
|
165
134
|
const counted = [];
|
|
166
135
|
const countedNatures = new Set();
|
|
167
136
|
for (const file of files) {
|
|
@@ -173,6 +142,13 @@ function classifyStaged(files, compiledNatures) {
|
|
|
173
142
|
return { counted, countedNatures };
|
|
174
143
|
}
|
|
175
144
|
|
|
145
|
+
function commitFiles(root, command) {
|
|
146
|
+
const files = effectiveCommitFiles(root, command);
|
|
147
|
+
if (files === null) return null;
|
|
148
|
+
const deletions = stagedFiles(root, { filter: 'D' }) ?? [];
|
|
149
|
+
return [...new Set([...files, ...deletions])];
|
|
150
|
+
}
|
|
151
|
+
|
|
176
152
|
runGate(
|
|
177
153
|
{
|
|
178
154
|
id: GATE_ID,
|
|
@@ -185,26 +161,23 @@ runGate(
|
|
|
185
161
|
natures: DEFAULT_NATURES,
|
|
186
162
|
},
|
|
187
163
|
},
|
|
188
|
-
({ toolName, toolInput, parameters }) => {
|
|
189
|
-
if (!toolInGroups(toolName,
|
|
164
|
+
({ toolName, toolInput, parameters, cwd }) => {
|
|
165
|
+
if (!toolInGroups(toolName, ['shell'])) return;
|
|
190
166
|
|
|
191
|
-
const command =
|
|
192
|
-
if (!
|
|
167
|
+
const command = shellCommandOf(toolInput);
|
|
168
|
+
if (!runsJudgedCommit(command)) return;
|
|
193
169
|
|
|
194
|
-
const escapeHatch = parameters.escapeHatch ??
|
|
170
|
+
const escapeHatch = String(parameters.escapeHatch ?? '');
|
|
195
171
|
if (escapeHatch && command.includes(escapeHatch)) return;
|
|
196
172
|
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
if (files.length === 0) return; // nothing staged (or git unqueryable): nothing to judge
|
|
173
|
+
const files = commitFiles(projectRootOf(cwd) ?? cwd, command);
|
|
174
|
+
if (!files || files.length === 0) return;
|
|
200
175
|
|
|
201
|
-
const
|
|
202
|
-
|
|
176
|
+
const { counted, countedNatures } = classify(
|
|
177
|
+
files,
|
|
178
|
+
compileNatures(parameters.natures),
|
|
203
179
|
);
|
|
204
|
-
const {
|
|
205
|
-
|
|
206
|
-
const maxFiles = parameters.maxFiles ?? DEFAULT_MAX_FILES;
|
|
207
|
-
const maxNatures = parameters.maxNatures ?? DEFAULT_MAX_NATURES;
|
|
180
|
+
const { maxFiles, maxNatures } = parameters;
|
|
208
181
|
|
|
209
182
|
if (countedNatures.size > maxNatures) {
|
|
210
183
|
deny(
|
|
@@ -220,7 +193,7 @@ runGate(
|
|
|
220
193
|
if (counted.length > maxFiles) {
|
|
221
194
|
deny(
|
|
222
195
|
CONFIG_KEY,
|
|
223
|
-
`This commit
|
|
196
|
+
`This commit carries ${counted.length} reviewable files (max ${maxFiles}) — too large ` +
|
|
224
197
|
`to review as one unit. Split it into smaller, cohesive commits (docs/assets/` +
|
|
225
198
|
`generated files are not counted). Add "${escapeHatch}" for one deliberately broad commit.`,
|
|
226
199
|
);
|
|
@@ -1,5 +1,24 @@
|
|
|
1
|
+
// audit-before-build — creating a new executable tool (a file under a tool folder, or a
|
|
2
|
+
// delegation asking to build one) must state that existing tools were audited. Editing an
|
|
3
|
+
// existing file is never a build; the exemption is what lets the gates maintain themselves.
|
|
4
|
+
|
|
1
5
|
import { existsSync } from 'node:fs';
|
|
2
|
-
import {
|
|
6
|
+
import { isAbsolute, join } from 'node:path';
|
|
7
|
+
import { projectRootOf } from '../../lib/config.mjs';
|
|
8
|
+
import {
|
|
9
|
+
runGate,
|
|
10
|
+
deny,
|
|
11
|
+
toolInGroups,
|
|
12
|
+
writtenPathOf,
|
|
13
|
+
writtenContentOf,
|
|
14
|
+
delegationPromptOf,
|
|
15
|
+
} from '../../lib/hook-io.mjs';
|
|
16
|
+
import { isBuildIntent } from '../../lib/signals.mjs';
|
|
17
|
+
import {
|
|
18
|
+
AUDIT_DONE_PATTERN,
|
|
19
|
+
hasAuditEvidence,
|
|
20
|
+
isToolPath,
|
|
21
|
+
} from '../../lib/tools.mjs';
|
|
3
22
|
|
|
4
23
|
const GATE_ID = 'audit-before-build';
|
|
5
24
|
const CONFIG_KEY = 'requireAuditBeforeBuilding';
|
|
@@ -13,87 +32,103 @@ const DEFAULT_EXECUTABLE_EXTENSIONS = [
|
|
|
13
32
|
'.sh',
|
|
14
33
|
'.ps1',
|
|
15
34
|
];
|
|
16
|
-
const DEFAULT_TOOL_FOLDERS = ['scripts
|
|
35
|
+
const DEFAULT_TOOL_FOLDERS = ['scripts', 'hooks', 'tools'];
|
|
36
|
+
// Vendored or VCS-internal trees are never the project's own new tool.
|
|
37
|
+
const FOREIGN_SEGMENTS = new Set(['.git', 'node_modules']);
|
|
38
|
+
const AUDIT_EXAMPLE_PHRASE = 'no existing tool covers this';
|
|
17
39
|
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
40
|
+
// "the build script" is a noun phrase, and "add tests for the gate" builds a test, not a
|
|
41
|
+
// gate; both would otherwise read as creation verb + tool noun.
|
|
42
|
+
const BUILD_AS_NOUN_PATTERN =
|
|
43
|
+
/\b(?:the|a|an|this|that|our|your|my|el|la|un|una|este|esta)\s+build\b/giu;
|
|
44
|
+
const CREATION_VERBS = [
|
|
45
|
+
'write',
|
|
46
|
+
'create',
|
|
47
|
+
'build',
|
|
48
|
+
'implement',
|
|
49
|
+
'add',
|
|
50
|
+
'make',
|
|
51
|
+
'escribe',
|
|
52
|
+
'escribi',
|
|
53
|
+
'escribir',
|
|
54
|
+
'crea',
|
|
55
|
+
'construye',
|
|
56
|
+
'construir',
|
|
57
|
+
'implementa',
|
|
58
|
+
'implementar',
|
|
59
|
+
'agrega',
|
|
60
|
+
'arma',
|
|
61
|
+
'hace',
|
|
62
|
+
'genera',
|
|
63
|
+
'generar',
|
|
64
|
+
'armá',
|
|
65
|
+
'hacé',
|
|
66
|
+
'creá',
|
|
67
|
+
'agregá',
|
|
68
|
+
'generá',
|
|
69
|
+
'escribí',
|
|
70
|
+
'construí',
|
|
71
|
+
];
|
|
72
|
+
const OBJECT_QUALIFIERS =
|
|
73
|
+
'a|the|some|new|more|unos|unas|los|las|más|nuevos|nuevas';
|
|
74
|
+
const TEST_OBJECT_NOUNS = 'tests?|specs?|pruebas?';
|
|
75
|
+
const TEST_OBJECT_PATTERN = new RegExp(
|
|
76
|
+
String.raw`\b(?:${CREATION_VERBS.join('|')})\s+(?:(?:${OBJECT_QUALIFIERS})\s+){0,2}(?:${TEST_OBJECT_NOUNS})\b`,
|
|
77
|
+
'giu',
|
|
78
|
+
);
|
|
27
79
|
|
|
28
|
-
function hasNewToolIntent(
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
let window = text.slice(afterVerb, afterVerb + NEW_TOOL_NOUN_WINDOW);
|
|
34
|
-
const dotIndex = window.indexOf('.');
|
|
35
|
-
if (dotIndex !== -1) window = window.slice(0, dotIndex);
|
|
36
|
-
if (NEW_TOOL_NOUN_PATTERN.test(window)) return true;
|
|
37
|
-
if (match.index === NEW_TOOL_VERB_PATTERN.lastIndex)
|
|
38
|
-
NEW_TOOL_VERB_PATTERN.lastIndex += 1;
|
|
39
|
-
}
|
|
40
|
-
return false;
|
|
80
|
+
function hasNewToolIntent(prompt) {
|
|
81
|
+
const masked = prompt
|
|
82
|
+
.replace(BUILD_AS_NOUN_PATTERN, 'it')
|
|
83
|
+
.replace(TEST_OBJECT_PATTERN, 'tests');
|
|
84
|
+
return isBuildIntent(masked);
|
|
41
85
|
}
|
|
42
86
|
|
|
43
|
-
const AUDIT_EVIDENCE_PATTERN =
|
|
44
|
-
/already exists|no existing tool|no plugin|audited and|justification:|no existe una herramienta|no existe la herramienta|no hay plugin|ya existe|busque? si ya existe|verifiqu[eé] que no (hay|existe)|audit[eé] herramientas|justificacion:|justificaci[oó]n:/i;
|
|
45
|
-
|
|
46
|
-
const INLINE_JUSTIFICATION_PATTERN =
|
|
47
|
-
/justification:|no-reinvent|audit:|justificacion:|justificaci[oó]n:/i;
|
|
48
|
-
|
|
49
87
|
function checkDelegation(toolInput) {
|
|
50
|
-
const prompt = toolInput
|
|
51
|
-
if (typeof prompt !== 'string') return;
|
|
88
|
+
const prompt = delegationPromptOf(toolInput);
|
|
52
89
|
if (!hasNewToolIntent(prompt)) return;
|
|
53
|
-
if (
|
|
90
|
+
if (hasAuditEvidence(prompt)) return;
|
|
54
91
|
|
|
55
92
|
deny(
|
|
56
93
|
CONFIG_KEY,
|
|
57
94
|
'Blocked: this delegation prompt asks to create a new script/checker/gate/hook/linter/' +
|
|
58
95
|
'tool, and nothing in the prompt shows a prior audit. No filesystem exploration is ' +
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
'(this exact phrase is guaranteed to match). Then relaunch the same delegation.',
|
|
96
|
+
`needed to fix this — add ONE phrase to the prompt such as "${AUDIT_EXAMPLE_PHRASE}" ` +
|
|
97
|
+
`(any phrase matching /${AUDIT_DONE_PATTERN.source}/iu works). Then relaunch the same delegation.`,
|
|
62
98
|
);
|
|
63
99
|
}
|
|
64
100
|
|
|
65
|
-
function
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
);
|
|
70
|
-
if (!inToolFolder) return false;
|
|
71
|
-
return parameters.executableExtensions.some((extension) =>
|
|
72
|
-
filePath.endsWith(extension),
|
|
73
|
-
);
|
|
101
|
+
function stripTrailingSlash(folder) {
|
|
102
|
+
let name = String(folder);
|
|
103
|
+
while (name.endsWith('/') || name.endsWith('\\')) name = name.slice(0, -1);
|
|
104
|
+
return name;
|
|
74
105
|
}
|
|
75
106
|
|
|
76
|
-
function
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
// Editing an EXISTING file is not building a new tool — only creation needs the audit.
|
|
82
|
-
// A file already on disk (any tool that carries file_path) is an edit, so it is allowed.
|
|
83
|
-
// This is what makes maintaining the gates themselves possible with the gates active.
|
|
84
|
-
if (rawPath && existsSync(rawPath)) return;
|
|
107
|
+
function isForeignPath(filePath) {
|
|
108
|
+
return filePath
|
|
109
|
+
.split('/')
|
|
110
|
+
.some((segment) => FOREIGN_SEGMENTS.has(segment.toLowerCase()));
|
|
111
|
+
}
|
|
85
112
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
113
|
+
function checkWrite(toolInput, parameters, root) {
|
|
114
|
+
const rawPath = writtenPathOf(toolInput);
|
|
115
|
+
const filePath = rawPath.replace(/\\/g, '/');
|
|
116
|
+
if (!filePath || isForeignPath(filePath)) return;
|
|
117
|
+
const isTool = isToolPath(filePath, {
|
|
118
|
+
folders: parameters.toolFolders.map(stripTrailingSlash),
|
|
119
|
+
extensions: parameters.executableExtensions,
|
|
120
|
+
namePatterns: [],
|
|
121
|
+
});
|
|
122
|
+
if (!isTool) return;
|
|
123
|
+
if (existsSync(isAbsolute(rawPath) ? rawPath : join(root, rawPath))) return;
|
|
124
|
+
if (hasAuditEvidence(writtenContentOf(toolInput))) return;
|
|
89
125
|
|
|
90
126
|
deny(
|
|
91
127
|
CONFIG_KEY,
|
|
92
|
-
`Blocked: '${filePath}' is a new executable tool with no
|
|
93
|
-
'content. No filesystem exploration is needed to fix this — add ONE line
|
|
94
|
-
`
|
|
95
|
-
|
|
96
|
-
'to match). Then retry the exact same write.',
|
|
128
|
+
`Blocked: '${filePath}' is a new executable tool with no audit statement in its ` +
|
|
129
|
+
'content. No filesystem exploration is needed to fix this — add ONE comment line ' +
|
|
130
|
+
`such as "// justification: ${AUDIT_EXAMPLE_PHRASE}" (any phrase matching ` +
|
|
131
|
+
`/${AUDIT_DONE_PATTERN.source}/iu works). Then retry the exact same write.`,
|
|
97
132
|
);
|
|
98
133
|
}
|
|
99
134
|
|
|
@@ -107,12 +142,12 @@ runGate(
|
|
|
107
142
|
toolFolders: DEFAULT_TOOL_FOLDERS,
|
|
108
143
|
},
|
|
109
144
|
},
|
|
110
|
-
({ toolName, toolInput, parameters }) => {
|
|
111
|
-
if (
|
|
145
|
+
({ toolName, toolInput, parameters, cwd }) => {
|
|
146
|
+
if (toolInGroups(toolName, ['delegation'])) {
|
|
112
147
|
checkDelegation(toolInput);
|
|
113
148
|
return;
|
|
114
149
|
}
|
|
115
|
-
if (!
|
|
116
|
-
checkWrite(toolInput, parameters);
|
|
150
|
+
if (!toolInGroups(toolName, ['write'])) return;
|
|
151
|
+
checkWrite(toolInput, parameters, projectRootOf(cwd) ?? cwd);
|
|
117
152
|
},
|
|
118
153
|
);
|
|
@@ -28,14 +28,14 @@ const CONFIG_KEY = 'autonomousMode';
|
|
|
28
28
|
const QUESTION_GROUPS = ['question'];
|
|
29
29
|
|
|
30
30
|
const DENY_MESSAGE =
|
|
31
|
-
'Autonomous mode is ON
|
|
32
|
-
'
|
|
33
|
-
'
|
|
34
|
-
'
|
|
35
|
-
'
|
|
36
|
-
'
|
|
37
|
-
'
|
|
38
|
-
'set "autonomousMode": false in .ai/config.json.';
|
|
31
|
+
'Autonomous mode is ON. Do not ask the user. Decide and proceed. ' +
|
|
32
|
+
'Use this order: existing project decisions > existing patterns > requirements > project goals > your judgment. ' +
|
|
33
|
+
'Keep decisions consistent with the project. Do not invent new patterns or technologies unless needed. ' +
|
|
34
|
+
'When several choices work, pick the one best for the project. Make reasonable reversible assumptions instead of asking. ' +
|
|
35
|
+
'In your visible reply, leave one line: "[autonomous] I was going to ask <question>; I chose <decision>, assuming <assumption>." ' +
|
|
36
|
+
'Only stop for genuinely irreversible or dangerous actions (data deletion, money, destructive production changes). ' +
|
|
37
|
+
'If stopping, explain what and why. Never ask the popup again. ' +
|
|
38
|
+
'To enable questions, set "autonomousMode": false in .ai/config.json.';
|
|
39
39
|
|
|
40
40
|
runGate(
|
|
41
41
|
{
|