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