@devrik-tools/claude-gates 0.8.0 → 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.
@@ -0,0 +1,126 @@
1
+ // require-monitor — enforces that every background command has an associated Monitor.
2
+ // Two-phase enforcement:
3
+ // 1. PreToolUse on shell: a command with run_in_background: true MUST carry the marker
4
+ // MONITOR-PLANNED: <reason>. Without it, the background is denied.
5
+ // 2. PreToolUse on execution+monitor: if the session has pending unmonitored backgrounds
6
+ // and the current tool is NOT Monitor, deny — forcing the agent to create a Monitor
7
+ // before doing anything else. When the current tool IS Monitor, the pending background
8
+ // is cleared.
9
+ //
10
+ // The marker is deterministic: no model judgment required. The pending-check is deterministic
11
+ // too: state is on disk, the check is a read + compare, and the deny is unconditional.
12
+
13
+ import {
14
+ deny,
15
+ runGate,
16
+ shellCommandOf,
17
+ toolInGroups,
18
+ } from '../../lib/hook-io.mjs';
19
+ import {
20
+ readSessionState,
21
+ writeSessionState,
22
+ } from '../../lib/session-state.mjs';
23
+
24
+ const GATE_ID = 'require-monitor';
25
+ const CONFIG_KEY = 'requireMonitorForBackground';
26
+
27
+ const DEFAULT_MARKER = 'MONITOR-PLANNED:';
28
+ const MARKER_REASON_PATTERN = /\S\s\S/;
29
+ const MAX_PENDING = 20;
30
+ const MAX_COMMAND_PREVIEW_LENGTH = 200;
31
+
32
+ function hasMarker(command, marker) {
33
+ if (!marker) return false;
34
+ const markerIndex = command.indexOf(marker);
35
+ if (markerIndex === -1) return false;
36
+ const afterMarker = command.slice(markerIndex + marker.length);
37
+ return MARKER_REASON_PATTERN.test(afterMarker);
38
+ }
39
+
40
+ function pendingBackgrounds(state) {
41
+ return Array.isArray(state.pending) ? state.pending : [];
42
+ }
43
+
44
+ function addPending(state, entry) {
45
+ const pending = [...pendingBackgrounds(state), entry].slice(-MAX_PENDING);
46
+ return { ...state, pending };
47
+ }
48
+
49
+ function clearOldestPending(state) {
50
+ const pending = pendingBackgrounds(state);
51
+ if (pending.length === 0) return state;
52
+ return { ...state, pending: pending.slice(1) };
53
+ }
54
+
55
+ runGate(
56
+ {
57
+ id: GATE_ID,
58
+ configKey: CONFIG_KEY,
59
+ enabledByDefault: true,
60
+ defaultParams: {
61
+ monitorPlannedMarker: DEFAULT_MARKER,
62
+ },
63
+ },
64
+ ({ toolName, toolInput, sessionId, parameters, cwd }) => {
65
+ const isShell = toolInGroups(toolName, ['shell']);
66
+ const isMonitor = toolInGroups(toolName, ['monitor']);
67
+ const isExecution = toolInGroups(toolName, ['execution']);
68
+
69
+ if (!isShell && !isMonitor && !isExecution) return;
70
+
71
+ const stateOptions = { cwd };
72
+ const state = readSessionState(GATE_ID, sessionId, {}, stateOptions);
73
+ const marker = String(parameters.monitorPlannedMarker ?? DEFAULT_MARKER);
74
+
75
+ // Phase 2: if Monitor tool, clear oldest pending and allow.
76
+ if (isMonitor) {
77
+ const pending = pendingBackgrounds(state);
78
+ if (pending.length > 0) {
79
+ writeSessionState(
80
+ GATE_ID,
81
+ sessionId,
82
+ clearOldestPending(state),
83
+ stateOptions,
84
+ );
85
+ }
86
+ return;
87
+ }
88
+
89
+ // Phase 1: shell with run_in_background requires the marker.
90
+ if (isShell && toolInput.run_in_background === true) {
91
+ const command = shellCommandOf(toolInput);
92
+ if (!hasMarker(command, marker)) {
93
+ deny(
94
+ CONFIG_KEY,
95
+ `A background command MUST declare its Monitor plan. Add "${marker} <what the ` +
96
+ 'Monitor will watch for>" to the command. Every background process needs a ' +
97
+ 'Monitor — an unmonitored background is invisible work that can hang or fail ' +
98
+ 'silently. The marker is a commitment to create a Monitor immediately after.',
99
+ );
100
+ }
101
+ writeSessionState(
102
+ GATE_ID,
103
+ sessionId,
104
+ addPending(state, {
105
+ command: command.slice(0, MAX_COMMAND_PREVIEW_LENGTH),
106
+ startedAt: Date.now(),
107
+ }),
108
+ stateOptions,
109
+ );
110
+ return;
111
+ }
112
+
113
+ // Phase 2: any execution/shell tool while backgrounds are unmonitored.
114
+ const pending = pendingBackgrounds(state);
115
+ if (pending.length > 0) {
116
+ const oldest = pending[0];
117
+ deny(
118
+ CONFIG_KEY,
119
+ `There are ${pending.length} background command(s) without a Monitor. Create a ` +
120
+ `Monitor for the pending background before doing anything else. Oldest pending: ` +
121
+ `"${oldest.command ?? '(unknown)'}". Use the Monitor tool to observe it, then ` +
122
+ 'proceed with your next action.',
123
+ );
124
+ }
125
+ },
126
+ );
@@ -0,0 +1,88 @@
1
+ // require-task-split — denies writes and execution when the active task is too large
2
+ // to implement without sub-tasks. Deterministic: reads active.json, checks size and
3
+ // children count, denies unconditionally when the criteria are not met.
4
+ //
5
+ // Inspired by the Depth Tree method (unlazy): each sub-task must own specific files
6
+ // (OWNS), carry its own verification gate, and be independently completable. If the
7
+ // task's scope is ambiguous or the description is too vague to split confidently, the
8
+ // model must ask the user to clarify before registering sub-tasks.
9
+ //
10
+ // Sizes that require splitting: medium, large, unspecified (anything that is not
11
+ // explicitly small or trivial). A task that already has sub-tasks (children with
12
+ // matching parentId) passes. A project with no tasks also passes (nothing to enforce).
13
+
14
+ import { join } from 'node:path';
15
+ import { projectRootOf, readJsonOrNull } from '../../lib/config.mjs';
16
+ import { deny, runGate, toolInGroups } from '../../lib/hook-io.mjs';
17
+
18
+ const GATE_ID = 'require-task-split';
19
+ const CONFIG_KEY = 'requireTaskSplitBeforeImplementing';
20
+
21
+ const ACTIVE_TASKS_FILE = join('.ai', 'tasks', 'active.json');
22
+ const SIZES_EXEMPT_FROM_SPLIT = new Set(['small', 'trivial']);
23
+ const IMPLEMENTATION_STATUSES = new Set(['open', 'in_forge']);
24
+
25
+ function readActiveTasks(root) {
26
+ const parsed = readJsonOrNull(join(root, ACTIVE_TASKS_FILE));
27
+ const tasks = Array.isArray(parsed?.tasks) ? parsed.tasks : [];
28
+ return tasks.filter((task) => task && typeof task === 'object');
29
+ }
30
+
31
+ function hasChildren(tasks, parentId) {
32
+ return tasks.some((task) => task.parentId === parentId);
33
+ }
34
+
35
+ function unsplitLargeTasks(tasks) {
36
+ return tasks.filter((task) => {
37
+ if (!IMPLEMENTATION_STATUSES.has(task.status)) return false;
38
+ if (task.parentId) return false;
39
+ if (SIZES_EXEMPT_FROM_SPLIT.has(task.size)) return false;
40
+ return !hasChildren(tasks, task.id);
41
+ });
42
+ }
43
+
44
+ const DENY_MESSAGE_TEMPLATE = (count, lines) =>
45
+ `${count} task(s) need splitting before implementation:\n${lines}\n\n` +
46
+ 'BEFORE SPLITTING — check scope clarity:\n' +
47
+ ' If the task description is vague or you are unsure what files/modules are affected,\n' +
48
+ ' ASK THE USER to clarify the scope before registering sub-tasks. Do not guess.\n\n' +
49
+ 'SPLITTING RULES (Depth Tree method):\n' +
50
+ ' 1. Each sub-task must be small and independently verifiable\n' +
51
+ ' 2. Each sub-task should OWN specific files — no two sub-tasks modify the same file\n' +
52
+ ' 3. Each sub-task carries its own verification gate (--verify-command or --verify-path)\n' +
53
+ ' 4. Split at natural boundaries: one module, one function, one test file\n' +
54
+ ' 5. If the task has unclear scope, ask the user — do not split blindly\n\n' +
55
+ 'Register sub-tasks:\n' +
56
+ ' claude-gates task add "<what this sub-task delivers>" --parent <parent-id> --size small \\\n' +
57
+ ' --verify-command "<check>" [--verify-expect <text>] \\\n' +
58
+ ' --description "OWNS: <file1>, <file2>"\n\n' +
59
+ 'Only after sub-tasks are registered can you proceed with writes and execution.';
60
+
61
+ runGate(
62
+ {
63
+ id: GATE_ID,
64
+ configKey: CONFIG_KEY,
65
+ enabledByDefault: true,
66
+ defaultParams: {},
67
+ },
68
+ ({ toolName, cwd }) => {
69
+ const isWrite = toolInGroups(toolName, ['write']);
70
+ const isExecution = toolInGroups(toolName, ['execution']);
71
+ if (!isWrite && !isExecution) return;
72
+
73
+ const root = projectRootOf(cwd) ?? cwd;
74
+ const tasks = readActiveTasks(root);
75
+ if (tasks.length === 0) return;
76
+
77
+ const unsplit = unsplitLargeTasks(tasks);
78
+ if (unsplit.length === 0) return;
79
+
80
+ const lines = unsplit
81
+ .map(
82
+ (task) =>
83
+ ` - [${task.status}] ${task.id}: ${task.title} (size: ${task.size ?? 'unspecified'})`,
84
+ )
85
+ .join('\n');
86
+ deny(CONFIG_KEY, DENY_MESSAGE_TEMPLATE(unsplit.length, lines));
87
+ },
88
+ );
@@ -51,6 +51,16 @@
51
51
  }
52
52
  ]
53
53
  },
54
+ {
55
+ "matcher": "Bash|run_command|PowerShell|Write|Edit|MultiEdit|NotebookEdit|write_to_file|replace_file_content|mcp__ide__executeCode|Monitor|mcp__.*",
56
+ "hooks": [
57
+ {
58
+ "type": "command",
59
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/require-monitor/index.mjs\"",
60
+ "timeout": 30
61
+ }
62
+ ]
63
+ },
54
64
  {
55
65
  "matcher": "Agent|Task|invoke_subagent|mcp__.*",
56
66
  "hooks": [
@@ -331,6 +341,16 @@
331
341
  }
332
342
  ]
333
343
  },
344
+ {
345
+ "matcher": "Bash|run_command|PowerShell|mcp__.*",
346
+ "hooks": [
347
+ {
348
+ "type": "command",
349
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/no-trivial-scripts/index.mjs\"",
350
+ "timeout": 30
351
+ }
352
+ ]
353
+ },
334
354
  {
335
355
  "matcher": "Write|Edit|MultiEdit|NotebookEdit|write_to_file|replace_file_content|Agent|Task|invoke_subagent|mcp__.*",
336
356
  "hooks": [
@@ -362,7 +382,7 @@
362
382
  ]
363
383
  },
364
384
  {
365
- "matcher": "Write|Edit|MultiEdit|NotebookEdit|write_to_file|replace_file_content|mcp__.*",
385
+ "matcher": "Write|Edit|MultiEdit|NotebookEdit|write_to_file|replace_file_content|Bash|run_command|PowerShell|mcp__.*",
366
386
  "hooks": [
367
387
  {
368
388
  "type": "command",
@@ -390,9 +410,29 @@
390
410
  "timeout": 30
391
411
  }
392
412
  ]
413
+ },
414
+ {
415
+ "matcher": "Write|Edit|MultiEdit|NotebookEdit|write_to_file|replace_file_content|Bash|run_command|PowerShell|mcp__ide__executeCode|mcp__.*",
416
+ "hooks": [
417
+ {
418
+ "type": "command",
419
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/require-task-split/index.mjs\"",
420
+ "timeout": 30
421
+ }
422
+ ]
393
423
  }
394
424
  ],
395
425
  "PostToolUse": [
426
+ {
427
+ "matcher": "Agent|Task|invoke_subagent|mcp__.*",
428
+ "hooks": [
429
+ {
430
+ "type": "command",
431
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/circuit-breaker/track.mjs\"",
432
+ "timeout": 30
433
+ }
434
+ ]
435
+ },
396
436
  {
397
437
  "matcher": "WebSearch|WebFetch|mcp__.*",
398
438
  "hooks": [
@@ -58,6 +58,8 @@ export const TOOL_GROUPS = Object.freeze({
58
58
  ],
59
59
  // Research surfaces: the web and documentation lookups the research gates sequence.
60
60
  research: ['WebSearch', 'WebFetch'],
61
+ // Observation tool for background processes.
62
+ monitor: ['Monitor'],
61
63
  });
62
64
 
63
65
  /** Every concrete tool name a set of groups expands to, de-duplicated. */
@@ -91,6 +93,7 @@ const MCP_GROUP_SIGNALS = Object.freeze({
91
93
  execution:
92
94
  /(?:write|edit|create|append|patch|replace|insert|modify|save|update|shell|bash|exec|run|command|terminal|process|spawn|cmd|powershell|sh)/i,
93
95
  research: /(?:search|fetch|browse|docs|documentation|library|lookup|query)/i,
96
+ monitor: /(?:monitor|observe|watch|stream|tail|follow|subscribe)/i,
94
97
  });
95
98
 
96
99
  const MCP_TOOL_PREFIX = 'mcp__';
@@ -560,10 +563,10 @@ export function allow() {
560
563
  /** A Stop-hook block: makes the agent continue instead of ending the turn. */
561
564
  export function block(label, reason) {
562
565
  record(DECISIONS.BLOCK, reason);
563
- process.stdout.write(
566
+ process.stderr.write(
564
567
  JSON.stringify({ decision: 'block', reason: `[${label}] ${reason}` }),
565
568
  );
566
- process.exit(0);
569
+ process.exit(2);
567
570
  }
568
571
 
569
572
  export const SEVERITY = Object.freeze({ DENY: 'deny', WARN: 'warn' });
@@ -53,7 +53,7 @@ export function runGateProcess(
53
53
  } = {},
54
54
  ) {
55
55
  const root = project ?? makeProject({ config, files });
56
- const out = execFileSync(process.execPath, [gatePath], {
56
+ const options = {
57
57
  input: typeof payload === 'string' ? payload : JSON.stringify(payload),
58
58
  encoding: 'utf8',
59
59
  cwd: cwd ?? root,
@@ -65,9 +65,20 @@ export function runGateProcess(
65
65
  ...environment,
66
66
  },
67
67
  timeout,
68
- });
69
- const trimmed = out.trim();
70
- return trimmed ? JSON.parse(trimmed) : null;
68
+ };
69
+ try {
70
+ const out = execFileSync(process.execPath, [gatePath], options);
71
+ const trimmed = out.trim();
72
+ return trimmed ? JSON.parse(trimmed) : null;
73
+ } catch (error) {
74
+ if (error.status === 2) {
75
+ const stderr = String(error.stderr ?? '').trim();
76
+ const stdout = String(error.stdout ?? '').trim();
77
+ const source = stderr || stdout;
78
+ return source ? JSON.parse(source) : null;
79
+ }
80
+ throw error;
81
+ }
71
82
  }
72
83
 
73
84
  /** 'deny' | 'warn' | 'block' | null from a gate's parsed output. */
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tasks",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Deterministic task tracking for Claude Code: persists tasks the model registers via the CLI, reminds of open tasks on a message counter, and lists active tasks on session start.",
5
5
  "author": {
6
6
  "name": "Devrik"
@@ -170,6 +170,12 @@ export function openTaskStore(startDirectory) {
170
170
  writeCollection(activePath, collection);
171
171
  return task;
172
172
  },
173
+ /** Active sub-tasks whose parentId equals the given id. */
174
+ childrenOf(id) {
175
+ return readCollection(activePath).tasks.filter(
176
+ (task) => task.parentId === id,
177
+ );
178
+ },
173
179
  /** Merges fields into the active task with matching id. Null if not found. */
174
180
  update(id, fields) {
175
181
  const collection = readCollection(activePath);
@@ -77,17 +77,44 @@ function readPayload() {
77
77
 
78
78
  // Asked on every message. The hook does not classify — it asks the model to, and the
79
79
  // model is the one that persists (via the CLI, which enforces the store's own rules,
80
- // e.g. evidence on close). This is legitimate: the hook is not depending on the model to
81
- // make something OCCUR deterministically; it is depending on the model's judgment for a
82
- // judgment call (whether this message describes a new task), same shape as ask-adoption.
80
+ // e.g. evidence on close). Default to registering: the USER defines what is a task, not
81
+ // the model. The model must not use judgment to skip what the user considers actionable.
83
82
  const CLASSIFY_PROMPT =
84
- '[tasks] MANDATORY before you respond: judge whether the message you just received describes ' +
85
- 'a new actionable task (not a question, not small talk, not a follow-up on an already-registered ' +
86
- 'task). If it does, you MUST register it now, before writing your reply run ' +
87
- '`claude-gates task add "<title>" [--description <text>] [--size <size>]` from the project root ' +
88
- "(or the CLI's absolute path if `claude-gates` is not on PATH). Do not defer this, do not decide " +
89
- 'to register it "later", do not silently skip it because the answer seems obvious. If it is not a ' +
90
- 'new task, skip registration — do not fabricate one.';
83
+ "[tasks] MANDATORY before writing your reply, you MUST register the user's message as a " +
84
+ 'task unless it is UNAMBIGUOUSLY one of these: (a) pure small talk with no request ("hello", ' +
85
+ '"thanks"), (b) a yes/no answer to a question YOU asked, (c) a message that says only "continue" ' +
86
+ 'or "go ahead". Everything else is a task including questions that require research, review ' +
87
+ 'requests, error reports, follow-ups that add scope, corrections, and messages with multiple ' +
88
+ 'requests (register one task per distinct request). DEFAULT TO REGISTERING: when in doubt, ' +
89
+ 'register.\n\n' +
90
+ 'VERIFICATION REQUIRED: every task MUST include a deterministic verification criterion. Use ' +
91
+ 'one of these:\n' +
92
+ ' --verify-command "<shell command>" [--verify-expect <text>] (command must exit 0 when done)\n' +
93
+ ' --verify-path <file-or-dir> [--verify-contains <text>] (must exist when done)\n' +
94
+ 'Examples:\n' +
95
+ ' claude-gates task add "Fix login bug" --verify-command "npm test -- --grep login" --verify-expect "passing"\n' +
96
+ ' claude-gates task add "Add config file" --verify-path "src/config.ts" --verify-contains "export"\n' +
97
+ 'Pick the criterion that a machine can check: a test that passes, a file that exists, a grep ' +
98
+ 'that matches. If the task is a question/research, use --verify-path for the file where the ' +
99
+ 'answer will be written, or --verify-command "claude-gates task list" --verify-expect "done".\n\n' +
100
+ 'SPLITTING (Depth Tree): tasks with size medium or larger MUST be split into sub-tasks before ' +
101
+ 'implementation. SCOPE FIRST: if the task description is vague or you are unsure what files or ' +
102
+ 'modules are affected, ASK THE USER to clarify the scope before splitting — do not guess. ' +
103
+ 'Once scope is clear:\n' +
104
+ ' 1. Each sub-task OWNS specific files (state in --description "OWNS: <paths>") — no overlap\n' +
105
+ ' 2. Each sub-task is --size small and independently verifiable\n' +
106
+ ' 3. Split at natural boundaries: one module, one function, one test file\n' +
107
+ ' 4. Register parent first, then sub-tasks with --parent <parent-id>\n' +
108
+ 'Example:\n' +
109
+ ' claude-gates task add "Refactor auth" --size large --verify-command "npm test" --verify-expect "passing"\n' +
110
+ ' claude-gates task add "Extract token validation" --parent <id> --size small ' +
111
+ '--verify-path "src/auth/validate.ts" --description "OWNS: src/auth/validate.ts"\n' +
112
+ ' claude-gates task add "Add token refresh" --parent <id> --size small ' +
113
+ '--verify-command "npm test -- --grep refresh" --description "OWNS: src/auth/refresh.ts"\n\n' +
114
+ 'Run `claude-gates task add "<title>" [--description <text>] [--size <size>] --verify-command|--verify-path ...` ' +
115
+ "from the project root (or the CLI's absolute path if `claude-gates` is not on PATH). Do not defer " +
116
+ 'this, do not decide to register it "later", do not silently skip it because the answer seems ' +
117
+ "obvious. The user's flow takes priority over your judgment of what deserves tracking.";
91
118
 
92
119
  function formatReminder(tasks) {
93
120
  const shown = tasks.slice(0, MAX_TASKS_SHOWN);
package/registry.json CHANGED
@@ -110,6 +110,22 @@
110
110
  "description": "Comment marker that authorizes a justified wait as an escape hatch."
111
111
  }
112
112
  ]
113
+ },
114
+ {
115
+ "id": "require-monitor",
116
+ "configKey": "requireMonitorForBackground",
117
+ "default": true,
118
+ "event": "PreToolUse",
119
+ "tools": ["shell", "execution", "monitor"],
120
+ "description": "Enforces that every background command (run_in_background: true) has an associated Monitor. Phase 1: denies background without MONITOR-PLANNED: marker. Phase 2: denies any execution tool while unmonitored backgrounds exist.",
121
+ "script": "gates/require-monitor/index.mjs",
122
+ "params": [
123
+ {
124
+ "name": "monitorPlannedMarker",
125
+ "type": "string",
126
+ "description": "Marker the command must carry to declare its Monitor plan. Default MONITOR-PLANNED:."
127
+ }
128
+ ]
113
129
  }
114
130
  ]
115
131
  },
@@ -187,8 +203,15 @@
187
203
  "default": false,
188
204
  "event": "PreToolUse",
189
205
  "tools": ["delegation"],
190
- "description": "Cuts a delegation retried without substantial change: the attempt count is recomputed from stored signatures across the session (Dice >= similarityThreshold); retryThreshold below 2 is treated as 2.",
206
+ "description": "Cuts a delegation retried without substantial change: the attempt count is recomputed from stored signatures across the session (Dice >= similarityThreshold); retryThreshold below 2 is treated as 2. Only delegations that actually launched (allowed by all gates) count as attempts.",
191
207
  "script": "gates/circuit-breaker/index.mjs",
208
+ "extraScripts": [
209
+ {
210
+ "event": "PostToolUse",
211
+ "script": "gates/circuit-breaker/track.mjs",
212
+ "tools": ["delegation"]
213
+ }
214
+ ],
192
215
  "params": [
193
216
  {
194
217
  "name": "retryThreshold",
@@ -226,10 +249,10 @@
226
249
  {
227
250
  "id": "force-parallel",
228
251
  "configKey": "warnSequentialDelegations",
229
- "default": false,
252
+ "default": true,
230
253
  "event": "PreToolUse",
231
254
  "tools": ["delegation"],
232
- "description": "Warns after N consecutive sequential delegations within a time window, suggesting they be launched together instead.",
255
+ "description": "Denies the Nth consecutive sequential delegation within a time window, requiring independent delegations to be launched together in one message. Escape hatch: SEQUENTIAL-JUSTIFIED in the prompt declares a genuine dependency.",
233
256
  "script": "gates/force-parallel/index.mjs",
234
257
  "params": [
235
258
  {
@@ -736,6 +759,16 @@
736
759
  "description": "Substring inside a comment that allows it once (a documented exception). Default comment-ok:."
737
760
  }
738
761
  ]
762
+ },
763
+ {
764
+ "id": "no-trivial-scripts",
765
+ "configKey": "blockTrivialInlineScripts",
766
+ "default": true,
767
+ "event": "PreToolUse",
768
+ "tools": ["shell"],
769
+ "description": "Denies inline interpreter scripts (node -e, python -c, sed -i, perl -i, PowerShell Set-Content/Add-Content) when they perform file operations that the Edit or Write tool handles directly. Legitimate computation scripts are not caught.",
770
+ "script": "gates/no-trivial-scripts/index.mjs",
771
+ "params": []
739
772
  }
740
773
  ]
741
774
  },
@@ -869,7 +902,7 @@
869
902
  "configKey": "requireDocsBeforeUsingNewLibrary",
870
903
  "default": true,
871
904
  "event": "PreToolUse",
872
- "tools": ["write"],
905
+ "tools": ["write", "shell"],
873
906
  "script": "gates/library-docs/index.mjs",
874
907
  "extraScripts": [
875
908
  {
@@ -878,7 +911,7 @@
878
911
  "tools": ["research"]
879
912
  }
880
913
  ],
881
- "description": "Denies a write that imports a package the project does not use anywhere yet unless this session looked it up: an engram hit about it, or context7 docs followed by a mem_save. Never guess a library API.",
914
+ "description": "Denies a write or shell command that imports a package the project does not use anywhere yet unless this session looked it up: an engram hit about it, or context7 docs followed by a mem_save. Shell heredocs are inspected; opaque redirections to code files are denied outright. Never guess a library API.",
882
915
  "params": [
883
916
  {
884
917
  "name": "codeExtensions",
@@ -964,6 +997,16 @@
964
997
  "description": "When true (default), a task with status 'blocked' does not prevent stop; only open/in_forge do."
965
998
  }
966
999
  ]
1000
+ },
1001
+ {
1002
+ "id": "require-task-split",
1003
+ "configKey": "requireTaskSplitBeforeImplementing",
1004
+ "default": true,
1005
+ "event": "PreToolUse",
1006
+ "tools": ["write", "execution"],
1007
+ "description": "Denies writes and execution when an active task has size > small/trivial and no sub-tasks registered. Forces splitting into smaller, independently verifiable sub-tasks before implementation begins.",
1008
+ "script": "gates/require-task-split/index.mjs",
1009
+ "params": []
967
1010
  }
968
1011
  ]
969
1012
  },