@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,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.
@@ -1,117 +1,179 @@
1
- // no-blocking — denies syntactic forms of waiting in the foreground (sleeping, polling
2
- // loops, following unbounded output, foreground dev servers) on a real shell command and
3
- // on a delegation prompt (a subagent can be told "wait with sleep" in prose). Migrated
4
- // from ~/.claude/hooks/guard-no-blocking.mjs. Off by default: a project opts in, because
5
- // many legitimate workflows still need a bounded wait the project accepts as normal.
6
- //
7
- // This gate only recognizes SYNTACTIC forms of waiting it never judges whether other
8
- // work was available meanwhile, because that is judgment, not a fact a regex can read.
9
- // A gate that misfires on the legitimate case gets disabled, taking every real catch
10
- // down with it.
11
- //
12
- // ── What a project can configure (params) ───────────────────────────────────────────
13
- // blockingPatterns regex sources (matched case-insensitively) of blocking/waiting
14
- // forms to deny. Replaces the built-in list wholesale.
15
- // waitJustifiedMarker a marker token that, present in the command with a reason,
16
- // escapes the block — a declared wait is a decision, not an
17
- // oversight. Replaces the built-in marker wholesale.
18
- // The defaults live here, in the source, so a project reads them and knows exactly what
19
- // its override replaces.
1
+ // no-blocking — denies syntactic forms of waiting in the foreground (sleeping, polling loops,
2
+ // following unbounded output, foreground dev servers, Windows pauses) on a real shell command
3
+ // and on a delegation prompt that orders one. Off by default: a project opts in. Deliberate
4
+ // limits: only SYNTACTIC waiting is recognized, never whether other work was available
5
+ // meanwhile; each command segment is judged on its own, so a detach form (`&`, nohup,
6
+ // Start-Job, --detach) exempts only the segment it is in, and only the real
7
+ // `run_in_background: true` flag exempts the whole callthe words in the command do not.
20
8
 
9
+ import { hasRealCommandIntent } from '../../lib/delegation.mjs';
21
10
  import {
22
- runGate,
11
+ compileRegex,
12
+ delegationPromptOf,
23
13
  deny,
14
+ escapeRegExp,
15
+ normalizeRulePairs,
16
+ runGate,
17
+ shellCommandOf,
24
18
  toolInGroups,
25
- delegationPromptOf,
26
19
  } from '../../lib/hook-io.mjs';
27
20
 
28
21
  const GATE_ID = 'no-blocking';
29
22
  const CONFIG_KEY = 'blockWaitingCommands';
30
23
 
31
24
  const DEFAULT_WAIT_JUSTIFIED_MARKER = 'WAIT-JUSTIFIED:';
25
+ const DEFAULT_REASON = 'Blocking/waiting command is not allowed.';
32
26
 
33
- // systemd's log-follow command, assembled from fragments so the spell checker does not
34
- // read it as prose (the project keeps an empty dictionary by policy).
35
- const SYSTEMD_LOG_COMMAND = 'journal' + 'ctl';
27
+ // Where a command may begin inside a segment or a sentence: the start, after a control word,
28
+ // or after a separator a prompt may still carry.
29
+ const COMMAND_START = String.raw`(?:^|[|;&(]\s*|\b(?:then|do|else)\s+)`;
30
+ const FOLLOW_COMMANDS =
31
+ '(?:tail|journalctl|kubectl\\s+logs|docker(?:-compose|\\s+compose)?\\s+logs)';
36
32
 
37
- /** Blocking rules as `[regexSource, reason]`, matched case-insensitively. */
38
33
  const DEFAULT_BLOCKING_PATTERNS = [
39
34
  [
40
- String.raw`(^|[|;&]\s*|\bthen\s+|\bdo\s+)(sleep|timeout)\s+\d`,
35
+ `${COMMAND_START}sleep\\s`,
41
36
  'Sleeping in the foreground spends the turn doing nothing. If waiting for something ' +
42
37
  'to finish, launch it in the background and move on to something else; if waiting ' +
43
38
  'on an external condition, use a monitor.',
44
39
  ],
40
+ [
41
+ `${COMMAND_START}timeout(?:\\.exe)?\\s+(?:/t\\s+)?\\d+(?:\\s+/nobreak)?\\s*$`,
42
+ "'timeout N' with no command is a sleep. Wrap a real command (timeout 120 npm test) " +
43
+ 'or launch the wait in the background.',
44
+ ],
45
45
  [
46
46
  String.raw`\bStart-Sleep\b`,
47
47
  'Start-Sleep freezes the turn. Launch the work in the background and continue with ' +
48
48
  'whatever does not depend on it.',
49
49
  ],
50
50
  [
51
- String.raw`(^|[|;&]\s*)wait\b|--wait\b|\bWait-Process\b|\bWait-Job\b`,
51
+ String.raw`${COMMAND_START}wait\b|\s--wait\b|\bWait-Process\b|\bWait-Job\b|\bStart-Process\b[^|;\n]*\s-Wait\b`,
52
52
  'Waiting for another process to finish blocks the whole turn. Launch it in the ' +
53
53
  'background and consume the result when it arrives, not before.',
54
54
  ],
55
55
  [
56
- [
57
- String.raw`\b(tail|`,
58
- SYSTEMD_LOG_COMMAND,
59
- String.raw`|kubectl\s+logs|docker\s+logs)\b[^|;&]*\s-{1,2}f\b`,
60
- ].join(''),
56
+ String.raw`\b${FOLLOW_COMMANDS}\b[^|;&\n]*\s(?:-[a-z]*f[a-z]*|--follow)(?=\s|$)`,
61
57
  'Following live output never returns and takes the turn with it. Read the file once, ' +
62
58
  'or leave the following to a background process.',
63
59
  ],
64
60
  [
65
- String.raw`(^|[|;&]\s*)watch\s+`,
61
+ `${COMMAND_START}watch\\s+`,
66
62
  "'watch' repeats forever and blocks. Run the command once; if a change genuinely " +
67
63
  'needs watching, use a monitor that does not take the turn.',
68
64
  ],
69
65
  [
70
- String.raw`\b(until|while)\b[^\n]{0,80}\bdo\b[^\n]{0,80}\bsleep\b`,
66
+ String.raw`\b(?:until|while)\b[\s\S]{0,80}\bdo\b[\s\S]{0,80}\bsleep\b`,
71
67
  'A loop that sleeps waiting for something to change is foreground polling: it blocks ' +
72
68
  'and it spins. Use a monitor with the condition, or launch the work in the ' +
73
69
  'background and wait for its notification.',
74
70
  ],
75
71
  [
76
- String.raw`\b(npm|pnpm|yarn|bun)\s+(run\s+)?(dev|start|serve|watch)\b`,
72
+ String.raw`\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:dev|start|serve|watch)(?=\s|$)`,
77
73
  'A dev server does not end on its own: in the foreground it takes the turn until ' +
78
74
  'something kills it. Launch it in the background and keep working while it comes up.',
79
75
  ],
76
+ [
77
+ String.raw`${COMMAND_START}ping\b[^|;&\n]*\s-n\s+\d{2,}\b|${COMMAND_START}pause\b|\bRead-Host\b`,
78
+ 'A ping loop, pause or Read-Host waits on the clock or on a keyboard nobody is at. ' +
79
+ 'Remove the wait; if something must be awaited, do it in the background.',
80
+ ],
80
81
  ];
81
82
 
82
- // Marks that a command will not take the turn: backgrounded, detached, or bounded. The bare
83
- // `-d` token was removed: it matched any -d flag (e.g. `curl -d payload`), letting an
84
- // unrelated flag whitelist a genuine foreground `sleep`. Detached forms are now matched
85
- // explicitly (--detach, docker/compose -d at a word boundary before end/pipe), and PowerShell's
86
- // Start-Job / Start-Process -NoNewWindow backgrounding is recognized.
87
- // One big alternation trips the linter's regex-complexity check, so each background form is
88
- // its own short regex tested with `.some()` — matching EXACTLY what the combined pattern did
89
- // (verified case-by-case). Order does not matter: any one match means the command detaches.
90
- const NOT_TAKING_THE_TURN_FORMS = [
91
- /&\s*$/i,
83
+ // ── Shell segments ──────────────────────────────────────────────────────────────────
84
+ // A lone `&` backgrounds the segment before it; `>&` and `&>` are redirections, not separators.
85
+ const SEPARATOR_PATTERN = /(\|\||&&|\||;|\n|(?<!>)&(?![&>]))/;
86
+ const SHELL_WRAPPER_PATTERN =
87
+ /^(?:sh|bash|zsh|dash|ksh)\s+(-\w+)\s+(?:"([^"]*)"|'([^']*)')/i;
88
+ const DETACHED_SEGMENT_FORMS = [
92
89
  /\bnohup\b/i,
93
90
  /\bstart\s+\/b\b/i,
94
91
  /--detach\b/i,
95
- /\b-d(?=\s*($|[|;&]))/i,
92
+ /^docker(?:-compose|\s+compose)?\b[^|;\n]*\s-d(?=\s|$)/i,
96
93
  /\bStart-Job\b/i,
97
- /\bStart-Process\b[^|;\n]*-NoNewWindow\b/i,
98
- /\brun_in_background\b/i,
99
94
  ];
100
95
 
101
- function detachesFromTurn(command) {
102
- return NOT_TAKING_THE_TURN_FORMS.some((pattern) => pattern.test(command));
96
+ function stripGrouping(text) {
97
+ let stripped = text.trim();
98
+ while (stripped.startsWith('(') || stripped.startsWith('$('))
99
+ stripped = stripped.replace(/^\$?\(/, '').trim();
100
+ while (stripped.endsWith(')')) stripped = stripped.slice(0, -1).trimEnd();
101
+ return stripped;
102
+ }
103
+
104
+ function shellSegments(command) {
105
+ const parts = String(command).split(SEPARATOR_PATTERN);
106
+ const segments = [];
107
+ for (let index = 0; index < parts.length; index += 2) {
108
+ const text = stripGrouping(parts[index]);
109
+ if (!text) continue;
110
+ const sentToBackground = parts[index + 1] === '&';
111
+ const wrapped = SHELL_WRAPPER_PATTERN.exec(text);
112
+ if (wrapped && wrapped[1].includes('c')) {
113
+ for (const inner of shellSegments(wrapped[2] ?? wrapped[3]))
114
+ segments.push({
115
+ text: inner.text,
116
+ detached: sentToBackground || inner.detached,
117
+ });
118
+ continue;
119
+ }
120
+ const detached =
121
+ sentToBackground ||
122
+ DETACHED_SEGMENT_FORMS.some((form) => form.test(text));
123
+ segments.push({ text, detached });
124
+ }
125
+ return segments;
126
+ }
127
+
128
+ // ── Rules ───────────────────────────────────────────────────────────────────────────
129
+ function blockingRules(parameters) {
130
+ return normalizeRulePairs(parameters.blockingPatterns, DEFAULT_REASON)
131
+ .map(({ source, reason }) => ({ pattern: compileRegex(source), reason }))
132
+ .filter((rule) => rule.pattern !== null);
133
+ }
134
+
135
+ function isJustified(text, marker) {
136
+ if (!marker) return false;
137
+ const reasonAfterMarker = String.raw`\s*\S+\s+\S+`;
138
+ return new RegExp(`${escapeRegExp(marker)}${reasonAfterMarker}`, 'i').test(
139
+ text,
140
+ );
141
+ }
142
+
143
+ function denyBlocking(reason, marker, matched) {
144
+ deny(
145
+ CONFIG_KEY,
146
+ `${reason} (matched: "${matched.trim()}") Launch it with run_in_background: true, or ` +
147
+ `if this wait is genuinely justified add "${marker} <concrete reason>" to the command. ` +
148
+ 'A declared wait is a decision; a silent one is an oversight.',
149
+ );
103
150
  }
104
151
 
105
- function compile(source) {
106
- return new RegExp(source, 'i');
152
+ function checkShellCommand(command, parameters, marker) {
153
+ const foreground = shellSegments(command)
154
+ .filter((segment) => !segment.detached)
155
+ .map((segment) => segment.text);
156
+ if (foreground.length === 0) return;
157
+ // The joined text lets a rule see a loop whose body sits in another segment.
158
+ const candidates =
159
+ foreground.length > 1 ? [...foreground, foreground.join('\n')] : foreground;
160
+ for (const { pattern, reason } of blockingRules(parameters)) {
161
+ for (const candidate of candidates) {
162
+ const match = pattern.exec(candidate);
163
+ if (match) denyBlocking(reason, marker, match[0]);
164
+ }
165
+ }
107
166
  }
108
167
 
109
- /** The text to inspect: a real command's command line, or the delegation prompt. */
110
- function commandTextFrom(toolName, toolInput) {
111
- if (toolInGroups(toolName, ['shell'])) {
112
- return String(toolInput.CommandLine ?? toolInput.command ?? '');
168
+ function checkDelegationPrompt(prompt, parameters, marker) {
169
+ for (const { pattern, reason } of blockingRules(parameters)) {
170
+ if (hasRealCommandIntent(prompt, pattern))
171
+ denyBlocking(
172
+ `${reason} The delegation prompt orders the wait; a subagent must not do it either.`,
173
+ marker,
174
+ pattern.exec(prompt)?.[0] ?? '',
175
+ );
113
176
  }
114
- return delegationPromptOf(toolInput);
115
177
  }
116
178
 
117
179
  runGate(
@@ -126,38 +188,17 @@ runGate(
126
188
  },
127
189
  ({ toolName, toolInput, parameters }) => {
128
190
  const isShell = toolInGroups(toolName, ['shell']);
129
- const isDelegation = toolInGroups(toolName, ['delegation']);
130
- if (!isShell && !isDelegation) return;
131
-
132
- const command = commandTextFrom(toolName, toolInput);
133
- if (!command.trim()) return;
134
-
135
- // Already declared in the background: the turn stays free, which is all that matters.
191
+ if (!isShell && !toolInGroups(toolName, ['delegation'])) return;
192
+ const text = isShell
193
+ ? shellCommandOf(toolInput)
194
+ : delegationPromptOf(toolInput);
195
+ if (!text.trim()) return;
136
196
  if (toolInput.run_in_background === true) return;
137
- if (detachesFromTurn(command)) return;
138
197
 
139
- // A declared wait with its reason is a decision, not an oversight.
140
198
  const marker = String(parameters.waitJustifiedMarker ?? '');
141
- if (marker) {
142
- const escapedMarker = marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
143
- const reasonAfterMarker = String.raw`\s*\S+\s+\S+`;
144
- const justified = new RegExp(`${escapedMarker}${reasonAfterMarker}`, 'i');
145
- if (justified.test(command)) return;
146
- }
199
+ if (isJustified(text, marker)) return;
147
200
 
148
- const blockingPairs = (parameters.blockingPatterns ?? []).map((entry) =>
149
- Array.isArray(entry)
150
- ? entry
151
- : [entry, 'Blocking/waiting command is not allowed.'],
152
- );
153
- for (const [source, reason] of blockingPairs) {
154
- if (compile(source).test(command)) {
155
- deny(
156
- CONFIG_KEY,
157
- `${reason} If this wait is genuinely justified, add "${marker} <concrete reason>" ` +
158
- 'to the command and try again. A declared wait is a decision; a silent one is an oversight.',
159
- );
160
- }
161
- }
201
+ if (isShell) checkShellCommand(text, parameters, marker);
202
+ else checkDelegationPrompt(text, parameters, marker);
162
203
  },
163
204
  );