@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.
Files changed (78) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/README.es.md +69 -9
  3. package/README.md +68 -7
  4. package/cli/doctor.mjs +132 -0
  5. package/cli/evidence.mjs +78 -0
  6. package/cli/hooks-manifest.mjs +89 -0
  7. package/cli/index.mjs +124 -6
  8. package/cli/init.mjs +97 -3
  9. package/cli/log.mjs +70 -0
  10. package/cli/materialize.mjs +36 -2
  11. package/cli/registry.mjs +11 -1
  12. package/cli/selection.mjs +18 -0
  13. package/cli/smoke-fixtures.json +114 -22
  14. package/cli/task.mjs +36 -8
  15. package/cli/toggle.mjs +125 -0
  16. package/package.json +1 -1
  17. package/plugins/gates/.claude-plugin/plugin.json +1 -1
  18. package/plugins/gates/hooks/doctor.mjs +47 -1
  19. package/plugins/gates/hooks/gates/atomic-commit/index.mjs +92 -119
  20. package/plugins/gates/hooks/gates/audit-before-build/index.mjs +101 -66
  21. package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +8 -8
  22. package/plugins/gates/hooks/gates/autonomous-mode/stop.mjs +13 -64
  23. package/plugins/gates/hooks/gates/bash-commands/index.mjs +186 -163
  24. package/plugins/gates/hooks/gates/block-remote-publish/index.mjs +100 -101
  25. package/plugins/gates/hooks/gates/brief-approved/index.mjs +71 -140
  26. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +56 -137
  27. package/plugins/gates/hooks/gates/capability-map/index.mjs +280 -506
  28. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +98 -212
  29. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +127 -46
  30. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +81 -19
  31. package/plugins/gates/hooks/gates/engram-first/index.mjs +41 -0
  32. package/plugins/gates/hooks/gates/engram-first/session-start.mjs +64 -0
  33. package/plugins/gates/hooks/gates/engram-first/shared.mjs +90 -0
  34. package/plugins/gates/hooks/gates/engram-first/stop.mjs +23 -0
  35. package/plugins/gates/hooks/gates/engram-first/track.mjs +97 -0
  36. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +156 -50
  37. package/plugins/gates/hooks/gates/force-parallel/index.mjs +51 -92
  38. package/plugins/gates/hooks/gates/forge-flow/index.mjs +51 -83
  39. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +40 -80
  40. package/plugins/gates/hooks/gates/intent-flow/index.mjs +23 -149
  41. package/plugins/gates/hooks/gates/library-docs/index.mjs +262 -0
  42. package/plugins/gates/hooks/gates/library-docs/track.mjs +95 -0
  43. package/plugins/gates/hooks/gates/lint-commit/index.mjs +88 -97
  44. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +48 -95
  45. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +69 -31
  46. package/plugins/gates/hooks/gates/never-assume/index.mjs +23 -18
  47. package/plugins/gates/hooks/gates/no-blocking/index.mjs +127 -86
  48. package/plugins/gates/hooks/gates/no-coauthor/index.mjs +108 -88
  49. package/plugins/gates/hooks/gates/no-explanatory-comments/index.mjs +281 -0
  50. package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +170 -110
  51. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +46 -77
  52. package/plugins/gates/hooks/gates/no-reconfirm/index.mjs +85 -47
  53. package/plugins/gates/hooks/gates/protected-paths/index.mjs +137 -90
  54. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +95 -51
  55. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +72 -216
  56. package/plugins/gates/hooks/gates/risk-level/index.mjs +51 -204
  57. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +34 -17
  58. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +160 -95
  59. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +196 -97
  60. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +90 -143
  61. package/plugins/gates/hooks/gates/staged-lint/index.mjs +98 -106
  62. package/plugins/gates/hooks/gates/stop-pending/index.mjs +47 -148
  63. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +78 -68
  64. package/plugins/gates/hooks/gates/test-matrix/index.mjs +53 -88
  65. package/plugins/gates/hooks/gates/tool-map/index.mjs +80 -110
  66. package/plugins/gates/hooks/hooks.json +112 -42
  67. package/plugins/gates/hooks/lib/config.mjs +6 -3
  68. package/plugins/gates/hooks/lib/delegation.mjs +352 -0
  69. package/plugins/gates/hooks/lib/gate-log.mjs +141 -0
  70. package/plugins/gates/hooks/lib/git.mjs +262 -0
  71. package/plugins/gates/hooks/lib/hook-io.mjs +448 -83
  72. package/plugins/gates/hooks/lib/session-state.mjs +150 -0
  73. package/plugins/gates/hooks/lib/signals.mjs +18 -13
  74. package/plugins/gates/hooks/lib/testing.mjs +137 -0
  75. package/plugins/gates/hooks/lib/tools.mjs +144 -0
  76. package/plugins/tasks/.claude-plugin/plugin.json +1 -1
  77. package/plugins/tasks/hooks/lib/task-store.mjs +6 -6
  78. package/registry.json +204 -36
@@ -1,92 +1,25 @@
1
- // stop-pending — the hook that obliges the assistant not to leave pending tasks behind.
2
- // On the Stop event, if the project's task store (`.ai/tasks/active.json`) has ACTIVE
3
- // tasks (status open/in_forge see allowStopWithBlockedTasks for `blocked`), this BLOCKS
4
- // the stop and lists them with how to close each: `task close --evidence` or `task
5
- // abandon`.
6
- //
7
- // justification: no existing tool covers this. The tasks plugin (register-requests.mjs,
8
- // session-tasks.mjs) reminds and recites; nothing in this repo stops the agent from ending
9
- // a turn while a task is still open — that requires a Stop hook that can BLOCK, which only
10
- // this event shape supports.
11
- //
12
- // ── Stop hook event shape (Claude Code) ──────────────────────────────────────────────
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 path = join(root, ACTIVE_TASKS_FILE);
83
- if (!existsSync(path)) return [];
84
- try {
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 describeTasks(tasks) {
100
- return tasks
101
- .map(
102
- (task) =>
103
- ` - [${task.status}] ${task.id}: ${task.title ?? '(untitled)'}${
104
- task.status === BLOCKED_STATUS && task.blockedReason
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
- function main() {
122
- try {
123
- const rawPayload = readHookPayload();
124
- if (rawPayload === null) return allow();
125
-
126
- let payload;
127
- try {
128
- payload = JSON.parse(rawPayload);
129
- } catch {
130
- return allow(); // unparseable Stop payload: fail-safe, never hang the session
131
- }
132
-
133
- // Loop-guard: a Stop hook already blocked once this cycle. Never block again.
134
- if (payload?.stop_hook_active === true) return allow();
135
-
136
- const cwd = process.cwd();
137
- // registryDefault MUST match this gate's `default` in registry.json (true). The gate
138
- // does not read the registry this literal IS its default when a project config is
139
- // silent about the key. It was false while the registry said false; both moved to true
140
- // so the pending-task reminder actually fires on a fresh install, not only when the
141
- // user's config names the key explicitly.
142
- if (!isGateEnabled(CONFIG_KEY, true, cwd)) return allow();
143
-
144
- const root = projectRootOf(cwd);
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
- } catch {
163
- // A broken Stop hook must never hang the session.
164
- return allow();
165
- }
166
- }
167
-
168
- main();
66
+ },
67
+ );
@@ -1,6 +1,21 @@
1
- import { execFileSync } from 'node:child_process';
2
- import { existsSync } from 'node:fs';
3
- import { basename, dirname, relative } from 'node:path';
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
- // `git status --porcelain` prefixes each line with a 2-character status code plus a space.
31
- const PORCELAIN_STATUS_PREFIX_LENGTH = 3;
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 gitStatusPorcelain(cwd) {
44
+ function filesUnder(root, directory, collected) {
45
+ let entries;
38
46
  try {
39
- const output = execFileSync('git', ['status', '--porcelain'], {
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
- // Not a git repo, or git unavailable — nothing to cross-check against.
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 filePath = writtenPathOf(toolInput);
67
- if (!TEST_FILE_PATTERN.test(filePath)) return;
68
- if (existsSync(filePath)) return; // only new test file creation, not edits
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 = baseNameWithoutTestSuffix(testFileName);
81
- // `git status --porcelain` reports paths relative to `cwd`; the test's own
82
- // directory (relative to `cwd`) is the fair comparison, not its absolute path.
83
- // `relative()` returns '' for the repo root itself, while `dirname()` on a
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
- lastDot >= 0 ? changedPath.slice(lastDot) : '',
100
- );
101
- return (
102
- changedBase === stem &&
103
- (dirname(changedPath) || '.') === testDirectoryRelativeToRepo
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 "${parameters.escapeHatch ?? DEFAULT_ESCAPE_HATCH}" in the test content to allow it.`,
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 an implementation delegation whose brief touches a domain that
2
- // makes a test type mandatory (money/auth/persistence → E2E; UI → visual/QA; always →
3
- // unit/mutation) without that type declared in the prompt. Migrated from
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
- // ── What a project can configure (params) ───────────────────────────────────────────
7
- // e2eSignals regex sources (matched case-insensitively) that make an E2E row
8
- // mandatory when found in the brief (money, auth, persistence...).
9
- // Replaces the built-in list wholesale.
10
- // visualSignals regex sources that make a visual/QA row mandatory (UI signals).
11
- // Replaces the built-in list wholesale.
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 { RISK_SIGNAL_SOURCES } from '../../lib/signals.mjs';
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
- ...RISK_SIGNAL_SOURCES.slice(0, 2), // money terms, auth terms
47
- 'login|session|token|permission|role|sesi[]n|permiso|rol',
33
+ 'money|payment|pago|dinero|cobro|charge|invoice|factura|precio|price|saldo|monto|cuota|checkout',
34
+ 'auth|autenticaci[oó]n|authentication|login|password|contrase[]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|\\.vue|\\.tsx?|\\.jsx?',
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\b|screenshot|snapshot)\b/i;
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 = withWordBoundary(
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
- function joinSignals(signals) {
93
- return withWordBoundary(signals.join('|'));
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
- const subagentType = String(
98
- toolInput.subagent_type ?? toolInput.subagentType ?? '',
99
- ).toLowerCase();
100
- if (exemptSubagents.includes(subagentType)) return true;
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
- /** Which mandatory test types are missing from the prompt, given the brief text. */
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 (e2ePattern.test(prompt) && !DETECTS_E2E.test(prompt)) {
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 (visualPattern.test(prompt) && !DETECTS_VISUAL.test(prompt)) {
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 exemptSubagents =
165
- parameters.exemptSubagents ?? DEFAULT_EXEMPT_SUBAGENTS;
166
- if (isExempt(toolInput, prompt, exemptSubagents)) return;
167
-
168
- const e2eSignals = parameters.e2eSignals ?? DEFAULT_E2E_SIGNALS;
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');