@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.
Files changed (83) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/README.es.md +129 -11
  3. package/README.md +120 -10
  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 +12 -1
  12. package/cli/selection.mjs +18 -0
  13. package/cli/smoke-fixtures.json +159 -25
  14. package/cli/task.mjs +104 -11
  15. package/cli/toggle.mjs +125 -0
  16. package/package.json +4 -3
  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 +94 -215
  29. package/plugins/gates/hooks/gates/circuit-breaker/track.mjs +285 -0
  30. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +127 -46
  31. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +81 -19
  32. package/plugins/gates/hooks/gates/engram-first/index.mjs +41 -0
  33. package/plugins/gates/hooks/gates/engram-first/session-start.mjs +64 -0
  34. package/plugins/gates/hooks/gates/engram-first/shared.mjs +90 -0
  35. package/plugins/gates/hooks/gates/engram-first/stop.mjs +23 -0
  36. package/plugins/gates/hooks/gates/engram-first/track.mjs +97 -0
  37. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +156 -50
  38. package/plugins/gates/hooks/gates/force-parallel/index.mjs +53 -95
  39. package/plugins/gates/hooks/gates/forge-flow/index.mjs +51 -83
  40. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +40 -80
  41. package/plugins/gates/hooks/gates/intent-flow/index.mjs +23 -149
  42. package/plugins/gates/hooks/gates/library-docs/index.mjs +338 -0
  43. package/plugins/gates/hooks/gates/library-docs/track.mjs +95 -0
  44. package/plugins/gates/hooks/gates/lint-commit/index.mjs +88 -97
  45. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +48 -95
  46. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +69 -31
  47. package/plugins/gates/hooks/gates/never-assume/index.mjs +23 -18
  48. package/plugins/gates/hooks/gates/no-blocking/index.mjs +127 -86
  49. package/plugins/gates/hooks/gates/no-coauthor/index.mjs +108 -88
  50. package/plugins/gates/hooks/gates/no-explanatory-comments/index.mjs +281 -0
  51. package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +170 -110
  52. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +46 -77
  53. package/plugins/gates/hooks/gates/no-reconfirm/index.mjs +85 -47
  54. package/plugins/gates/hooks/gates/no-trivial-scripts/index.mjs +114 -0
  55. package/plugins/gates/hooks/gates/protected-paths/index.mjs +137 -90
  56. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +95 -51
  57. package/plugins/gates/hooks/gates/require-monitor/index.mjs +126 -0
  58. package/plugins/gates/hooks/gates/require-task-split/index.mjs +88 -0
  59. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +72 -216
  60. package/plugins/gates/hooks/gates/risk-level/index.mjs +51 -204
  61. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +34 -17
  62. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +160 -95
  63. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +196 -97
  64. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +90 -143
  65. package/plugins/gates/hooks/gates/staged-lint/index.mjs +98 -106
  66. package/plugins/gates/hooks/gates/stop-pending/index.mjs +47 -148
  67. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +78 -68
  68. package/plugins/gates/hooks/gates/test-matrix/index.mjs +53 -88
  69. package/plugins/gates/hooks/gates/tool-map/index.mjs +80 -110
  70. package/plugins/gates/hooks/hooks.json +152 -42
  71. package/plugins/gates/hooks/lib/config.mjs +6 -3
  72. package/plugins/gates/hooks/lib/delegation.mjs +352 -0
  73. package/plugins/gates/hooks/lib/gate-log.mjs +141 -0
  74. package/plugins/gates/hooks/lib/git.mjs +262 -0
  75. package/plugins/gates/hooks/lib/hook-io.mjs +451 -83
  76. package/plugins/gates/hooks/lib/session-state.mjs +150 -0
  77. package/plugins/gates/hooks/lib/signals.mjs +18 -13
  78. package/plugins/gates/hooks/lib/testing.mjs +148 -0
  79. package/plugins/gates/hooks/lib/tools.mjs +144 -0
  80. package/plugins/tasks/.claude-plugin/plugin.json +1 -1
  81. package/plugins/tasks/hooks/lib/task-store.mjs +12 -6
  82. package/plugins/tasks/hooks/register-requests.mjs +37 -10
  83. package/registry.json +249 -38
@@ -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
  );
@@ -1,28 +1,44 @@
1
+ // neutral-spanish — regional Spanish (voseo, Rioplatense lexicon) is denied in Spanish
2
+ // prose. Only prose is scanned: a text-type file, or content carrying enough Spanish
3
+ // function words to be Spanish — so an English "Author: Dale Carnegie" in code is not a
4
+ // marker hit. A deliberate quote/fixture/log opts out with the escape-hatch marker.
5
+
6
+ import { extname } from 'node:path';
1
7
  import {
2
8
  runGate,
3
9
  deny,
4
10
  toolInGroups,
5
11
  writtenContentOf,
12
+ writtenPathOf,
13
+ escapeRegExp,
6
14
  } from '../../lib/hook-io.mjs';
15
+ import { withUnicodeWordBoundary } from '../../lib/signals.mjs';
7
16
 
8
17
  const GATE_ID = 'neutral-spanish';
9
18
  const CONFIG_KEY = 'warnNonNeutralSpanish';
10
19
 
11
20
  const MAX_REPORTED_MARKERS = 8;
12
-
13
- // Escape hatch: when a written file legitimately needs regional text (a literal quote, a
14
- // test fixture, a captured log, a data sample), placing this marker anywhere in the content
15
- // tells the gate the regional wording is intentional and lets the write through. A deny (as
16
- // opposed to the old warn) cannot "advise and let pass", so a legitimate case needs an
17
- // explicit, greppable opt-out — a marker the author writes on purpose, never one the agent
18
- // could infer. A project can override it via config (escapeHatch).
21
+ const MIN_SPANISH_FUNCTION_WORDS = 3;
19
22
  const DEFAULT_ESCAPE_HATCH = 'neutral-spanish:allow';
23
+ const DEFAULT_TEXT_EXTENSIONS = [
24
+ '.md',
25
+ '.mdx',
26
+ '.txt',
27
+ '.rst',
28
+ '.html',
29
+ '.htm',
30
+ '.adoc',
31
+ '.json',
32
+ '.yml',
33
+ '.yaml',
34
+ ];
35
+
36
+ const SPANISH_FUNCTION_WORD = withUnicodeWordBoundary(
37
+ 'que|de|la|el|los|las|para|con|una|por|como|también|está|más|este|esta|hay|muy|sin',
38
+ );
20
39
 
21
40
  // Data strings, not identifiers — never add these to a cSpell dictionary.
22
41
  // neutral-spanish:allow — this list IS the marker data; the gate must not deny its own source.
23
- // Now that this gate DENIES (not warns), a marker must be unambiguously regional. Tokens that
24
- // also occur in neutral Spanish were removed: "de una" (matches "de una lista/vez"), "allá"
25
- // (standard across the whole language) — a false positive here blocks a legitimate write.
26
42
  const DEFAULT_REGIONAL_MARKERS = [
27
43
  'tenés',
28
44
  'podés',
@@ -54,9 +70,34 @@ const DEFAULT_REGIONAL_MARKERS = [
54
70
  'capaz que', // neutral-spanish:allow (marker data, not prose)
55
71
  ];
56
72
 
57
- function extractText(toolName, toolInput) {
58
- if (!toolInGroups(toolName, ['write'])) return '';
59
- return writtenContentOf(toolInput);
73
+ function isSpanishProse(text) {
74
+ const global = new RegExp(
75
+ SPANISH_FUNCTION_WORD.source,
76
+ `${SPANISH_FUNCTION_WORD.flags}g`,
77
+ );
78
+ const distinct = new Set();
79
+ for (const match of text.matchAll(global)) {
80
+ distinct.add(match[0].toLowerCase());
81
+ if (distinct.size >= MIN_SPANISH_FUNCTION_WORDS) return true;
82
+ }
83
+ return false;
84
+ }
85
+
86
+ function hasTextExtension(path, textExtensions) {
87
+ const extension = extname(path).toLowerCase();
88
+ return textExtensions.some(
89
+ (candidate) => String(candidate).toLowerCase() === extension,
90
+ );
91
+ }
92
+
93
+ function markerHits(text, markers) {
94
+ const hits = [];
95
+ for (const marker of markers) {
96
+ if (typeof marker !== 'string' || marker.length === 0) continue;
97
+ if (withUnicodeWordBoundary(escapeRegExp(marker)).test(text))
98
+ hits.push(marker);
99
+ }
100
+ return hits;
60
101
  }
61
102
 
62
103
  runGate(
@@ -66,30 +107,27 @@ runGate(
66
107
  enabledByDefault: true,
67
108
  defaultParams: {
68
109
  regionalMarkers: DEFAULT_REGIONAL_MARKERS,
110
+ textExtensions: DEFAULT_TEXT_EXTENSIONS,
69
111
  escapeHatch: DEFAULT_ESCAPE_HATCH,
70
112
  },
71
113
  },
72
114
  ({ toolName, toolInput, parameters }) => {
73
- const text = extractText(toolName, toolInput).toLowerCase();
115
+ if (!toolInGroups(toolName, ['write'])) return;
116
+ const text = writtenContentOf(toolInput);
74
117
  if (!text) return;
75
118
 
76
- // Explicit opt-out for legitimate regional text (quote/fixture/log/data sample).
77
- const escapeHatch = (
78
- parameters.escapeHatch ?? DEFAULT_ESCAPE_HATCH
79
- ).toLowerCase();
80
- if (escapeHatch && text.includes(escapeHatch)) return;
119
+ const escapeHatch = parameters.escapeHatch ?? DEFAULT_ESCAPE_HATCH;
120
+ if (escapeHatch && text.toLowerCase().includes(escapeHatch.toLowerCase()))
121
+ return;
81
122
 
82
- const hits = [];
83
- for (const marker of parameters.regionalMarkers) {
84
- const escaped = marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
85
- // \p{L} (any Unicode letter) as the non-boundary class, instead of a hand-written
86
- // accented-character set, so accented word boundaries are handled correctly without
87
- // spelling out literal accented characters in the source (the project keeps its
88
- // spell-check dictionary untouched, so no word list may lean on this being a string).
89
- const pattern = new RegExp(`(^|[^\\p{L}])${escaped}([^\\p{L}]|$)`, 'iu');
90
- if (pattern.test(text)) hits.push(marker);
91
- }
123
+ const path = writtenPathOf(toolInput);
124
+ if (
125
+ !hasTextExtension(path, parameters.textExtensions) &&
126
+ !isSpanishProse(text)
127
+ )
128
+ return;
92
129
 
130
+ const hits = markerHits(text, parameters.regionalMarkers);
93
131
  if (hits.length === 0) return;
94
132
 
95
133
  const unique = [...new Set(hits)].slice(0, MAX_REPORTED_MARKERS);
@@ -97,8 +135,8 @@ runGate(
97
135
  CONFIG_KEY,
98
136
  `Text being written contains regional Spanish markers: ${unique.join(', ')}. ` +
99
137
  'Rewrite in neutral Spanish before writing. If the regional wording is intentional ' +
100
- `(a literal quote, a test fixture, a captured log, a data sample), add the marker ` +
101
- `"${parameters.escapeHatch ?? DEFAULT_ESCAPE_HATCH}" somewhere in the content to allow it.`,
138
+ '(a literal quote, a test fixture, a captured log, a data sample), add the marker ' +
139
+ `"${escapeHatch}" somewhere in the content to allow it.`,
102
140
  );
103
141
  },
104
142
  );
@@ -1,59 +1,64 @@
1
+ // never-assume — conjecture phrasing in a brief or in written code gets a reminder to verify.
2
+ // Advisory only: a guess is a prompt to check, never a blocker. Every pattern (default or
3
+ // configured) is word-bounded, so "might benefit" does not trip "might be".
4
+
1
5
  import {
2
6
  runGate,
3
7
  warn,
4
8
  toolInGroups,
5
9
  writtenContentOf,
6
10
  delegationPromptOf,
11
+ compileRegex,
7
12
  } from '../../lib/hook-io.mjs';
8
13
  import { CONJECTURE_SOURCES } from '../../lib/signals.mjs';
9
14
 
10
15
  const GATE_ID = 'never-assume';
11
16
  const CONFIG_KEY = 'requireVerificationBeforeAssuming';
12
17
 
13
- // Bilingual (ES+EN) conjecture phrasing, centralized in lib/signals.mjs so every gate
14
- // that needs to recognize unverified-assumption prose shares the same coverage. See
15
- // signals.mjs header for the ES/EN-only limitation.
16
- const DEFAULT_CONJECTURE_PATTERNS = CONJECTURE_SOURCES;
17
-
18
18
  function extractContent(toolName, toolInput) {
19
19
  if (toolInGroups(toolName, ['delegation']))
20
20
  return delegationPromptOf(toolInput);
21
21
  return writtenContentOf(toolInput);
22
22
  }
23
23
 
24
+ // Same boundary as signals.withUnicodeWordBoundary, applied per source so an invalid
25
+ // config entry is skipped instead of poisoning the whole alternation.
26
+ function boundedPattern(source) {
27
+ if (typeof source !== 'string' || source.length === 0) return null;
28
+ return compileRegex(
29
+ `(?<![\\p{L}\\p{N}_])(?:${source})(?![\\p{L}\\p{N}_])`,
30
+ 'iu',
31
+ );
32
+ }
33
+
24
34
  runGate(
25
35
  {
26
36
  id: GATE_ID,
27
37
  configKey: CONFIG_KEY,
28
38
  enabledByDefault: false,
39
+ severity: 'warn',
29
40
  defaultParams: {
30
- conjecturePatterns: DEFAULT_CONJECTURE_PATTERNS,
41
+ conjecturePatterns: CONJECTURE_SOURCES,
31
42
  },
32
43
  },
33
44
  ({ toolName, toolInput, parameters }) => {
34
- const isWrite = toolInGroups(toolName, ['write']);
35
- const isDelegation = toolInGroups(toolName, ['delegation']);
36
- if (!isWrite && !isDelegation) return;
45
+ if (!toolInGroups(toolName, ['write', 'delegation'])) return;
37
46
 
38
47
  const content = extractContent(toolName, toolInput);
39
48
  if (!content) return;
40
49
 
41
- const patterns = parameters.conjecturePatterns.map(
42
- (source) => new RegExp(source, 'i'),
43
- );
44
50
  const hits = [];
45
- for (const pattern of patterns) {
46
- const match = content.match(pattern);
51
+ for (const source of parameters.conjecturePatterns) {
52
+ const match = boundedPattern(source)?.exec(content);
47
53
  if (match) hits.push(match[0]);
48
54
  }
49
55
  if (hits.length === 0) return;
50
56
 
51
57
  warn(
52
58
  CONFIG_KEY,
53
- `Content contains conjecture phrasing without stated verification: ${hits.join(', ')}. Verify before asserting instead of assuming.`,
59
+ `Content contains conjecture phrasing without stated verification: ${hits.join(', ')}. ` +
60
+ 'Verify before asserting instead of assuming. This is advisory; to change the ' +
61
+ `phrases, set conjecturePatterns for ${CONFIG_KEY} in .ai/config.json.`,
54
62
  );
55
63
  },
56
64
  );
57
-
58
- // The source guard (guard-never-assume.mjs) is explicit: this never denies,
59
- // only warns — conjecture language is a prompt to verify, not a blocker.