@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,92 +1,25 @@
|
|
|
1
|
-
// stop-pending — the hook that
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
// stdin carries JSON: { session_id, stop_hook_active, ... }. To block the stop (make the
|
|
14
|
-
// agent continue instead of ending the turn), print {"decision":"block","reason":"..."}
|
|
15
|
-
// and exit 0 — `reason` is shown to the model so it knows what to do next. To allow the
|
|
16
|
-
// stop, print nothing (or {}) and exit 0.
|
|
17
|
-
//
|
|
18
|
-
// ── stop_hook_active loop-guard (mandatory) ──────────────────────────────────────────
|
|
19
|
-
// If a Stop hook already blocked once in this cycle, Claude Code re-invokes Stop hooks
|
|
20
|
-
// with stop_hook_active=true. Blocking AGAIN here would never let the turn end — an
|
|
21
|
-
// infinite loop. So: whenever stop_hook_active === true, this gate unconditionally allows
|
|
22
|
-
// the stop, even if pending tasks remain. That trades "always caught" for "never hangs the
|
|
23
|
-
// session" — a single missed reminder is recoverable, a frozen session is not.
|
|
24
|
-
//
|
|
25
|
-
// ── Why this re-reads active.json directly instead of importing task-store.mjs ──────
|
|
26
|
-
// The task store lives in a DIFFERENT plugin (plugins/tasks/hooks/lib/task-store.mjs).
|
|
27
|
-
// Importing across plugin boundaries makes each plugin's installability depend on the
|
|
28
|
-
// other being present at a specific relative path — exactly the coupling every other gate
|
|
29
|
-
// in this repo avoids by being self-contained (Node built-ins only). This gate instead
|
|
30
|
-
// re-reads the on-disk JSON shape directly: `{ tasks: [ { id, title, status, ... } ] }`
|
|
31
|
-
// with status one of open/blocked/in_forge/done/abandoned (done/abandoned already live
|
|
32
|
-
// only in history.json, never in active.json, by task-store's own close() contract — so
|
|
33
|
-
// active.json is never filtered by status here beyond blocked/allowStopWithBlockedTasks).
|
|
34
|
-
// A change to that shape would need updating in two places, but a cross-plugin import
|
|
35
|
-
// would need the OTHER plugin installed at all, which is worse for a gate meant to work
|
|
36
|
-
// standalone.
|
|
37
|
-
//
|
|
38
|
-
// ── What a project can configure (params) ───────────────────────────────────────────
|
|
39
|
-
// allowStopWithBlockedTasks when true (default), a task with status 'blocked' (a
|
|
40
|
-
// declared cause + a next condition) does NOT prevent stop —
|
|
41
|
-
// only open/in_forge do. Set false to also block on 'blocked'.
|
|
42
|
-
//
|
|
43
|
-
// ── Fail-safe shape (mandatory for a Stop hook) ──────────────────────────────────────
|
|
44
|
-
// Everything below is wrapped in try/catch. No plugin/tasks installed, no .ai/tasks/, a
|
|
45
|
-
// corrupt active.json, or ANY unexpected error: allow the stop. A broken Stop hook must
|
|
46
|
-
// never hang a session — that is worse than one missed reminder.
|
|
47
|
-
|
|
48
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
49
|
-
import { dirname, join } from 'node:path';
|
|
50
|
-
import { isGateEnabled, gateParameters } from '../../lib/config.mjs';
|
|
51
|
-
import { readHookPayload } from '../../lib/hook-io.mjs';
|
|
52
|
-
|
|
1
|
+
// stop-pending — the Stop hook that keeps the assistant from ending a turn while the
|
|
2
|
+
// project's task store still has ACTIVE tasks (open/in_forge; `blocked` only when
|
|
3
|
+
// allowStopWithBlockedTasks is false). It re-reads .ai/tasks/active.json directly rather
|
|
4
|
+
// than importing the tasks plugin: a cross-plugin import would make this gate depend on the
|
|
5
|
+
// other plugin being installed at a fixed relative path. Loop guard and fail-open come from
|
|
6
|
+
// runStopHook — a broken Stop hook must never hang a session.
|
|
7
|
+
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { projectRootOf, readJsonOrNull } from '../../lib/config.mjs';
|
|
10
|
+
import { block, runStopHook } from '../../lib/hook-io.mjs';
|
|
11
|
+
|
|
12
|
+
const GATE_ID = 'stop-pending';
|
|
53
13
|
const CONFIG_KEY = 'blockStopWithPendingTasks';
|
|
54
14
|
|
|
55
|
-
const PROJECT_ROOT_MARKERS = ['.git', '.ai'];
|
|
56
15
|
const ACTIVE_TASKS_FILE = join('.ai', 'tasks', 'active.json');
|
|
57
16
|
const BLOCKING_STATUSES = new Set(['open', 'in_forge']);
|
|
58
17
|
const BLOCKED_STATUS = 'blocked';
|
|
59
18
|
|
|
60
|
-
const BOM_CODE_POINT = 0xfeff;
|
|
61
|
-
|
|
62
|
-
function stripBom(text) {
|
|
63
|
-
return text.charCodeAt(0) === BOM_CODE_POINT ? text.slice(1) : text;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function projectRootOf(startDirectory) {
|
|
67
|
-
let current = startDirectory;
|
|
68
|
-
while (true) {
|
|
69
|
-
if (
|
|
70
|
-
PROJECT_ROOT_MARKERS.some((marker) => existsSync(join(current, marker)))
|
|
71
|
-
) {
|
|
72
|
-
return current;
|
|
73
|
-
}
|
|
74
|
-
const parent = dirname(current);
|
|
75
|
-
if (parent === current) return null;
|
|
76
|
-
current = parent;
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/** Reads .ai/tasks/active.json's task list directly. [] on any absence/corruption. */
|
|
81
19
|
function readActiveTasks(root) {
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const parsed = JSON.parse(stripBom(readFileSync(path, 'utf8')));
|
|
86
|
-
return Array.isArray(parsed.tasks) ? parsed.tasks : [];
|
|
87
|
-
} catch {
|
|
88
|
-
return [];
|
|
89
|
-
}
|
|
20
|
+
const parsed = readJsonOrNull(join(root, ACTIVE_TASKS_FILE));
|
|
21
|
+
const tasks = Array.isArray(parsed?.tasks) ? parsed.tasks : [];
|
|
22
|
+
return tasks.filter((task) => task && typeof task === 'object');
|
|
90
23
|
}
|
|
91
24
|
|
|
92
25
|
function blockingTasksFrom(tasks, allowBlocked) {
|
|
@@ -96,73 +29,39 @@ function blockingTasksFrom(tasks, allowBlocked) {
|
|
|
96
29
|
});
|
|
97
30
|
}
|
|
98
31
|
|
|
99
|
-
function
|
|
100
|
-
|
|
101
|
-
.
|
|
102
|
-
(task)
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
? ` (blocked: ${task.blockedReason})`
|
|
106
|
-
: ''
|
|
107
|
-
}`,
|
|
108
|
-
)
|
|
109
|
-
.join('\n');
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function block(reason) {
|
|
113
|
-
process.stdout.write(JSON.stringify({ decision: 'block', reason }));
|
|
114
|
-
process.exit(0);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function allow() {
|
|
118
|
-
process.exit(0);
|
|
32
|
+
function describeTask(task) {
|
|
33
|
+
const blockedNote =
|
|
34
|
+
task.status === BLOCKED_STATUS && task.blockedReason
|
|
35
|
+
? ` (blocked: ${task.blockedReason})`
|
|
36
|
+
: '';
|
|
37
|
+
return ` - [${task.status}] ${task.id ?? '(no id)'}: ${task.title ?? '(untitled)'}${blockedNote}`;
|
|
119
38
|
}
|
|
120
39
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
if (
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
if (!root) return allow(); // no project: nothing to check
|
|
146
|
-
|
|
147
|
-
const parameters = gateParameters(CONFIG_KEY, cwd);
|
|
148
|
-
const allowBlocked = parameters.allowStopWithBlockedTasks !== false; // default true
|
|
149
|
-
|
|
150
|
-
const tasks = readActiveTasks(root);
|
|
151
|
-
const blockingTasks = blockingTasksFrom(tasks, allowBlocked);
|
|
152
|
-
if (blockingTasks.length === 0) return allow();
|
|
153
|
-
|
|
154
|
-
return block(
|
|
155
|
-
`[${CONFIG_KEY}] There are ${blockingTasks.length} pending task(s) still active for this ` +
|
|
156
|
-
`project:\n${describeTasks(blockingTasks)}\n` +
|
|
157
|
-
'Close each before ending the turn: `task close <id> --evidence "..."` when done, ' +
|
|
158
|
-
'or `task abandon <id> --reason "..."` when it will not be finished. A task left ' +
|
|
159
|
-
'`blocked` with a stated cause does not require closing unless ' +
|
|
40
|
+
runStopHook(
|
|
41
|
+
{
|
|
42
|
+
id: GATE_ID,
|
|
43
|
+
configKey: CONFIG_KEY,
|
|
44
|
+
enabledByDefault: true,
|
|
45
|
+
defaultParams: { allowStopWithBlockedTasks: true },
|
|
46
|
+
},
|
|
47
|
+
({ parameters, cwd }) => {
|
|
48
|
+
const root = projectRootOf(cwd) ?? cwd;
|
|
49
|
+
const blockingTasks = blockingTasksFrom(
|
|
50
|
+
readActiveTasks(root),
|
|
51
|
+
parameters.allowStopWithBlockedTasks !== false,
|
|
52
|
+
);
|
|
53
|
+
if (blockingTasks.length === 0) return;
|
|
54
|
+
|
|
55
|
+
block(
|
|
56
|
+
CONFIG_KEY,
|
|
57
|
+
`There are ${blockingTasks.length} pending task(s) still active for this ` +
|
|
58
|
+
`project:\n${blockingTasks.map(describeTask).join('\n')}\n` +
|
|
59
|
+
'Close each before ending the turn, with VERIFIED evidence (free text is not ' +
|
|
60
|
+
'accepted): `task close <id> --check "<command that must exit 0>" [--expect <text>]` ' +
|
|
61
|
+
'or `task close <id> --exists <path> [--contains <text>]` (add `--note "..."` for ' +
|
|
62
|
+
'context), or `task abandon <id> --reason "..."` when it will not be finished. A task ' +
|
|
63
|
+
'left `blocked` with a stated cause does not require closing unless ' +
|
|
160
64
|
'allowStopWithBlockedTasks is set to false.',
|
|
161
65
|
);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
return allow();
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
main();
|
|
66
|
+
},
|
|
67
|
+
);
|
|
@@ -1,6 +1,21 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
// test-after-implementation — creating a test whose paired implementation is already
|
|
2
|
+
// modified and uncommitted is denied: a test that passes the first time it runs proves
|
|
3
|
+
// nothing about the change. A regression test written after reproducing a bug is the
|
|
4
|
+
// legitimate exception, opted into with the escape-hatch marker. Only creation is judged;
|
|
5
|
+
// editing an existing test is maintenance. Pairing is same directory + same stem, on
|
|
6
|
+
// purpose narrow: a sibling __tests__/ layout is not paired (documented in the tests).
|
|
7
|
+
|
|
8
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
9
|
+
import {
|
|
10
|
+
basename,
|
|
11
|
+
dirname,
|
|
12
|
+
extname,
|
|
13
|
+
isAbsolute,
|
|
14
|
+
join,
|
|
15
|
+
relative,
|
|
16
|
+
} from 'node:path';
|
|
17
|
+
import { projectRootOf } from '../../lib/config.mjs';
|
|
18
|
+
import { workingTreeChanges } from '../../lib/git.mjs';
|
|
4
19
|
import {
|
|
5
20
|
runGate,
|
|
6
21
|
deny,
|
|
@@ -12,12 +27,7 @@ import {
|
|
|
12
27
|
const GATE_ID = 'test-after-implementation';
|
|
13
28
|
const CONFIG_KEY = 'warnTestWrittenAfterImplementation';
|
|
14
29
|
|
|
15
|
-
// Escape hatch: a regression test written AFTER reproducing a bug is a legitimate
|
|
16
|
-
// test-after-implementation case (the project's own rule: reproduce the bug, then write the
|
|
17
|
-
// test that pins it). A deny cannot advise-and-pass, so that legitimate case needs an
|
|
18
|
-
// explicit opt-out — this marker anywhere in the test file's content lets it through.
|
|
19
30
|
const DEFAULT_ESCAPE_HATCH = 'test-after-impl:allow';
|
|
20
|
-
|
|
21
31
|
const DEFAULT_IMPLEMENTATION_EXTENSIONS = [
|
|
22
32
|
'.ts',
|
|
23
33
|
'.tsx',
|
|
@@ -27,27 +37,53 @@ const DEFAULT_IMPLEMENTATION_EXTENSIONS = [
|
|
|
27
37
|
'.vue',
|
|
28
38
|
];
|
|
29
39
|
const TEST_FILE_PATTERN = /\.(spec|test)\.[cm]?[jt]sx?$/i;
|
|
30
|
-
//
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
function baseNameWithoutTestSuffix(fileName) {
|
|
34
|
-
return fileName.replace(TEST_FILE_PATTERN, '');
|
|
35
|
-
}
|
|
40
|
+
// An untracked directory is one porcelain entry; expanding it is bounded so a stray huge
|
|
41
|
+
// tree cannot stall the hook.
|
|
42
|
+
const MAX_EXPANDED_FILES = 5000;
|
|
36
43
|
|
|
37
|
-
function
|
|
44
|
+
function filesUnder(root, directory, collected) {
|
|
45
|
+
let entries;
|
|
38
46
|
try {
|
|
39
|
-
|
|
40
|
-
cwd,
|
|
41
|
-
timeout: 5000,
|
|
42
|
-
}).toString();
|
|
43
|
-
return output
|
|
44
|
-
.split('\n')
|
|
45
|
-
.map((line) => line.trim())
|
|
46
|
-
.filter(Boolean);
|
|
47
|
+
entries = readdirSync(join(root, directory));
|
|
47
48
|
} catch {
|
|
48
|
-
|
|
49
|
-
return [];
|
|
49
|
+
return collected;
|
|
50
50
|
}
|
|
51
|
+
for (const name of entries) {
|
|
52
|
+
if (collected.length >= MAX_EXPANDED_FILES) break;
|
|
53
|
+
const relativePath = `${directory}${name}`;
|
|
54
|
+
let isDirectory;
|
|
55
|
+
try {
|
|
56
|
+
isDirectory = statSync(join(root, relativePath)).isDirectory();
|
|
57
|
+
} catch {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (isDirectory) filesUnder(root, `${relativePath}/`, collected);
|
|
61
|
+
else collected.push(relativePath);
|
|
62
|
+
}
|
|
63
|
+
return collected;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function changedPaths(root) {
|
|
67
|
+
const changes = workingTreeChanges(root);
|
|
68
|
+
if (changes === null) return [];
|
|
69
|
+
return changes.flatMap((entry) =>
|
|
70
|
+
entry.path.endsWith('/') ? filesUnder(root, entry.path, []) : [entry.path],
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function normalizeDirectory(path) {
|
|
75
|
+
const directory = dirname(path).replace(/\\/g, '/');
|
|
76
|
+
return directory === '' || directory === '.' ? '.' : directory;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isPairedImplementation(changedPath, stem, testDirectory, extensions) {
|
|
80
|
+
if (!extensions.some((extension) => changedPath.endsWith(extension)))
|
|
81
|
+
return false;
|
|
82
|
+
if (TEST_FILE_PATTERN.test(changedPath)) return false;
|
|
83
|
+
const changedStem = basename(changedPath, extname(changedPath));
|
|
84
|
+
return (
|
|
85
|
+
changedStem === stem && normalizeDirectory(changedPath) === testDirectory
|
|
86
|
+
);
|
|
51
87
|
}
|
|
52
88
|
|
|
53
89
|
runGate(
|
|
@@ -60,51 +96,31 @@ runGate(
|
|
|
60
96
|
escapeHatch: DEFAULT_ESCAPE_HATCH,
|
|
61
97
|
},
|
|
62
98
|
},
|
|
63
|
-
({ toolName, toolInput, parameters }) => {
|
|
99
|
+
({ toolName, toolInput, parameters, cwd }) => {
|
|
64
100
|
if (!toolInGroups(toolName, ['write'])) return;
|
|
65
101
|
|
|
66
|
-
const
|
|
67
|
-
if (!TEST_FILE_PATTERN.test(
|
|
68
|
-
|
|
102
|
+
const rawPath = writtenPathOf(toolInput);
|
|
103
|
+
if (!TEST_FILE_PATTERN.test(rawPath)) return;
|
|
104
|
+
const root = projectRootOf(cwd) ?? cwd;
|
|
105
|
+
const filePath = isAbsolute(rawPath) ? rawPath : join(root, rawPath);
|
|
106
|
+
if (existsSync(filePath)) return;
|
|
69
107
|
|
|
70
|
-
// Explicit opt-out for a legitimate regression test (bug reproduced first, then pinned).
|
|
71
108
|
const escapeHatch = parameters.escapeHatch ?? DEFAULT_ESCAPE_HATCH;
|
|
72
109
|
if (escapeHatch && writtenContentOf(toolInput).includes(escapeHatch))
|
|
73
110
|
return;
|
|
74
111
|
|
|
75
|
-
const cwd = process.cwd();
|
|
76
|
-
const statusLines = gitStatusPorcelain(cwd);
|
|
77
|
-
if (statusLines.length === 0) return;
|
|
78
|
-
|
|
79
112
|
const testFileName = basename(filePath);
|
|
80
|
-
const stem =
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
// bare file name returns '.' — normalize both to '.' so root-level files compare equal.
|
|
85
|
-
const testDirectoryRelativeToRepo = relative(cwd, dirname(filePath)) || '.';
|
|
86
|
-
|
|
87
|
-
const pairedImplementationChanged = statusLines.some((line) => {
|
|
88
|
-
const changedPath = line.slice(PORCELAIN_STATUS_PREFIX_LENGTH).trim();
|
|
89
|
-
if (
|
|
90
|
-
!parameters.implementationExtensions.some((extension) =>
|
|
91
|
-
changedPath.endsWith(extension),
|
|
92
|
-
)
|
|
93
|
-
)
|
|
94
|
-
return false;
|
|
95
|
-
if (TEST_FILE_PATTERN.test(changedPath)) return false;
|
|
96
|
-
const lastDot = changedPath.lastIndexOf('.');
|
|
97
|
-
const changedBase = basename(
|
|
113
|
+
const stem = testFileName.replace(TEST_FILE_PATTERN, '');
|
|
114
|
+
const testDirectory = normalizeDirectory(relative(root, filePath));
|
|
115
|
+
const paired = changedPaths(root).some((changedPath) =>
|
|
116
|
+
isPairedImplementation(
|
|
98
117
|
changedPath,
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
if (!pairedImplementationChanged) return;
|
|
118
|
+
stem,
|
|
119
|
+
testDirectory,
|
|
120
|
+
parameters.implementationExtensions,
|
|
121
|
+
),
|
|
122
|
+
);
|
|
123
|
+
if (!paired) return;
|
|
108
124
|
|
|
109
125
|
deny(
|
|
110
126
|
CONFIG_KEY,
|
|
@@ -112,13 +128,7 @@ runGate(
|
|
|
112
128
|
'(uncommitted). Write the test before or alongside the implementation, not after — a ' +
|
|
113
129
|
'test that passes the first time it runs proves nothing about the change. If this is a ' +
|
|
114
130
|
'regression test for a bug you already reproduced (reproduce first, then pin it), add ' +
|
|
115
|
-
`the marker "${
|
|
131
|
+
`the marker "${escapeHatch}" in the test content to allow it.`,
|
|
116
132
|
);
|
|
117
133
|
},
|
|
118
134
|
);
|
|
119
|
-
|
|
120
|
-
// Note: this restores the source guard's (guard-test-despues-de-implementar.mjs) original
|
|
121
|
-
// deny severity. It was warn during the config-key migration; the user's directive is that a
|
|
122
|
-
// gate stays warn only when the defect is genuinely invisible to a hook. Here the defect is a
|
|
123
|
-
// complete, git-verifiable fact, so it denies — with an escape hatch for the legitimate
|
|
124
|
-
// regression-test case. The config key still reads warn* for backward compatibility.
|
|
@@ -1,112 +1,81 @@
|
|
|
1
|
-
// test-matrix — denies
|
|
2
|
-
// makes a test type mandatory (money/auth/persistence → E2E; UI → visual/QA;
|
|
3
|
-
// unit/mutation) without that type declared in the prompt.
|
|
4
|
-
// ~/.claude/hooks/guard-matriz-de-tests.mjs.
|
|
1
|
+
// test-matrix — denies a STANDARD/HIGH-RISK implementation delegation whose brief touches a
|
|
2
|
+
// domain that makes a test type mandatory (money/auth/persistence → E2E; UI → visual/QA;
|
|
3
|
+
// always → unit/mutation) without that type declared in the prompt.
|
|
5
4
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// The defaults live here, in the source, so a project reads them and knows exactly what
|
|
13
|
-
// its override replaces.
|
|
14
|
-
//
|
|
15
|
-
// ── Off by default, and quiet outside its narrow trigger ───────────────────────────
|
|
16
|
-
// Only STANDARD/HIGH-RISK implementation delegations on builder subagents, not about
|
|
17
|
-
// the harness itself, reach the check. A brief that only describes work never triggers.
|
|
5
|
+
// Decisions: the E2E signals are the terms that name a money/auth/data-loss domain; generic
|
|
6
|
+
// words (role, balance, amount, session, token) were dropped because they fire on ordinary
|
|
7
|
+
// briefs. An empty `e2eSignals`/`visualSignals` means that row is never mandatory. A file
|
|
8
|
+
// extension signal (`.tsx`) matches a real path, so it is compiled without a leading word
|
|
9
|
+
// boundary. "QA:" counts as a visual row only when something follows it. Only the exempt
|
|
10
|
+
// list exempts a subagent type; harness work (.claude/, .ai/...) is exempt.
|
|
18
11
|
|
|
12
|
+
import {
|
|
13
|
+
DEFAULT_EXEMPT_SUBAGENTS,
|
|
14
|
+
DEMANDING_LEVELS,
|
|
15
|
+
isHarnessWork,
|
|
16
|
+
isImplementationRequest,
|
|
17
|
+
isSubagentNamedIn,
|
|
18
|
+
operativeLevelOf,
|
|
19
|
+
} from '../../lib/delegation.mjs';
|
|
19
20
|
import {
|
|
20
21
|
runGate,
|
|
21
22
|
deny,
|
|
23
|
+
compileRegex,
|
|
22
24
|
toolInGroups,
|
|
23
25
|
delegationPromptOf,
|
|
24
26
|
} from '../../lib/hook-io.mjs';
|
|
25
|
-
import {
|
|
27
|
+
import { withUnicodeWordBoundary } from '../../lib/signals.mjs';
|
|
26
28
|
|
|
27
29
|
const GATE_ID = 'test-matrix';
|
|
28
30
|
const CONFIG_KEY = 'requireTestMatrixWhenImplementing';
|
|
29
31
|
|
|
30
|
-
const DEFAULT_EXEMPT_SUBAGENTS = [
|
|
31
|
-
'scout',
|
|
32
|
-
'explore',
|
|
33
|
-
'plan',
|
|
34
|
-
'revision',
|
|
35
|
-
'contraste',
|
|
36
|
-
'test-planner',
|
|
37
|
-
'qa',
|
|
38
|
-
'ui',
|
|
39
|
-
'ux',
|
|
40
|
-
];
|
|
41
|
-
|
|
42
|
-
// Money/auth signals shared with RISK_SIGNAL (lib/signals.mjs, ES+EN), plus this gate's
|
|
43
|
-
// own persistence-domain terms (also bilingual) — a project override still replaces the
|
|
44
|
-
// whole list wholesale, same as before.
|
|
45
32
|
const DEFAULT_E2E_SIGNALS = [
|
|
46
|
-
|
|
47
|
-
'
|
|
33
|
+
'money|payment|pago|dinero|cobro|charge|invoice|factura|precio|price|saldo|monto|cuota|checkout',
|
|
34
|
+
'auth|autenticaci[oó]n|authentication|login|password|contrase[nñ]a|credencial|credential',
|
|
48
35
|
'persist|database|migration|transaction|persistencia|base de datos|migraci[oó]n|transacci[oó]n',
|
|
36
|
+
'data loss|p[eé]rdida de datos|delete|borrar|drop',
|
|
49
37
|
];
|
|
50
38
|
|
|
51
39
|
const DEFAULT_VISUAL_SIGNALS = [
|
|
52
40
|
'ui|interface|interfaz|component|componente|screen|pantalla|view|vista',
|
|
53
41
|
'form|formulario|button|bot[oó]n|modal|layout|style|estilo',
|
|
54
|
-
'responsive|mobile|m[oó]vil|visual
|
|
42
|
+
'responsive|mobile|m[oó]vil|visual',
|
|
43
|
+
'\\.(vue|tsx?|jsx?)(?!\\w)',
|
|
55
44
|
];
|
|
56
45
|
|
|
57
|
-
function withWordBoundary(alternation) {
|
|
58
|
-
return new RegExp(
|
|
59
|
-
`(?:^|[^\\p{L}\\p{N}_])(?:${alternation})(?:[^\\p{L}\\p{N}_]|$)`,
|
|
60
|
-
'iu',
|
|
61
|
-
);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
const IMPLEMENTATION_VERBS = withWordBoundary(
|
|
65
|
-
'implementa|implementar|implement(á|é)|agreg(a|á)|agregar|añad(e|í)|añadir|cre(a|á)|crear|' +
|
|
66
|
-
'arregl(a|á)|arreglar|cambi(a|á)|cambiar|migr(a|á)|migrar|' +
|
|
67
|
-
'corrige|corregir|correg(í|ir)|constru(ye|í)|construir|modific(a|á)|modificar|' +
|
|
68
|
-
'refactoriz(a|á)|refactorizar|elimin(a|á)|eliminar|reescrib(e|í)|reescribir|desplieg(a|á)|desplegar|' +
|
|
69
|
-
'escrib(í|e)|escribir|implement\\w*|writ(?:e|ing)|creat\\w*|fix\\w*|build\\w*|refactor\\w*|migrat\\w*|' +
|
|
70
|
-
'add\\w*|remov\\w*|delet\\w*|modify|modifies|modifying|rewrit\\w*',
|
|
71
|
-
);
|
|
72
|
-
|
|
73
|
-
/** Declared LEVEL near the word "level"/"classification" in Spanish or English, matching
|
|
74
|
-
* the plugin-wide convention (see risk-level.mjs). */
|
|
75
|
-
const DEMANDING_LEVEL_PATTERN =
|
|
76
|
-
/(nivel|level|clasificaci[oó]n|classification)[^\n]{0,25}?\b(STANDARD|HIGH-RISK)\b/iu;
|
|
77
|
-
const EXEMPT_LEVEL_PATTERN =
|
|
78
|
-
/(nivel|level|clasificaci[oó]n|classification)[^\n]{0,25}?\b(QUESTION|MICRO)\b/iu;
|
|
79
|
-
|
|
80
|
-
const HARNESS_PATTERN =
|
|
81
|
-
/(\.claude[\\/]|hooks[\\/]|plugins[\\/]gates|settings\.json|[\\/]agents[\\/]\w|[\\/]rules[\\/]\w|[\\/]skills[\\/]\w)/i;
|
|
82
|
-
|
|
83
46
|
const DETECTS_E2E = /\b(e2e|end[- ]to[- ]end|playwright)\b/i;
|
|
84
|
-
const DETECTS_VISUAL = /\b(visual|qa\
|
|
47
|
+
const DETECTS_VISUAL = /\b(visual|qa\s*:\s*\p{L}|screenshot|snapshot)/iu;
|
|
85
48
|
const DETECTS_UNIT_OR_MUTATION =
|
|
86
49
|
/\b(unit\b|units\b|unitari[ao]s?|vitest|jest|mutation|mutaci[oó]n|mutant|stryker)\b/i;
|
|
87
|
-
const DETECTS_NEGATIVE_CASES =
|
|
50
|
+
const DETECTS_NEGATIVE_CASES = withUnicodeWordBoundary(
|
|
88
51
|
'negative|edge|empty|error|no data|zero|count 0|failure|invalid|limit|' +
|
|
89
52
|
'negativ[ao]s?|borde|vac[ií]o|sin datos|cero|falla|inv[aá]lid[ao]|l[ií]mite',
|
|
90
53
|
);
|
|
91
54
|
|
|
92
|
-
|
|
93
|
-
|
|
55
|
+
// A source that starts with an escaped dot is a file-extension shape and must match inside
|
|
56
|
+
// a path; every other source is a word and gets the Unicode word boundary.
|
|
57
|
+
function compileSignal(source) {
|
|
58
|
+
if (typeof source !== 'string' || !compileRegex(source, 'iu')) return null;
|
|
59
|
+
return source.startsWith('\\.')
|
|
60
|
+
? compileRegex(source, 'iu')
|
|
61
|
+
: withUnicodeWordBoundary(source);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function anySignalMatches(sources, prompt) {
|
|
65
|
+
return sources
|
|
66
|
+
.map(compileSignal)
|
|
67
|
+
.filter(Boolean)
|
|
68
|
+
.some((pattern) => pattern.test(prompt));
|
|
94
69
|
}
|
|
95
70
|
|
|
96
71
|
function isExempt(toolInput, prompt, exemptSubagents) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
if (EXEMPT_LEVEL_PATTERN.test(prompt)) return true;
|
|
102
|
-
if (!DEMANDING_LEVEL_PATTERN.test(prompt)) return true;
|
|
103
|
-
if (!IMPLEMENTATION_VERBS.test(prompt)) return true;
|
|
104
|
-
if (HARNESS_PATTERN.test(prompt)) return true;
|
|
105
|
-
return false;
|
|
72
|
+
if (isSubagentNamedIn(toolInput, exemptSubagents)) return true;
|
|
73
|
+
if (!DEMANDING_LEVELS.has(operativeLevelOf(prompt))) return true;
|
|
74
|
+
if (!isImplementationRequest(prompt)) return true;
|
|
75
|
+
return isHarnessWork(prompt);
|
|
106
76
|
}
|
|
107
77
|
|
|
108
|
-
|
|
109
|
-
function missingTypes(prompt, e2ePattern, visualPattern) {
|
|
78
|
+
function missingTypes(prompt, e2eSignals, visualSignals) {
|
|
110
79
|
const missing = [];
|
|
111
80
|
|
|
112
81
|
if (!DETECTS_UNIT_OR_MUTATION.test(prompt)) {
|
|
@@ -116,14 +85,14 @@ function missingTypes(prompt, e2ePattern, visualPattern) {
|
|
|
116
85
|
});
|
|
117
86
|
}
|
|
118
87
|
|
|
119
|
-
if (
|
|
88
|
+
if (anySignalMatches(e2eSignals, prompt) && !DETECTS_E2E.test(prompt)) {
|
|
120
89
|
missing.push({
|
|
121
90
|
type: 'E2E',
|
|
122
91
|
line: '- E2E: yes — <end-to-end flow verified> (mandatory: the requirement touches money/auth/persistence)',
|
|
123
92
|
});
|
|
124
93
|
}
|
|
125
94
|
|
|
126
|
-
if (
|
|
95
|
+
if (anySignalMatches(visualSignals, prompt) && !DETECTS_VISUAL.test(prompt)) {
|
|
127
96
|
missing.push({
|
|
128
97
|
type: 'visual/QA',
|
|
129
98
|
line: '- visual/QA: yes — <screen/state reviewed in the browser> (mandatory: the requirement touches UI)',
|
|
@@ -160,17 +129,13 @@ runGate(
|
|
|
160
129
|
|
|
161
130
|
const prompt = delegationPromptOf(toolInput);
|
|
162
131
|
if (!prompt.trim()) return;
|
|
132
|
+
if (isExempt(toolInput, prompt, parameters.exemptSubagents)) return;
|
|
163
133
|
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
const visualSignals = parameters.visualSignals ?? DEFAULT_VISUAL_SIGNALS;
|
|
170
|
-
const e2ePattern = joinSignals(e2eSignals);
|
|
171
|
-
const visualPattern = joinSignals(visualSignals);
|
|
172
|
-
|
|
173
|
-
const missing = missingTypes(prompt, e2ePattern, visualPattern);
|
|
134
|
+
const missing = missingTypes(
|
|
135
|
+
prompt,
|
|
136
|
+
parameters.e2eSignals,
|
|
137
|
+
parameters.visualSignals,
|
|
138
|
+
);
|
|
174
139
|
if (missing.length === 0) return;
|
|
175
140
|
|
|
176
141
|
const lines = missing.map((entry) => ` ${entry.line}`).join('\n');
|